From 7214d062af429a016c767852f41af566ab1f09cf Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 5 Apr 2026 23:02:26 -0700 Subject: [PATCH 01/11] WIP - checkpoint --- .../DashboardSidebarWorkspaceItem.tsx | 6 +- .../DashboardSidebarExpandedWorkspaceRow.tsx | 24 +- ...hboardSidebarWorkspaceHoverCardContent.tsx | 21 +- .../utils/getWorkspaceRowMocks.ts | 8 - .../hooks/useDashboardDiffStats/index.ts | 1 + .../useDashboardDiffStats.ts | 103 +++++++ .../useDashboardSidebarData.ts | 13 + .../components/DashboardSidebar/types.ts | 1 + packages/host-service/package.json | 6 +- packages/host-service/src/app.ts | 13 +- packages/host-service/src/events/event-bus.ts | 252 +++++++++++++++++ .../host-service/src/events/git-watcher.ts | 148 ++++++++++ packages/host-service/src/events/index.ts | 10 + packages/host-service/src/events/types.ts | 38 +++ .../host-service/src/filesystem/events.ts | 164 ----------- packages/host-service/src/filesystem/index.ts | 5 - packages/host-service/src/index.ts | 8 +- .../src/hooks/useEventBus/index.ts | 1 + .../src/hooks/useEventBus/useEventBus.ts | 12 + .../src/hooks/useGitChangeEvents/index.ts | 1 + .../useGitChangeEvents/useGitChangeEvents.ts | 25 ++ packages/workspace-client/src/index.ts | 4 +- packages/workspace-client/src/lib/eventBus.ts | 263 ++++++++++++++++++ .../src/lib/workspaceFsEventRegistry.ts | 96 ++++--- .../WorkspaceClientProvider.tsx | 107 ------- .../WorkspaceClientProvider/index.ts | 1 - 26 files changed, 968 insertions(+), 363 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts create mode 100644 packages/host-service/src/events/event-bus.ts create mode 100644 packages/host-service/src/events/git-watcher.ts create mode 100644 packages/host-service/src/events/index.ts create mode 100644 packages/host-service/src/events/types.ts delete mode 100644 packages/host-service/src/filesystem/events.ts delete mode 100644 packages/host-service/src/filesystem/index.ts create mode 100644 packages/workspace-client/src/hooks/useEventBus/index.ts create mode 100644 packages/workspace-client/src/hooks/useEventBus/useEventBus.ts create mode 100644 packages/workspace-client/src/hooks/useGitChangeEvents/index.ts create mode 100644 packages/workspace-client/src/hooks/useGitChangeEvents/useGitChangeEvents.ts create mode 100644 packages/workspace-client/src/lib/eventBus.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 391563f4b14..720167e73ea 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -94,7 +94,6 @@ export function DashboardSidebarWorkspaceItem({ hoverCardContent={ } onCreateSection={handleCreateSection} @@ -153,10 +152,7 @@ export function DashboardSidebarWorkspaceItem({ hostType === "local-device" ? onHoverCardOpen : undefined } hoverCardContent={ - + } onCreateSection={handleCreateSection} onMoveToSection={(targetSectionId) => diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index dbf3ec7c258..ac16bb76389 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -154,11 +154,13 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< ) : ( <> - + {workspace.diffStats && ( + + )}
{shortcutLabel && ( @@ -236,11 +238,13 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< ) : ( <> - + {workspace.diffStats && ( + + )}
{shortcutLabel && ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx index 2434b65d0fe..47b492fd3f5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx @@ -5,7 +5,6 @@ import { FaGithub } from "react-icons/fa"; import { LuExternalLink, LuGlobe, LuTriangleAlert } from "react-icons/lu"; import { useHotkeyDisplay } from "renderer/hotkeys"; import type { DashboardSidebarWorkspace } from "../../../../types"; -import type { WorkspaceRowMockData } from "../../utils"; import { ChecksList } from "./components/ChecksList"; import { ChecksSummary } from "./components/ChecksSummary"; import { PullRequestStatusBadge } from "./components/PullRequestStatusBadge"; @@ -13,12 +12,10 @@ import { ReviewStatus } from "./components/ReviewStatus"; interface DashboardSidebarWorkspaceHoverCardContentProps { workspace: DashboardSidebarWorkspace; - mockData: WorkspaceRowMockData; } export function DashboardSidebarWorkspaceHoverCardContent({ workspace, - mockData, }: DashboardSidebarWorkspaceHoverCardContentProps) { const { name, @@ -107,14 +104,16 @@ export function DashboardSidebarWorkspaceHoverCardContent({ /> )}
-
- - +{mockData.diffStats.additions} - - - -{mockData.diffStats.deletions} - -
+ {workspace.diffStats && ( +
+ + +{workspace.diffStats.additions} + + + -{workspace.diffStats.deletions} + +
+ )}

diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts index cd42813fca8..ff4029e8352 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts @@ -1,10 +1,6 @@ import type { ActivePaneStatus } from "shared/tabs-types"; export interface WorkspaceRowMockData { - diffStats: { - additions: number; - deletions: number; - }; workspaceStatus: ActivePaneStatus | null; } @@ -24,10 +20,6 @@ export function getWorkspaceRowMocks( seed % 6 === 0 ? paneStatuses[seed % paneStatuses.length] : null; return { - diffStats: { - additions: (seed % 24) + 3, - deletions: (seed % 9) + 1, - }, workspaceStatus: status, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts new file mode 100644 index 00000000000..c62c4b6d33f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts @@ -0,0 +1 @@ +export { type DiffStats, useDashboardDiffStats } from "./useDashboardDiffStats"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts new file mode 100644 index 00000000000..97e969133db --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts @@ -0,0 +1,103 @@ +import { getEventBus } from "@superset/workspace-client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; +import type { HostServiceClient } from "renderer/lib/host-service-client"; + +export interface DiffStats { + additions: number; + deletions: number; +} + +export function useDashboardDiffStats( + localWorkspaceIds: string[], + hostUrl: string | null, + client: HostServiceClient | null, +): Map { + const [statsMap, setStatsMap] = useState>( + () => new Map(), + ); + const clientRef = useRef(client); + clientRef.current = client; + + const fetchDiffStats = useCallback(async (workspaceId: string) => { + const currentClient = clientRef.current; + if (!currentClient) return; + + try { + const status = await currentClient.git.getStatus.query({ + workspaceId, + }); + + let additions = 0; + let deletions = 0; + for (const file of status.againstBase) { + additions += file.additions; + deletions += file.deletions; + } + for (const file of status.staged) { + additions += file.additions; + deletions += file.deletions; + } + for (const file of status.unstaged) { + additions += file.additions; + deletions += file.deletions; + } + + setStatsMap((prev) => { + const next = new Map(prev); + next.set(workspaceId, { additions, deletions }); + return next; + }); + } catch { + // Workspace might have been deleted or host-service unavailable + } + }, []); + + // Fetch initial data for all workspaces + useEffect(() => { + if (!hostUrl || !client || localWorkspaceIds.length === 0) return; + + for (const id of localWorkspaceIds) { + void fetchDiffStats(id); + } + }, [client, fetchDiffStats, hostUrl, localWorkspaceIds]); + + // Subscribe to git:changed events and refetch on change + useEffect(() => { + if (!hostUrl || localWorkspaceIds.length === 0) return; + + const bus = getEventBus(hostUrl, () => getHostServiceWsToken(hostUrl)); + const workspaceIdSet = new Set(localWorkspaceIds); + + const removeListener = bus.on("git:changed", "*", (workspaceId) => { + if (workspaceIdSet.has(workspaceId)) { + void fetchDiffStats(workspaceId); + } + }); + + const release = bus.retain(); + + return () => { + removeListener(); + release(); + }; + }, [fetchDiffStats, hostUrl, localWorkspaceIds]); + + // Clean up stale entries when workspace list changes + useEffect(() => { + const idSet = new Set(localWorkspaceIds); + setStatsMap((prev) => { + let changed = false; + const next = new Map(prev); + for (const key of next.keys()) { + if (!idSet.has(key)) { + next.delete(key); + changed = true; + } + } + return changed ? next : prev; + }); + }, [localWorkspaceIds]); + + return statsMap; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index f17287a786e..8f20f606d91 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -16,6 +16,7 @@ import type { DashboardSidebarSection, DashboardSidebarWorkspace, } from "../../types"; +import { useDashboardDiffStats } from "../useDashboardDiffStats"; // Pending workspaces are always rendered at the end of the project's workspace list const PENDING_WORKSPACE_TAB_ORDER = Number.MAX_SAFE_INTEGER; @@ -136,6 +137,12 @@ export function useDashboardSidebarData() { [deviceInfo?.deviceId, sidebarWorkspaces], ); + const diffStatsByWorkspaceId = useDashboardDiffStats( + localWorkspaceIds, + activeHostService?.url ?? null, + activeHostService?.client ?? null, + ); + const { data: pullRequestData, refetch: refetchPullRequests } = useQuery({ queryKey: [ "dashboard-sidebar", @@ -245,6 +252,10 @@ export function useDashboardSidebarData() { : null, branchExistsOnRemote: project.githubOwner !== null && project.githubRepoName !== null, + diffStats: + hostType === "local-device" + ? (diffStatsByWorkspaceId.get(workspace.id) ?? null) + : null, previewUrl: null, needsRebase: null, behindCount: null, @@ -295,6 +306,7 @@ export function useDashboardSidebarData() { ? `https://github.com/${project.githubOwner}/${project.githubRepoName}` : null, branchExistsOnRemote: false, + diffStats: null, previewUrl: null, needsRebase: null, behindCount: null, @@ -328,6 +340,7 @@ export function useDashboardSidebarData() { }); }, [ deviceInfo?.deviceId, + diffStatsByWorkspaceId, localPullRequestsByWorkspaceId, pendingWorkspace, sidebarProjects, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts index bd8b377c4b2..6b9ed3c3e65 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts @@ -34,6 +34,7 @@ export interface DashboardSidebarWorkspace { previewUrl: string | null; needsRebase: boolean | null; behindCount: number | null; + diffStats: { additions: number; deletions: number } | null; createdAt: Date; updatedAt: Date; creationStatus?: "preparing" | "generating-branch" | "creating"; diff --git a/packages/host-service/package.json b/packages/host-service/package.json index 3d26d7ef096..2875d4d984d 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -12,9 +12,9 @@ "types": "./src/db/index.ts", "default": "./src/db/index.ts" }, - "./filesystem": { - "types": "./src/filesystem/index.ts", - "default": "./src/filesystem/index.ts" + "./events": { + "types": "./src/events/index.ts", + "default": "./src/events/index.ts" }, "./git": { "types": "./src/runtime/git/index.ts", diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 05aaedf05a1..22ac1c21131 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -8,7 +8,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { createApiClient } from "./api"; import { createDb } from "./db"; -import { registerWorkspaceFilesystemEventsRoute } from "./filesystem"; +import { EventBus, registerEventBusRoute } from "./events"; import type { ApiAuthProvider } from "./providers/auth"; import { LocalGitCredentialProvider } from "./providers/git"; import type { HostAuthProvider } from "./providers/host-auth"; @@ -93,6 +93,9 @@ export function createApp(options?: CreateAppOptions): CreateAppResult { }), ); + const eventBus = new EventBus({ db, filesystem }); + eventBus.start(); + if (options?.hostAuth) { const { hostAuth } = options; const wsAuth: MiddlewareHandler = async (c, next) => { @@ -104,14 +107,10 @@ export function createApp(options?: CreateAppOptions): CreateAppResult { return next(); }; app.use("/terminal/*", wsAuth); - app.use("/workspace-filesystem/*", wsAuth); + app.use("/events", wsAuth); } - registerWorkspaceFilesystemEventsRoute({ - app, - filesystem, - upgradeWebSocket, - }); + registerEventBusRoute({ app, eventBus, upgradeWebSocket }); registerWorkspaceTerminalRoute({ app, db, diff --git a/packages/host-service/src/events/event-bus.ts b/packages/host-service/src/events/event-bus.ts new file mode 100644 index 00000000000..bf9d8f3d25c --- /dev/null +++ b/packages/host-service/src/events/event-bus.ts @@ -0,0 +1,252 @@ +import type { NodeWebSocket } from "@hono/node-ws"; +import type { FsWatchEvent } from "@superset/workspace-fs/host"; +import type { Hono } from "hono"; +import type { HostDb } from "../db"; +import type { WorkspaceFilesystemManager } from "../runtime/filesystem"; +import { GitWatcher } from "./git-watcher"; +import type { ClientMessage, ServerMessage } from "./types"; + +type WsSocket = { + send: (data: string) => void; + readyState: number; + close: (code?: number, reason?: string) => void; +}; + +interface FsSubscription { + workspaceId: string; + dispose: () => void; +} + +interface ClientState { + fsSubscriptions: Map; +} + +function sendMessage(socket: WsSocket, message: ServerMessage): void { + if (socket.readyState !== 1) return; + socket.send(JSON.stringify(message)); +} + +function parseClientMessage(data: unknown): ClientMessage | null { + try { + const raw = typeof data === "string" ? data : String(data); + const parsed = JSON.parse(raw); + if ( + parsed && + typeof parsed === "object" && + typeof parsed.type === "string" && + typeof parsed.workspaceId === "string" + ) { + if (parsed.type === "fs:watch" || parsed.type === "fs:unwatch") { + return parsed as ClientMessage; + } + } + } catch { + // Malformed message — ignore + } + return null; +} + +export interface EventBusOptions { + db: HostDb; + filesystem: WorkspaceFilesystemManager; +} + +/** + * Unified WebSocket event bus for the host-service. + * + * One connection per client. Carries: + * - `git:changed` events (auto-pushed for all workspaces) + * - `fs:events` (on-demand per client request) + */ +export class EventBus { + private readonly clients = new Map(); + private readonly gitWatcher: GitWatcher; + private readonly filesystem: WorkspaceFilesystemManager; + private removeGitListener: (() => void) | null = null; + + constructor(options: EventBusOptions) { + this.filesystem = options.filesystem; + this.gitWatcher = new GitWatcher(options.db); + } + + start(): void { + this.gitWatcher.start(); + this.removeGitListener = this.gitWatcher.onChanged((workspaceId) => { + this.broadcast({ type: "git:changed", workspaceId }); + }); + } + + close(): void { + this.removeGitListener?.(); + this.removeGitListener = null; + this.gitWatcher.close(); + for (const [socket, state] of this.clients) { + this.cleanupClient(socket, state); + } + this.clients.clear(); + } + + handleOpen(socket: WsSocket): void { + this.clients.set(socket, { fsSubscriptions: new Map() }); + } + + handleMessage(socket: WsSocket, data: unknown): void { + const state = this.clients.get(socket); + if (!state) return; + + const message = parseClientMessage(data); + if (!message) return; + + if (message.type === "fs:watch") { + this.startFsWatch(socket, state, message.workspaceId); + } else if (message.type === "fs:unwatch") { + this.stopFsWatch(state, message.workspaceId); + } + } + + handleClose(socket: WsSocket): void { + const state = this.clients.get(socket); + if (state) { + this.cleanupClient(socket, state); + this.clients.delete(socket); + } + } + + private broadcast(message: ServerMessage): void { + for (const socket of this.clients.keys()) { + sendMessage(socket, message); + } + } + + private startFsWatch( + socket: WsSocket, + state: ClientState, + workspaceId: string, + ): void { + // Already watching this workspace for this client + if (state.fsSubscriptions.has(workspaceId)) return; + + let rootPath: string; + try { + rootPath = this.filesystem.resolveWorkspaceRoot(workspaceId); + } catch { + sendMessage(socket, { + type: "error", + message: `Workspace not found: ${workspaceId}`, + }); + return; + } + + let disposed = false; + let iterator: AsyncIterator<{ events: FsWatchEvent[] }> | null = null; + + try { + const service = this.filesystem.getServiceForWorkspace(workspaceId); + const stream = service.watchPath({ + absolutePath: rootPath, + recursive: true, + }); + iterator = stream[Symbol.asyncIterator](); + } catch (error) { + sendMessage(socket, { + type: "error", + message: + error instanceof Error + ? error.message + : "Failed to start filesystem watcher", + }); + return; + } + + const dispose = () => { + disposed = true; + void iterator?.return?.().catch((error: unknown) => { + console.error("[event-bus] fs watcher cleanup failed:", { + workspaceId, + error, + }); + }); + iterator = null; + }; + + state.fsSubscriptions.set(workspaceId, { workspaceId, dispose }); + + // Start streaming events to this client + void (async () => { + try { + while (!disposed && iterator) { + const next = await iterator.next(); + if (disposed || next.done) return; + + sendMessage(socket, { + type: "fs:events", + workspaceId, + events: next.value.events, + }); + } + } catch (error) { + if (disposed) return; + console.error("[event-bus] fs stream failed:", { + workspaceId, + error, + }); + sendMessage(socket, { + type: "error", + message: + error instanceof Error + ? error.message + : "Filesystem event stream failed", + }); + } + })(); + } + + private stopFsWatch(state: ClientState, workspaceId: string): void { + const sub = state.fsSubscriptions.get(workspaceId); + if (sub) { + sub.dispose(); + state.fsSubscriptions.delete(workspaceId); + } + } + + private cleanupClient(_socket: WsSocket, state: ClientState): void { + for (const sub of state.fsSubscriptions.values()) { + sub.dispose(); + } + state.fsSubscriptions.clear(); + } +} + +// ── Route Registration ───────────────────────────────────────────── + +export interface RegisterEventBusRouteOptions { + app: Hono; + eventBus: EventBus; + upgradeWebSocket: NodeWebSocket["upgradeWebSocket"]; +} + +export function registerEventBusRoute({ + app, + eventBus, + upgradeWebSocket, +}: RegisterEventBusRouteOptions) { + app.get( + "/events", + upgradeWebSocket(() => { + return { + onOpen: (_event, ws) => { + eventBus.handleOpen(ws); + }, + onMessage: (event, ws) => { + eventBus.handleMessage(ws, event.data); + }, + onClose: (_event, ws) => { + eventBus.handleClose(ws); + }, + onError: (_event, ws) => { + eventBus.handleClose(ws); + }, + }; + }), + ); +} diff --git a/packages/host-service/src/events/git-watcher.ts b/packages/host-service/src/events/git-watcher.ts new file mode 100644 index 00000000000..c24ecf7bba7 --- /dev/null +++ b/packages/host-service/src/events/git-watcher.ts @@ -0,0 +1,148 @@ +import { execFile } from "node:child_process"; +import { type FSWatcher, watch } from "node:fs"; +import { promisify } from "node:util"; +import type { HostDb } from "../db"; +import { workspaces } from "../db/schema"; + +const execFileAsync = promisify(execFile); + +const RESCAN_INTERVAL_MS = 30_000; + +export type GitChangedListener = (workspaceId: string) => void; + +interface WatchedWorkspace { + workspaceId: string; + worktreePath: string; + gitDir: string; + watcher: FSWatcher; +} + +/** + * Watches `.git` directories for all workspaces in the host-service DB. + * Emits workspace IDs when git state changes (commits, staging, branch switches, etc). + * Auto-discovers new workspaces and stops watching removed ones every 30s. + */ +export class GitWatcher { + private readonly db: HostDb; + private readonly listeners = new Set(); + private readonly watched = new Map(); + private rescanTimer: ReturnType | null = null; + private closed = false; + + constructor(db: HostDb) { + this.db = db; + } + + start(): void { + void this.rescan(); + this.rescanTimer = setInterval( + () => void this.rescan(), + RESCAN_INTERVAL_MS, + ); + } + + onChanged(listener: GitChangedListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + close(): void { + this.closed = true; + if (this.rescanTimer) { + clearInterval(this.rescanTimer); + this.rescanTimer = null; + } + for (const entry of this.watched.values()) { + entry.watcher.close(); + } + this.watched.clear(); + } + + private emit(workspaceId: string): void { + for (const listener of this.listeners) { + listener(workspaceId); + } + } + + private async rescan(): Promise { + if (this.closed) return; + + let rows: Array<{ id: string; worktreePath: string }>; + try { + rows = this.db + .select({ + id: workspaces.id, + worktreePath: workspaces.worktreePath, + }) + .from(workspaces) + .all(); + } catch { + return; + } + + const currentIds = new Set(rows.map((r) => r.id)); + + // Remove watchers for workspaces that no longer exist + for (const [id, entry] of this.watched) { + if (!currentIds.has(id)) { + entry.watcher.close(); + this.watched.delete(id); + } + } + + // Add watchers for new workspaces + for (const row of rows) { + if (this.watched.has(row.id)) continue; + await this.watchWorkspace(row.id, row.worktreePath); + } + } + + private async watchWorkspace( + workspaceId: string, + worktreePath: string, + ): Promise { + if (this.closed) return; + + let gitDir: string; + try { + const { stdout } = await execFileAsync( + "git", + ["rev-parse", "--git-dir"], + { cwd: worktreePath }, + ); + gitDir = stdout.trim(); + // If relative, resolve against worktree path + if (!gitDir.startsWith("/")) { + gitDir = `${worktreePath}/${gitDir}`; + } + } catch { + // Not a git repo or path doesn't exist — skip + return; + } + + if (this.closed || this.watched.has(workspaceId)) return; + + try { + const watcher = watch(gitDir, { recursive: true }, () => { + this.emit(workspaceId); + }); + + watcher.on("error", () => { + // Watcher died — remove it so rescan can re-add + this.watched.delete(workspaceId); + watcher.close(); + }); + + this.watched.set(workspaceId, { + workspaceId, + worktreePath, + gitDir, + watcher, + }); + } catch { + // fs.watch failed (e.g. directory doesn't exist) + } + } +} diff --git a/packages/host-service/src/events/index.ts b/packages/host-service/src/events/index.ts new file mode 100644 index 00000000000..e64d91df2bd --- /dev/null +++ b/packages/host-service/src/events/index.ts @@ -0,0 +1,10 @@ +export { EventBus, registerEventBusRoute } from "./event-bus"; +export type { + ClientMessage, + EventBusErrorMessage, + FsEventsMessage, + FsUnwatchCommand, + FsWatchCommand, + GitChangedMessage, + ServerMessage, +} from "./types"; diff --git a/packages/host-service/src/events/types.ts b/packages/host-service/src/events/types.ts new file mode 100644 index 00000000000..7915f296975 --- /dev/null +++ b/packages/host-service/src/events/types.ts @@ -0,0 +1,38 @@ +import type { FsWatchEvent } from "@superset/workspace-fs/host"; + +// ── Server → Client ──────────────────────────────────────────────── + +export interface FsEventsMessage { + type: "fs:events"; + workspaceId: string; + events: FsWatchEvent[]; +} + +export interface GitChangedMessage { + type: "git:changed"; + workspaceId: string; +} + +export interface EventBusErrorMessage { + type: "error"; + message: string; +} + +export type ServerMessage = + | FsEventsMessage + | GitChangedMessage + | EventBusErrorMessage; + +// ── Client → Server ──────────────────────────────────────────────── + +export interface FsWatchCommand { + type: "fs:watch"; + workspaceId: string; +} + +export interface FsUnwatchCommand { + type: "fs:unwatch"; + workspaceId: string; +} + +export type ClientMessage = FsWatchCommand | FsUnwatchCommand; diff --git a/packages/host-service/src/filesystem/events.ts b/packages/host-service/src/filesystem/events.ts deleted file mode 100644 index 2c54e90e397..00000000000 --- a/packages/host-service/src/filesystem/events.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { NodeWebSocket } from "@hono/node-ws"; -import type { FsWatchEvent } from "@superset/workspace-fs/host"; -import type { Hono } from "hono"; -import type { WorkspaceFilesystemManager } from "../runtime/filesystem"; - -export interface WorkspaceFilesystemEventsMessage { - type: "events"; - events: FsWatchEvent[]; -} - -export interface WorkspaceFilesystemErrorMessage { - type: "error"; - message: string; -} - -export type WorkspaceFilesystemServerMessage = - | WorkspaceFilesystemEventsMessage - | WorkspaceFilesystemErrorMessage; - -export function buildWorkspaceFilesystemEventsPath( - workspaceId: string, -): string { - return `/workspace-filesystem/${encodeURIComponent(workspaceId)}/events`; -} - -interface RegisterWorkspaceFilesystemEventsRouteOptions { - app: Hono; - filesystem: WorkspaceFilesystemManager; - upgradeWebSocket: NodeWebSocket["upgradeWebSocket"]; -} - -function sendMessage( - socket: { - send: (data: string) => void; - readyState: number; - close: (code?: number, reason?: string) => void; - }, - message: WorkspaceFilesystemServerMessage, -): void { - if (socket.readyState !== 1) { - return; - } - - socket.send(JSON.stringify(message)); -} - -export function registerWorkspaceFilesystemEventsRoute({ - app, - filesystem, - upgradeWebSocket, -}: RegisterWorkspaceFilesystemEventsRouteOptions) { - app.get( - "/workspace-filesystem/:workspaceId/events", - upgradeWebSocket((c) => { - const workspaceId = c.req.param("workspaceId"); - let disposed = false; - let iterator: AsyncIterator<{ events: FsWatchEvent[] }> | null = null; - - const disposeIterator = () => { - if (disposed) { - return; - } - - disposed = true; - const currentIterator = iterator; - iterator = null; - void currentIterator?.return?.().catch((error: unknown) => { - console.error( - "[host-service/workspace-filesystem-events] Cleanup failed:", - { - workspaceId, - error, - }, - ); - }); - }; - - return { - onOpen: (_event, ws) => { - if (!workspaceId) { - sendMessage(ws, { - type: "error", - message: "Workspace not found", - }); - ws.close(1008, "Workspace not found"); - return; - } - - let rootPath: string; - try { - rootPath = filesystem.resolveWorkspaceRoot(workspaceId); - } catch (error) { - sendMessage(ws, { - type: "error", - message: - error instanceof Error ? error.message : "Workspace not found", - }); - ws.close(1011, "Workspace not found"); - return; - } - - try { - const service = filesystem.getServiceForWorkspace(workspaceId); - iterator = service - .watchPath({ - absolutePath: rootPath, - recursive: true, - }) - [Symbol.asyncIterator](); - } catch (error) { - sendMessage(ws, { - type: "error", - message: - error instanceof Error - ? error.message - : "Failed to start filesystem watcher", - }); - ws.close(1011, "Failed to start filesystem watcher"); - return; - } - - void (async () => { - try { - while (!disposed && iterator) { - const next = await iterator.next(); - if (disposed || next.done) { - return; - } - - sendMessage(ws, { - type: "events", - events: next.value.events, - }); - } - } catch (error) { - console.error( - "[host-service/workspace-filesystem-events] Stream failed:", - { - workspaceId, - error, - }, - ); - - sendMessage(ws, { - type: "error", - message: - error instanceof Error - ? error.message - : "Filesystem event stream failed", - }); - ws.close(1011, "Filesystem event stream failed"); - } - })(); - }, - onClose: () => { - disposeIterator(); - }, - onError: () => { - disposeIterator(); - }, - }; - }), - ); -} diff --git a/packages/host-service/src/filesystem/index.ts b/packages/host-service/src/filesystem/index.ts deleted file mode 100644 index f9ed02cc0d5..00000000000 --- a/packages/host-service/src/filesystem/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - buildWorkspaceFilesystemEventsPath, - registerWorkspaceFilesystemEventsRoute, - type WorkspaceFilesystemServerMessage, -} from "./events"; diff --git a/packages/host-service/src/index.ts b/packages/host-service/src/index.ts index 8414141622b..e90980eb042 100644 --- a/packages/host-service/src/index.ts +++ b/packages/host-service/src/index.ts @@ -1,10 +1,10 @@ export { createApiClient } from "./api"; export { type CreateAppOptions, createApp } from "./app"; export type { HostDb } from "./db"; -export { - buildWorkspaceFilesystemEventsPath, - type WorkspaceFilesystemServerMessage, -} from "./filesystem"; +export type { + ClientMessage as EventBusClientMessage, + ServerMessage as EventBusServerMessage, +} from "./events"; export type { ApiAuthProvider } from "./providers/auth"; export { DeviceKeyApiAuthProvider, JwtApiAuthProvider } from "./providers/auth"; export { diff --git a/packages/workspace-client/src/hooks/useEventBus/index.ts b/packages/workspace-client/src/hooks/useEventBus/index.ts new file mode 100644 index 00000000000..c928ae72ecf --- /dev/null +++ b/packages/workspace-client/src/hooks/useEventBus/index.ts @@ -0,0 +1 @@ +export { useEventBus } from "./useEventBus"; diff --git a/packages/workspace-client/src/hooks/useEventBus/useEventBus.ts b/packages/workspace-client/src/hooks/useEventBus/useEventBus.ts new file mode 100644 index 00000000000..360927bf20b --- /dev/null +++ b/packages/workspace-client/src/hooks/useEventBus/useEventBus.ts @@ -0,0 +1,12 @@ +import { useMemo } from "react"; +import { type EventBusHandle, getEventBus } from "../../lib/eventBus"; +import { useWorkspaceClient } from "../../providers/WorkspaceClientProvider"; + +/** + * Returns an EventBusHandle for the current host. + * One WS connection is shared across all components using the same host. + */ +export function useEventBus(): EventBusHandle { + const { hostUrl, getWsToken } = useWorkspaceClient(); + return useMemo(() => getEventBus(hostUrl, getWsToken), [hostUrl, getWsToken]); +} diff --git a/packages/workspace-client/src/hooks/useGitChangeEvents/index.ts b/packages/workspace-client/src/hooks/useGitChangeEvents/index.ts new file mode 100644 index 00000000000..88ae03ff035 --- /dev/null +++ b/packages/workspace-client/src/hooks/useGitChangeEvents/index.ts @@ -0,0 +1 @@ +export { useGitChangeEvents } from "./useGitChangeEvents"; diff --git a/packages/workspace-client/src/hooks/useGitChangeEvents/useGitChangeEvents.ts b/packages/workspace-client/src/hooks/useGitChangeEvents/useGitChangeEvents.ts new file mode 100644 index 00000000000..015a894d40c --- /dev/null +++ b/packages/workspace-client/src/hooks/useGitChangeEvents/useGitChangeEvents.ts @@ -0,0 +1,25 @@ +import { useEffect, useEffectEvent } from "react"; +import { getEventBus } from "../../lib/eventBus"; +import { useWorkspaceClient } from "../../providers/WorkspaceClientProvider"; + +/** + * Subscribe to `git:changed` events for a specific workspace (or all workspaces with "*"). + * Calls `onChanged` with the workspace ID whenever git state changes. + */ +export function useGitChangeEvents( + workspaceId: string | "*", + onChanged: (workspaceId: string) => void, + enabled = true, +): void { + const { hostUrl, getWsToken } = useWorkspaceClient(); + const handler = useEffectEvent(onChanged); + + useEffect(() => { + if (!enabled) return; + + const bus = getEventBus(hostUrl, getWsToken); + return bus.on("git:changed", workspaceId, (id) => { + handler(id); + }); + }, [hostUrl, getWsToken, workspaceId, enabled]); +} diff --git a/packages/workspace-client/src/index.ts b/packages/workspace-client/src/index.ts index dc68deaf589..a7fa0fdc89a 100644 --- a/packages/workspace-client/src/index.ts +++ b/packages/workspace-client/src/index.ts @@ -1,3 +1,4 @@ +export { useEventBus } from "./hooks/useEventBus"; export { type UseFileDocumentParams, type UseFileDocumentResult, @@ -9,14 +10,15 @@ export { type UseFileTreeResult, useFileTree, } from "./hooks/useFileTree"; +export { useGitChangeEvents } from "./hooks/useGitChangeEvents"; export { useWorkspaceFsEventBridge } from "./hooks/useWorkspaceFsEventBridge"; export { useWorkspaceFsEvents } from "./hooks/useWorkspaceFsEvents"; +export { type EventBusHandle, getEventBus } from "./lib/eventBus"; export { useWorkspaceClient, useWorkspaceHostUrl, useWorkspaceWsUrl, type WorkspaceClientContextValue, WorkspaceClientProvider, - type WorkspaceFsSubscriptionInput, } from "./providers/WorkspaceClientProvider"; export { workspaceTrpc } from "./workspace-trpc"; diff --git a/packages/workspace-client/src/lib/eventBus.ts b/packages/workspace-client/src/lib/eventBus.ts new file mode 100644 index 00000000000..ac4470524c7 --- /dev/null +++ b/packages/workspace-client/src/lib/eventBus.ts @@ -0,0 +1,263 @@ +import type { + ClientMessage, + ServerMessage, +} from "@superset/host-service/events"; +import type { FsWatchEvent } from "@superset/workspace-fs/host"; + +type EventType = "fs:events" | "git:changed"; + +interface FsEventsPayload { + events: FsWatchEvent[]; +} + +type EventListener = T extends "fs:events" + ? (workspaceId: string, payload: FsEventsPayload) => void + : T extends "git:changed" + ? (workspaceId: string) => void + : never; + +interface ListenerEntry { + type: EventType; + workspaceId: string | "*"; + callback: (...args: unknown[]) => void; +} + +const RECONNECT_BASE_MS = 1_000; +const RECONNECT_MAX_MS = 30_000; + +interface ConnectionState { + socket: WebSocket | null; + refCount: number; + listeners: Set; + fsWatchedWorkspaces: Set; + reconnectAttempts: number; + reconnectTimer: ReturnType | null; + disposed: boolean; +} + +const connections = new Map(); + +function getConnectionKey(hostUrl: string): string { + return hostUrl; +} + +function buildEventBusUrl(hostUrl: string, wsToken: string | null): string { + const url = new URL("/events", hostUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + if (wsToken) { + url.searchParams.set("token", wsToken); + } + return url.toString(); +} + +function sendCommand(state: ConnectionState, message: ClientMessage): void { + if (state.socket?.readyState === WebSocket.OPEN) { + state.socket.send(JSON.stringify(message)); + } +} + +function handleMessage(state: ConnectionState, data: unknown): void { + let message: ServerMessage; + try { + message = JSON.parse(String(data)) as ServerMessage; + } catch { + return; + } + + if (message.type === "error") { + console.error("[event-bus-client]", message.message); + return; + } + + for (const entry of state.listeners) { + if (entry.type !== message.type) continue; + + const workspaceId = + message.type === "fs:events" || message.type === "git:changed" + ? message.workspaceId + : null; + + if ( + workspaceId && + entry.workspaceId !== "*" && + entry.workspaceId !== workspaceId + ) { + continue; + } + + if (message.type === "fs:events") { + (entry.callback as EventListener<"fs:events">)(message.workspaceId, { + events: message.events, + }); + } else if (message.type === "git:changed") { + (entry.callback as EventListener<"git:changed">)(message.workspaceId); + } + } +} + +function connect( + state: ConnectionState, + hostUrl: string, + getWsToken: () => string | null, +): void { + if (state.disposed) return; + + const wsUrl = buildEventBusUrl(hostUrl, getWsToken()); + const socket = new WebSocket(wsUrl); + state.socket = socket; + + socket.onopen = () => { + state.reconnectAttempts = 0; + + // Re-send all active fs:watch commands + for (const workspaceId of state.fsWatchedWorkspaces) { + sendCommand(state, { type: "fs:watch", workspaceId }); + } + }; + + socket.onmessage = (event) => { + handleMessage(state, event.data); + }; + + socket.onclose = () => { + if (state.disposed) return; + state.socket = null; + scheduleReconnect(state, hostUrl, getWsToken); + }; + + socket.onerror = () => { + // onclose will fire after onerror + }; +} + +function scheduleReconnect( + state: ConnectionState, + hostUrl: string, + getWsToken: () => string | null, +): void { + if (state.disposed || state.reconnectTimer) return; + + const delay = Math.min( + RECONNECT_BASE_MS * 2 ** state.reconnectAttempts, + RECONNECT_MAX_MS, + ); + state.reconnectAttempts++; + + state.reconnectTimer = setTimeout(() => { + state.reconnectTimer = null; + if (!state.disposed) { + connect(state, hostUrl, getWsToken); + } + }, delay); +} + +function getOrCreateConnection( + hostUrl: string, + getWsToken: () => string | null, +): ConnectionState { + const key = getConnectionKey(hostUrl); + const existing = connections.get(key); + if (existing) return existing; + + const state: ConnectionState = { + socket: null, + refCount: 0, + listeners: new Set(), + fsWatchedWorkspaces: new Set(), + reconnectAttempts: 0, + reconnectTimer: null, + disposed: false, + }; + connections.set(key, state); + connect(state, hostUrl, getWsToken); + return state; +} + +function maybeCleanupConnection(hostUrl: string): void { + const key = getConnectionKey(hostUrl); + const state = connections.get(key); + if (!state) return; + + if (state.refCount > 0 || state.listeners.size > 0) return; + + state.disposed = true; + if (state.reconnectTimer) { + clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + } + if ( + state.socket?.readyState === WebSocket.CONNECTING || + state.socket?.readyState === WebSocket.OPEN + ) { + state.socket.close(1000, "No more subscribers"); + } + connections.delete(key); +} + +// ── Public API ───────────────────────────────────────────────────── + +export interface EventBusHandle { + on( + type: T, + workspaceId: string | "*", + listener: EventListener, + ): () => void; + watchFs(workspaceId: string): void; + unwatchFs(workspaceId: string): void; + retain(): () => void; +} + +/** + * Get a handle to the event bus for a given host. + * One WS connection is shared across all handles for the same hostUrl. + */ +export function getEventBus( + hostUrl: string, + getWsToken: () => string | null, +): EventBusHandle { + const state = getOrCreateConnection(hostUrl, getWsToken); + + return { + on( + type: T, + workspaceId: string | "*", + listener: EventListener, + ): () => void { + const entry: ListenerEntry = { + type, + workspaceId, + callback: listener as (...args: unknown[]) => void, + }; + state.listeners.add(entry); + + return () => { + state.listeners.delete(entry); + maybeCleanupConnection(hostUrl); + }; + }, + + watchFs(workspaceId: string): void { + if (state.fsWatchedWorkspaces.has(workspaceId)) return; + state.fsWatchedWorkspaces.add(workspaceId); + sendCommand(state, { type: "fs:watch", workspaceId }); + }, + + unwatchFs(workspaceId: string): void { + if (!state.fsWatchedWorkspaces.has(workspaceId)) return; + state.fsWatchedWorkspaces.delete(workspaceId); + sendCommand(state, { type: "fs:unwatch", workspaceId }); + }, + + /** + * Increment ref count to keep the connection alive even without listeners. + * Returns a release function. + */ + retain(): () => void { + state.refCount++; + return () => { + state.refCount = Math.max(0, state.refCount - 1); + maybeCleanupConnection(hostUrl); + }; + }, + }; +} diff --git a/packages/workspace-client/src/lib/workspaceFsEventRegistry.ts b/packages/workspace-client/src/lib/workspaceFsEventRegistry.ts index 2ba2a620d71..8a78469247a 100644 --- a/packages/workspace-client/src/lib/workspaceFsEventRegistry.ts +++ b/packages/workspace-client/src/lib/workspaceFsEventRegistry.ts @@ -1,33 +1,30 @@ import type { FsWatchEvent } from "@superset/workspace-fs/host"; -import type { - WorkspaceClientContextValue, - WorkspaceFsSubscriptionInput, -} from "../providers/WorkspaceClientProvider"; +import { getEventBus } from "./eventBus"; export type WorkspaceFsEventListener = (event: FsWatchEvent) => void; interface WorkspaceFsSubscriptionState { bridgeCount: number; - client: WorkspaceClientContextValue; + hostUrl: string; + getWsToken: () => string | null; listeners: Set; - unsubscribeTransport: (() => void) | null; workspaceId: string; + removeBusListener: (() => void) | null; + watching: boolean; } const subscriptions = new Map(); -function getSubscriptionKey( - client: WorkspaceClientContextValue, - workspaceId: string, -): string { - return `${client.hostUrl}:${workspaceId}`; +function getSubscriptionKey(hostUrl: string, workspaceId: string): string { + return `${hostUrl}:${workspaceId}`; } function getOrCreateSubscription( - client: WorkspaceClientContextValue, + hostUrl: string, + getWsToken: () => string | null, workspaceId: string, ): WorkspaceFsSubscriptionState { - const key = getSubscriptionKey(client, workspaceId); + const key = getSubscriptionKey(hostUrl, workspaceId); const existing = subscriptions.get(key); if (existing) { return existing; @@ -35,10 +32,12 @@ function getOrCreateSubscription( const nextState: WorkspaceFsSubscriptionState = { bridgeCount: 0, - client, + hostUrl, + getWsToken, listeners: new Set(), - unsubscribeTransport: null, workspaceId, + removeBusListener: null, + watching: false, }; subscriptions.set(key, nextState); return nextState; @@ -51,13 +50,22 @@ function removeSubscriptionIfInactive( return; } - state.unsubscribeTransport?.(); - state.unsubscribeTransport = null; - subscriptions.delete(getSubscriptionKey(state.client, state.workspaceId)); + // Stop watching fs for this workspace + if (state.watching) { + const bus = getEventBus(state.hostUrl, state.getWsToken); + bus.unwatchFs(state.workspaceId); + state.watching = false; + } + + // Remove bus listener + state.removeBusListener?.(); + state.removeBusListener = null; + + subscriptions.delete(getSubscriptionKey(state.hostUrl, state.workspaceId)); } function ensureTransport(state: WorkspaceFsSubscriptionState): void { - if (state.unsubscribeTransport) { + if (state.removeBusListener) { return; } @@ -65,30 +73,40 @@ function ensureTransport(state: WorkspaceFsSubscriptionState): void { return; } - const input: WorkspaceFsSubscriptionInput = { - workspaceId: state.workspaceId, - onEvent: (event) => { - for (const listener of state.listeners) { - listener(event); + const bus = getEventBus(state.hostUrl, state.getWsToken); + + // Listen for fs events on the event bus for this workspace + state.removeBusListener = bus.on( + "fs:events", + state.workspaceId, + (_workspaceId, payload) => { + for (const event of payload.events) { + for (const listener of state.listeners) { + listener(event); + } } }, - onError: (error) => { - console.error("[workspace-client/fs-events] Stream failed:", { - hostUrl: state.client.hostUrl, - workspaceId: state.workspaceId, - error, - }); - }, - }; + ); + + // Tell the server to start watching this workspace's filesystem + bus.watchFs(state.workspaceId); + state.watching = true; +} - state.unsubscribeTransport = state.client.subscribeToWorkspaceFsEvents(input); +export interface FsEventRegistryClient { + hostUrl: string; + getWsToken: () => string | null; } export function retainWorkspaceFsBridge( - client: WorkspaceClientContextValue, + client: FsEventRegistryClient, workspaceId: string, ): () => void { - const state = getOrCreateSubscription(client, workspaceId); + const state = getOrCreateSubscription( + client.hostUrl, + client.getWsToken, + workspaceId, + ); state.bridgeCount += 1; ensureTransport(state); @@ -99,11 +117,15 @@ export function retainWorkspaceFsBridge( } export function subscribeToWorkspaceFsEvents( - client: WorkspaceClientContextValue, + client: FsEventRegistryClient, workspaceId: string, listener: WorkspaceFsEventListener, ): () => void { - const state = getOrCreateSubscription(client, workspaceId); + const state = getOrCreateSubscription( + client.hostUrl, + client.getWsToken, + workspaceId, + ); state.listeners.add(listener); ensureTransport(state); diff --git a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx index 2bf551423fa..f87c1467c44 100644 --- a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx +++ b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx @@ -1,6 +1,3 @@ -import type { WorkspaceFilesystemServerMessage } from "@superset/host-service/filesystem"; -import { buildWorkspaceFilesystemEventsPath } from "@superset/host-service/filesystem"; -import type { FsWatchEvent } from "@superset/workspace-fs/host"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import { createContext, type ReactNode, useContext } from "react"; @@ -10,18 +7,9 @@ import { workspaceTrpc } from "../../workspace-trpc"; const STALE_TIME_MS = 5_000; const GC_TIME_MS = 30 * 60 * 1_000; -export interface WorkspaceFsSubscriptionInput { - workspaceId: string; - onEvent: (event: FsWatchEvent) => void; - onError?: (error: unknown) => void; -} - export interface WorkspaceClientContextValue { hostUrl: string; queryClient: QueryClient; - subscribeToWorkspaceFsEvents: ( - input: WorkspaceFsSubscriptionInput, - ) => () => void; getWsToken: () => string | null; } @@ -37,9 +25,6 @@ interface WorkspaceClients { hostUrl: string; queryClient: QueryClient; trpcClient: ReturnType; - subscribeToWorkspaceFsEvents: ( - input: WorkspaceFsSubscriptionInput, - ) => () => void; getWsToken: () => string | null; } @@ -47,94 +32,6 @@ const workspaceClientsCache = new Map(); const WorkspaceClientContext = createContext(null); -function toWorkspaceFilesystemEventsUrl( - hostUrl: string, - workspaceId: string, - getWsToken?: () => string | null, -): string { - const url = new URL(buildWorkspaceFilesystemEventsPath(workspaceId), hostUrl); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - const token = getWsToken?.(); - if (token) { - url.searchParams.set("token", token); - } - return url.toString(); -} - -function toSubscriptionError(message: string, event?: CloseEvent): Error { - return new Error(event ? `${message} (code ${event.code})` : message); -} - -function createWorkspaceFsSubscription( - hostUrl: string, - input: WorkspaceFsSubscriptionInput, - getWsToken?: () => string | null, -): () => void { - const socket = new WebSocket( - toWorkspaceFilesystemEventsUrl(hostUrl, input.workspaceId, getWsToken), - ); - let disposed = false; - let opened = false; - - socket.onopen = () => { - opened = true; - }; - - socket.onmessage = (messageEvent) => { - let message: WorkspaceFilesystemServerMessage; - try { - message = JSON.parse( - String(messageEvent.data), - ) as WorkspaceFilesystemServerMessage; - } catch (error) { - input.onError?.(error); - return; - } - - if (message.type === "error") { - input.onError?.(new Error(message.message)); - return; - } - - for (const event of message.events) { - input.onEvent(event); - } - }; - - socket.onerror = () => { - input.onError?.( - toSubscriptionError( - "Workspace filesystem event stream encountered an error", - ), - ); - }; - - socket.onclose = (event) => { - if (disposed) { - return; - } - - if (!opened || !event.wasClean) { - input.onError?.( - toSubscriptionError( - "Workspace filesystem event stream closed unexpectedly", - event, - ), - ); - } - }; - - return () => { - disposed = true; - if ( - socket.readyState === WebSocket.CONNECTING || - socket.readyState === WebSocket.OPEN - ) { - socket.close(1000, "Client unsubscribed"); - } - }; -} - function getWorkspaceClients( cacheKey: string, hostUrl: string, @@ -174,9 +71,6 @@ function getWorkspaceClients( queryClient, trpcClient, getWsToken, - subscribeToWorkspaceFsEvents(input) { - return createWorkspaceFsSubscription(hostUrl, input, getWsToken); - }, }; workspaceClientsCache.set(clientKey, clients); return clients; @@ -193,7 +87,6 @@ export function WorkspaceClientProvider({ const contextValue: WorkspaceClientContextValue = { hostUrl: clients.hostUrl, queryClient: clients.queryClient, - subscribeToWorkspaceFsEvents: clients.subscribeToWorkspaceFsEvents, getWsToken: clients.getWsToken, }; diff --git a/packages/workspace-client/src/providers/WorkspaceClientProvider/index.ts b/packages/workspace-client/src/providers/WorkspaceClientProvider/index.ts index 71e8bd48df8..acb561c2dc3 100644 --- a/packages/workspace-client/src/providers/WorkspaceClientProvider/index.ts +++ b/packages/workspace-client/src/providers/WorkspaceClientProvider/index.ts @@ -4,5 +4,4 @@ export { useWorkspaceWsUrl, type WorkspaceClientContextValue, WorkspaceClientProvider, - type WorkspaceFsSubscriptionInput, } from "./WorkspaceClientProvider"; From 7afe516c17c84b77a82f6b2af429cda7229d0c6d Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 20:36:36 -0700 Subject: [PATCH 02/11] feat: unified WS event bus, v2Hosts data model, real diff stats - Add unified /events WebSocket endpoint on host-service replacing per-workspace filesystem WS connections. Carries git:changed (auto) and fs:events (on-demand) over a single connection per host. - Add v2_hosts, v2_clients, v2_users_hosts tables replacing v2_devices, v2_device_presence, v2_users_devices. Workspaces now reference hostId instead of deviceId. Hosts identified by machineId (null = cloud). - Wire real git diff stats into left sidebar chips using event bus to trigger refetches instead of polling. - Update tRPC routers (ensureV2Host, ensureV2Client, workspace create), Electric SQL sync, and all renderer references. --- .../src/app/api/electric/[...path]/utils.ts | 32 +- .../hooks/useDashboardDiffStats/index.ts | 6 +- .../useDashboardDiffStats.ts | 143 +- .../useDashboardSidebarData.ts | 41 +- .../components/DashboardSidebar/types.ts | 2 +- .../V2WorkspaceOpenInButton.tsx | 14 +- .../_dashboard/v2-workspace/layout.tsx | 14 +- .../components/DevicePicker/DevicePicker.tsx | 47 +- .../hooks/useWorkspaceHostOptions/index.ts | 2 +- .../useWorkspaceHostOptions.ts | 48 +- .../CollectionsProvider/collections.ts | 47 +- apps/electric-proxy/src/where.ts | 26 +- .../db/drizzle/0031_v2_hosts_and_clients.sql | 63 + packages/db/drizzle/meta/0031_snapshot.json | 5257 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/enums.ts | 8 + packages/db/src/schema/relations.ts | 70 +- packages/db/src/schema/schema.ts | 92 +- .../src/trpc/router/workspace/workspace.ts | 6 +- packages/trpc/src/router/device/device.ts | 84 +- .../src/router/v2-workspace/v2-workspace.ts | 24 +- 21 files changed, 5715 insertions(+), 318 deletions(-) create mode 100644 packages/db/drizzle/0031_v2_hosts_and_clients.sql create mode 100644 packages/db/drizzle/meta/0031_snapshot.json diff --git a/apps/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts index 81d5dabcb7f..7206700204a 100644 --- a/apps/api/src/app/api/electric/[...path]/utils.ts +++ b/apps/api/src/app/api/electric/[...path]/utils.ts @@ -14,10 +14,10 @@ import { subscriptions, taskStatuses, tasks, - v2DevicePresence, - v2Devices, + v2Clients, + v2Hosts, v2Projects, - v2UsersDevices, + v2UsersHosts, v2Workspaces, workspaces, } from "@superset/db/schema"; @@ -29,10 +29,10 @@ export type AllowedTable = | "tasks" | "task_statuses" | "projects" - | "v2_devices" - | "v2_device_presence" + | "v2_hosts" + | "v2_clients" | "v2_projects" - | "v2_users_devices" + | "v2_users_hosts" | "v2_workspaces" | "auth.members" | "auth.organizations" @@ -84,22 +84,14 @@ export async function buildWhereClause( case "v2_projects": return build(v2Projects, v2Projects.organizationId, organizationId); - case "v2_devices": - return build(v2Devices, v2Devices.organizationId, organizationId); + case "v2_hosts": + return build(v2Hosts, v2Hosts.organizationId, organizationId); - case "v2_device_presence": - return build( - v2DevicePresence, - v2DevicePresence.organizationId, - organizationId, - ); + case "v2_clients": + return build(v2Clients, v2Clients.organizationId, organizationId); - case "v2_users_devices": - return build( - v2UsersDevices, - v2UsersDevices.organizationId, - organizationId, - ); + case "v2_users_hosts": + return build(v2UsersHosts, v2UsersHosts.organizationId, organizationId); case "v2_workspaces": return build(v2Workspaces, v2Workspaces.organizationId, organizationId); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts index c62c4b6d33f..72d09563223 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts @@ -1 +1,5 @@ -export { type DiffStats, useDashboardDiffStats } from "./useDashboardDiffStats"; +export { + type DiffStats, + useDashboardDiffStats, + type WorkspaceHostInfo, +} from "./useDashboardDiffStats"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts index 97e969133db..27bf3f21bc9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts @@ -1,91 +1,118 @@ import { getEventBus } from "@superset/workspace-client"; import { useCallback, useEffect, useRef, useState } from "react"; import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; -import type { HostServiceClient } from "renderer/lib/host-service-client"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; export interface DiffStats { additions: number; deletions: number; } +export interface WorkspaceHostInfo { + workspaceId: string; + hostUrl: string; +} + export function useDashboardDiffStats( - localWorkspaceIds: string[], - hostUrl: string | null, - client: HostServiceClient | null, + workspaceHosts: WorkspaceHostInfo[], ): Map { const [statsMap, setStatsMap] = useState>( () => new Map(), ); - const clientRef = useRef(client); - clientRef.current = client; - - const fetchDiffStats = useCallback(async (workspaceId: string) => { - const currentClient = clientRef.current; - if (!currentClient) return; - - try { - const status = await currentClient.git.getStatus.query({ - workspaceId, - }); - - let additions = 0; - let deletions = 0; - for (const file of status.againstBase) { - additions += file.additions; - deletions += file.deletions; - } - for (const file of status.staged) { - additions += file.additions; - deletions += file.deletions; - } - for (const file of status.unstaged) { - additions += file.additions; - deletions += file.deletions; + + const fetchDiffStats = useCallback( + async (workspaceId: string, hostUrl: string) => { + try { + const client = getHostServiceClientByUrl(hostUrl); + const status = await client.git.getStatus.query({ workspaceId }); + + let additions = 0; + let deletions = 0; + for (const file of status.againstBase) { + additions += file.additions; + deletions += file.deletions; + } + for (const file of status.staged) { + additions += file.additions; + deletions += file.deletions; + } + for (const file of status.unstaged) { + additions += file.additions; + deletions += file.deletions; + } + + setStatsMap((prev) => { + const next = new Map(prev); + next.set(workspaceId, { additions, deletions }); + return next; + }); + } catch { + // Workspace might have been deleted or host-service unavailable } + }, + [], + ); - setStatsMap((prev) => { - const next = new Map(prev); - next.set(workspaceId, { additions, deletions }); - return next; - }); - } catch { - // Workspace might have been deleted or host-service unavailable - } - }, []); + // Stable serialization key for the workspace-host list + const _workspaceHostsKey = workspaceHosts + .map((wh) => `${wh.workspaceId}:${wh.hostUrl}`) + .join(","); + + // Keep a ref so event handlers can access current mapping + const workspaceHostsRef = useRef(workspaceHosts); + workspaceHostsRef.current = workspaceHosts; // Fetch initial data for all workspaces useEffect(() => { - if (!hostUrl || !client || localWorkspaceIds.length === 0) return; - - for (const id of localWorkspaceIds) { - void fetchDiffStats(id); + for (const { workspaceId, hostUrl } of workspaceHosts) { + void fetchDiffStats(workspaceId, hostUrl); } - }, [client, fetchDiffStats, hostUrl, localWorkspaceIds]); + }, [fetchDiffStats, workspaceHosts]); - // Subscribe to git:changed events and refetch on change + // Subscribe to git:changed events per unique host useEffect(() => { - if (!hostUrl || localWorkspaceIds.length === 0) return; + if (workspaceHosts.length === 0) return; - const bus = getEventBus(hostUrl, () => getHostServiceWsToken(hostUrl)); - const workspaceIdSet = new Set(localWorkspaceIds); - - const removeListener = bus.on("git:changed", "*", (workspaceId) => { - if (workspaceIdSet.has(workspaceId)) { - void fetchDiffStats(workspaceId); + // Group workspaces by hostUrl + const byHost = new Map>(); + for (const { workspaceId, hostUrl } of workspaceHosts) { + let set = byHost.get(hostUrl); + if (!set) { + set = new Set(); + byHost.set(hostUrl, set); } - }); + set.add(workspaceId); + } - const release = bus.retain(); + const cleanups: Array<() => void> = []; + + for (const [hostUrl, workspaceIds] of byHost) { + const bus = getEventBus(hostUrl, () => getHostServiceWsToken(hostUrl)); + + const removeListener = bus.on( + "git:changed", + "*", + (changedWorkspaceId) => { + if (workspaceIds.has(changedWorkspaceId)) { + void fetchDiffStats(changedWorkspaceId, hostUrl); + } + }, + ); + + const release = bus.retain(); + cleanups.push(removeListener, release); + } return () => { - removeListener(); - release(); + for (const cleanup of cleanups) { + cleanup(); + } }; - }, [fetchDiffStats, hostUrl, localWorkspaceIds]); + }, [fetchDiffStats, workspaceHosts]); // Clean up stale entries when workspace list changes useEffect(() => { - const idSet = new Set(localWorkspaceIds); + const idSet = new Set(workspaceHosts.map((wh) => wh.workspaceId)); setStatsMap((prev) => { let changed = false; const next = new Map(prev); @@ -97,7 +124,7 @@ export function useDashboardDiffStats( } return changed ? next : prev; }); - }, [localWorkspaceIds]); + }, [workspaceHosts.map]); return statsMap; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index 8f20f606d91..a7e3c74048b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -16,7 +16,10 @@ import type { DashboardSidebarSection, DashboardSidebarWorkspace, } from "../../types"; -import { useDashboardDiffStats } from "../useDashboardDiffStats"; +import { + useDashboardDiffStats, + type WorkspaceHostInfo, +} from "../useDashboardDiffStats"; // Pending workspaces are always rendered at the end of the project's workspace list const PENDING_WORKSPACE_TAB_ORDER = Number.MAX_SAFE_INTEGER; @@ -100,20 +103,18 @@ export function useDashboardSidebarData() { ({ sidebarWorkspaces, workspaces }) => eq(sidebarWorkspaces.workspaceId, workspaces.id), ) - .leftJoin( - { devices: collections.v2Devices }, - ({ workspaces, devices }) => eq(workspaces.deviceId, devices.id), + .leftJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => + eq(workspaces.hostId, hosts.id), ) .orderBy( ({ sidebarWorkspaces }) => sidebarWorkspaces.sidebarState.tabOrder, "asc", ) - .select(({ sidebarWorkspaces, workspaces, devices }) => ({ + .select(({ sidebarWorkspaces, workspaces, hosts }) => ({ id: workspaces.id, projectId: sidebarWorkspaces.sidebarState.projectId, - deviceId: workspaces.deviceId, - deviceType: devices?.type ?? null, - deviceClientId: devices?.clientId ?? null, + hostId: workspaces.hostId, + hostMachineId: hosts?.machineId ?? null, name: workspaces.name, branch: workspaces.branch, createdAt: workspaces.createdAt, @@ -129,19 +130,21 @@ export function useDashboardSidebarData() { sidebarWorkspaces .filter( (workspace) => - workspace.deviceType !== "cloud" && - workspace.deviceClientId === deviceInfo?.deviceId, + workspace.hostMachineId != null && + workspace.hostMachineId === deviceInfo?.deviceId, ) .map((workspace) => workspace.id) .sort(), [deviceInfo?.deviceId, sidebarWorkspaces], ); - const diffStatsByWorkspaceId = useDashboardDiffStats( - localWorkspaceIds, - activeHostService?.url ?? null, - activeHostService?.client ?? null, - ); + const workspaceHosts = useMemo(() => { + const hostUrl = activeHostService?.url; + if (!hostUrl) return []; + return localWorkspaceIds.map((id) => ({ workspaceId: id, hostUrl })); + }, [activeHostService?.url, localWorkspaceIds]); + + const diffStatsByWorkspaceId = useDashboardDiffStats(workspaceHosts); const { data: pullRequestData, refetch: refetchPullRequests } = useQuery({ queryKey: [ @@ -228,16 +231,16 @@ export function useDashboardSidebarData() { if (!project) continue; const hostType: DashboardSidebarWorkspace["hostType"] = - workspace.deviceType === "cloud" + workspace.hostMachineId == null ? "cloud" - : workspace.deviceClientId === deviceInfo?.deviceId + : workspace.hostMachineId === deviceInfo?.deviceId ? "local-device" : "remote-device"; const sidebarWorkspace: DashboardSidebarWorkspace = { id: workspace.id, projectId: workspace.projectId, - deviceId: workspace.deviceId, + hostId: workspace.hostId, hostType, accentColor: null, name: workspace.name, @@ -295,7 +298,7 @@ export function useDashboardSidebarData() { const pendingItem: DashboardSidebarWorkspace = { id: pendingWorkspace.id, projectId: pendingWorkspace.projectId, - deviceId: deviceInfo.deviceId, + hostId: "", hostType: "local-device", accentColor: null, name: pendingWorkspace.name, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts index 6b9ed3c3e65..57a07d99459 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts @@ -23,7 +23,7 @@ export interface DashboardSidebarWorkspacePullRequest { export interface DashboardSidebarWorkspace { id: string; projectId: string; - deviceId: string; + hostId: string; hostType: DashboardSidebarWorkspaceHostType; accentColor: string | null; name: string; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx index ccfb65d3a1f..1b7fb51f36d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx @@ -25,24 +25,24 @@ export function V2WorkspaceOpenInButton({ [collections, workspaceId], ); const workspace = workspaces[0] ?? null; - const { data: currentDevices = [] } = useLiveQuery( + const { data: localHosts = [] } = useLiveQuery( (q) => q - .from({ devices: collections.v2Devices }) - .where(({ devices }) => + .from({ hosts: collections.v2Hosts }) + .where(({ hosts }) => and( - eq(devices.clientId, deviceInfo?.deviceId ?? ""), - eq(devices.organizationId, workspace?.organizationId ?? ""), + eq(hosts.machineId, deviceInfo?.deviceId ?? ""), + eq(hosts.organizationId, workspace?.organizationId ?? ""), ), ), [collections, deviceInfo?.deviceId, workspace?.organizationId], ); - const currentDevice = currentDevices[0] ?? null; + const localHost = localHosts[0] ?? null; const hostUrl = workspace ? (services.get(workspace.organizationId)?.url ?? null) : null; const isLocalWorkspace = - Boolean(workspace) && workspace.deviceId === currentDevice?.id; + Boolean(workspace) && workspace.hostId === localHost?.id; const workspaceQuery = useQuery({ queryKey: ["v2-open-in-workspace", hostUrl, workspaceId], diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index a13c0229aed..536d7a68e7e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -40,24 +40,24 @@ function V2WorkspaceLayout() { [collections, workspaceId], ); const workspace = workspaces[0] ?? null; - const { data: currentDevices = [] } = useLiveQuery( + const { data: hosts = [] } = useLiveQuery( (q) => q - .from({ v2Devices: collections.v2Devices }) - .where(({ v2Devices }) => + .from({ v2Hosts: collections.v2Hosts }) + .where(({ v2Hosts }) => and( - eq(v2Devices.clientId, deviceInfo?.deviceId ?? ""), - eq(v2Devices.organizationId, workspace?.organizationId ?? ""), + eq(v2Hosts.machineId, deviceInfo?.deviceId ?? ""), + eq(v2Hosts.organizationId, workspace?.organizationId ?? ""), ), ), [collections, deviceInfo?.deviceId, workspace?.organizationId], ); - const currentDevice = currentDevices[0] ?? null; + const localHost = hosts[0] ?? null; const localHostUrl = workspace ? (services.get(workspace.organizationId)?.url ?? null) : null; const shouldWaitForDeviceInfo = workspace !== null && isDeviceInfoPending; - const isLocal = workspace?.deviceId === currentDevice?.id; + const isLocal = workspace?.hostId === localHost?.id; const hostUrl = !workspace || shouldWaitForDeviceInfo ? null diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx index a585eebbcb2..7fdbb1b0b17 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx @@ -14,13 +14,12 @@ import { HiChevronUpDown, HiOutlineCloud, HiOutlineComputerDesktop, - HiOutlineGlobeAlt, HiOutlineServer, } from "react-icons/hi2"; import type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; import { useWorkspaceHostOptions, - type WorkspaceHostDeviceOption, + type WorkspaceHostOption, } from "./hooks/useWorkspaceHostOptions"; interface DevicePickerProps { @@ -28,21 +27,14 @@ interface DevicePickerProps { onSelectHostTarget: (target: WorkspaceHostTarget) => void; } -function getDeviceIcon(type: WorkspaceHostDeviceOption["type"]) { - switch (type) { - case "cloud": - return HiOutlineCloud; - case "viewer": - return HiOutlineGlobeAlt; - default: - return HiOutlineComputerDesktop; - } +function getHostIcon(host: WorkspaceHostOption) { + return host.isCloud ? HiOutlineCloud : HiOutlineComputerDesktop; } function getSelectedLabel( hostTarget: WorkspaceHostTarget, currentDeviceName: string | null, - otherDevices: WorkspaceHostDeviceOption[], + otherHosts: WorkspaceHostOption[], ) { if (hostTarget.kind === "local") { return currentDeviceName ?? "Local Device"; @@ -53,8 +45,8 @@ function getSelectedLabel( } return ( - otherDevices.find((device) => device.id === hostTarget.deviceId)?.name ?? - "Unknown Device" + otherHosts.find((host) => host.id === hostTarget.deviceId)?.name ?? + "Unknown Host" ); } @@ -74,11 +66,11 @@ export function DevicePicker({ hostTarget, onSelectHostTarget, }: DevicePickerProps) { - const { currentDeviceName, otherDevices } = useWorkspaceHostOptions(); + const { currentDeviceName, otherHosts } = useWorkspaceHostOptions(); const selectedLabel = getSelectedLabel( hostTarget, currentDeviceName, - otherDevices, + otherHosts, ); return ( @@ -111,34 +103,31 @@ export function DevicePicker({ - Other Devices + Other Hosts - {otherDevices.length === 0 ? ( - No devices found + {otherHosts.length === 0 ? ( + No hosts found ) : ( - otherDevices.map((device) => { - const DeviceIcon = getDeviceIcon(device.type); + otherHosts.map((host) => { + const HostIcon = getHostIcon(host); const isSelected = hostTarget.kind === "device" && - hostTarget.deviceId === device.id; + hostTarget.deviceId === host.id; return ( onSelectHostTarget({ kind: "device", - deviceId: device.id, + deviceId: host.id, }) } > - +

-
{device.name}
-
- {device.type} -
+
{host.name}
{isSelected && } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/index.ts index 06c290fd571..c029dd2553e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/index.ts @@ -1,2 +1,2 @@ -export type { WorkspaceHostDeviceOption } from "./useWorkspaceHostOptions"; +export type { WorkspaceHostOption } from "./useWorkspaceHostOptions"; export { useWorkspaceHostOptions } from "./useWorkspaceHostOptions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts index 43b33093306..de8ae1ba296 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts @@ -11,16 +11,16 @@ import { } from "renderer/routes/_authenticated/providers/HostServiceProvider"; import { MOCK_ORG_ID } from "shared/constants"; -export interface WorkspaceHostDeviceOption { +export interface WorkspaceHostOption { id: string; name: string; - type: "host" | "cloud" | "viewer"; + isCloud: boolean; } interface UseWorkspaceHostOptionsResult { currentDeviceName: string | null; localHostService: OrgService | null; - otherDevices: WorkspaceHostDeviceOption[]; + otherHosts: WorkspaceHostOption[]; } export function useWorkspaceHostOptions(): UseWorkspaceHostOptionsResult { @@ -39,45 +39,43 @@ export function useWorkspaceHostOptions(): UseWorkspaceHostOptionsResult { ? (services.get(activeOrganizationId) ?? null) : null; - const { data: accessibleDevices = [] } = useLiveQuery( + const { data: accessibleHosts = [] } = useLiveQuery( (q) => q - .from({ userDevices: collections.v2UsersDevices }) - .innerJoin( - { devices: collections.v2Devices }, - ({ userDevices, devices }) => eq(userDevices.deviceId, devices.id), + .from({ userHosts: collections.v2UsersHosts }) + .innerJoin({ hosts: collections.v2Hosts }, ({ userHosts, hosts }) => + eq(userHosts.hostId, hosts.id), ) - .where(({ userDevices, devices }) => + .where(({ userHosts, hosts }) => and( - eq(userDevices.userId, currentUserId ?? ""), - eq(devices.organizationId, activeOrganizationId ?? ""), + eq(userHosts.userId, currentUserId ?? ""), + eq(hosts.organizationId, activeOrganizationId ?? ""), ), ) - .select(({ devices }) => ({ - id: devices.id, - clientId: devices.clientId, - name: devices.name, - type: devices.type, + .select(({ hosts }) => ({ + id: hosts.id, + machineId: hosts.machineId, + name: hosts.name, })), [activeOrganizationId, collections, currentUserId], ); - const otherDevices = useMemo( + const otherHosts = useMemo( () => - accessibleDevices - .filter((device) => device.clientId !== deviceInfo?.deviceId) - .map((device) => ({ - id: device.id, - name: device.name, - type: device.type, + accessibleHosts + .filter((host) => host.machineId !== deviceInfo?.deviceId) + .map((host) => ({ + id: host.id, + name: host.name, + isCloud: host.machineId == null, })) .sort((a, b) => a.name.localeCompare(b.name)), - [accessibleDevices, deviceInfo?.deviceId], + [accessibleHosts, deviceInfo?.deviceId], ); return { currentDeviceName: deviceInfo?.deviceName ?? null, localHostService, - otherDevices, + otherHosts, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index 76cca3d0f6d..7e63d20e21c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -14,9 +14,10 @@ import type { SelectTask, SelectTaskStatus, SelectUser, - SelectV2Device, + SelectV2Client, + SelectV2Host, SelectV2Project, - SelectV2UsersDevices, + SelectV2UsersHosts, SelectV2Workspace, SelectWorkspace, } from "@superset/db/schema"; @@ -67,9 +68,10 @@ export interface OrgCollections { tasks: Collection; taskStatuses: Collection; projects: Collection; - v2Devices: Collection; + v2Hosts: Collection; + v2Clients: Collection; + v2UsersHosts: Collection; v2Projects: Collection; - v2UsersDevices: Collection; v2Workspaces: Collection; workspaces: Collection; members: Collection; @@ -230,13 +232,13 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const v2Devices = createCollection( - electricCollectionOptions({ - id: `v2_devices-${organizationId}`, + const v2Hosts = createCollection( + electricCollectionOptions({ + id: `v2_hosts-${organizationId}`, shapeOptions: { url: electricUrl, params: { - table: "v2_devices", + table: "v2_hosts", organizationId, }, headers: electricHeaders, @@ -246,13 +248,29 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const v2UsersDevices = createCollection( - electricCollectionOptions({ - id: `v2_users_devices-${organizationId}`, + const v2Clients = createCollection( + electricCollectionOptions({ + id: `v2_clients-${organizationId}`, shapeOptions: { url: electricUrl, params: { - table: "v2_users_devices", + table: "v2_clients", + organizationId, + }, + headers: electricHeaders, + columnMapper, + }, + getKey: (item) => item.id, + }), + ); + + const v2UsersHosts = createCollection( + electricCollectionOptions({ + id: `v2_users_hosts-${organizationId}`, + shapeOptions: { + url: electricUrl, + params: { + table: "v2_users_hosts", organizationId, }, headers: electricHeaders, @@ -509,9 +527,10 @@ function createOrgCollections(organizationId: string): OrgCollections { tasks, taskStatuses, projects, - v2Devices, + v2Hosts, + v2Clients, + v2UsersHosts, v2Projects, - v2UsersDevices, v2Workspaces, workspaces, members, diff --git a/apps/electric-proxy/src/where.ts b/apps/electric-proxy/src/where.ts index b7ce63acc3c..43efdb7e006 100644 --- a/apps/electric-proxy/src/where.ts +++ b/apps/electric-proxy/src/where.ts @@ -13,10 +13,10 @@ import { subscriptions, taskStatuses, tasks, - v2DevicePresence, - v2Devices, + v2Clients, + v2Hosts, v2Projects, - v2UsersDevices, + v2UsersHosts, v2Workspaces, workspaces, } from "@superset/db/schema"; @@ -55,22 +55,14 @@ export function buildWhereClause( case "v2_projects": return build(v2Projects, v2Projects.organizationId, organizationId); - case "v2_devices": - return build(v2Devices, v2Devices.organizationId, organizationId); + case "v2_hosts": + return build(v2Hosts, v2Hosts.organizationId, organizationId); - case "v2_device_presence": - return build( - v2DevicePresence, - v2DevicePresence.organizationId, - organizationId, - ); + case "v2_clients": + return build(v2Clients, v2Clients.organizationId, organizationId); - case "v2_users_devices": - return build( - v2UsersDevices, - v2UsersDevices.organizationId, - organizationId, - ); + case "v2_users_hosts": + return build(v2UsersHosts, v2UsersHosts.organizationId, organizationId); case "v2_workspaces": return build(v2Workspaces, v2Workspaces.organizationId, organizationId); diff --git a/packages/db/drizzle/0031_v2_hosts_and_clients.sql b/packages/db/drizzle/0031_v2_hosts_and_clients.sql new file mode 100644 index 00000000000..37d67843974 --- /dev/null +++ b/packages/db/drizzle/0031_v2_hosts_and_clients.sql @@ -0,0 +1,63 @@ +DELETE FROM "v2_workspaces";--> statement-breakpoint +CREATE TYPE "public"."v2_client_type" AS ENUM('desktop', 'mobile', 'web');--> statement-breakpoint +CREATE TYPE "public"."v2_users_host_role" AS ENUM('owner', 'member');--> statement-breakpoint +CREATE TABLE "v2_clients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "machine_id" text NOT NULL, + "type" "v2_client_type" NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "v2_clients_org_user_machine_unique" UNIQUE("organization_id","user_id","machine_id") +); +--> statement-breakpoint +CREATE TABLE "v2_hosts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "machine_id" text, + "name" text NOT NULL, + "last_seen_at" timestamp with time zone, + "created_by_user_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "v2_hosts_org_machine_id_unique" UNIQUE("organization_id","machine_id") +); +--> statement-breakpoint +CREATE TABLE "v2_users_hosts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "host_id" uuid NOT NULL, + "role" "v2_users_host_role" DEFAULT 'member' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "v2_users_hosts_org_user_host_unique" UNIQUE("organization_id","user_id","host_id") +); +--> statement-breakpoint +ALTER TABLE "v2_device_presence" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "v2_devices" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "v2_users_devices" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +DROP TABLE "v2_device_presence" CASCADE;--> statement-breakpoint +DROP TABLE "v2_devices" CASCADE;--> statement-breakpoint +DROP TABLE "v2_users_devices" CASCADE;--> statement-breakpoint +DROP INDEX IF EXISTS "v2_workspaces_device_id_idx";--> statement-breakpoint +ALTER TABLE "v2_workspaces" ADD COLUMN "host_id" uuid NOT NULL;--> statement-breakpoint +ALTER TABLE "v2_clients" ADD CONSTRAINT "v2_clients_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "v2_clients" ADD CONSTRAINT "v2_clients_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "v2_hosts" ADD CONSTRAINT "v2_hosts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "v2_hosts" ADD CONSTRAINT "v2_hosts_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "auth"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "v2_users_hosts" ADD CONSTRAINT "v2_users_hosts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "v2_users_hosts" ADD CONSTRAINT "v2_users_hosts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "v2_users_hosts" ADD CONSTRAINT "v2_users_hosts_host_id_v2_hosts_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."v2_hosts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "v2_clients_organization_id_idx" ON "v2_clients" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "v2_clients_user_id_idx" ON "v2_clients" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "v2_hosts_organization_id_idx" ON "v2_hosts" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "v2_users_hosts_organization_id_idx" ON "v2_users_hosts" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "v2_users_hosts_user_id_idx" ON "v2_users_hosts" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "v2_users_hosts_host_id_idx" ON "v2_users_hosts" USING btree ("host_id");--> statement-breakpoint +ALTER TABLE "v2_workspaces" ADD CONSTRAINT "v2_workspaces_host_id_v2_hosts_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."v2_hosts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "v2_workspaces_host_id_idx" ON "v2_workspaces" USING btree ("host_id");--> statement-breakpoint +ALTER TABLE "v2_workspaces" DROP COLUMN "device_id";--> statement-breakpoint +DROP TYPE "public"."v2_device_type";--> statement-breakpoint +DROP TYPE "public"."v2_users_device_role"; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0031_snapshot.json b/packages/db/drizzle/meta/0031_snapshot.json new file mode 100644 index 00000000000..deb4f0d2039 --- /dev/null +++ b/packages/db/drizzle/meta/0031_snapshot.json @@ -0,0 +1,5257 @@ +{ + "id": "39c7746a-9827-47ff-b502-a9f75a9af94a", + "prevId": "b4f3b2ee-8405-448f-b162-948895af940c", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.apikeys": { + "name": "apikeys", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikeys_configId_idx": { + "name": "apikeys_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_referenceId_idx": { + "name": "apikeys_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.device_codes": { + "name": "device_codes", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.jwkss": { + "name": "jwkss", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_refresh_tokens", + "schemaTo": "auth", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_clients": { + "name": "oauth_clients", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_consents": { + "name": "oauth_consents", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_domains": { + "name": "allowed_domains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_allowed_domains_idx": { + "name": "organizations_allowed_domains_idx", + "columns": [ + { + "expression": "allowed_domains", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_org_id_idx": { + "name": "github_pull_requests_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pull_requests_organization_id_organizations_id_fk": { + "name": "github_pull_requests_organization_id_organizations_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_org_id_idx": { + "name": "github_repositories_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_repositories_organization_id_organizations_id_fk": { + "name": "github_repositories_organization_id_organizations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_commands": { + "name": "agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_device_id": { + "name": "target_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_device_type": { + "name": "target_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_command_id": { + "name": "parent_command_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "command_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_commands_user_status_idx": { + "name": "agent_commands_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_target_device_status_idx": { + "name": "agent_commands_target_device_status_idx", + "columns": [ + { + "expression": "target_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_org_created_idx": { + "name": "agent_commands_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_commands_user_id_users_id_fk": { + "name": "agent_commands_user_id_users_id_fk", + "tableFrom": "agent_commands", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commands_organization_id_organizations_id_fk": { + "name": "agent_commands_organization_id_organizations_id_fk", + "tableFrom": "agent_commands", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_org_idx": { + "name": "chat_sessions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_created_by_idx": { + "name": "chat_sessions_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_last_active_idx": { + "name": "chat_sessions_last_active_idx", + "columns": [ + { + "expression": "last_active_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_created_by_users_id_fk": { + "name": "chat_sessions_created_by_users_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_workspace_id_workspaces_id_fk": { + "name": "chat_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_sessions_v2_workspace_id_v2_workspaces_id_fk": { + "name": "chat_sessions_v2_workspace_id_v2_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "v2_workspaces", + "columnsFrom": [ + "v2_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_presence": { + "name": "device_presence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "device_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_presence_user_org_idx": { + "name": "device_presence_user_org_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_user_device_idx": { + "name": "device_presence_user_device_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_last_seen_idx": { + "name": "device_presence_last_seen_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_presence_user_id_users_id_fk": { + "name": "device_presence_user_id_users_id_fk", + "tableFrom": "device_presence", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_presence_organization_id_organizations_id_fk": { + "name": "device_presence_organization_id_organizations_id_fk", + "tableFrom": "device_presence", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connections_unique": { + "name": "integration_connections_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_github_repository_id_github_repositories_id_fk": { + "name": "projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_org_slug_unique": { + "name": "projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_images": { + "name": "sandbox_images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "setup_commands": { + "name": "setup_commands", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "base_image": { + "name": "base_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_images_organization_id_idx": { + "name": "sandbox_images_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sandbox_images_organization_id_organizations_id_fk": { + "name": "sandbox_images_organization_id_organizations_id_fk", + "tableFrom": "sandbox_images", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sandbox_images_project_id_projects_id_fk": { + "name": "sandbox_images_project_id_projects_id_fk", + "tableFrom": "sandbox_images", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sandbox_images_project_unique": { + "name": "sandbox_images_project_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secrets_project_id_idx": { + "name": "secrets_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secrets_organization_id_idx": { + "name": "secrets_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secrets_organization_id_organizations_id_fk": { + "name": "secrets_organization_id_organizations_id_fk", + "tableFrom": "secrets", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "secrets_project_id_projects_id_fk": { + "name": "secrets_project_id_projects_id_fk", + "tableFrom": "secrets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "secrets_created_by_user_id_users_id_fk": { + "name": "secrets_created_by_user_id_users_id_fk", + "tableFrom": "secrets", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "secrets_project_key_unique": { + "name": "secrets_project_key_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_hosts": { + "name": "session_hosts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_hosts_session_id_idx": { + "name": "session_hosts_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_hosts_org_idx": { + "name": "session_hosts_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_hosts_device_id_idx": { + "name": "session_hosts_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_hosts_session_id_chat_sessions_id_fk": { + "name": "session_hosts_session_id_chat_sessions_id_fk", + "tableFrom": "session_hosts", + "tableTo": "chat_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_hosts_organization_id_organizations_id_fk": { + "name": "session_hosts_organization_id_organizations_id_fk", + "tableFrom": "session_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_reference_id_idx": { + "name": "subscriptions_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_customer_id_idx": { + "name": "subscriptions_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_status_idx": { + "name": "subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_reference_id_organizations_id_fk": { + "name": "subscriptions_reference_id_organizations_id_fk", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_external_id": { + "name": "assignee_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_display_name": { + "name": "assignee_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_avatar_url": { + "name": "assignee_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_external_id_idx": { + "name": "tasks_assignee_external_id_idx", + "columns": [ + { + "expression": "assignee_external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + }, + "tasks_org_slug_unique": { + "name": "tasks_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users__slack_users": { + "name": "users__slack_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "model_preference": { + "name": "model_preference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users__slack_users_user_idx": { + "name": "users__slack_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users__slack_users_org_idx": { + "name": "users__slack_users_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users__slack_users_user_id_users_id_fk": { + "name": "users__slack_users_user_id_users_id_fk", + "tableFrom": "users__slack_users", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users__slack_users_organization_id_organizations_id_fk": { + "name": "users__slack_users_organization_id_organizations_id_fk", + "tableFrom": "users__slack_users", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users__slack_users_unique": { + "name": "users__slack_users_unique", + "nullsNotDistinct": false, + "columns": [ + "slack_user_id", + "team_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_clients": { + "name": "v2_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_clients_organization_id_idx": { + "name": "v2_clients_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_clients_user_id_idx": { + "name": "v2_clients_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_clients_organization_id_organizations_id_fk": { + "name": "v2_clients_organization_id_organizations_id_fk", + "tableFrom": "v2_clients", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_clients_user_id_users_id_fk": { + "name": "v2_clients_user_id_users_id_fk", + "tableFrom": "v2_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_clients_org_user_machine_unique": { + "name": "v2_clients_org_user_machine_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "user_id", + "machine_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_hosts": { + "name": "v2_hosts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_hosts_organization_id_idx": { + "name": "v2_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_hosts_organization_id_organizations_id_fk": { + "name": "v2_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_hosts_created_by_user_id_users_id_fk": { + "name": "v2_hosts_created_by_user_id_users_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_hosts_org_machine_id_unique": { + "name": "v2_hosts_org_machine_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "machine_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_projects": { + "name": "v2_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_projects_organization_id_idx": { + "name": "v2_projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_projects_organization_id_organizations_id_fk": { + "name": "v2_projects_organization_id_organizations_id_fk", + "tableFrom": "v2_projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_projects_github_repository_id_github_repositories_id_fk": { + "name": "v2_projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "v2_projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_projects_org_slug_unique": { + "name": "v2_projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_users_hosts": { + "name": "v2_users_hosts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "v2_users_host_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_users_hosts_organization_id_idx": { + "name": "v2_users_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_user_id_idx": { + "name": "v2_users_hosts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_host_id_idx": { + "name": "v2_users_hosts_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_users_hosts_organization_id_organizations_id_fk": { + "name": "v2_users_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_user_id_users_id_fk": { + "name": "v2_users_hosts_user_id_users_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_host_id_v2_hosts_id_fk": { + "name": "v2_users_hosts_host_id_v2_hosts_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "v2_hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_users_hosts_org_user_host_unique": { + "name": "v2_users_hosts_org_user_host_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "user_id", + "host_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_workspaces": { + "name": "v2_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_workspaces_project_id_idx": { + "name": "v2_workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_organization_id_idx": { + "name": "v2_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_host_id_idx": { + "name": "v2_workspaces_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_workspaces_organization_id_organizations_id_fk": { + "name": "v2_workspaces_organization_id_organizations_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_workspaces_project_id_v2_projects_id_fk": { + "name": "v2_workspaces_project_id_v2_projects_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "v2_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_workspaces_host_id_v2_hosts_id_fk": { + "name": "v2_workspaces_host_id_v2_hosts_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "v2_hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "v2_workspaces_created_by_user_id_users_id_fk": { + "name": "v2_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_organization_id_idx": { + "name": "workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_type_idx": { + "name": "workspaces_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_user_id_users_id_fk": { + "name": "workspaces_created_by_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.command_status": { + "name": "command_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed", + "timeout" + ] + }, + "public.device_type": { + "name": "device_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github", + "slack" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + }, + "public.v2_client_type": { + "name": "v2_client_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.v2_users_host_role": { + "name": "v2_users_host_role", + "schema": "public", + "values": [ + "owner", + "member" + ] + }, + "public.workspace_type": { + "name": "workspace_type", + "schema": "public", + "values": [ + "local", + "cloud" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index e5ff54de0c5..638b51fa1d1 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1775419931545, "tag": "0030_better_auth_1_5_upgrade", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1775457250045, + "tag": "0031_v2_hosts_and_clients", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts index 368efaa5913..a0c74fc3716 100644 --- a/packages/db/src/schema/enums.ts +++ b/packages/db/src/schema/enums.ts @@ -39,6 +39,14 @@ export const v2UsersDeviceRoleValues = ["owner", "member", "viewer"] as const; export const v2UsersDeviceRoleEnum = z.enum(v2UsersDeviceRoleValues); export type V2UsersDeviceRole = z.infer; +export const v2ClientTypeValues = ["desktop", "mobile", "web"] as const; +export const v2ClientTypeEnum = z.enum(v2ClientTypeValues); +export type V2ClientType = z.infer; + +export const v2UsersHostRoleValues = ["owner", "member"] as const; +export const v2UsersHostRoleEnum = z.enum(v2UsersHostRoleValues); +export type V2UsersHostRole = z.infer; + export const commandStatusValues = [ "pending", "completed", diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts index 58c5ad708d7..fa9400cf189 100644 --- a/packages/db/src/schema/relations.ts +++ b/packages/db/src/schema/relations.ts @@ -26,10 +26,10 @@ import { taskStatuses, tasks, usersSlackUsers, - v2DevicePresence, - v2Devices, + v2Clients, + v2Hosts, v2Projects, - v2UsersDevices, + v2UsersHosts, v2Workspaces, workspaces, } from "./schema"; @@ -44,8 +44,9 @@ export const usersRelations = relations(users, ({ many }) => ({ connectedIntegrations: many(integrationConnections), githubInstallations: many(githubInstallations), devicePresence: many(devicePresence), - v2Devices: many(v2Devices), - v2UsersDevices: many(v2UsersDevices), + v2Hosts: many(v2Hosts), + v2Clients: many(v2Clients), + v2UsersHosts: many(v2UsersHosts), v2Workspaces: many(v2Workspaces), agentCommands: many(agentCommands), chatSessions: many(chatSessions), @@ -70,10 +71,10 @@ export const organizationsRelations = relations(organizations, ({ many }) => ({ invitations: many(invitations), subscriptions: many(subscriptions), projects: many(projects), - v2Devices: many(v2Devices), - v2DevicePresence: many(v2DevicePresence), + v2Hosts: many(v2Hosts), + v2Clients: many(v2Clients), + v2UsersHosts: many(v2UsersHosts), v2Projects: many(v2Projects), - v2UsersDevices: many(v2UsersDevices), v2Workspaces: many(v2Workspaces), secrets: many(secrets), sandboxImages: many(sandboxImages), @@ -279,51 +280,44 @@ export const v2ProjectsRelations = relations(v2Projects, ({ one, many }) => ({ workspaces: many(v2Workspaces), })); -export const v2DevicesRelations = relations(v2Devices, ({ one, many }) => ({ +export const v2HostsRelations = relations(v2Hosts, ({ one, many }) => ({ organization: one(organizations, { - fields: [v2Devices.organizationId], + fields: [v2Hosts.organizationId], references: [organizations.id], }), createdBy: one(users, { - fields: [v2Devices.createdByUserId], + fields: [v2Hosts.createdByUserId], references: [users.id], }), - presence: one(v2DevicePresence, { - fields: [v2Devices.id], - references: [v2DevicePresence.deviceId], - }), - usersDevices: many(v2UsersDevices), + usersHosts: many(v2UsersHosts), workspaces: many(v2Workspaces), })); -export const v2UsersDevicesRelations = relations(v2UsersDevices, ({ one }) => ({ +export const v2ClientsRelations = relations(v2Clients, ({ one }) => ({ organization: one(organizations, { - fields: [v2UsersDevices.organizationId], + fields: [v2Clients.organizationId], references: [organizations.id], }), user: one(users, { - fields: [v2UsersDevices.userId], + fields: [v2Clients.userId], references: [users.id], }), - device: one(v2Devices, { - fields: [v2UsersDevices.deviceId], - references: [v2Devices.id], - }), })); -export const v2DevicePresenceRelations = relations( - v2DevicePresence, - ({ one }) => ({ - organization: one(organizations, { - fields: [v2DevicePresence.organizationId], - references: [organizations.id], - }), - device: one(v2Devices, { - fields: [v2DevicePresence.deviceId], - references: [v2Devices.id], - }), +export const v2UsersHostsRelations = relations(v2UsersHosts, ({ one }) => ({ + organization: one(organizations, { + fields: [v2UsersHosts.organizationId], + references: [organizations.id], }), -); + user: one(users, { + fields: [v2UsersHosts.userId], + references: [users.id], + }), + host: one(v2Hosts, { + fields: [v2UsersHosts.hostId], + references: [v2Hosts.id], + }), +})); export const v2WorkspacesRelations = relations( v2Workspaces, @@ -336,9 +330,9 @@ export const v2WorkspacesRelations = relations( fields: [v2Workspaces.projectId], references: [v2Projects.id], }), - device: one(v2Devices, { - fields: [v2Workspaces.deviceId], - references: [v2Devices.id], + host: one(v2Hosts, { + fields: [v2Workspaces.hostId], + references: [v2Hosts.id], }), createdBy: one(users, { fields: [v2Workspaces.createdByUserId], diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index 6d5d6c3dfcb..bbe1f8f9c8c 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -20,8 +20,8 @@ import { integrationProviderValues, taskPriorityValues, taskStatusEnumValues, - v2DeviceTypeValues, - v2UsersDeviceRoleValues, + v2ClientTypeValues, + v2UsersHostRoleValues, workspaceTypeValues, } from "./enums"; import { githubRepositories } from "./github"; @@ -35,12 +35,12 @@ export const integrationProvider = pgEnum( integrationProviderValues, ); export const deviceType = pgEnum("device_type", deviceTypeValues); -export const v2DeviceType = pgEnum("v2_device_type", v2DeviceTypeValues); -export const v2UsersDeviceRole = pgEnum( - "v2_users_device_role", - v2UsersDeviceRoleValues, -); export const commandStatus = pgEnum("command_status", commandStatusValues); +export const v2ClientType = pgEnum("v2_client_type", v2ClientTypeValues); +export const v2UsersHostRole = pgEnum( + "v2_users_host_role", + v2UsersHostRoleValues, +); export const taskStatuses = pgTable( "task_statuses", @@ -406,16 +406,16 @@ export const v2Projects = pgTable( export type InsertV2Project = typeof v2Projects.$inferInsert; export type SelectV2Project = typeof v2Projects.$inferSelect; -export const v2Devices = pgTable( - "v2_devices", +export const v2Hosts = pgTable( + "v2_hosts", { id: uuid().primaryKey().defaultRandom(), organizationId: uuid("organization_id") .notNull() .references(() => organizations.id, { onDelete: "cascade" }), - clientId: text("client_id"), + machineId: text("machine_id"), name: text().notNull(), - type: v2DeviceType().notNull(), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), createdByUserId: uuid("created_by_user_id").references(() => users.id, { onDelete: "set null", }), @@ -428,19 +428,19 @@ export const v2Devices = pgTable( .$onUpdate(() => new Date()), }, (table) => [ - index("v2_devices_organization_id_idx").on(table.organizationId), - unique("v2_devices_org_client_id_unique").on( + index("v2_hosts_organization_id_idx").on(table.organizationId), + unique("v2_hosts_org_machine_id_unique").on( table.organizationId, - table.clientId, + table.machineId, ), ], ); -export type InsertV2Device = typeof v2Devices.$inferInsert; -export type SelectV2Device = typeof v2Devices.$inferSelect; +export type InsertV2Host = typeof v2Hosts.$inferInsert; +export type SelectV2Host = typeof v2Hosts.$inferSelect; -export const v2UsersDevices = pgTable( - "v2_users_devices", +export const v2Clients = pgTable( + "v2_clients", { id: uuid().primaryKey().defaultRandom(), organizationId: uuid("organization_id") @@ -449,10 +449,8 @@ export const v2UsersDevices = pgTable( userId: uuid("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), - deviceId: uuid("device_id") - .notNull() - .references(() => v2Devices.id, { onDelete: "cascade" }), - role: v2UsersDeviceRole().notNull().default("member"), + machineId: text("machine_id").notNull(), + type: v2ClientType().notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -462,31 +460,33 @@ export const v2UsersDevices = pgTable( .$onUpdate(() => new Date()), }, (table) => [ - index("v2_users_devices_organization_id_idx").on(table.organizationId), - index("v2_users_devices_user_id_idx").on(table.userId), - index("v2_users_devices_device_id_idx").on(table.deviceId), - unique("v2_users_devices_user_device_unique").on( + index("v2_clients_organization_id_idx").on(table.organizationId), + index("v2_clients_user_id_idx").on(table.userId), + unique("v2_clients_org_user_machine_unique").on( + table.organizationId, table.userId, - table.deviceId, + table.machineId, ), ], ); -export type InsertV2UsersDevices = typeof v2UsersDevices.$inferInsert; -export type SelectV2UsersDevices = typeof v2UsersDevices.$inferSelect; +export type InsertV2Client = typeof v2Clients.$inferInsert; +export type SelectV2Client = typeof v2Clients.$inferSelect; -export const v2DevicePresence = pgTable( - "v2_device_presence", +export const v2UsersHosts = pgTable( + "v2_users_hosts", { - deviceId: uuid("device_id") - .primaryKey() - .references(() => v2Devices.id, { onDelete: "cascade" }), + id: uuid().primaryKey().defaultRandom(), organizationId: uuid("organization_id") .notNull() .references(() => organizations.id, { onDelete: "cascade" }), - lastSeenAt: timestamp("last_seen_at", { withTimezone: true }) + userId: uuid("user_id") .notNull() - .defaultNow(), + .references(() => users.id, { onDelete: "cascade" }), + hostId: uuid("host_id") + .notNull() + .references(() => v2Hosts.id, { onDelete: "cascade" }), + role: v2UsersHostRole().notNull().default("member"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -496,13 +496,19 @@ export const v2DevicePresence = pgTable( .$onUpdate(() => new Date()), }, (table) => [ - index("v2_device_presence_organization_id_idx").on(table.organizationId), - index("v2_device_presence_last_seen_idx").on(table.lastSeenAt), + index("v2_users_hosts_organization_id_idx").on(table.organizationId), + index("v2_users_hosts_user_id_idx").on(table.userId), + index("v2_users_hosts_host_id_idx").on(table.hostId), + unique("v2_users_hosts_org_user_host_unique").on( + table.organizationId, + table.userId, + table.hostId, + ), ], ); -export type InsertV2DevicePresence = typeof v2DevicePresence.$inferInsert; -export type SelectV2DevicePresence = typeof v2DevicePresence.$inferSelect; +export type InsertV2UsersHosts = typeof v2UsersHosts.$inferInsert; +export type SelectV2UsersHosts = typeof v2UsersHosts.$inferSelect; export const v2Workspaces = pgTable( "v2_workspaces", @@ -514,9 +520,9 @@ export const v2Workspaces = pgTable( projectId: uuid("project_id") .notNull() .references(() => v2Projects.id, { onDelete: "cascade" }), - deviceId: uuid("device_id") + hostId: uuid("host_id") .notNull() - .references(() => v2Devices.id), + .references(() => v2Hosts.id), name: text().notNull(), branch: text().notNull(), createdByUserId: uuid("created_by_user_id").references(() => users.id, { @@ -533,7 +539,7 @@ export const v2Workspaces = pgTable( (table) => [ index("v2_workspaces_project_id_idx").on(table.projectId), index("v2_workspaces_organization_id_idx").on(table.organizationId), - index("v2_workspaces_device_id_idx").on(table.deviceId), + index("v2_workspaces_host_id_idx").on(table.hostId), ], ); diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts index 37aa1da2010..b2de548d5dc 100644 --- a/packages/host-service/src/trpc/router/workspace/workspace.ts +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -93,8 +93,8 @@ export const workspaceRouter = router({ await git.raw(["worktree", "add", "-b", input.branch, worktreePath]); } - const device = await ctx.api.device.ensureV2Host.mutate({ - clientId: ctx.deviceClientId, + const host = await ctx.api.device.ensureV2Host.mutate({ + machineId: ctx.deviceClientId, name: ctx.deviceName, }); @@ -103,7 +103,7 @@ export const workspaceRouter = router({ projectId: input.projectId, name: input.name, branch: input.branch, - deviceId: device.id, + hostId: host.id, }) .catch(async (err) => { try { diff --git a/packages/trpc/src/router/device/device.ts b/packages/trpc/src/router/device/device.ts index 6ae9b2b01ac..17a5da464ab 100644 --- a/packages/trpc/src/router/device/device.ts +++ b/packages/trpc/src/router/device/device.ts @@ -2,9 +2,10 @@ import { db, dbWs } from "@superset/db/client"; import { devicePresence, deviceTypeValues, - v2DevicePresence, - v2Devices, - v2UsersDevices, + v2Clients, + v2ClientTypeValues, + v2Hosts, + v2UsersHosts, } from "@superset/db/schema"; import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; import { z } from "zod"; @@ -14,7 +15,7 @@ export const deviceRouter = { ensureV2Host: protectedProcedure .input( z.object({ - clientId: z.string().min(1), + machineId: z.string().min(1), name: z.string().min(1), }), ) @@ -30,59 +31,96 @@ export const deviceRouter = { const userId = ctx.session.user.id; const now = new Date(); - const [device] = await dbWs - .insert(v2Devices) + const [host] = await dbWs + .insert(v2Hosts) .values({ organizationId, - clientId: input.clientId, + machineId: input.machineId, name: input.name, - type: "host", + lastSeenAt: now, createdByUserId: userId, }) .onConflictDoUpdate({ - target: [v2Devices.organizationId, v2Devices.clientId], + target: [v2Hosts.organizationId, v2Hosts.machineId], set: { name: input.name, - type: "host", + lastSeenAt: now, }, }) .returning(); - if (!device) { + if (!host) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Failed to ensure device", + message: "Failed to ensure host", }); } await dbWs - .insert(v2UsersDevices) + .insert(v2UsersHosts) .values({ organizationId, userId, - deviceId: device.id, + hostId: host.id, role: "owner", }) .onConflictDoNothing({ - target: [v2UsersDevices.userId, v2UsersDevices.deviceId], + target: [ + v2UsersHosts.organizationId, + v2UsersHosts.userId, + v2UsersHosts.hostId, + ], }); - await dbWs - .insert(v2DevicePresence) + return host; + }), + + ensureV2Client: protectedProcedure + .input( + z.object({ + machineId: z.string().min(1), + type: z.enum(v2ClientTypeValues), + }), + ) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No active organization selected", + }); + } + + const userId = ctx.session.user.id; + + const [client] = await dbWs + .insert(v2Clients) .values({ - deviceId: device.id, organizationId, - lastSeenAt: now, + userId, + machineId: input.machineId, + type: input.type, }) .onConflictDoUpdate({ - target: [v2DevicePresence.deviceId], + target: [ + v2Clients.organizationId, + v2Clients.userId, + v2Clients.machineId, + ], set: { - organizationId, - lastSeenAt: now, + type: input.type, }, + }) + .returning(); + + if (!client) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to ensure client", }); + } - return device; + return client; }), /** diff --git a/packages/trpc/src/router/v2-workspace/v2-workspace.ts b/packages/trpc/src/router/v2-workspace/v2-workspace.ts index 00150bfaa3e..251e94e6052 100644 --- a/packages/trpc/src/router/v2-workspace/v2-workspace.ts +++ b/packages/trpc/src/router/v2-workspace/v2-workspace.ts @@ -1,5 +1,5 @@ import { dbWs } from "@superset/db/client"; -import { v2Devices, v2Projects, v2Workspaces } from "@superset/db/schema"; +import { v2Hosts, v2Projects, v2Workspaces } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; @@ -32,19 +32,19 @@ async function getScopedProject(organizationId: string, projectId: string) { ); } -async function getScopedDevice(organizationId: string, deviceId: string) { +async function getScopedHost(organizationId: string, hostId: string) { return requireOrgScopedResource( () => - dbWs.query.v2Devices.findFirst({ + dbWs.query.v2Hosts.findFirst({ columns: { id: true, organizationId: true, }, - where: eq(v2Devices.id, deviceId), + where: eq(v2Hosts.id, hostId), }), { code: "BAD_REQUEST", - message: "Device not found in this organization", + message: "Host not found in this organization", organizationId, }, ); @@ -100,7 +100,7 @@ export const v2WorkspaceRouter = { projectId: z.string().uuid(), name: z.string().min(1), branch: z.string().min(1), - deviceId: z.string().uuid(), + hostId: z.string().uuid(), }), ) .mutation(async ({ ctx, input }) => { @@ -110,7 +110,7 @@ export const v2WorkspaceRouter = { ); const project = await getScopedProject(organizationId, input.projectId); - const device = await getScopedDevice(organizationId, input.deviceId); + const host = await getScopedHost(organizationId, input.hostId); const [workspace] = await dbWs .insert(v2Workspaces) @@ -119,7 +119,7 @@ export const v2WorkspaceRouter = { projectId: project.id, name: input.name, branch: input.branch, - deviceId: device.id, + hostId: host.id, createdByUserId: ctx.session.user.id, }) .returning(); @@ -132,7 +132,7 @@ export const v2WorkspaceRouter = { id: z.string().uuid(), name: z.string().min(1).optional(), branch: z.string().min(1).optional(), - deviceId: z.string().uuid().optional(), + hostId: z.string().uuid().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -148,13 +148,13 @@ export const v2WorkspaceRouter = { }, ); - if (input.deviceId !== undefined) { - await getScopedDevice(workspace.organizationId, input.deviceId); + if (input.hostId !== undefined) { + await getScopedHost(workspace.organizationId, input.hostId); } const data = { branch: input.branch, - deviceId: input.deviceId, + hostId: input.hostId, name: input.name, }; if ( From f9f443f092742b243f6a8d0981d0549aa50bf812 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 22:01:21 -0700 Subject: [PATCH 03/11] fix: multi-host routing, GIT_OPTIONAL_LOCKS, UI polish - Route diff stats per-workspace to correct host (local vs remote proxy URL) - Set GIT_OPTIONAL_LOCKS=0 on all host-service git operations to prevent index.lock contention with user git commands - Fix Changes tab header stats not updating when switching filter dropdown - Fix diff stats pill padding (w-fit + justify-self-end) - Restore LuFolderGit2 icon for local workspaces - Update WorkspaceHostTarget kind from "device" to "host" - Make v2_hosts.machineId NOT NULL (every host is a machine) - Bump PR polling to 10s (host-service + client) --- .../src/renderer/lib/v2-workspace-host.ts | 16 +++----- .../DashboardSidebarWorkspaceDiffStats.tsx | 2 +- .../DashboardSidebarWorkspaceIcon.tsx | 4 +- .../useDashboardSidebarData.ts | 30 ++++++++++---- .../hooks/useChangesTab/useChangesTab.tsx | 40 +++++++++---------- .../_dashboard/v2-workspace/layout.tsx | 4 +- .../components/DevicePicker/DevicePicker.tsx | 9 ++--- .../db/drizzle/0031_v2_hosts_and_clients.sql | 2 +- packages/db/drizzle/meta/0031_snapshot.json | 4 +- packages/db/drizzle/meta/_journal.json | 2 +- packages/db/src/schema/schema.ts | 2 +- packages/host-service/src/runtime/git/git.ts | 1 + .../runtime/pull-requests/pull-requests.ts | 2 +- 13 files changed, 64 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src/renderer/lib/v2-workspace-host.ts b/apps/desktop/src/renderer/lib/v2-workspace-host.ts index 044e70a291f..058ffc3cc18 100644 --- a/apps/desktop/src/renderer/lib/v2-workspace-host.ts +++ b/apps/desktop/src/renderer/lib/v2-workspace-host.ts @@ -3,18 +3,14 @@ import { env } from "renderer/env.renderer"; export type WorkspaceHostTarget = | { kind: "local" } | { kind: "cloud" } - | { kind: "device"; deviceId: string }; + | { kind: "host"; hostId: string }; export function getCloudWorkspaceHostUrl(): string { - return `${env.NEXT_PUBLIC_API_URL}/api/v2-workspaces/cloud/host`; + return `${env.NEXT_PUBLIC_API_URL}/api/v2-hosts/cloud/trpc`; } -export function getWorkspaceHostUrlForDevice(deviceId: string): string { - return `${env.NEXT_PUBLIC_API_URL}/api/v2-devices/${deviceId}/host`; -} - -export function getWorkspaceHostUrlForWorkspace(workspaceId: string): string { - return `${env.NEXT_PUBLIC_API_URL}/api/v2-workspaces/${workspaceId}/host`; +export function getRemoteHostUrl(hostId: string): string { + return `${env.NEXT_PUBLIC_API_URL}/api/v2-hosts/${hostId}/trpc`; } export function resolveCreateWorkspaceHostUrl( @@ -26,7 +22,7 @@ export function resolveCreateWorkspaceHostUrl( return localHostUrl; case "cloud": return getCloudWorkspaceHostUrl(); - case "device": - return getWorkspaceHostUrlForDevice(target.deviceId); + case "host": + return getRemoteHostUrl(target.hostId); } } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceDiffStats/DashboardSidebarWorkspaceDiffStats.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceDiffStats/DashboardSidebarWorkspaceDiffStats.tsx index 05875fe4393..06d431aa2c9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceDiffStats/DashboardSidebarWorkspaceDiffStats.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceDiffStats/DashboardSidebarWorkspaceDiffStats.tsx @@ -14,7 +14,7 @@ export function DashboardSidebarWorkspaceDiffStats({ return (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx index 56276b432f2..c87f5536e6e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx @@ -1,5 +1,5 @@ import { cn } from "@superset/ui/utils"; -import { LuCloud, LuGitMerge, LuLaptop } from "react-icons/lu"; +import { LuCloud, LuFolderGit2, LuLaptop } from "react-icons/lu"; import { AsciiSpinner } from "renderer/screens/main/components/AsciiSpinner"; import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; import type { ActivePaneStatus } from "shared/tabs-types"; @@ -50,7 +50,7 @@ export function DashboardSidebarWorkspaceIcon({ strokeWidth={1.75} /> ) : ( - sidebarWorkspaces .filter( (workspace) => workspace.hostMachineId != null && - workspace.hostMachineId === deviceInfo?.deviceId, + workspace.hostMachineId === myMachineId, ) .map((workspace) => workspace.id) .sort(), - [deviceInfo?.deviceId, sidebarWorkspaces], + [myMachineId, sidebarWorkspaces], ); const workspaceHosts = useMemo(() => { - const hostUrl = activeHostService?.url; - if (!hostUrl) return []; - return localWorkspaceIds.map((id) => ({ workspaceId: id, hostUrl })); - }, [activeHostService?.url, localWorkspaceIds]); + const results: WorkspaceHostInfo[] = []; + for (const workspace of sidebarWorkspaces) { + if (workspace.hostMachineId == null) continue; // cloud — no git + if (workspace.hostMachineId === myMachineId) { + if (localHostUrl) { + results.push({ workspaceId: workspace.id, hostUrl: localHostUrl }); + } + } else { + results.push({ + workspaceId: workspace.id, + hostUrl: getRemoteHostUrl(workspace.hostId), + }); + } + } + return results; + }, [localHostUrl, myMachineId, sidebarWorkspaces]); const diffStatsByWorkspaceId = useDashboardDiffStats(workspaceHosts); @@ -154,7 +170,7 @@ export function useDashboardSidebarData() { localWorkspaceIds, ], enabled: activeHostService !== null && localWorkspaceIds.length > 0, - refetchInterval: 15_000, + refetchInterval: 10_000, queryFn: () => activeHostService?.client.pullRequests.getByWorkspaces.query({ workspaceIds: localWorkspaceIds, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx index e3706d681b0..52c7d6c6d7e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -267,27 +267,25 @@ export function useChangesTab({ { enabled: filter.kind === "commit" || filter.kind === "range" }, ); - const totalChanges = status.data - ? status.data.againstBase.length + - status.data.staged.length + - status.data.unstaged.length - : 0; - - const totalAdditions = status.data - ? [ - ...status.data.againstBase, - ...status.data.staged, - ...status.data.unstaged, - ].reduce((sum, f) => sum + f.additions, 0) - : 0; - - const totalDeletions = status.data - ? [ - ...status.data.againstBase, - ...status.data.staged, - ...status.data.unstaged, - ].reduce((sum, f) => sum + f.deletions, 0) - : 0; + const filteredFiles = useMemo(() => { + if (!status.data) return []; + if (filter.kind === "uncommitted") { + return [...status.data.staged, ...status.data.unstaged]; + } + if (filter.kind === "commit" || filter.kind === "range") { + return commitFiles.data?.files ?? []; + } + // "all" — deduplicate by path + const map = new Map(); + for (const f of status.data.againstBase) map.set(f.path, f); + for (const f of status.data.staged) map.set(f.path, f); + for (const f of status.data.unstaged) map.set(f.path, f); + return Array.from(map.values()); + }, [status.data, filter.kind, commitFiles.data?.files]); + + const totalChanges = filteredFiles.length; + const totalAdditions = filteredFiles.reduce((sum, f) => sum + f.additions, 0); + const totalDeletions = filteredFiles.reduce((sum, f) => sum + f.deletions, 0); const content = useMemo(() => { if (status.isLoading) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index 536d7a68e7e..f6da5d23f6d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -7,7 +7,7 @@ import { getHostServiceHeaders, getHostServiceWsToken, } from "renderer/lib/host-service-auth"; -import { getWorkspaceHostUrlForWorkspace } from "renderer/lib/v2-workspace-host"; +import { getRemoteHostUrl } from "renderer/lib/v2-workspace-host"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useHostService } from "renderer/routes/_authenticated/providers/HostServiceProvider"; @@ -63,7 +63,7 @@ function V2WorkspaceLayout() { ? null : isLocal ? localHostUrl - : getWorkspaceHostUrlForWorkspace(workspace.id); + : getRemoteHostUrl(workspace.hostId); const lastEnsuredWorkspaceIdRef = useRef(null); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx index 7fdbb1b0b17..89e9f9c87c3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx @@ -45,7 +45,7 @@ function getSelectedLabel( } return ( - otherHosts.find((host) => host.id === hostTarget.deviceId)?.name ?? + otherHosts.find((host) => host.id === hostTarget.hostId)?.name ?? "Unknown Host" ); } @@ -112,16 +112,15 @@ export function DevicePicker({ otherHosts.map((host) => { const HostIcon = getHostIcon(host); const isSelected = - hostTarget.kind === "device" && - hostTarget.deviceId === host.id; + hostTarget.kind === "host" && hostTarget.hostId === host.id; return ( onSelectHostTarget({ - kind: "device", - deviceId: host.id, + kind: "host", + hostId: host.id, }) } > diff --git a/packages/db/drizzle/0031_v2_hosts_and_clients.sql b/packages/db/drizzle/0031_v2_hosts_and_clients.sql index 37d67843974..e6857044734 100644 --- a/packages/db/drizzle/0031_v2_hosts_and_clients.sql +++ b/packages/db/drizzle/0031_v2_hosts_and_clients.sql @@ -15,7 +15,7 @@ CREATE TABLE "v2_clients" ( CREATE TABLE "v2_hosts" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "organization_id" uuid NOT NULL, - "machine_id" text, + "machine_id" text NOT NULL, "name" text NOT NULL, "last_seen_at" timestamp with time zone, "created_by_user_id" uuid, diff --git a/packages/db/drizzle/meta/0031_snapshot.json b/packages/db/drizzle/meta/0031_snapshot.json index deb4f0d2039..b7095caeb47 100644 --- a/packages/db/drizzle/meta/0031_snapshot.json +++ b/packages/db/drizzle/meta/0031_snapshot.json @@ -1,5 +1,5 @@ { - "id": "39c7746a-9827-47ff-b502-a9f75a9af94a", + "id": "117b93c3-ed88-4d67-8d8e-71b79af2f3af", "prevId": "b4f3b2ee-8405-448f-b162-948895af940c", "version": "7", "dialect": "postgresql", @@ -4464,7 +4464,7 @@ "name": "machine_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "name": { "name": "name", diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 638b51fa1d1..275965b18a1 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -222,7 +222,7 @@ { "idx": 31, "version": "7", - "when": 1775457250045, + "when": 1775535603220, "tag": "0031_v2_hosts_and_clients", "breakpoints": true } diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index bbe1f8f9c8c..9c7402e9f77 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -413,7 +413,7 @@ export const v2Hosts = pgTable( organizationId: uuid("organization_id") .notNull() .references(() => organizations.id, { onDelete: "cascade" }), - machineId: text("machine_id"), + machineId: text("machine_id").notNull(), name: text().notNull(), lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), createdByUserId: uuid("created_by_user_id").references(() => users.id, { diff --git a/packages/host-service/src/runtime/git/git.ts b/packages/host-service/src/runtime/git/git.ts index 466a7cae20c..40009cab3cd 100644 --- a/packages/host-service/src/runtime/git/git.ts +++ b/packages/host-service/src/runtime/git/git.ts @@ -13,6 +13,7 @@ export function createGitFactory(provider: GitCredentialProvider): GitFactory { return git.env({ ...initialCredentials.env, ...credentials.env, + GIT_OPTIONAL_LOCKS: "0", }); }; } diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.ts index 55e4a537ba5..a64d9f55871 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.ts @@ -22,7 +22,7 @@ import { } from "./utils/pull-request-mappers"; const BRANCH_SYNC_INTERVAL_MS = 30_000; -const PROJECT_REFRESH_INTERVAL_MS = 30_000; +const PROJECT_REFRESH_INTERVAL_MS = 10_000; const UNBORN_HEAD_ERROR_PATTERNS = [ "ambiguous argument 'head'", "unknown revision or path not in the working tree", From 4d28b3531bc0e8ea4b24ad36de682a8dae784ccb Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 22:17:56 -0700 Subject: [PATCH 04/11] refactor: per-workspace diff stats via useWorkspaceEvent hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useWorkspaceEvent(type, workspaceId, callback) hook that resolves workspace → host → event bus connection automatically - Simplify useDiffStats to a per-workspace hook called from each DashboardSidebarWorkspaceItem instead of batch-fetching in sidebar data - Remove diff stats logic from useDashboardSidebarData (no more workspaceHosts mapping, diffStatsByWorkspaceId, or diffStats on type) - Pass diffStats as prop to ExpandedWorkspaceRow and HoverCardContent --- .../DashboardSidebarWorkspaceItem.tsx | 9 +- .../DashboardSidebarExpandedWorkspaceRow.tsx | 9 +- ...hboardSidebarWorkspaceHoverCardContent.tsx | 11 +- .../hooks/useDashboardDiffStats/index.ts | 6 +- .../useDashboardDiffStats.ts | 154 +++++------------- .../useDashboardSidebarData.ts | 38 +---- .../hooks/useWorkspaceEvent/index.ts | 1 + .../useWorkspaceEvent/useWorkspaceEvent.ts | 72 ++++++++ .../components/DashboardSidebar/types.ts | 1 - 9 files changed, 135 insertions(+), 166 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 6cc52be5535..9bff9a20b22 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,3 +1,4 @@ +import { useDiffStats } from "../../hooks/useDashboardDiffStats"; import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarDeleteDialog } from "../DashboardSidebarDeleteDialog"; import { DashboardSidebarCollapsedWorkspaceButton } from "./components/DashboardSidebarCollapsedWorkspaceButton"; @@ -32,6 +33,7 @@ export function DashboardSidebarWorkspaceItem({ creationStatus, } = workspace; const mockData = getWorkspaceRowMocks(id); + const diffStats = useDiffStats(id); const { cancelRename, handleClick, @@ -97,6 +99,7 @@ export function DashboardSidebarWorkspaceItem({ hoverCardContent={ } onCreateSection={handleCreateSection} @@ -135,6 +138,7 @@ export function DashboardSidebarWorkspaceItem({ renameValue={renameValue} shortcutLabel={shortcutLabel} mockData={isCreating ? { ...mockData, workspaceStatus: null } : mockData} + diffStats={isCreating ? null : diffStats} onClick={isCreating ? undefined : handleClick} onDoubleClick={isCreating ? undefined : startRename} onDeleteClick={() => setIsDeleteDialogOpen(true)} @@ -156,7 +160,10 @@ export function DashboardSidebarWorkspaceItem({ hostType === "local-device" ? onHoverCardOpen : undefined } hoverCardContent={ - + } onCreateSection={handleCreateSection} onMoveToSection={(targetSectionId) => diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index 1ac489fb347..c0cc2a9eb53 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -10,6 +10,7 @@ import { import { HiMiniXMark } from "react-icons/hi2"; import { HotkeyLabel } from "renderer/hotkeys"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; +import type { DiffStats } from "../../../../hooks/useDashboardDiffStats"; import type { DashboardSidebarWorkspace } from "../../../../types"; import type { WorkspaceRowMockData } from "../../utils"; import { getCreationStatusText } from "../../utils/getCreationStatusText"; @@ -25,6 +26,7 @@ interface DashboardSidebarExpandedWorkspaceRowProps renameValue: string; shortcutLabel?: string; mockData: WorkspaceRowMockData; + diffStats: DiffStats | null; onClick?: () => void; onDoubleClick?: () => void; onDeleteClick: () => void; @@ -45,6 +47,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< renameValue, shortcutLabel, mockData, + diffStats, onClick, onDoubleClick, onDeleteClick, @@ -167,10 +170,10 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< ) : ( <> - {workspace.diffStats && ( + {diffStats && ( )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx index 47b492fd3f5..f3368845ca2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx @@ -4,6 +4,7 @@ import { formatDistanceToNow } from "date-fns"; import { FaGithub } from "react-icons/fa"; import { LuExternalLink, LuGlobe, LuTriangleAlert } from "react-icons/lu"; import { useHotkeyDisplay } from "renderer/hotkeys"; +import type { DiffStats } from "../../../../hooks/useDashboardDiffStats"; import type { DashboardSidebarWorkspace } from "../../../../types"; import { ChecksList } from "./components/ChecksList"; import { ChecksSummary } from "./components/ChecksSummary"; @@ -12,10 +13,12 @@ import { ReviewStatus } from "./components/ReviewStatus"; interface DashboardSidebarWorkspaceHoverCardContentProps { workspace: DashboardSidebarWorkspace; + diffStats: DiffStats | null; } export function DashboardSidebarWorkspaceHoverCardContent({ workspace, + diffStats, }: DashboardSidebarWorkspaceHoverCardContentProps) { const { name, @@ -104,13 +107,11 @@ export function DashboardSidebarWorkspaceHoverCardContent({ /> )}
- {workspace.diffStats && ( + {diffStats && (
- - +{workspace.diffStats.additions} - + +{diffStats.additions} - -{workspace.diffStats.deletions} + -{diffStats.deletions}
)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts index 72d09563223..22003cebae4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts @@ -1,5 +1 @@ -export { - type DiffStats, - useDashboardDiffStats, - type WorkspaceHostInfo, -} from "./useDashboardDiffStats"; +export { type DiffStats, useDiffStats } from "./useDashboardDiffStats"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts index 27bf3f21bc9..9751dffa1b6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts @@ -1,130 +1,54 @@ -import { getEventBus } from "@superset/workspace-client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; +import { useCallback, useEffect, useState } from "react"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useWorkspaceEvent, useWorkspaceHostUrl } from "../useWorkspaceEvent"; export interface DiffStats { additions: number; deletions: number; } -export interface WorkspaceHostInfo { - workspaceId: string; - hostUrl: string; -} - -export function useDashboardDiffStats( - workspaceHosts: WorkspaceHostInfo[], -): Map { - const [statsMap, setStatsMap] = useState>( - () => new Map(), - ); - - const fetchDiffStats = useCallback( - async (workspaceId: string, hostUrl: string) => { - try { - const client = getHostServiceClientByUrl(hostUrl); - const status = await client.git.getStatus.query({ workspaceId }); - - let additions = 0; - let deletions = 0; - for (const file of status.againstBase) { - additions += file.additions; - deletions += file.deletions; - } - for (const file of status.staged) { - additions += file.additions; - deletions += file.deletions; - } - for (const file of status.unstaged) { - additions += file.additions; - deletions += file.deletions; - } - - setStatsMap((prev) => { - const next = new Map(prev); - next.set(workspaceId, { additions, deletions }); - return next; - }); - } catch { - // Workspace might have been deleted or host-service unavailable +/** + * Fetches diff stats for a single workspace, auto-updates on git changes. + * Just pass the workspaceId — host resolution is handled internally. + */ +export function useDiffStats(workspaceId: string): DiffStats | null { + const [stats, setStats] = useState(null); + const hostUrl = useWorkspaceHostUrl(workspaceId); + + const fetchStats = useCallback(async () => { + if (!hostUrl) return; + try { + const client = getHostServiceClientByUrl(hostUrl); + const status = await client.git.getStatus.query({ workspaceId }); + + let additions = 0; + let deletions = 0; + for (const file of status.againstBase) { + additions += file.additions; + deletions += file.deletions; } - }, - [], - ); - - // Stable serialization key for the workspace-host list - const _workspaceHostsKey = workspaceHosts - .map((wh) => `${wh.workspaceId}:${wh.hostUrl}`) - .join(","); - - // Keep a ref so event handlers can access current mapping - const workspaceHostsRef = useRef(workspaceHosts); - workspaceHostsRef.current = workspaceHosts; - - // Fetch initial data for all workspaces - useEffect(() => { - for (const { workspaceId, hostUrl } of workspaceHosts) { - void fetchDiffStats(workspaceId, hostUrl); - } - }, [fetchDiffStats, workspaceHosts]); - - // Subscribe to git:changed events per unique host - useEffect(() => { - if (workspaceHosts.length === 0) return; - - // Group workspaces by hostUrl - const byHost = new Map>(); - for (const { workspaceId, hostUrl } of workspaceHosts) { - let set = byHost.get(hostUrl); - if (!set) { - set = new Set(); - byHost.set(hostUrl, set); + for (const file of status.staged) { + additions += file.additions; + deletions += file.deletions; + } + for (const file of status.unstaged) { + additions += file.additions; + deletions += file.deletions; } - set.add(workspaceId); - } - - const cleanups: Array<() => void> = []; - - for (const [hostUrl, workspaceIds] of byHost) { - const bus = getEventBus(hostUrl, () => getHostServiceWsToken(hostUrl)); - - const removeListener = bus.on( - "git:changed", - "*", - (changedWorkspaceId) => { - if (workspaceIds.has(changedWorkspaceId)) { - void fetchDiffStats(changedWorkspaceId, hostUrl); - } - }, - ); - const release = bus.retain(); - cleanups.push(removeListener, release); + setStats({ additions, deletions }); + } catch { + // Host unavailable or workspace deleted } + }, [hostUrl, workspaceId]); - return () => { - for (const cleanup of cleanups) { - cleanup(); - } - }; - }, [fetchDiffStats, workspaceHosts]); - - // Clean up stale entries when workspace list changes useEffect(() => { - const idSet = new Set(workspaceHosts.map((wh) => wh.workspaceId)); - setStatsMap((prev) => { - let changed = false; - const next = new Map(prev); - for (const key of next.keys()) { - if (!idSet.has(key)) { - next.delete(key); - changed = true; - } - } - return changed ? next : prev; - }); - }, [workspaceHosts.map]); + void fetchStats(); + }, [fetchStats]); + + useWorkspaceEvent("git:changed", workspaceId, () => { + void fetchStats(); + }); - return statsMap; + return stats; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index 441170ee5c5..445d21cf687 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -5,7 +5,6 @@ import { useCallback, useMemo } from "react"; import { env } from "renderer/env.renderer"; import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { getRemoteHostUrl } from "renderer/lib/v2-workspace-host"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useHostService } from "renderer/routes/_authenticated/providers/HostServiceProvider"; @@ -17,10 +16,6 @@ import type { DashboardSidebarSection, DashboardSidebarWorkspace, } from "../../types"; -import { - useDashboardDiffStats, - type WorkspaceHostInfo, -} from "../useDashboardDiffStats"; // Pending workspaces are always rendered at the end of the project's workspace list const PENDING_WORKSPACE_TAB_ORDER = Number.MAX_SAFE_INTEGER; @@ -126,42 +121,19 @@ export function useDashboardSidebarData() { [collections], ); - const localHostUrl = activeHostService?.url ?? null; - const myMachineId = deviceInfo?.deviceId ?? null; - const localWorkspaceIds = useMemo( () => sidebarWorkspaces .filter( (workspace) => workspace.hostMachineId != null && - workspace.hostMachineId === myMachineId, + workspace.hostMachineId === deviceInfo?.deviceId, ) .map((workspace) => workspace.id) .sort(), - [myMachineId, sidebarWorkspaces], + [deviceInfo?.deviceId, sidebarWorkspaces], ); - const workspaceHosts = useMemo(() => { - const results: WorkspaceHostInfo[] = []; - for (const workspace of sidebarWorkspaces) { - if (workspace.hostMachineId == null) continue; // cloud — no git - if (workspace.hostMachineId === myMachineId) { - if (localHostUrl) { - results.push({ workspaceId: workspace.id, hostUrl: localHostUrl }); - } - } else { - results.push({ - workspaceId: workspace.id, - hostUrl: getRemoteHostUrl(workspace.hostId), - }); - } - } - return results; - }, [localHostUrl, myMachineId, sidebarWorkspaces]); - - const diffStatsByWorkspaceId = useDashboardDiffStats(workspaceHosts); - const { data: pullRequestData, refetch: refetchPullRequests } = useQuery({ queryKey: [ "dashboard-sidebar", @@ -271,10 +243,6 @@ export function useDashboardSidebarData() { : null, branchExistsOnRemote: project.githubOwner !== null && project.githubRepoName !== null, - diffStats: - hostType === "local-device" - ? (diffStatsByWorkspaceId.get(workspace.id) ?? null) - : null, previewUrl: null, needsRebase: null, behindCount: null, @@ -325,7 +293,6 @@ export function useDashboardSidebarData() { ? `https://github.com/${project.githubOwner}/${project.githubRepoName}` : null, branchExistsOnRemote: false, - diffStats: null, previewUrl: null, needsRebase: null, behindCount: null, @@ -359,7 +326,6 @@ export function useDashboardSidebarData() { }); }, [ deviceInfo?.deviceId, - diffStatsByWorkspaceId, localPullRequestsByWorkspaceId, pendingWorkspace, sidebarProjects, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/index.ts new file mode 100644 index 00000000000..be5b7dda84e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/index.ts @@ -0,0 +1 @@ +export { useWorkspaceEvent, useWorkspaceHostUrl } from "./useWorkspaceEvent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts new file mode 100644 index 00000000000..6545fb6c517 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts @@ -0,0 +1,72 @@ +import { getEventBus } from "@superset/workspace-client"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useEffect, useEffectEvent, useMemo } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; +import { getRemoteHostUrl } from "renderer/lib/v2-workspace-host"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useHostService } from "renderer/routes/_authenticated/providers/HostServiceProvider"; + +/** + * Subscribe to an event bus event for a workspace. + * Resolves the workspace's host and connects to the correct event bus automatically. + */ +export function useWorkspaceEvent( + type: "git:changed", + workspaceId: string, + callback: () => void, + enabled = true, +): void { + const hostUrl = useWorkspaceHostUrl(workspaceId); + const handler = useEffectEvent(callback); + + useEffect(() => { + if (!enabled || !hostUrl) return; + + const bus = getEventBus(hostUrl, () => getHostServiceWsToken(hostUrl)); + const removeListener = bus.on(type, workspaceId, () => handler()); + const release = bus.retain(); + + return () => { + removeListener(); + release(); + }; + }, [enabled, hostUrl, type, workspaceId]); +} + +/** + * Resolves a workspace ID to its host-service URL. + * Local host → localhost port. Remote host → relay proxy URL. + */ +export function useWorkspaceHostUrl(workspaceId: string): string | null { + const collections = useCollections(); + const { services } = useHostService(); + const { data: deviceInfo } = electronTrpc.auth.getDeviceInfo.useQuery(); + + const { data: workspaceWithHost = [] } = useLiveQuery( + (q) => + q + .from({ workspaces: collections.v2Workspaces }) + .innerJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => + eq(workspaces.hostId, hosts.id), + ) + .where(({ workspaces }) => eq(workspaces.id, workspaceId)) + .select(({ workspaces, hosts }) => ({ + hostId: workspaces.hostId, + hostMachineId: hosts.machineId, + hostOrgId: hosts.organizationId, + })), + [collections, workspaceId], + ); + + const match = workspaceWithHost[0] ?? null; + + return useMemo(() => { + if (!match) return null; + if (match.hostMachineId === deviceInfo?.deviceId) { + return services.get(match.hostOrgId)?.url ?? null; + } + return getRemoteHostUrl(match.hostId); + }, [match, deviceInfo?.deviceId, services]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts index 57a07d99459..a8a52331141 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts @@ -34,7 +34,6 @@ export interface DashboardSidebarWorkspace { previewUrl: string | null; needsRebase: boolean | null; behindCount: number | null; - diffStats: { additions: number; deletions: number } | null; createdAt: Date; updatedAt: Date; creationStatus?: "preparing" | "generating-branch" | "creating"; From 822dd91885941d67e38a66d7e46780a208f83e49 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 22:20:59 -0700 Subject: [PATCH 05/11] chore: remove workspace status mock data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete getWorkspaceRowMocks entirely — no more fake data in the sidebar. workspaceStatus will be null until wired up with real data. --- .../DashboardSidebarWorkspaceItem.tsx | 4 --- .../DashboardSidebarExpandedWorkspaceRow.tsx | 5 +--- .../utils/getWorkspaceRowMocks.ts | 25 ------------------- .../utils/index.ts | 2 -- 4 files changed, 1 insertion(+), 35 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 9bff9a20b22..91ba8a395d9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -6,7 +6,6 @@ import { DashboardSidebarExpandedWorkspaceRow } from "./components/DashboardSide import { DashboardSidebarWorkspaceContextMenu } from "./components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu"; import { DashboardSidebarWorkspaceHoverCardContent } from "./components/DashboardSidebarWorkspaceHoverCardContent"; import { useDashboardSidebarWorkspaceItemActions } from "./hooks/useDashboardSidebarWorkspaceItemActions"; -import { getWorkspaceRowMocks } from "./utils"; interface DashboardSidebarWorkspaceItemProps { workspace: DashboardSidebarWorkspace; @@ -32,7 +31,6 @@ export function DashboardSidebarWorkspaceItem({ branch, creationStatus, } = workspace; - const mockData = getWorkspaceRowMocks(id); const diffStats = useDiffStats(id); const { cancelRename, @@ -75,7 +73,6 @@ export function DashboardSidebarWorkspaceItem({ hostType={hostType} isActive={isActive} onClick={isCreating ? undefined : handleClick} - workspaceStatus={isCreating ? null : mockData.workspaceStatus} creationStatus={creationStatus} disabled={isCreating} aria-label={ @@ -137,7 +134,6 @@ export function DashboardSidebarWorkspaceItem({ isRenaming={isRenaming} renameValue={renameValue} shortcutLabel={shortcutLabel} - mockData={isCreating ? { ...mockData, workspaceStatus: null } : mockData} diffStats={isCreating ? null : diffStats} onClick={isCreating ? undefined : handleClick} onDoubleClick={isCreating ? undefined : startRename} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index c0cc2a9eb53..e7fb62dd6d9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -12,7 +12,6 @@ import { HotkeyLabel } from "renderer/hotkeys"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; import type { DiffStats } from "../../../../hooks/useDashboardDiffStats"; import type { DashboardSidebarWorkspace } from "../../../../types"; -import type { WorkspaceRowMockData } from "../../utils"; import { getCreationStatusText } from "../../utils/getCreationStatusText"; import { DashboardSidebarWorkspaceDiffStats } from "../DashboardSidebarWorkspaceDiffStats"; import { DashboardSidebarWorkspaceIcon } from "../DashboardSidebarWorkspaceIcon"; @@ -25,7 +24,6 @@ interface DashboardSidebarExpandedWorkspaceRowProps isRenaming: boolean; renameValue: string; shortcutLabel?: string; - mockData: WorkspaceRowMockData; diffStats: DiffStats | null; onClick?: () => void; onDoubleClick?: () => void; @@ -46,7 +44,6 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< isRenaming, renameValue, shortcutLabel, - mockData, diffStats, onClick, onDoubleClick, @@ -127,7 +124,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< hostType={hostType} isActive={isActive} variant="expanded" - workspaceStatus={mockData.workspaceStatus} + workspaceStatus={null} creationStatus={creationStatus} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts deleted file mode 100644 index ff4029e8352..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ActivePaneStatus } from "shared/tabs-types"; - -export interface WorkspaceRowMockData { - workspaceStatus: ActivePaneStatus | null; -} - -function getSeed(input: string): number { - return [...input].reduce( - (seed, character, index) => seed + character.charCodeAt(0) * (index + 1), - 0, - ); -} - -export function getWorkspaceRowMocks( - workspaceId: string, -): WorkspaceRowMockData { - const seed = getSeed(workspaceId); - const paneStatuses: ActivePaneStatus[] = ["permission", "working", "review"]; - const status = - seed % 6 === 0 ? paneStatuses[seed % paneStatuses.length] : null; - - return { - workspaceStatus: status, - }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/index.ts index d6d66d3e0c0..5298c6aa549 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/index.ts @@ -1,3 +1 @@ export { getCreationStatusText } from "./getCreationStatusText"; -export type { WorkspaceRowMockData } from "./getWorkspaceRowMocks"; -export { getWorkspaceRowMocks } from "./getWorkspaceRowMocks"; From d00e7693c5fa5e51b8aa6911425b4fef6e46e57e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 22:49:23 -0700 Subject: [PATCH 06/11] refactor: delete FS event adapter layer, event-driven Changes tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend useWorkspaceEvent to support "fs:events" (with watchFs/unwatchFs) - Move useFileTree + useFileDocument from workspace-client to desktop app, swap useWorkspaceFsEvents → useWorkspaceEvent internally - Update FilesTab to use useWorkspaceEvent directly for search invalidation - Switch useChangesTab from 3s polling to git:changed event bus invalidation - Delete workspaceFsEventRegistry, useWorkspaceFsEventBridge, useWorkspaceFsEvents from workspace-client entirely --- .../useWorkspaceEvent/useWorkspaceEvent.ts | 43 +++++- .../components/FilesTab/FilesTab.tsx | 18 +-- .../WorkspaceFilesTreeItem.tsx | 2 +- .../hooks/useChangesTab/useChangesTab.tsx | 12 +- .../hooks/useFileDocument/index.ts | 0 .../hooks/useFileDocument/useFileDocument.ts | 7 +- .../$workspaceId}/hooks/useFileTree/index.ts | 0 .../hooks/useFileTree/useFileTree.ts | 8 +- .../components/FilePane/FilePane.tsx | 2 +- .../hooks/useWorkspaceFsEventBridge/index.ts | 1 - .../useWorkspaceFsEventBridge.ts | 18 --- .../src/hooks/useWorkspaceFsEvents/index.ts | 1 - .../useWorkspaceFsEvents.ts | 23 --- packages/workspace-client/src/index.ts | 13 -- .../src/lib/workspaceFsEventRegistry.ts | 136 ------------------ 15 files changed, 65 insertions(+), 219 deletions(-) rename {packages/workspace-client/src => apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId}/hooks/useFileDocument/index.ts (100%) rename {packages/workspace-client/src => apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId}/hooks/useFileDocument/useFileDocument.ts (97%) rename {packages/workspace-client/src => apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId}/hooks/useFileTree/index.ts (100%) rename {packages/workspace-client/src => apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId}/hooks/useFileTree/useFileTree.ts (97%) delete mode 100644 packages/workspace-client/src/hooks/useWorkspaceFsEventBridge/index.ts delete mode 100644 packages/workspace-client/src/hooks/useWorkspaceFsEventBridge/useWorkspaceFsEventBridge.ts delete mode 100644 packages/workspace-client/src/hooks/useWorkspaceFsEvents/index.ts delete mode 100644 packages/workspace-client/src/hooks/useWorkspaceFsEvents/useWorkspaceFsEvents.ts delete mode 100644 packages/workspace-client/src/lib/workspaceFsEventRegistry.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts index 6545fb6c517..557c2809c8a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts @@ -1,4 +1,6 @@ import { getEventBus } from "@superset/workspace-client"; +// biome-ignore lint/style/noRestrictedImports: type-only import, no Node runtime dependency +import type { FsWatchEvent } from "@superset/workspace-fs/host"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useEffect, useEffectEvent, useMemo } from "react"; @@ -16,6 +18,18 @@ export function useWorkspaceEvent( type: "git:changed", workspaceId: string, callback: () => void, + enabled?: boolean, +): void; +export function useWorkspaceEvent( + type: "fs:events", + workspaceId: string, + callback: (event: FsWatchEvent) => void, + enabled?: boolean, +): void; +export function useWorkspaceEvent( + type: "git:changed" | "fs:events", + workspaceId: string, + callback: ((event: FsWatchEvent) => void) | (() => void), enabled = true, ): void { const hostUrl = useWorkspaceHostUrl(workspaceId); @@ -25,12 +39,33 @@ export function useWorkspaceEvent( if (!enabled || !hostUrl) return; const bus = getEventBus(hostUrl, () => getHostServiceWsToken(hostUrl)); - const removeListener = bus.on(type, workspaceId, () => handler()); - const release = bus.retain(); + const cleanups: Array<() => void> = []; + + if (type === "fs:events") { + bus.watchFs(workspaceId); + const removeListener = bus.on( + "fs:events", + workspaceId, + (_wid, payload) => { + for (const event of payload.events) { + (handler as (event: FsWatchEvent) => void)(event); + } + }, + ); + cleanups.push(removeListener, () => bus.unwatchFs(workspaceId)); + } else { + const removeListener = bus.on("git:changed", workspaceId, () => { + (handler as () => void)(); + }); + cleanups.push(removeListener); + } + + cleanups.push(bus.retain()); return () => { - removeListener(); - release(); + for (const cleanup of cleanups) { + cleanup(); + } }; }, [enabled, hostUrl, type, workspaceId]); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx index 9928aec55b2..e1f0deb5b33 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx @@ -2,19 +2,15 @@ import { alert } from "@superset/ui/atoms/Alert"; import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { - type FileTreeNode, - useFileTree, - useWorkspaceFsEventBridge, - useWorkspaceFsEvents, - workspaceTrpc, -} from "@superset/workspace-client"; +import { workspaceTrpc } from "@superset/workspace-client"; import { FilePlus, FolderPlus, FoldVertical, RefreshCw } from "lucide-react"; import { Fragment, useCallback, useEffect, useRef, useState } from "react"; +import { useWorkspaceEvent } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent"; import { ROW_HEIGHT, TREE_INDENT, } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; +import { type FileTreeNode, useFileTree } from "../../../../hooks/useFileTree"; import { NewItemInput } from "./components/NewItemInput"; import { WorkspaceFilesTreeItem } from "./components/WorkspaceFilesTreeItem"; @@ -173,14 +169,10 @@ export function FilesTab({ workspaceTrpc.filesystem.createDirectory.useMutation(); const movePath = workspaceTrpc.filesystem.movePath.useMutation(); - useWorkspaceFsEventBridge( - workspaceId, - Boolean(workspaceId && workspaceQuery.data?.worktreePath), - ); - const fileTree = useFileTree({ workspaceId, rootPath }); - useWorkspaceFsEvents( + useWorkspaceEvent( + "fs:events", workspaceId, () => void utils.filesystem.searchFiles.invalidate(), Boolean(workspaceId), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx index f4d85c967df..05ac9b7fae6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx @@ -1,8 +1,8 @@ import { ContextMenu, ContextMenuTrigger } from "@superset/ui/context-menu"; import { cn } from "@superset/ui/utils"; -import type { FileTreeNode } from "@superset/workspace-client"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import type { FileTreeNode } from "../../../../../../hooks/useFileTree"; import { FileContextMenu } from "./components/FileContextMenu"; import { FolderContextMenu } from "./components/FolderContextMenu"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx index 52c7d6c6d7e..5a482eed1d3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -4,6 +4,7 @@ import { workspaceTrpc } from "@superset/workspace-client"; import type { inferRouterOutputs } from "@trpc/server"; import { GitBranch, Pencil } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; +import { useWorkspaceEvent } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import type { SidebarTabDefinition } from "../../types"; @@ -214,14 +215,16 @@ export function useChangesTab({ [collections, workspaceId], ); + const statusUtils = workspaceTrpc.useUtils(); + const status = workspaceTrpc.git.getStatus.useQuery( { workspaceId, baseBranch: baseBranch ?? undefined }, - { refetchInterval: 3_000, refetchOnWindowFocus: true }, + { refetchOnWindowFocus: true }, ); const commits = workspaceTrpc.git.listCommits.useQuery( { workspaceId, baseBranch: baseBranch ?? undefined }, - { refetchInterval: 3_000, refetchOnWindowFocus: true }, + { refetchOnWindowFocus: true }, ); const branches = workspaceTrpc.git.listBranches.useQuery( @@ -229,6 +232,11 @@ export function useChangesTab({ { refetchInterval: 30_000, refetchOnWindowFocus: true }, ); + useWorkspaceEvent("git:changed", workspaceId, () => { + void statusUtils.git.getStatus.invalidate({ workspaceId }); + void statusUtils.git.listCommits.invalidate({ workspaceId }); + }); + const renameBranchMutation = workspaceTrpc.git.renameBranch.useMutation(); const handleRenameBranch = useCallback( diff --git a/packages/workspace-client/src/hooks/useFileDocument/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileDocument/index.ts similarity index 100% rename from packages/workspace-client/src/hooks/useFileDocument/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileDocument/index.ts diff --git a/packages/workspace-client/src/hooks/useFileDocument/useFileDocument.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileDocument/useFileDocument.ts similarity index 97% rename from packages/workspace-client/src/hooks/useFileDocument/useFileDocument.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileDocument/useFileDocument.ts index cd33c82e96e..7d597d4f73d 100644 --- a/packages/workspace-client/src/hooks/useFileDocument/useFileDocument.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileDocument/useFileDocument.ts @@ -1,6 +1,6 @@ +import { workspaceTrpc } from "@superset/workspace-client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { workspaceTrpc } from "../../workspace-trpc"; -import { useWorkspaceFsEvents } from "../useWorkspaceFsEvents"; +import { useWorkspaceEvent } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent"; const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; const BINARY_CHECK_SIZE = 8192; @@ -162,7 +162,8 @@ export function useFileDocument({ setConflict({ diskContent }); }, [fetchCurrentDiskContent, mode]); - useWorkspaceFsEvents( + useWorkspaceEvent( + "fs:events", workspaceId, (event) => { const path = currentPathRef.current; diff --git a/packages/workspace-client/src/hooks/useFileTree/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/index.ts similarity index 100% rename from packages/workspace-client/src/hooks/useFileTree/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/index.ts diff --git a/packages/workspace-client/src/hooks/useFileTree/useFileTree.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/useFileTree.ts similarity index 97% rename from packages/workspace-client/src/hooks/useFileTree/useFileTree.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/useFileTree.ts index 28272b73d29..cac2add945f 100644 --- a/packages/workspace-client/src/hooks/useFileTree/useFileTree.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/useFileTree.ts @@ -1,7 +1,8 @@ +import { workspaceTrpc } from "@superset/workspace-client"; +// biome-ignore lint/style/noRestrictedImports: type-only import, no Node runtime dependency import type { FsEntry, FsEntryKind } from "@superset/workspace-fs/host"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { workspaceTrpc } from "../../workspace-trpc"; -import { useWorkspaceFsEvents } from "../useWorkspaceFsEvents"; +import { useWorkspaceEvent } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent"; export interface FileTreeNode { absolutePath: string; @@ -354,7 +355,8 @@ export function useFileTree({ void loadDirectory(rootPath, true); }, [loadDirectory, rootPath, updateState]); - useWorkspaceFsEvents( + useWorkspaceEvent( + "fs:events", workspaceId, (event) => { if (!rootPath) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx index 587a32d0032..9cd17b60b1d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -1,8 +1,8 @@ import type { RendererContext } from "@superset/panes"; -import { useFileDocument } from "@superset/workspace-client"; import { useCallback } from "react"; import { isImageFile, isMarkdownFile } from "shared/file-types"; import type { FilePaneData, PaneViewerData } from "../../../../types"; +import { useFileDocument } from "../../../useFileDocument"; import { CodeRenderer } from "./renderers/CodeRenderer"; import { ImageRenderer } from "./renderers/ImageRenderer"; import { MarkdownRenderer } from "./renderers/MarkdownRenderer"; diff --git a/packages/workspace-client/src/hooks/useWorkspaceFsEventBridge/index.ts b/packages/workspace-client/src/hooks/useWorkspaceFsEventBridge/index.ts deleted file mode 100644 index 790ffd1a9c5..00000000000 --- a/packages/workspace-client/src/hooks/useWorkspaceFsEventBridge/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useWorkspaceFsEventBridge } from "./useWorkspaceFsEventBridge"; diff --git a/packages/workspace-client/src/hooks/useWorkspaceFsEventBridge/useWorkspaceFsEventBridge.ts b/packages/workspace-client/src/hooks/useWorkspaceFsEventBridge/useWorkspaceFsEventBridge.ts deleted file mode 100644 index a7133339d23..00000000000 --- a/packages/workspace-client/src/hooks/useWorkspaceFsEventBridge/useWorkspaceFsEventBridge.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useEffect } from "react"; -import { retainWorkspaceFsBridge } from "../../lib/workspaceFsEventRegistry"; -import { useWorkspaceClient } from "../../providers/WorkspaceClientProvider"; - -export function useWorkspaceFsEventBridge( - workspaceId: string, - enabled = true, -): void { - const client = useWorkspaceClient(); - - useEffect(() => { - if (!enabled || !workspaceId) { - return; - } - - return retainWorkspaceFsBridge(client, workspaceId); - }, [client, enabled, workspaceId]); -} diff --git a/packages/workspace-client/src/hooks/useWorkspaceFsEvents/index.ts b/packages/workspace-client/src/hooks/useWorkspaceFsEvents/index.ts deleted file mode 100644 index 516d396e403..00000000000 --- a/packages/workspace-client/src/hooks/useWorkspaceFsEvents/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useWorkspaceFsEvents } from "./useWorkspaceFsEvents"; diff --git a/packages/workspace-client/src/hooks/useWorkspaceFsEvents/useWorkspaceFsEvents.ts b/packages/workspace-client/src/hooks/useWorkspaceFsEvents/useWorkspaceFsEvents.ts deleted file mode 100644 index 90b195b4284..00000000000 --- a/packages/workspace-client/src/hooks/useWorkspaceFsEvents/useWorkspaceFsEvents.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect, useEffectEvent } from "react"; -import type { WorkspaceFsEventListener } from "../../lib/workspaceFsEventRegistry"; -import { subscribeToWorkspaceFsEvents } from "../../lib/workspaceFsEventRegistry"; -import { useWorkspaceClient } from "../../providers/WorkspaceClientProvider"; - -export function useWorkspaceFsEvents( - workspaceId: string, - listener: WorkspaceFsEventListener, - enabled = true, -): void { - const client = useWorkspaceClient(); - const onEvent = useEffectEvent(listener); - - useEffect(() => { - if (!enabled || !workspaceId) { - return; - } - - return subscribeToWorkspaceFsEvents(client, workspaceId, (event) => { - onEvent(event); - }); - }, [client, enabled, workspaceId]); -} diff --git a/packages/workspace-client/src/index.ts b/packages/workspace-client/src/index.ts index a7fa0fdc89a..7ba699a2e34 100644 --- a/packages/workspace-client/src/index.ts +++ b/packages/workspace-client/src/index.ts @@ -1,18 +1,5 @@ export { useEventBus } from "./hooks/useEventBus"; -export { - type UseFileDocumentParams, - type UseFileDocumentResult, - useFileDocument, -} from "./hooks/useFileDocument"; -export { - type FileTreeNode, - type UseFileTreeParams, - type UseFileTreeResult, - useFileTree, -} from "./hooks/useFileTree"; export { useGitChangeEvents } from "./hooks/useGitChangeEvents"; -export { useWorkspaceFsEventBridge } from "./hooks/useWorkspaceFsEventBridge"; -export { useWorkspaceFsEvents } from "./hooks/useWorkspaceFsEvents"; export { type EventBusHandle, getEventBus } from "./lib/eventBus"; export { useWorkspaceClient, diff --git a/packages/workspace-client/src/lib/workspaceFsEventRegistry.ts b/packages/workspace-client/src/lib/workspaceFsEventRegistry.ts deleted file mode 100644 index 8a78469247a..00000000000 --- a/packages/workspace-client/src/lib/workspaceFsEventRegistry.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { FsWatchEvent } from "@superset/workspace-fs/host"; -import { getEventBus } from "./eventBus"; - -export type WorkspaceFsEventListener = (event: FsWatchEvent) => void; - -interface WorkspaceFsSubscriptionState { - bridgeCount: number; - hostUrl: string; - getWsToken: () => string | null; - listeners: Set; - workspaceId: string; - removeBusListener: (() => void) | null; - watching: boolean; -} - -const subscriptions = new Map(); - -function getSubscriptionKey(hostUrl: string, workspaceId: string): string { - return `${hostUrl}:${workspaceId}`; -} - -function getOrCreateSubscription( - hostUrl: string, - getWsToken: () => string | null, - workspaceId: string, -): WorkspaceFsSubscriptionState { - const key = getSubscriptionKey(hostUrl, workspaceId); - const existing = subscriptions.get(key); - if (existing) { - return existing; - } - - const nextState: WorkspaceFsSubscriptionState = { - bridgeCount: 0, - hostUrl, - getWsToken, - listeners: new Set(), - workspaceId, - removeBusListener: null, - watching: false, - }; - subscriptions.set(key, nextState); - return nextState; -} - -function removeSubscriptionIfInactive( - state: WorkspaceFsSubscriptionState, -): void { - if (state.bridgeCount > 0 || state.listeners.size > 0) { - return; - } - - // Stop watching fs for this workspace - if (state.watching) { - const bus = getEventBus(state.hostUrl, state.getWsToken); - bus.unwatchFs(state.workspaceId); - state.watching = false; - } - - // Remove bus listener - state.removeBusListener?.(); - state.removeBusListener = null; - - subscriptions.delete(getSubscriptionKey(state.hostUrl, state.workspaceId)); -} - -function ensureTransport(state: WorkspaceFsSubscriptionState): void { - if (state.removeBusListener) { - return; - } - - if (state.bridgeCount === 0 && state.listeners.size === 0) { - return; - } - - const bus = getEventBus(state.hostUrl, state.getWsToken); - - // Listen for fs events on the event bus for this workspace - state.removeBusListener = bus.on( - "fs:events", - state.workspaceId, - (_workspaceId, payload) => { - for (const event of payload.events) { - for (const listener of state.listeners) { - listener(event); - } - } - }, - ); - - // Tell the server to start watching this workspace's filesystem - bus.watchFs(state.workspaceId); - state.watching = true; -} - -export interface FsEventRegistryClient { - hostUrl: string; - getWsToken: () => string | null; -} - -export function retainWorkspaceFsBridge( - client: FsEventRegistryClient, - workspaceId: string, -): () => void { - const state = getOrCreateSubscription( - client.hostUrl, - client.getWsToken, - workspaceId, - ); - state.bridgeCount += 1; - ensureTransport(state); - - return () => { - state.bridgeCount = Math.max(0, state.bridgeCount - 1); - removeSubscriptionIfInactive(state); - }; -} - -export function subscribeToWorkspaceFsEvents( - client: FsEventRegistryClient, - workspaceId: string, - listener: WorkspaceFsEventListener, -): () => void { - const state = getOrCreateSubscription( - client.hostUrl, - client.getWsToken, - workspaceId, - ); - state.listeners.add(listener); - ensureTransport(state); - - return () => { - state.listeners.delete(listener); - removeSubscriptionIfInactive(state); - }; -} From e2c5324e1e646cd539e06bcfd1289444951453e0 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 22:53:13 -0700 Subject: [PATCH 07/11] fix: re-export FS types from workspace-fs/client instead of /host Renderer code shouldn't import from @superset/workspace-fs/host. Re-export FsEntry, FsEntryKind, FsWatchEvent from the browser-safe /client entry point. --- .../hooks/useWorkspaceEvent/useWorkspaceEvent.ts | 3 +-- .../v2-workspace/$workspaceId/hooks/useFileTree/useFileTree.ts | 3 +-- packages/workspace-fs/src/client/index.ts | 1 + 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts index 557c2809c8a..a1aec8cb7c7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts @@ -1,6 +1,5 @@ import { getEventBus } from "@superset/workspace-client"; -// biome-ignore lint/style/noRestrictedImports: type-only import, no Node runtime dependency -import type { FsWatchEvent } from "@superset/workspace-fs/host"; +import type { FsWatchEvent } from "@superset/workspace-fs/client"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useEffect, useEffectEvent, useMemo } from "react"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/useFileTree.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/useFileTree.ts index cac2add945f..31283b24725 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/useFileTree.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/useFileTree.ts @@ -1,6 +1,5 @@ import { workspaceTrpc } from "@superset/workspace-client"; -// biome-ignore lint/style/noRestrictedImports: type-only import, no Node runtime dependency -import type { FsEntry, FsEntryKind } from "@superset/workspace-fs/host"; +import type { FsEntry, FsEntryKind } from "@superset/workspace-fs/client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useWorkspaceEvent } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent"; diff --git a/packages/workspace-fs/src/client/index.ts b/packages/workspace-fs/src/client/index.ts index 4305f130647..1afc1870bfc 100644 --- a/packages/workspace-fs/src/client/index.ts +++ b/packages/workspace-fs/src/client/index.ts @@ -3,6 +3,7 @@ export type { FsService, FsSubscriptionMap, } from "../core/service"; +export type { FsEntry, FsEntryKind, FsWatchEvent } from "../types"; import type { FsRequestMap, From f84c6e50baa33791ad327bdd637f24991c4de814 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 7 Apr 2026 08:47:54 -0700 Subject: [PATCH 08/11] fix: host URL resolution race, editor width, watchFs ref counting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove electron IPC dependency from useWorkspaceHostUrl — resolve local vs remote by checking if org has a running host-service in the services map instead of comparing machineId - Fix CodeRenderer not taking full pane width (add w-full + min-w-0) - Fix watchFs/unwatchFs lacking ref counting in event bus client (multiple subscribers could prematurely unwatch) --- .../hooks/useWorkspaceEvent/useWorkspaceEvent.ts | 12 +++++------- .../FilePane/renderers/CodeRenderer/CodeRenderer.tsx | 4 ++-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts index a1aec8cb7c7..d3768aef237 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts @@ -3,7 +3,6 @@ import type { FsWatchEvent } from "@superset/workspace-fs/client"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useEffect, useEffectEvent, useMemo } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; import { getRemoteHostUrl } from "renderer/lib/v2-workspace-host"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; @@ -76,7 +75,6 @@ export function useWorkspaceEvent( export function useWorkspaceHostUrl(workspaceId: string): string | null { const collections = useCollections(); const { services } = useHostService(); - const { data: deviceInfo } = electronTrpc.auth.getDeviceInfo.useQuery(); const { data: workspaceWithHost = [] } = useLiveQuery( (q) => @@ -88,7 +86,6 @@ export function useWorkspaceHostUrl(workspaceId: string): string | null { .where(({ workspaces }) => eq(workspaces.id, workspaceId)) .select(({ workspaces, hosts }) => ({ hostId: workspaces.hostId, - hostMachineId: hosts.machineId, hostOrgId: hosts.organizationId, })), [collections, workspaceId], @@ -98,9 +95,10 @@ export function useWorkspaceHostUrl(workspaceId: string): string | null { return useMemo(() => { if (!match) return null; - if (match.hostMachineId === deviceInfo?.deviceId) { - return services.get(match.hostOrgId)?.url ?? null; - } + // If we have a local host-service for this org, use it directly + const localService = services.get(match.hostOrgId); + if (localService) return localService.url; + // Otherwise route through the relay proxy return getRemoteHostUrl(match.hostId); - }, [match, deviceInfo?.deviceId, services]); + }, [match, services]); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/CodeRenderer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/CodeRenderer.tsx index 0f896fdf4e0..662386d2d35 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/CodeRenderer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/renderers/CodeRenderer/CodeRenderer.tsx @@ -43,9 +43,9 @@ export function CodeRenderer({ }, [onSave]); return ( -
+
{hasExternalChange && } -
+
Date: Tue, 7 Apr 2026 08:57:52 -0700 Subject: [PATCH 09/11] fix: ref-count watchFs/unwatchFs in event bus client Multiple subscribers to fs:events for the same workspace now correctly share a single watch. Only the first subscriber sends fs:watch, only the last unsubscriber sends fs:unwatch. --- packages/workspace-client/src/lib/eventBus.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/workspace-client/src/lib/eventBus.ts b/packages/workspace-client/src/lib/eventBus.ts index ac4470524c7..7bca51da52e 100644 --- a/packages/workspace-client/src/lib/eventBus.ts +++ b/packages/workspace-client/src/lib/eventBus.ts @@ -29,7 +29,7 @@ interface ConnectionState { socket: WebSocket | null; refCount: number; listeners: Set; - fsWatchedWorkspaces: Set; + fsWatchedWorkspaces: Map; reconnectAttempts: number; reconnectTimer: ReturnType | null; disposed: boolean; @@ -110,7 +110,7 @@ function connect( state.reconnectAttempts = 0; // Re-send all active fs:watch commands - for (const workspaceId of state.fsWatchedWorkspaces) { + for (const workspaceId of state.fsWatchedWorkspaces.keys()) { sendCommand(state, { type: "fs:watch", workspaceId }); } }; @@ -163,7 +163,7 @@ function getOrCreateConnection( socket: null, refCount: 0, listeners: new Set(), - fsWatchedWorkspaces: new Set(), + fsWatchedWorkspaces: new Map(), reconnectAttempts: 0, reconnectTimer: null, disposed: false, @@ -237,15 +237,21 @@ export function getEventBus( }, watchFs(workspaceId: string): void { - if (state.fsWatchedWorkspaces.has(workspaceId)) return; - state.fsWatchedWorkspaces.add(workspaceId); - sendCommand(state, { type: "fs:watch", workspaceId }); + const count = state.fsWatchedWorkspaces.get(workspaceId) ?? 0; + state.fsWatchedWorkspaces.set(workspaceId, count + 1); + if (count === 0) { + sendCommand(state, { type: "fs:watch", workspaceId }); + } }, unwatchFs(workspaceId: string): void { - if (!state.fsWatchedWorkspaces.has(workspaceId)) return; - state.fsWatchedWorkspaces.delete(workspaceId); - sendCommand(state, { type: "fs:unwatch", workspaceId }); + const count = state.fsWatchedWorkspaces.get(workspaceId) ?? 0; + if (count <= 1) { + state.fsWatchedWorkspaces.delete(workspaceId); + sendCommand(state, { type: "fs:unwatch", workspaceId }); + } else { + state.fsWatchedWorkspaces.set(workspaceId, count - 1); + } }, /** From 3614f5cda85a6d0279c4487eba1f743d9fc7590e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 7 Apr 2026 09:10:35 -0700 Subject: [PATCH 10/11] refactor: co-locate host-service hooks in renderer/hooks/host-service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all host-service-related hooks to renderer/hooks/host-service/ following repo structure guidelines (one hook per file, folder per hook, matching names). These will eventually move to a shared package. - Split useWorkspaceEvent.ts into useWorkspaceEvent/ and useWorkspaceHostUrl/ - Rename useDashboardDiffStats/ → useDiffStats/ (match hook name) - Move useFileTree/ and useFileDocument/ from v2-workspace route - Update all consumer import paths --- .../hooks/host-service/useDiffStats/index.ts | 1 + .../useDiffStats/useDiffStats.ts} | 3 +- .../host-service}/useFileDocument/index.ts | 0 .../useFileDocument/useFileDocument.ts | 2 +- .../host-service}/useFileTree/index.ts | 0 .../host-service}/useFileTree/useFileTree.ts | 2 +- .../host-service/useWorkspaceEvent/index.ts | 1 + .../useWorkspaceEvent/useWorkspaceEvent.ts | 43 +------------------ .../host-service/useWorkspaceHostUrl/index.ts | 1 + .../useWorkspaceHostUrl.ts | 39 +++++++++++++++++ .../DashboardSidebarWorkspaceItem.tsx | 2 +- .../DashboardSidebarExpandedWorkspaceRow.tsx | 2 +- ...hboardSidebarWorkspaceHoverCardContent.tsx | 2 +- .../hooks/useDashboardDiffStats/index.ts | 1 - .../hooks/useWorkspaceEvent/index.ts | 1 - .../components/FilesTab/FilesTab.tsx | 7 ++- .../WorkspaceFilesTreeItem.tsx | 2 +- .../hooks/useChangesTab/useChangesTab.tsx | 2 +- .../components/FilePane/FilePane.tsx | 2 +- 19 files changed, 59 insertions(+), 54 deletions(-) create mode 100644 apps/desktop/src/renderer/hooks/host-service/useDiffStats/index.ts rename apps/desktop/src/renderer/{routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts => hooks/host-service/useDiffStats/useDiffStats.ts} (92%) rename apps/desktop/src/renderer/{routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks => hooks/host-service}/useFileDocument/index.ts (100%) rename apps/desktop/src/renderer/{routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks => hooks/host-service}/useFileDocument/useFileDocument.ts (98%) rename apps/desktop/src/renderer/{routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks => hooks/host-service}/useFileTree/index.ts (100%) rename apps/desktop/src/renderer/{routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks => hooks/host-service}/useFileTree/useFileTree.ts (99%) create mode 100644 apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/index.ts rename apps/desktop/src/renderer/{routes/_authenticated/_dashboard/components/DashboardSidebar/hooks => hooks/host-service}/useWorkspaceEvent/useWorkspaceEvent.ts (53%) create mode 100644 apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/index.ts create mode 100644 apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/useWorkspaceHostUrl.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/index.ts diff --git a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/index.ts b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/index.ts new file mode 100644 index 00000000000..5dc048a8ea2 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/index.ts @@ -0,0 +1 @@ +export { type DiffStats, useDiffStats } from "./useDiffStats"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts similarity index 92% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts rename to apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts index 9751dffa1b6..6be84b58b92 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/useDashboardDiffStats.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { useWorkspaceEvent, useWorkspaceHostUrl } from "../useWorkspaceEvent"; +import { useWorkspaceEvent } from "../useWorkspaceEvent"; +import { useWorkspaceHostUrl } from "../useWorkspaceHostUrl"; export interface DiffStats { additions: number; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileDocument/index.ts b/apps/desktop/src/renderer/hooks/host-service/useFileDocument/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileDocument/index.ts rename to apps/desktop/src/renderer/hooks/host-service/useFileDocument/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileDocument/useFileDocument.ts b/apps/desktop/src/renderer/hooks/host-service/useFileDocument/useFileDocument.ts similarity index 98% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileDocument/useFileDocument.ts rename to apps/desktop/src/renderer/hooks/host-service/useFileDocument/useFileDocument.ts index 7d597d4f73d..cf9de5de59f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileDocument/useFileDocument.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useFileDocument/useFileDocument.ts @@ -1,6 +1,6 @@ import { workspaceTrpc } from "@superset/workspace-client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useWorkspaceEvent } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent"; +import { useWorkspaceEvent } from "../useWorkspaceEvent"; const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; const BINARY_CHECK_SIZE = 8192; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/index.ts b/apps/desktop/src/renderer/hooks/host-service/useFileTree/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/index.ts rename to apps/desktop/src/renderer/hooks/host-service/useFileTree/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/useFileTree.ts b/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts similarity index 99% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/useFileTree.ts rename to apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts index 31283b24725..5542ca1117f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useFileTree/useFileTree.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts @@ -1,7 +1,7 @@ import { workspaceTrpc } from "@superset/workspace-client"; import type { FsEntry, FsEntryKind } from "@superset/workspace-fs/client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useWorkspaceEvent } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent"; +import { useWorkspaceEvent } from "../useWorkspaceEvent"; export interface FileTreeNode { absolutePath: string; diff --git a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/index.ts b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/index.ts new file mode 100644 index 00000000000..35bb950c735 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/index.ts @@ -0,0 +1 @@ +export { useWorkspaceEvent } from "./useWorkspaceEvent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts similarity index 53% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts rename to apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts index d3768aef237..3c647b4b778 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/useWorkspaceEvent.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts @@ -1,12 +1,8 @@ import { getEventBus } from "@superset/workspace-client"; import type { FsWatchEvent } from "@superset/workspace-fs/client"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useEffect, useEffectEvent, useMemo } from "react"; +import { useEffect, useEffectEvent } from "react"; import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; -import { getRemoteHostUrl } from "renderer/lib/v2-workspace-host"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useHostService } from "renderer/routes/_authenticated/providers/HostServiceProvider"; +import { useWorkspaceHostUrl } from "../useWorkspaceHostUrl"; /** * Subscribe to an event bus event for a workspace. @@ -67,38 +63,3 @@ export function useWorkspaceEvent( }; }, [enabled, hostUrl, type, workspaceId]); } - -/** - * Resolves a workspace ID to its host-service URL. - * Local host → localhost port. Remote host → relay proxy URL. - */ -export function useWorkspaceHostUrl(workspaceId: string): string | null { - const collections = useCollections(); - const { services } = useHostService(); - - const { data: workspaceWithHost = [] } = useLiveQuery( - (q) => - q - .from({ workspaces: collections.v2Workspaces }) - .innerJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => - eq(workspaces.hostId, hosts.id), - ) - .where(({ workspaces }) => eq(workspaces.id, workspaceId)) - .select(({ workspaces, hosts }) => ({ - hostId: workspaces.hostId, - hostOrgId: hosts.organizationId, - })), - [collections, workspaceId], - ); - - const match = workspaceWithHost[0] ?? null; - - return useMemo(() => { - if (!match) return null; - // If we have a local host-service for this org, use it directly - const localService = services.get(match.hostOrgId); - if (localService) return localService.url; - // Otherwise route through the relay proxy - return getRemoteHostUrl(match.hostId); - }, [match, services]); -} diff --git a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/index.ts b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/index.ts new file mode 100644 index 00000000000..4b07976d824 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/index.ts @@ -0,0 +1 @@ +export { useWorkspaceHostUrl } from "./useWorkspaceHostUrl"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/useWorkspaceHostUrl.ts b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/useWorkspaceHostUrl.ts new file mode 100644 index 00000000000..cddbe90c9ef --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/useWorkspaceHostUrl.ts @@ -0,0 +1,39 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; +import { getRemoteHostUrl } from "renderer/lib/v2-workspace-host"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useHostService } from "renderer/routes/_authenticated/providers/HostServiceProvider"; + +/** + * Resolves a workspace ID to its host-service URL. + * Local host → localhost port. Remote host → relay proxy URL. + */ +export function useWorkspaceHostUrl(workspaceId: string): string | null { + const collections = useCollections(); + const { services } = useHostService(); + + const { data: workspaceWithHost = [] } = useLiveQuery( + (q) => + q + .from({ workspaces: collections.v2Workspaces }) + .innerJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => + eq(workspaces.hostId, hosts.id), + ) + .where(({ workspaces }) => eq(workspaces.id, workspaceId)) + .select(({ workspaces, hosts }) => ({ + hostId: workspaces.hostId, + hostOrgId: hosts.organizationId, + })), + [collections, workspaceId], + ); + + const match = workspaceWithHost[0] ?? null; + + return useMemo(() => { + if (!match) return null; + const localService = services.get(match.hostOrgId); + if (localService) return localService.url; + return getRemoteHostUrl(match.hostId); + }, [match, services]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 91ba8a395d9..1fc60f6f72f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,4 +1,4 @@ -import { useDiffStats } from "../../hooks/useDashboardDiffStats"; +import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarDeleteDialog } from "../DashboardSidebarDeleteDialog"; import { DashboardSidebarCollapsedWorkspaceButton } from "./components/DashboardSidebarCollapsedWorkspaceButton"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index e7fb62dd6d9..52431995bd4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -8,9 +8,9 @@ import { useRef, } from "react"; import { HiMiniXMark } from "react-icons/hi2"; +import type { DiffStats } from "renderer/hooks/host-service/useDiffStats"; import { HotkeyLabel } from "renderer/hotkeys"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; -import type { DiffStats } from "../../../../hooks/useDashboardDiffStats"; import type { DashboardSidebarWorkspace } from "../../../../types"; import { getCreationStatusText } from "../../utils/getCreationStatusText"; import { DashboardSidebarWorkspaceDiffStats } from "../DashboardSidebarWorkspaceDiffStats"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx index f3368845ca2..667b816e84e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx @@ -3,8 +3,8 @@ import { Kbd, KbdGroup } from "@superset/ui/kbd"; import { formatDistanceToNow } from "date-fns"; import { FaGithub } from "react-icons/fa"; import { LuExternalLink, LuGlobe, LuTriangleAlert } from "react-icons/lu"; +import type { DiffStats } from "renderer/hooks/host-service/useDiffStats"; import { useHotkeyDisplay } from "renderer/hotkeys"; -import type { DiffStats } from "../../../../hooks/useDashboardDiffStats"; import type { DashboardSidebarWorkspace } from "../../../../types"; import { ChecksList } from "./components/ChecksList"; import { ChecksSummary } from "./components/ChecksSummary"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts deleted file mode 100644 index 22003cebae4..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardDiffStats/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type DiffStats, useDiffStats } from "./useDashboardDiffStats"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/index.ts deleted file mode 100644 index be5b7dda84e..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useWorkspaceEvent, useWorkspaceHostUrl } from "./useWorkspaceEvent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx index e1f0deb5b33..c36c4056b63 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx @@ -5,12 +5,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { workspaceTrpc } from "@superset/workspace-client"; import { FilePlus, FolderPlus, FoldVertical, RefreshCw } from "lucide-react"; import { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import { useWorkspaceEvent } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent"; +import { + type FileTreeNode, + useFileTree, +} from "renderer/hooks/host-service/useFileTree"; +import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; import { ROW_HEIGHT, TREE_INDENT, } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; -import { type FileTreeNode, useFileTree } from "../../../../hooks/useFileTree"; import { NewItemInput } from "./components/NewItemInput"; import { WorkspaceFilesTreeItem } from "./components/WorkspaceFilesTreeItem"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx index 05ac9b7fae6..848c37e887f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx @@ -1,8 +1,8 @@ import { ContextMenu, ContextMenuTrigger } from "@superset/ui/context-menu"; import { cn } from "@superset/ui/utils"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; +import type { FileTreeNode } from "renderer/hooks/host-service/useFileTree"; import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; -import type { FileTreeNode } from "../../../../../../hooks/useFileTree"; import { FileContextMenu } from "./components/FileContextMenu"; import { FolderContextMenu } from "./components/FolderContextMenu"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx index 5a482eed1d3..9dc4f1418a1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -4,7 +4,7 @@ import { workspaceTrpc } from "@superset/workspace-client"; import type { inferRouterOutputs } from "@trpc/server"; import { GitBranch, Pencil } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; -import { useWorkspaceEvent } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useWorkspaceEvent"; +import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import type { SidebarTabDefinition } from "../../types"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx index 9cd17b60b1d..b99017294e6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -1,8 +1,8 @@ import type { RendererContext } from "@superset/panes"; import { useCallback } from "react"; +import { useFileDocument } from "renderer/hooks/host-service/useFileDocument"; import { isImageFile, isMarkdownFile } from "shared/file-types"; import type { FilePaneData, PaneViewerData } from "../../../../types"; -import { useFileDocument } from "../../../useFileDocument"; import { CodeRenderer } from "./renderers/CodeRenderer"; import { ImageRenderer } from "./renderers/ImageRenderer"; import { MarkdownRenderer } from "./renderers/MarkdownRenderer"; From 1aeccdcb59357672eec9b665cb7a93e1640e5a43 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 7 Apr 2026 09:25:11 -0700 Subject: [PATCH 11/11] fix: debounce git watcher, remove stale enums, fix double-counting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 300ms per-workspace debounce to GitWatcher — a single git operation no longer fires dozens of events - Remove dead v2DeviceType and v2UsersDeviceRole enums from schema - Make v2_hosts.machineId nullable (cloud hosts have no machine) - Deduplicate files by path in useDiffStats to prevent double-counting when a file appears in both againstBase and staged/unstaged - Remove getConnectionKey no-op function from event bus client --- .../host-service/useDiffStats/useDiffStats.ts | 19 ++++++++----- packages/db/src/schema/enums.ts | 8 ------ .../host-service/src/events/git-watcher.ts | 27 +++++++++++++++---- packages/workspace-client/src/lib/eventBus.ts | 8 ++---- 4 files changed, 37 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts index 6be84b58b92..13f27f2e853 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts @@ -22,17 +22,24 @@ export function useDiffStats(workspaceId: string): DiffStats | null { const client = getHostServiceClientByUrl(hostUrl); const status = await client.git.getStatus.query({ workspaceId }); - let additions = 0; - let deletions = 0; + // Deduplicate by path — a file can appear in multiple categories + const byPath = new Map< + string, + { additions: number; deletions: number } + >(); for (const file of status.againstBase) { - additions += file.additions; - deletions += file.deletions; + byPath.set(file.path, file); } for (const file of status.staged) { - additions += file.additions; - deletions += file.deletions; + byPath.set(file.path, file); } for (const file of status.unstaged) { + byPath.set(file.path, file); + } + + let additions = 0; + let deletions = 0; + for (const file of byPath.values()) { additions += file.additions; deletions += file.deletions; } diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts index a0c74fc3716..ca37b8ecf8a 100644 --- a/packages/db/src/schema/enums.ts +++ b/packages/db/src/schema/enums.ts @@ -31,14 +31,6 @@ export const deviceTypeValues = ["desktop", "mobile", "web"] as const; export const deviceTypeEnum = z.enum(deviceTypeValues); export type DeviceType = z.infer; -export const v2DeviceTypeValues = ["host", "cloud", "viewer"] as const; -export const v2DeviceTypeEnum = z.enum(v2DeviceTypeValues); -export type V2DeviceType = z.infer; - -export const v2UsersDeviceRoleValues = ["owner", "member", "viewer"] as const; -export const v2UsersDeviceRoleEnum = z.enum(v2UsersDeviceRoleValues); -export type V2UsersDeviceRole = z.infer; - export const v2ClientTypeValues = ["desktop", "mobile", "web"] as const; export const v2ClientTypeEnum = z.enum(v2ClientTypeValues); export type V2ClientType = z.infer; diff --git a/packages/host-service/src/events/git-watcher.ts b/packages/host-service/src/events/git-watcher.ts index c24ecf7bba7..ede0175ebe3 100644 --- a/packages/host-service/src/events/git-watcher.ts +++ b/packages/host-service/src/events/git-watcher.ts @@ -7,6 +7,7 @@ import { workspaces } from "../db/schema"; const execFileAsync = promisify(execFile); const RESCAN_INTERVAL_MS = 30_000; +const DEBOUNCE_MS = 300; export type GitChangedListener = (workspaceId: string) => void; @@ -26,6 +27,10 @@ export class GitWatcher { private readonly db: HostDb; private readonly listeners = new Set(); private readonly watched = new Map(); + private readonly debounceTimers = new Map< + string, + ReturnType + >(); private rescanTimer: ReturnType | null = null; private closed = false; @@ -54,16 +59,28 @@ export class GitWatcher { clearInterval(this.rescanTimer); this.rescanTimer = null; } + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); for (const entry of this.watched.values()) { entry.watcher.close(); } this.watched.clear(); } - private emit(workspaceId: string): void { - for (const listener of this.listeners) { - listener(workspaceId); - } + private debouncedEmit(workspaceId: string): void { + const existing = this.debounceTimers.get(workspaceId); + if (existing) clearTimeout(existing); + this.debounceTimers.set( + workspaceId, + setTimeout(() => { + this.debounceTimers.delete(workspaceId); + for (const listener of this.listeners) { + listener(workspaceId); + } + }, DEBOUNCE_MS), + ); } private async rescan(): Promise { @@ -126,7 +143,7 @@ export class GitWatcher { try { const watcher = watch(gitDir, { recursive: true }, () => { - this.emit(workspaceId); + this.debouncedEmit(workspaceId); }); watcher.on("error", () => { diff --git a/packages/workspace-client/src/lib/eventBus.ts b/packages/workspace-client/src/lib/eventBus.ts index 7bca51da52e..69ec65ffab3 100644 --- a/packages/workspace-client/src/lib/eventBus.ts +++ b/packages/workspace-client/src/lib/eventBus.ts @@ -37,10 +37,6 @@ interface ConnectionState { const connections = new Map(); -function getConnectionKey(hostUrl: string): string { - return hostUrl; -} - function buildEventBusUrl(hostUrl: string, wsToken: string | null): string { const url = new URL("/events", hostUrl); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; @@ -155,7 +151,7 @@ function getOrCreateConnection( hostUrl: string, getWsToken: () => string | null, ): ConnectionState { - const key = getConnectionKey(hostUrl); + const key = hostUrl; const existing = connections.get(key); if (existing) return existing; @@ -174,7 +170,7 @@ function getOrCreateConnection( } function maybeCleanupConnection(hostUrl: string): void { - const key = getConnectionKey(hostUrl); + const key = hostUrl; const state = connections.get(key); if (!state) return;