diff --git a/apps/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts index 7206700204a..952225da380 100644 --- a/apps/api/src/app/api/electric/[...path]/utils.ts +++ b/apps/api/src/app/api/electric/[...path]/utils.ts @@ -15,6 +15,7 @@ import { taskStatuses, tasks, v2Clients, + v2HostProjects, v2Hosts, v2Projects, v2UsersHosts, @@ -34,6 +35,7 @@ export type AllowedTable = | "v2_projects" | "v2_users_hosts" | "v2_workspaces" + | "v2_host_projects" | "auth.members" | "auth.organizations" | "auth.users" @@ -96,6 +98,13 @@ export async function buildWhereClause( case "v2_workspaces": return build(v2Workspaces, v2Workspaces.organizationId, organizationId); + case "v2_host_projects": + return build( + v2HostProjects, + v2HostProjects.organizationId, + organizationId, + ); + case "auth.members": return build(members, members.organizationId, organizationId); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx new file mode 100644 index 00000000000..bbb1d2b62e4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx @@ -0,0 +1,84 @@ +import { toast } from "@superset/ui/sonner"; +import { useEffect } from "react"; +import { + useAddRepositoryModalActive, + useCloseAddRepositoryModal, + useFolderImportTrigger, +} from "renderer/stores/add-repository-modal"; +import { FolderFirstImportModal } from "../../v2-workspaces/components/FolderFirstImportModal"; +import { NewProjectModal } from "../../v2-workspaces/components/NewProjectModal"; +import { PinAndSetupModal } from "../../v2-workspaces/components/PinAndSetupModal"; +import { useFolderFirstImport } from "../../v2-workspaces/hooks/useFolderFirstImport"; + +/** + * Layout-level host for the three add-repository flows (New project, Import + * existing folder, Pin & set up). Any component in the dashboard can open + * one via the `useAddRepositoryModalStore` actions — sidebar dropdown, + * workspaces-tab Available rows, future empty-state CTAs, etc. + * + * Why centralize: modal state lives once per app, not once per trigger. + * Also keeps the folder-first picker's internal state machine in one place + * so nothing races if two triggers happen quickly. + */ +export function AddRepositoryModals() { + const active = useAddRepositoryModalActive(); + const close = useCloseAddRepositoryModal(); + const folderImportTrigger = useFolderImportTrigger(); + + const folderImport = useFolderFirstImport({ + onSuccess: () => { + toast.success("Project ready — open it from the sidebar."); + }, + onError: (message) => { + toast.error(`Import failed: ${message}`); + }, + }); + + // Run the folder-first picker when the store's trigger counter bumps. + // Using a counter (vs a boolean) lets successive clicks re-invoke the + // flow after the previous one resolves. + useEffect(() => { + if (folderImportTrigger === 0) return; + void folderImport.start(); + // We intentionally depend only on the counter — folderImport.start's + // identity changes every render (new hook instance per render) and + // we don't want to restart the flow on those changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [folderImportTrigger, folderImport.start]); + + return ( + <> + { + if (!open) close(); + }} + onSuccess={() => toast.success("Project created.")} + onError={(message) => toast.error(`Create failed: ${message}`)} + /> + { + if (!open) close(); + }} + onSuccess={() => { + toast.success("Project pinned and set up."); + // Per-open one-shot callback (e.g. retry a pending workspace + // create that surfaced PROJECT_NOT_SETUP). + if (active.kind === "pin-and-setup") active.onSuccess?.(); + }} + onError={(message) => toast.error(`Setup failed: ${message}`)} + /> + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/index.ts new file mode 100644 index 00000000000..fcc618e3efb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/index.ts @@ -0,0 +1 @@ +export { AddRepositoryModals } from "./AddRepositoryModals"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx index a8c6f2f03d1..2226af77ac5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx @@ -1,10 +1,21 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; -import { LuFolderPlus, LuLayers, LuPlus } from "react-icons/lu"; +import { HiMiniPlus } from "react-icons/hi2"; +import { LuFolderInput, LuFolderPlus, LuLayers, LuPlus } from "react-icons/lu"; import { useHotkeyDisplay } from "renderer/hotkeys"; import { OrganizationDropdown } from "renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown"; import { STROKE_WIDTH_THICK } from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import { + useOpenNewProjectModal, + useTriggerFolderImport, +} from "renderer/stores/add-repository-modal"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; interface DashboardSidebarHeaderProps { @@ -15,6 +26,8 @@ export function DashboardSidebarHeader({ isCollapsed = false, }: DashboardSidebarHeaderProps) { const openModal = useOpenNewWorkspaceModal(); + const openNewProject = useOpenNewProjectModal(); + const triggerFolderImport = useTriggerFolderImport(); const shortcutText = useHotkeyDisplay("NEW_WORKSPACE").text; const navigate = useNavigate(); const matchRoute = useMatchRoute(); @@ -47,17 +60,31 @@ export function DashboardSidebarHeader({ Workspaces - - - - - Add Repository - + + + + + + + + Add repository + + + + + New project + + + + Import existing folder + + + @@ -83,17 +110,31 @@ export function DashboardSidebarHeader({
- - - - - Add Repository - + + + + + + + + Add repository + + + + + New project + + + + Import existing folder + + +
{projectName} - - {totalWorkspaceCount} workspace - {totalWorkspaceCount !== 1 ? "s" : ""} - + {backingState === "not-set-up-here" ? ( + + Not set up — click to set up here + + ) : backingState === "stale-path" ? ( + + Path missing — click to repair + + ) : ( + + {totalWorkspaceCount} workspace + {totalWorkspaceCount !== 1 ? "s" : ""} + + )}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx index 405ba3a1a86..e0dd96a1019 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx @@ -4,11 +4,14 @@ import { type ComponentPropsWithoutRef, forwardRef } from "react"; import { HiChevronRight, HiMiniPlus } from "react-icons/hi2"; import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; +import type { DashboardSidebarProjectBackingState } from "../../../../types"; +import { ProjectBackingStateIndicator } from "../ProjectBackingStateIndicator"; interface DashboardSidebarProjectRowProps extends ComponentPropsWithoutRef<"div"> { projectName: string; githubOwner: string | null; + backingState: DashboardSidebarProjectBackingState; totalWorkspaceCount: number; isCollapsed: boolean; isRenaming: boolean; @@ -19,6 +22,8 @@ interface DashboardSidebarProjectRowProps onStartRename: () => void; onToggleCollapse: () => void; onNewWorkspace: () => void; + onSetUpHere: () => void; + onRepairPath: () => void; } export const DashboardSidebarProjectRow = forwardRef< @@ -29,6 +34,7 @@ export const DashboardSidebarProjectRow = forwardRef< { projectName, githubOwner, + backingState, totalWorkspaceCount, isCollapsed, isRenaming, @@ -39,6 +45,8 @@ export const DashboardSidebarProjectRow = forwardRef< onStartRename, onToggleCollapse, onNewWorkspace, + onSetUpHere, + onRepairPath, className, ...props }, @@ -101,26 +109,58 @@ export const DashboardSidebarProjectRow = forwardRef< ({totalWorkspaceCount}) )} + {/* Only render the passive "Offline" marker here — the + "Not set up here" / "Stale path" states surface as + action buttons on the right instead. */} + {!isRenaming && backingState === "host-offline" && ( + + )} - - - - - - New workspace - - + {!isRenaming && backingState === "not-set-up-here" ? ( + + ) : !isRenaming && backingState === "stale-path" ? ( + + ) : ( + + + + + + New workspace + + + )} ); }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/ProjectBackingStateIndicator/ProjectBackingStateIndicator.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/ProjectBackingStateIndicator/ProjectBackingStateIndicator.tsx new file mode 100644 index 00000000000..ec28de5e619 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/ProjectBackingStateIndicator/ProjectBackingStateIndicator.tsx @@ -0,0 +1,91 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import type { DashboardSidebarProjectBackingState } from "../../../../types"; + +// Per state → short label shown in the row + longer tooltip copy. +// "normal" is handled by returning null — no indicator when the project is +// fully backed. +const STATE_META: Record< + Exclude, + { label: string; tooltip: string; dotClass: string; textClass: string } +> = { + "host-offline": { + label: "Offline", + tooltip: + "Only backed on a device that's currently offline. Will resume when it reconnects.", + dotClass: "bg-muted-foreground/60", + textClass: "text-muted-foreground", + }, + "not-set-up-here": { + label: "Not here", + tooltip: + "This project isn't set up on this device yet. Click Set up to clone it here.", + dotClass: "bg-amber-500", + textClass: "text-amber-600 dark:text-amber-400", + }, + "stale-path": { + label: "Path missing", + tooltip: + "This project's folder is missing on disk. Click Repair to re-point it.", + dotClass: "bg-destructive", + textClass: "text-destructive", + }, +}; + +interface ProjectBackingStateIndicatorProps { + state: DashboardSidebarProjectBackingState; + /** + * Compact variant for the collapsed sidebar — dot only, no label, no + * tooltip wrapper. The caller is expected to already be inside a parent + * Tooltip (the collapsed thumbnail wraps its button in one) and nesting + * Radix Tooltip triggers causes ref-composition update loops. A `title` + * attribute covers native a11y. + */ + variant?: "default" | "dot-only"; + className?: string; +} + +export function ProjectBackingStateIndicator({ + state, + variant = "default", + className, +}: ProjectBackingStateIndicatorProps) { + if (state === "normal") return null; + const meta = STATE_META[state]; + + if (variant === "dot-only") { + return ( + + ); + } + + return ( + + + + + {meta.label} + + + {meta.tooltip} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/ProjectBackingStateIndicator/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/ProjectBackingStateIndicator/index.ts new file mode 100644 index 00000000000..23500df6520 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/ProjectBackingStateIndicator/index.ts @@ -0,0 +1 @@ +export { ProjectBackingStateIndicator } from "./ProjectBackingStateIndicator"; 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 1abd8cfe523..f6f6e65d93b 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 @@ -11,7 +11,10 @@ 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 { DashboardSidebarWorkspace } from "../../../../types"; +import type { + DashboardSidebarWorkspace, + DashboardSidebarWorkspaceHostType, +} from "../../../../types"; import { getCreationStatusText } from "../../utils/getCreationStatusText"; import { DashboardSidebarWorkspaceDiffStats } from "../DashboardSidebarWorkspaceDiffStats"; import { DashboardSidebarWorkspaceIcon } from "../DashboardSidebarWorkspaceIcon"; @@ -214,9 +217,14 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< )} - - {branch} - +
+ {hostType !== "local-device" && ( + + )} + + {branch} + +
{pullRequest && ( ; +}) { + const label = hostType === "cloud" ? "Cloud" : "Other device"; + return ( + + {label} + + ); +} 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 9c60cb14333..1c7450611f7 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 @@ -11,6 +11,7 @@ import { useLocalHostService } from "renderer/routes/_authenticated/providers/Lo import { MOCK_ORG_ID } from "shared/constants"; import type { DashboardSidebarProject, + DashboardSidebarProjectBackingState, DashboardSidebarProjectChild, DashboardSidebarSection, DashboardSidebarWorkspace, @@ -19,6 +20,33 @@ import type { // Pending workspaces are always rendered at the end of the project's workspace list const PENDING_WORKSPACE_TAB_ORDER = Number.MAX_SAFE_INTEGER; +// Module-level stable fallbacks. The destructure `= []` default creates a +// NEW array every render while a query's data is undefined, which cascades +// new references through our useMemo/useCallback chain and — eventually — +// causes dnd-kit's sortable items to re-register their refs on every +// render. That produces a Radix/compose-refs setState loop when the item +// is a draggable button. Pinning the empty fallbacks keeps references +// stable across renders and stops the churn. +type LocalProjectListRow = { + id: string; + repoPath: string; + pathStatus: "healthy" | "missing"; +}; +type RemoteBackingRow = { + projectId: string; + hostId: string; + hostMachineId: string; + isOnline: boolean; +}; +const EMPTY_LOCAL_PROJECT_LIST: LocalProjectListRow[] = []; +const EMPTY_REMOTE_BACKING_ROWS: RemoteBackingRow[] = []; + +// Phase 4: poll project.list so out-of-band directory deletions surface +// without requiring the user to trigger an operation that fails. Kept at +// 30s to stay lightweight — repoPath changes are the only signal we need +// to catch and they happen infrequently. +const PROJECT_LIST_REFETCH_INTERVAL_MS = 30_000; + export function useDashboardSidebarData() { const { data: session } = authClient.useSession(); const collections = useCollections(); @@ -44,6 +72,78 @@ export function useDashboardSidebarData() { ? getHostServiceClientByUrl(activeHostUrl) : null; + // Local backing — authoritative for this machine. Invalidated by + // project.create / project.setup / project.remove mutations and by + // operations that surface a vanished-path error. + const { data: localProjectList = EMPTY_LOCAL_PROJECT_LIST } = useQuery< + LocalProjectListRow[] + >({ + queryKey: ["project", "list", activeHostUrl], + enabled: activeHostClient !== null, + refetchInterval: PROJECT_LIST_REFETCH_INTERVAL_MS, + queryFn: () => + activeHostClient?.project.list.query() ?? EMPTY_LOCAL_PROJECT_LIST, + }); + + const localProjectPathStatus = useMemo( + () => new Map(localProjectList.map((p) => [p.id, p.pathStatus])), + [localProjectList], + ); + + // Remote backing — v2_host_projects ⋈ v2_hosts, excluding rows for the + // current machine (current host's backing is covered by localProjectList + // above, which is authoritative and lag-free). + const { data: remoteBackingRows = EMPTY_REMOTE_BACKING_ROWS } = useLiveQuery( + (q) => + q + .from({ hp: collections.v2HostProjects }) + .innerJoin({ h: collections.v2Hosts }, ({ hp, h }) => + eq(hp.hostId, h.id), + ) + .select(({ hp, h }) => ({ + projectId: hp.projectId, + hostId: h.id, + hostMachineId: h.machineId, + isOnline: h.isOnline, + })), + [collections], + ); + + const remoteBackingByProject = useMemo(() => { + const byProject = new Map< + string, + { online: Set; offline: Set } + >(); + for (const row of remoteBackingRows) { + if (row.hostMachineId === machineId) continue; + let entry = byProject.get(row.projectId); + if (!entry) { + entry = { online: new Set(), offline: new Set() }; + byProject.set(row.projectId, entry); + } + (row.isOnline ? entry.online : entry.offline).add(row.hostId); + } + return byProject; + }, [remoteBackingRows, machineId]); + + const deriveBackingState = useCallback( + (projectId: string): DashboardSidebarProjectBackingState => { + // Local backing wins — but pathStatus discriminates healthy vs + // stale. A stale local path takes precedence over remote backing + // because the user clearly wanted this project set up here and + // should fix it rather than silently fall back to a teammate's + // copy. + const localStatus = localProjectPathStatus.get(projectId); + if (localStatus === "healthy") return "normal"; + if (localStatus === "missing") return "stale-path"; + const remote = remoteBackingByProject.get(projectId); + if (remote?.online.size) return "normal"; + if (remote?.offline.size) return "host-offline"; + return "not-set-up-here"; + }, + [localProjectPathStatus, remoteBackingByProject], + ); + const { data: rawSidebarProjects = [] } = useLiveQuery( (q) => q @@ -198,6 +298,7 @@ export function useDashboardSidebarData() { for (const project of sidebarProjects) { projectsById.set(project.id, { ...project, + backingState: deriveBackingState(project.id), children: [], sectionMap: new Map(), childEntries: [], @@ -330,6 +431,7 @@ export function useDashboardSidebarData() { return [sidebarProject]; }); }, [ + deriveBackingState, machineId, localPullRequestsByWorkspaceId, pendingWorkspaces, 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 329d90bd701..2a331193960 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 @@ -60,6 +60,21 @@ export type DashboardSidebarProjectChild = section: DashboardSidebarSection; }; +export type DashboardSidebarProjectBackingState = + // Project is backed on the current host with a healthy path, or at + // least one remote host backing it is online — user can do everything. + | "normal" + // Project is set up on this host but the repoPath no longer exists on + // disk. Re-pointing via project.setup repairs it (and invalidates any + // existing workspace worktrees under the old path). + | "stale-path" + // No local backing, no online remote backing, but at least one offline + // remote host backs it — passive state, resolves when that host reconnects. + | "host-offline" + // No local backing, no remote backing anywhere — user needs to set up + // on this host (or wait for a teammate to). + | "not-set-up-here"; + export interface DashboardSidebarProject { id: string; name: string; @@ -70,5 +85,6 @@ export interface DashboardSidebarProject { createdAt: Date; updatedAt: Date; isCollapsed: boolean; + backingState: DashboardSidebarProjectBackingState; children: DashboardSidebarProjectChild[]; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index e46f4cc6da2..0a32883fed0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -19,6 +19,7 @@ import { MAX_WORKSPACE_SIDEBAR_WIDTH, useWorkspaceSidebarStore, } from "renderer/stores/workspace-sidebar-state"; +import { AddRepositoryModals } from "./components/AddRepositoryModals"; import { TopBar } from "./components/TopBar"; export const Route = createFileRoute("/_authenticated/_dashboard")({ @@ -120,6 +121,7 @@ function DashboardLayout() {
+ {deleteTarget && ( { + const fire = useCallback(async () => { if (!pending) return; collections.pendingWorkspaces.update(pendingId, (draft) => { @@ -143,22 +147,66 @@ function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { }); void clearAttachments(pendingId); } catch (err) { + // Host-service signals "project isn't set up on this host" via a + // structured cause, surfaced in data.projectNotSetup by the error + // formatter. Intercept it here: open Pin & set up with the + // projectId pre-filled, and schedule a retry of the original + // intent once setup succeeds. Any other error falls through to the + // generic failed state below. + if ( + err instanceof TRPCClientError && + (err.data as { projectNotSetup?: { projectId?: string } } | undefined) + ?.projectNotSetup?.projectId && + pending + ) { + const projectId = ( + err.data as { projectNotSetup: { projectId: string } } + ).projectNotSetup.projectId; + const cloudProject = collections.v2Projects.get(projectId); + const repo = cloudProject?.githubRepositoryId + ? collections.githubRepositories.get(cloudProject.githubRepositoryId) + : null; + // Leave the pending row in "creating" — the user is mid-flow. + // When setup succeeds we retry immediately; if they cancel the + // modal, flip to failed so the UI isn't stuck on the spinner. + openPinAndSetup( + { + id: projectId, + name: cloudProject?.name ?? "this project", + githubOwner: repo?.owner ?? null, + githubRepoName: repo?.name ?? null, + }, + { onSuccess: () => void fire() }, + ); + return; + } collections.pendingWorkspaces.update(pendingId, (draft) => { draft.status = "failed"; draft.error = err instanceof Error ? err.message : "Failed to create workspace"; }); + // A workspace-create failure is often a signal that the project's + // local backing has drifted (vanished path, stale repoPath). Refetch + // project.list so the sidebar can flip the project row to its true + // state instead of lying about it being Normal. + queryClient.invalidateQueries({ + queryKey: ["project", "list", activeHostUrl], + }); } }, [ collections, createWorkspace, checkoutWorkspace, adoptWorktree, + openPinAndSetup, pending, pendingId, trpcUtils, activeHostUrl, + queryClient, ]); + + return fire; } function PendingWorkspacePage() { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 8f2923ed7ae..ac62991a07b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -14,6 +14,7 @@ import { HiMiniXMark } from "react-icons/hi2"; import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; import { HotkeyLabel, useHotkey } from "renderer/hotkeys"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { CommandPalette } from "renderer/screens/main/components/CommandPalette"; import { toAbsoluteWorkspacePath, @@ -21,6 +22,7 @@ import { } from "shared/absolute-paths"; import { useStore } from "zustand"; import { WorkspaceNotFoundState } from "../components/WorkspaceNotFoundState"; +import { WorkspaceNotOnThisHostState } from "../components/WorkspaceNotOnThisHostState"; import { AddTabMenu } from "./components/AddTabMenu"; import { V2PresetsBar } from "./components/V2PresetsBar"; import { WorkspaceEmptyState } from "./components/WorkspaceEmptyState"; @@ -52,29 +54,66 @@ export const Route = createFileRoute( function V2WorkspacePage() { const { workspaceId } = Route.useParams(); const collections = useCollections(); + const { machineId } = useLocalHostService(); - const { data: workspaces } = useLiveQuery( + const { data: rows } = useLiveQuery( (q) => q .from({ v2Workspaces: collections.v2Workspaces }) - .where(({ v2Workspaces }) => eq(v2Workspaces.id, workspaceId)), + .leftJoin({ hosts: collections.v2Hosts }, ({ v2Workspaces, hosts }) => + eq(v2Workspaces.hostId, hosts.id), + ) + .leftJoin( + { projects: collections.v2Projects }, + ({ v2Workspaces, projects }) => + eq(v2Workspaces.projectId, projects.id), + ) + .leftJoin( + { repos: collections.githubRepositories }, + ({ projects, repos }) => eq(projects.githubRepositoryId, repos.id), + ) + .where(({ v2Workspaces }) => eq(v2Workspaces.id, workspaceId)) + .select(({ v2Workspaces, hosts, projects, repos }) => ({ + id: v2Workspaces.id, + projectId: v2Workspaces.projectId, + name: v2Workspaces.name, + hostMachineId: hosts?.machineId ?? null, + hostName: hosts?.name ?? null, + projectName: projects?.name ?? null, + projectGithubOwner: repos?.owner ?? null, + projectGithubRepoName: repos?.name ?? null, + })), [collections, workspaceId], ); - const workspace = workspaces?.[0] ?? null; + const row = rows?.[0] ?? null; - if (!workspaces) { + if (!rows) { return
; } - if (!workspace) { + if (!row) { return ; } + // Remote-device workspace: render the stub and stop. Opening the real + // workspace tree would crash because the worktree lives on another host. + if (row.hostMachineId != null && row.hostMachineId !== machineId) { + return ( + + ); + } + return ( ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotOnThisHostState/WorkspaceNotOnThisHostState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotOnThisHostState/WorkspaceNotOnThisHostState.tsx new file mode 100644 index 00000000000..0298228ec59 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotOnThisHostState/WorkspaceNotOnThisHostState.tsx @@ -0,0 +1,66 @@ +import { Button } from "@superset/ui/button"; +import { Link } from "@tanstack/react-router"; +import { LuLaptop } from "react-icons/lu"; +import { useOpenPinAndSetupModal } from "renderer/stores/add-repository-modal"; + +interface WorkspaceNotOnThisHostStateProps { + hostName: string | null; + projectId: string; + projectName: string; + projectGithubOwner: string | null; + projectGithubRepoName: string | null; +} + +/** + * Phase 3 stub shown when the user clicks a workspace whose host is a + * different device. Explains the situation and offers the two paths out + * (switch to the owning host, or set this project up locally so a new + * workspace can be created here). A richer design — including a + * remote-terminal fallback — lives outside this plan. + */ +export function WorkspaceNotOnThisHostState({ + hostName, + projectId, + projectName, + projectGithubOwner, + projectGithubRepoName, +}: WorkspaceNotOnThisHostStateProps) { + const openPinAndSetup = useOpenPinAndSetupModal(); + const hostLabel = hostName ?? "another device"; + + return ( +
+
+
+ +
+

+ Workspace lives on {hostLabel} +

+

+ Workspaces are bound to the host that created them. To open this one, + switch to {hostLabel}, or set {projectName} up on this device and + create a new workspace here. +

+
+ + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotOnThisHostState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotOnThisHostState/index.ts new file mode 100644 index 00000000000..1a95c264466 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotOnThisHostState/index.ts @@ -0,0 +1 @@ +export { WorkspaceNotOnThisHostState } from "./WorkspaceNotOnThisHostState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/FolderFirstImportModal/FolderFirstImportModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/FolderFirstImportModal/FolderFirstImportModal.tsx new file mode 100644 index 00000000000..c2e3791e9c7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/FolderFirstImportModal/FolderFirstImportModal.tsx @@ -0,0 +1,280 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { type FormEvent, useState } from "react"; +import type { + FolderFirstImportState, + UseFolderFirstImportResult, +} from "../../hooks/useFolderFirstImport"; + +interface FolderFirstImportModalProps { + state: FolderFirstImportState; + onCancel: UseFolderFirstImportResult["cancel"]; + onConfirmCreateAsNew: UseFolderFirstImportResult["confirmCreateAsNew"]; + onConfirmPickCandidate: UseFolderFirstImportResult["confirmPickCandidate"]; + onConfirmRepoint: UseFolderFirstImportResult["confirmRepoint"]; +} + +export function FolderFirstImportModal({ + state, + onCancel, + onConfirmCreateAsNew, + onConfirmPickCandidate, + onConfirmRepoint, +}: FolderFirstImportModalProps) { + const open = state.kind !== "idle"; + return ( + { + if (!next) onCancel(); + }} + > + + {state.kind === "no-match" && ( + + )} + {state.kind === "pick" && ( + + )} + {state.kind === "confirm-repoint" && ( + + )} + + + ); +} + +interface ConfirmRepointContentProps { + repoPath: string; + projectName: string; + working: boolean; + onCancel: () => void; + onConfirm: () => Promise; +} + +function ConfirmRepointContent({ + repoPath, + projectName, + working, + onCancel, + onConfirm, +}: ConfirmRepointContentProps) { + return ( + <> + + Re-point {projectName} to this folder? + + This project is already set up on this device at a different path. + Re-pointing it here will invalidate existing workspaces under it — + their worktrees will no longer open until each workspace is + re-created. Continue? + + +
+ + {repoPath} + +
+ + + + + + ); +} + +interface NoMatchContentProps { + repoPath: string; + working: boolean; + onCancel: () => void; + onConfirm: (input: { name: string }) => Promise; +} + +function NoMatchContent({ + repoPath, + working, + onCancel, + onConfirm, +}: NoMatchContentProps) { + const [name, setName] = useState(""); + const trimmed = name.trim(); + const canSubmit = trimmed.length > 0 && !working; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + void onConfirm({ name: trimmed }); + }; + + return ( +
+ + Create a new project? + + No existing project matches this folder. Name it to create a new + project bound to the folder's git remote. + + +
+
+ + + {repoPath} + +
+
+ + setName(event.target.value)} + disabled={working} + placeholder="e.g. my-project" + /> +
+
+ + + + +
+ ); +} + +interface CandidatePickerContentProps { + repoPath: string; + candidates: Array<{ + id: string; + name: string; + organizationName: string; + }>; + working: boolean; + onCancel: () => void; + onConfirm: (candidateId: string) => Promise; +} + +function CandidatePickerContent({ + repoPath, + candidates, + working, + onCancel, + onConfirm, +}: CandidatePickerContentProps) { + const [selectedId, setSelectedId] = useState( + candidates[0]?.id ?? null, + ); + const canSubmit = selectedId != null && !working; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit || !selectedId) return; + void onConfirm(selectedId); + }; + + return ( +
+ + Pick a project + + This folder's git remote matches multiple projects you have access to. + Which one is this folder for? + + +
+
+ + + {repoPath} + +
+
+ {candidates.map((candidate) => { + const selected = candidate.id === selectedId; + return ( + + ); + })} +
+
+ + + + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/FolderFirstImportModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/FolderFirstImportModal/index.ts new file mode 100644 index 00000000000..e337b5a82cf --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/FolderFirstImportModal/index.ts @@ -0,0 +1 @@ +export { FolderFirstImportModal } from "./FolderFirstImportModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/NewProjectModal/NewProjectModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/NewProjectModal/NewProjectModal.tsx new file mode 100644 index 00000000000..c6d5873f96d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/NewProjectModal/NewProjectModal.tsx @@ -0,0 +1,147 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { useQueryClient } from "@tanstack/react-query"; +import { type FormEvent, useState } from "react"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { ParentDirectoryPicker } from "../ParentDirectoryPicker"; + +interface NewProjectModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: (result: { projectId: string; repoPath: string }) => void; + onError?: (message: string) => void; +} + +export function NewProjectModal({ + open, + onOpenChange, + onSuccess, + onError, +}: NewProjectModalProps) { + const { activeHostUrl } = useLocalHostService(); + const queryClient = useQueryClient(); + const { ensureProjectInSidebar } = useDashboardSidebarState(); + + const [name, setName] = useState(""); + const [url, setUrl] = useState(""); + const [parentDir, setParentDir] = useState(null); + const [working, setWorking] = useState(false); + + const trimmedName = name.trim(); + const trimmedUrl = url.trim(); + const canSubmit = + trimmedName.length > 0 && + trimmedUrl.length > 0 && + parentDir !== null && + !working; + + const reset = () => { + setName(""); + setUrl(""); + setParentDir(null); + setWorking(false); + }; + + const handleOpenChange = (next: boolean) => { + if (!next && working) return; + if (!next) reset(); + onOpenChange(next); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit || !activeHostUrl || !parentDir) return; + + setWorking(true); + try { + const client = getHostServiceClientByUrl(activeHostUrl); + const result = await client.project.create.mutate({ + name: trimmedName, + visibility: "private", + mode: { kind: "clone", parentDir, url: trimmedUrl }, + }); + ensureProjectInSidebar(result.projectId); + queryClient.invalidateQueries({ + queryKey: ["project", "list", activeHostUrl], + }); + onSuccess?.(result); + reset(); + onOpenChange(false); + } catch (err) { + onError?.(err instanceof Error ? err.message : String(err)); + setWorking(false); + } + }; + + return ( + + +
+ + New project + + Clone a GitHub repository into a local folder and register it as a + new project in this organization. + + +
+
+ + setName(event.target.value)} + disabled={working} + placeholder="e.g. my-project" + /> +
+
+ + setUrl(event.target.value)} + disabled={working} + placeholder="https://github.com/owner/name.git" + /> +
+
+ + +
+
+ + + + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/NewProjectModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/NewProjectModal/index.ts new file mode 100644 index 00000000000..1fb78225d72 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/NewProjectModal/index.ts @@ -0,0 +1 @@ +export { NewProjectModal } from "./NewProjectModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/ParentDirectoryPicker/ParentDirectoryPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/ParentDirectoryPicker/ParentDirectoryPicker.tsx new file mode 100644 index 00000000000..1791849fe40 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/ParentDirectoryPicker/ParentDirectoryPicker.tsx @@ -0,0 +1,49 @@ +import { Button } from "@superset/ui/button"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +interface ParentDirectoryPickerProps { + value: string | null; + onChange: (path: string) => void; + disabled?: boolean; + dialogTitle?: string; +} + +/** + * Compact inline control: shows the current path as code + a Browse button. + * Used in project create/setup modals to choose where to clone into. + */ +export function ParentDirectoryPicker({ + value, + onChange, + disabled, + dialogTitle = "Select parent directory", +}: ParentDirectoryPickerProps) { + const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); + + const handleBrowse = async () => { + const result = await selectDirectory.mutateAsync({ + title: dialogTitle, + defaultPath: value ?? undefined, + }); + if (!result.canceled && result.path) { + onChange(result.path); + } + }; + + return ( +
+ + {value ?? "No directory selected"} + + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/ParentDirectoryPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/ParentDirectoryPicker/index.ts new file mode 100644 index 00000000000..3e5440d6398 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/ParentDirectoryPicker/index.ts @@ -0,0 +1 @@ +export { ParentDirectoryPicker } from "./ParentDirectoryPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/PinAndSetupModal/PinAndSetupModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/PinAndSetupModal/PinAndSetupModal.tsx new file mode 100644 index 00000000000..9a1103b8749 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/PinAndSetupModal/PinAndSetupModal.tsx @@ -0,0 +1,171 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Label } from "@superset/ui/label"; +import { useQueryClient } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; +import { type FormEvent, useState } from "react"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import type { PinAndSetupTarget } from "renderer/stores/add-repository-modal"; +import { ParentDirectoryPicker } from "../ParentDirectoryPicker"; + +interface PinAndSetupModalProps { + project: PinAndSetupTarget | null; + /** When true the modal opens in re-point mode. Used for stale-path repair. */ + forceRepoint?: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: (result: { projectId: string; repoPath: string }) => void; + onError?: (message: string) => void; +} + +function isConflictError(err: unknown): boolean { + return ( + err instanceof TRPCClientError && + (err.data as { code?: string } | undefined)?.code === "CONFLICT" + ); +} + +export function PinAndSetupModal({ + project, + forceRepoint = false, + onOpenChange, + onSuccess, + onError, +}: PinAndSetupModalProps) { + const { activeHostUrl } = useLocalHostService(); + const queryClient = useQueryClient(); + const { ensureProjectInSidebar } = useDashboardSidebarState(); + + const [parentDir, setParentDir] = useState(null); + const [working, setWorking] = useState(false); + // When setup returns CONFLICT (project already set up at a different path), + // flip into re-point confirmation mode: same form, different copy + a + // destructive submit button that retries with the ack flag set. + // `forceRepoint` pre-sets this for the stale-path repair flow so the user + // doesn't have to submit once just to see the CONFLICT and re-submit. + const [conflict, setConflict] = useState(forceRepoint); + + const canSubmit = project !== null && parentDir !== null && !working; + + const reset = () => { + setParentDir(null); + setWorking(false); + setConflict(forceRepoint); + }; + + const handleOpenChange = (next: boolean) => { + if (!next && working) return; + if (!next) reset(); + onOpenChange(next); + }; + + const runSetup = async (acknowledgeWorkspaceInvalidation: boolean) => { + if (!activeHostUrl || !project || !parentDir) return; + setWorking(true); + try { + const client = getHostServiceClientByUrl(activeHostUrl); + const result = await client.project.setup.mutate({ + projectId: project.id, + acknowledgeWorkspaceInvalidation: acknowledgeWorkspaceInvalidation + ? true + : undefined, + mode: { kind: "clone", parentDir }, + }); + ensureProjectInSidebar(project.id); + queryClient.invalidateQueries({ + queryKey: ["project", "list", activeHostUrl], + }); + onSuccess?.({ projectId: project.id, repoPath: result.repoPath }); + reset(); + onOpenChange(false); + } catch (err) { + if (!acknowledgeWorkspaceInvalidation && isConflictError(err)) { + setConflict(true); + setWorking(false); + return; + } + onError?.(err instanceof Error ? err.message : String(err)); + setWorking(false); + } + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + void runSetup(conflict); + }; + + return ( + + +
+ + + {conflict ? "Re-point project?" : "Pin & set up"} + + + {conflict + ? `${project?.name ?? "This project"} is already set up on this device at a different path. Re-pointing it here will invalidate existing workspaces — their worktrees won't open until each is re-created.` + : `Clone ${project?.name ?? "the project"} onto this device and pin it to the sidebar.`} + + +
+ {project && ( +
+ +
+ {project.name} + {project.githubOwner && project.githubRepoName && ( + + {project.githubOwner}/{project.githubRepoName} + + )} +
+
+ )} +
+ + +
+
+ + + + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/PinAndSetupModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/PinAndSetupModal/index.ts new file mode 100644 index 00000000000..5c6bb4d21c5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/PinAndSetupModal/index.ts @@ -0,0 +1 @@ +export { PinAndSetupModal } from "./PinAndSetupModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2AvailableProjectsSection/V2AvailableProjectsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2AvailableProjectsSection/V2AvailableProjectsSection.tsx new file mode 100644 index 00000000000..365073246e9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2AvailableProjectsSection/V2AvailableProjectsSection.tsx @@ -0,0 +1,87 @@ +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { Item, ItemContent, ItemGroup, ItemTitle } from "@superset/ui/item"; +import { HiChevronDown, HiMiniPlus } from "react-icons/hi2"; +import { LuFolderInput } from "react-icons/lu"; +import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; +import type { AvailableV2Project } from "../../hooks/useAvailableV2Projects"; + +interface V2AvailableProjectsSectionProps { + projects: AvailableV2Project[]; + onCreateNewProject: () => void; + onImportExistingFolder: () => void; + onPinAndSetup: (project: AvailableV2Project) => void; +} + +export function V2AvailableProjectsSection({ + projects, + onCreateNewProject, + onImportExistingFolder, + onPinAndSetup, +}: V2AvailableProjectsSectionProps) { + return ( +
+
+
+

Available

+ + {projects.length} + +
+ + + + + + + + New project + + + + Import existing folder + + + +
+ + {projects.length > 0 && ( + + {projects.map((project) => ( + + + + {project.name} + {project.githubOwner && project.githubRepoName && ( + + {project.githubOwner}/{project.githubRepoName} + + )} + + + + ))} + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2AvailableProjectsSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2AvailableProjectsSection/index.ts new file mode 100644 index 00000000000..d560852e039 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2AvailableProjectsSection/index.ts @@ -0,0 +1 @@ +export { V2AvailableProjectsSection } from "./V2AvailableProjectsSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx index 10a29d40088..6820c68351a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx @@ -16,16 +16,22 @@ import type { AccessibleV2Workspace, V2WorkspaceHostType, } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; +import type { AvailableV2Project } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAvailableV2Projects"; import { useV2WorkspacesFilterStore, type V2WorkspacesDeviceFilter, } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore"; +import { V2AvailableProjectsSection } from "../V2AvailableProjectsSection"; import { V2WorkspaceRow } from "./components/V2WorkspaceRow"; interface V2WorkspacesListProps { pinned: AccessibleV2Workspace[]; others: AccessibleV2Workspace[]; + availableProjects: AvailableV2Project[]; hasAnyAccessible: boolean; + onCreateNewProject: () => void; + onImportExistingFolder: () => void; + onPinAndSetup: (project: AvailableV2Project) => void; } interface ProjectGroup { @@ -78,7 +84,11 @@ function groupByProject(workspaces: AccessibleV2Workspace[]): ProjectGroup[] { export function V2WorkspacesList({ pinned, others, + availableProjects, hasAnyAccessible, + onCreateNewProject, + onImportExistingFolder, + onPinAndSetup, }: V2WorkspacesListProps) { const matchRoute = useMatchRoute(); const currentWorkspaceMatch = matchRoute({ @@ -120,27 +130,40 @@ export function V2WorkspacesList({ const hasAnyMatches = pinnedCount > 0 || othersCount > 0; const hasActiveFilters = searchQuery.trim() !== "" || deviceFilter !== "all"; - if (!hasAnyAccessible) { + // If the user has neither workspaces nor any cloud projects available to + // pin, they genuinely have nothing — show the onboarding empty state + // (which includes the Available section's create/import CTAs above it). + if (!hasAnyAccessible && availableProjects.length === 0) { return ( - - - - - - No workspaces yet - - Create a workspace from the sidebar to get started. Workspaces you - have access to across all your devices will show up here. - - - + +
+ + + + + + + No workspaces yet + + Create a new project above to get started. Workspaces you have + access to across all your devices will show up here. + + + +
+
); } - if (!hasAnyMatches) { + if (!hasAnyMatches && availableProjects.length === 0) { return ( @@ -196,6 +219,13 @@ export function V2WorkspacesList({ return (
+ + {pinnedCount > 0 ? (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAvailableV2Projects/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAvailableV2Projects/index.ts new file mode 100644 index 00000000000..7b592e20f3f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAvailableV2Projects/index.ts @@ -0,0 +1,4 @@ +export { + type AvailableV2Project, + useAvailableV2Projects, +} from "./useAvailableV2Projects"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAvailableV2Projects/useAvailableV2Projects.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAvailableV2Projects/useAvailableV2Projects.ts new file mode 100644 index 00000000000..fe890381434 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAvailableV2Projects/useAvailableV2Projects.ts @@ -0,0 +1,112 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { MOCK_ORG_ID } from "shared/constants"; + +export interface AvailableV2Project { + id: string; + name: string; + slug: string; + organizationId: string; + githubRepositoryId: string | null; + githubOwner: string | null; + githubRepoName: string | null; + createdAt: Date; +} + +export interface UseAvailableV2ProjectsResult { + projects: AvailableV2Project[]; +} + +interface UseAvailableV2ProjectsOptions { + searchQuery?: string; +} + +function projectMatchesSearch( + project: AvailableV2Project, + query: string, +): boolean { + if (!query.trim()) return true; + const needle = query.trim().toLowerCase(); + return ( + project.name.toLowerCase().includes(needle) || + project.slug.toLowerCase().includes(needle) || + (project.githubOwner?.toLowerCase().includes(needle) ?? false) || + (project.githubRepoName?.toLowerCase().includes(needle) ?? false) + ); +} + +/** + * Lists cloud projects in the user's active org that are NOT currently + * pinned in the sidebar. Powers the "Available" section of the workspaces + * tab. No backing filter — a pinned-and-unbacked project stays in the + * sidebar, not here (per design D7.3). + */ +export function useAvailableV2Projects( + options: UseAvailableV2ProjectsOptions = {}, +): UseAvailableV2ProjectsResult { + const searchQuery = options.searchQuery ?? ""; + const { data: session } = authClient.useSession(); + const collections = useCollections(); + + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + + const { data: rows = [] } = useLiveQuery( + (q) => + q + .from({ projects: collections.v2Projects }) + .leftJoin( + { pins: collections.v2SidebarProjects }, + ({ projects, pins }) => eq(projects.id, pins.projectId), + ) + .leftJoin( + { repos: collections.githubRepositories }, + ({ projects, repos }) => eq(projects.githubRepositoryId, repos.id), + ) + .where(({ projects }) => + eq(projects.organizationId, activeOrganizationId ?? ""), + ) + .select(({ projects, pins, repos }) => ({ + id: projects.id, + name: projects.name, + slug: projects.slug, + organizationId: projects.organizationId, + githubRepositoryId: projects.githubRepositoryId, + githubOwner: repos?.owner ?? null, + githubRepoName: repos?.name ?? null, + createdAt: projects.createdAt, + pinProjectId: pins?.projectId ?? null, + })), + [activeOrganizationId, collections], + ); + + const projects = useMemo(() => { + const deduped = new Map(); + for (const row of rows) { + // Left-join to v2SidebarProjects — only rows with no sidebar pin are + // "available." The antijoin pattern matches useAccessibleV2Workspaces. + if (row.pinProjectId != null) continue; + if (deduped.has(row.id)) continue; + deduped.set(row.id, { + id: row.id, + name: row.name, + slug: row.slug, + organizationId: row.organizationId, + githubRepositoryId: row.githubRepositoryId, + githubOwner: row.githubOwner ?? null, + githubRepoName: row.githubRepoName ?? null, + createdAt: new Date(row.createdAt), + }); + } + return Array.from(deduped.values()) + .filter((project) => projectMatchesSearch(project, searchQuery)) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + }, [rows, searchQuery]); + + return { projects }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useFolderFirstImport/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useFolderFirstImport/index.ts new file mode 100644 index 00000000000..bf7afaae9a0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useFolderFirstImport/index.ts @@ -0,0 +1,6 @@ +export { + type FolderFirstImportState, + type FolderImportCandidate, + type UseFolderFirstImportResult, + useFolderFirstImport, +} from "./useFolderFirstImport"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useFolderFirstImport/useFolderFirstImport.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useFolderFirstImport/useFolderFirstImport.ts new file mode 100644 index 00000000000..e7f92dfed16 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useFolderFirstImport/useFolderFirstImport.ts @@ -0,0 +1,287 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; +import { useCallback, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +export interface FolderImportCandidate { + id: string; + name: string; + slug: string; + organizationId: string; + organizationName: string; +} + +/** + * State machine for the folder-first import flow. + * + * idle — no modal. + * no-match — picked folder has no cloud project; user names it to + * create. + * pick — multiple candidates; user picks which cloud project to + * bind. + * confirm-repoint — the target project is already set up on this host at + * some other path. Re-pointing would invalidate existing + * workspaces; user must explicitly acknowledge. + * + * The 1-match case without a conflict has no state here — we run setup + * immediately without a modal because there's nothing to disambiguate. + */ +export type FolderFirstImportState = + | { kind: "idle" } + | { kind: "no-match"; repoPath: string; working: boolean } + | { + kind: "pick"; + repoPath: string; + candidates: FolderImportCandidate[]; + working: boolean; + } + | { + kind: "confirm-repoint"; + repoPath: string; + projectId: string; + projectName: string; + working: boolean; + }; + +export interface UseFolderFirstImportResult { + state: FolderFirstImportState; + /** Open the native picker and branch on candidate count. */ + start: () => Promise; + /** Close the modal. No-op while a mutation is working. */ + cancel: () => void; + /** no-match branch: user confirmed a project name → create as new. */ + confirmCreateAsNew: (input: { name: string }) => Promise; + /** pick branch: user selected one of the candidates → run setup. */ + confirmPickCandidate: (candidateId: string) => Promise; + /** confirm-repoint branch: user accepts workspace invalidation → retry. */ + confirmRepoint: () => Promise; +} + +type SetupInvokeResult = + | { status: "ok"; projectId: string; repoPath: string } + | { status: "conflict" } + | { status: "error"; message: string }; + +function isConflictError(err: unknown): boolean { + return ( + err instanceof TRPCClientError && + (err.data as { code?: string } | undefined)?.code === "CONFLICT" + ); +} + +export function useFolderFirstImport(options?: { + onSuccess?: (result: { projectId: string; repoPath: string }) => void; + onError?: (message: string) => void; +}): UseFolderFirstImportResult { + const { activeHostUrl } = useLocalHostService(); + const queryClient = useQueryClient(); + const { ensureProjectInSidebar } = useDashboardSidebarState(); + const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); + + const [state, setState] = useState({ kind: "idle" }); + + const reset = useCallback(() => setState({ kind: "idle" }), []); + + const invalidateProjectList = useCallback(() => { + // Keep in sync with useDashboardSidebarData. + queryClient.invalidateQueries({ + queryKey: ["project", "list", activeHostUrl], + }); + }, [queryClient, activeHostUrl]); + + const reportSuccess = useCallback( + (result: { projectId: string; repoPath: string }) => { + ensureProjectInSidebar(result.projectId); + invalidateProjectList(); + options?.onSuccess?.(result); + reset(); + }, + [ensureProjectInSidebar, invalidateProjectList, options, reset], + ); + + const reportError = useCallback( + (message: string) => { + options?.onError?.(message); + }, + [options], + ); + + const runSetup = useCallback( + async ( + projectId: string, + repoPath: string, + opts: { acknowledgeWorkspaceInvalidation?: boolean } = {}, + ): Promise => { + if (!activeHostUrl) { + return { status: "error", message: "Host service not available" }; + } + const client = getHostServiceClientByUrl(activeHostUrl); + try { + const result = await client.project.setup.mutate({ + projectId, + acknowledgeWorkspaceInvalidation: + opts.acknowledgeWorkspaceInvalidation, + mode: { kind: "import", repoPath }, + }); + return { status: "ok", projectId, repoPath: result.repoPath }; + } catch (err) { + if (isConflictError(err)) return { status: "conflict" }; + const message = err instanceof Error ? err.message : String(err); + return { status: "error", message }; + } + }, + [activeHostUrl], + ); + + const start = useCallback(async () => { + if (!activeHostUrl) { + reportError("Host service not available"); + return; + } + + const picked = await selectDirectory.mutateAsync({ + title: "Import existing folder", + }); + if (picked.canceled || !picked.path) return; + const repoPath = picked.path; + + const client = getHostServiceClientByUrl(activeHostUrl); + let candidates: FolderImportCandidate[]; + try { + const response = await client.project.findByPath.query({ repoPath }); + candidates = response.candidates; + } catch (err) { + reportError(err instanceof Error ? err.message : String(err)); + return; + } + + if (candidates.length === 0) { + setState({ kind: "no-match", repoPath, working: false }); + return; + } + const [only, ...rest] = candidates; + if (only && rest.length === 0) { + // Auto-advance: no ambiguity, no user input needed — unless the + // project is already set up on this host at a different path. + const result = await runSetup(only.id, repoPath); + if (result.status === "ok") { + reportSuccess(result); + } else if (result.status === "conflict") { + setState({ + kind: "confirm-repoint", + repoPath, + projectId: only.id, + projectName: only.name, + working: false, + }); + } else { + reportError(result.message); + } + return; + } + setState({ kind: "pick", repoPath, candidates, working: false }); + }, [activeHostUrl, reportError, reportSuccess, runSetup, selectDirectory]); + + const cancel = useCallback(() => { + setState((prev) => { + // Don't drop the modal while a mutation is mid-flight; the user will + // see the disabled state and wait, or the mutation will resolve and + // reset us. + if (prev.kind !== "idle" && prev.working) return prev; + return { kind: "idle" }; + }); + }, []); + + const confirmCreateAsNew = useCallback( + async ({ name }: { name: string }) => { + if (state.kind !== "no-match") return; + if (!activeHostUrl) { + reportError("Host service not available"); + return; + } + const repoPath = state.repoPath; + setState({ kind: "no-match", repoPath, working: true }); + const client = getHostServiceClientByUrl(activeHostUrl); + try { + const result = await client.project.create.mutate({ + name, + visibility: "private", + mode: { kind: "importLocal", repoPath }, + }); + reportSuccess(result); + } catch (err) { + reportError(err instanceof Error ? err.message : String(err)); + setState({ kind: "no-match", repoPath, working: false }); + } + }, + [activeHostUrl, reportError, reportSuccess, state], + ); + + const confirmPickCandidate = useCallback( + async (candidateId: string) => { + if (state.kind !== "pick") return; + const { repoPath, candidates } = state; + const candidate = candidates.find((c) => c.id === candidateId); + setState({ kind: "pick", repoPath, candidates, working: true }); + const result = await runSetup(candidateId, repoPath); + if (result.status === "ok") { + reportSuccess(result); + } else if (result.status === "conflict") { + setState({ + kind: "confirm-repoint", + repoPath, + projectId: candidateId, + projectName: candidate?.name ?? "this project", + working: false, + }); + } else { + reportError(result.message); + setState({ kind: "pick", repoPath, candidates, working: false }); + } + }, + [reportError, reportSuccess, runSetup, state], + ); + + const confirmRepoint = useCallback(async () => { + if (state.kind !== "confirm-repoint") return; + const { repoPath, projectId, projectName } = state; + setState({ + kind: "confirm-repoint", + repoPath, + projectId, + projectName, + working: true, + }); + const result = await runSetup(projectId, repoPath, { + acknowledgeWorkspaceInvalidation: true, + }); + if (result.status === "ok") { + reportSuccess(result); + } else { + const message = + result.status === "conflict" + ? "Unexpected conflict after acknowledging re-point" + : result.message; + reportError(message); + setState({ + kind: "confirm-repoint", + repoPath, + projectId, + projectName, + working: false, + }); + } + }, [reportError, reportSuccess, runSetup, state]); + + return { + state, + start, + cancel, + confirmCreateAsNew, + confirmPickCandidate, + confirmRepoint, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx index d8286acf22d..57cb0558405 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx @@ -1,8 +1,17 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; +import { + useOpenNewProjectModal, + useOpenPinAndSetupModal, + useTriggerFolderImport, +} from "renderer/stores/add-repository-modal"; import { V2WorkspacesHeader } from "./components/V2WorkspacesHeader"; import { V2WorkspacesList } from "./components/V2WorkspacesList"; import { useAccessibleV2Workspaces } from "./hooks/useAccessibleV2Workspaces"; +import { + type AvailableV2Project, + useAvailableV2Projects, +} from "./hooks/useAvailableV2Projects"; import { useV2WorkspacesFilterStore } from "./stores/v2WorkspacesFilterStore"; export const Route = createFileRoute( @@ -23,15 +32,38 @@ function V2WorkspacesPage() { }, [resetFilters]); const { pinned, others, counts } = useAccessibleV2Workspaces({ searchQuery }); + const { projects: availableProjects } = useAvailableV2Projects({ + searchQuery, + }); const hasAnyAccessible = pinned.length > 0 || others.length > 0; + const openNewProject = useOpenNewProjectModal(); + const openPinAndSetup = useOpenPinAndSetupModal(); + const triggerFolderImport = useTriggerFolderImport(); + + const handlePinAndSetup = useCallback( + (project: AvailableV2Project) => { + openPinAndSetup({ + id: project.id, + name: project.name, + githubOwner: project.githubOwner, + githubRepoName: project.githubRepoName, + }); + }, + [openPinAndSetup], + ); + return (
); 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 2bfa3148808..b2020f59193 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -16,6 +16,7 @@ import type { SelectUser, SelectV2Client, SelectV2Host, + SelectV2HostProject, SelectV2Project, SelectV2UsersHosts, SelectV2Workspace, @@ -77,6 +78,7 @@ export interface OrgCollections { v2UsersHosts: Collection; v2Projects: Collection; v2Workspaces: Collection; + v2HostProjects: Collection; workspaces: Collection; members: Collection; users: Collection; @@ -314,6 +316,22 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); + const v2HostProjects = createCollection( + electricCollectionOptions({ + id: `v2_host_projects-${organizationId}`, + shapeOptions: { + url: electricUrl, + params: { + table: "v2_host_projects", + organizationId, + }, + headers: electricHeaders, + columnMapper, + }, + getKey: (item) => item.id, + }), + ); + const workspaces = createCollection( electricCollectionOptions({ id: `workspaces-${organizationId}`, @@ -568,6 +586,7 @@ function createOrgCollections(organizationId: string): OrgCollections { v2UsersHosts, v2Projects, v2Workspaces, + v2HostProjects, workspaces, members, users, diff --git a/apps/desktop/src/renderer/stores/add-repository-modal.ts b/apps/desktop/src/renderer/stores/add-repository-modal.ts new file mode 100644 index 00000000000..541b01c50fa --- /dev/null +++ b/apps/desktop/src/renderer/stores/add-repository-modal.ts @@ -0,0 +1,85 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +/** + * Minimum shape needed to render the Pin & set up modal. Kept local so the + * store doesn't depend on the v2-workspaces route types. + */ +export interface PinAndSetupTarget { + id: string; + name: string; + githubOwner: string | null; + githubRepoName: string | null; +} + +type ActiveModal = + | { kind: "none" } + | { kind: "new-project" } + | { + kind: "pin-and-setup"; + target: PinAndSetupTarget; + // Optional one-shot callback invoked by the host component after the + // modal's setup mutation resolves successfully. Used by callers that + // want to retry the operation that originally surfaced + // PROJECT_NOT_SETUP (e.g. the pending workspace-create page). + onSuccess?: () => void; + // When true, the modal opens directly in "re-point" mode and sends + // acknowledgeWorkspaceInvalidation on the first submit. Used for + // stale-path repair where the user explicitly chose Repair and we + // already know the project is set up here. + forceRepoint?: boolean; + }; + +interface AddRepositoryModalState { + active: ActiveModal; + // Monotonically increasing counter; bumping it signals the host + // component to run the folder-first import flow (which is owned by a + // useFolderFirstImport hook — hooks can't live in a zustand store, so we + // use a trigger pulse instead). + folderImportTrigger: number; + openNewProject: () => void; + triggerFolderImport: () => void; + openPinAndSetup: ( + target: PinAndSetupTarget, + opts?: { onSuccess?: () => void; forceRepoint?: boolean }, + ) => void; + close: () => void; +} + +export const useAddRepositoryModalStore = create()( + devtools( + (set) => ({ + active: { kind: "none" }, + folderImportTrigger: 0, + openNewProject: () => set({ active: { kind: "new-project" } }), + triggerFolderImport: () => + set((state) => ({ + folderImportTrigger: state.folderImportTrigger + 1, + })), + openPinAndSetup: (target, opts) => + set({ + active: { + kind: "pin-and-setup", + target, + onSuccess: opts?.onSuccess, + forceRepoint: opts?.forceRepoint, + }, + }), + close: () => set({ active: { kind: "none" } }), + }), + { name: "add-repository-modal" }, + ), +); + +export const useAddRepositoryModalActive = () => + useAddRepositoryModalStore((state) => state.active); +export const useFolderImportTrigger = () => + useAddRepositoryModalStore((state) => state.folderImportTrigger); +export const useOpenNewProjectModal = () => + useAddRepositoryModalStore((state) => state.openNewProject); +export const useTriggerFolderImport = () => + useAddRepositoryModalStore((state) => state.triggerFolderImport); +export const useOpenPinAndSetupModal = () => + useAddRepositoryModalStore((state) => state.openPinAndSetup); +export const useCloseAddRepositoryModal = () => + useAddRepositoryModalStore((state) => state.close); diff --git a/apps/electric-proxy/src/where.ts b/apps/electric-proxy/src/where.ts index 43efdb7e006..7d061965466 100644 --- a/apps/electric-proxy/src/where.ts +++ b/apps/electric-proxy/src/where.ts @@ -14,6 +14,7 @@ import { taskStatuses, tasks, v2Clients, + v2HostProjects, v2Hosts, v2Projects, v2UsersHosts, @@ -67,6 +68,13 @@ export function buildWhereClause( case "v2_workspaces": return build(v2Workspaces, v2Workspaces.organizationId, organizationId); + case "v2_host_projects": + return build( + v2HostProjects, + v2HostProjects.organizationId, + organizationId, + ); + case "auth.members": return build(members, members.organizationId, organizationId); diff --git a/docs/design/v2-project-create-import.md b/docs/design/v2-project-create-import.md new file mode 100644 index 00000000000..c2b0cbfb79e --- /dev/null +++ b/docs/design/v2-project-create-import.md @@ -0,0 +1,347 @@ +# V2 Project Create & Import + +Design for the v2 "create project" and "import project" flows. V2 projects are cloud-driven, and materialization is per-host. Companion: `v2-host-project-paths.md` — path mapping + throw-on-create mechanics for workspaces. + +--- + +## Backing: source of truth + +A project is **backed on a host** iff that host's `host-service.projects` table has a row for it (`packages/host-service/src/db/schema.ts:32`): + +```ts +projects { + id text PK // matches cloud v2_projects.id + repoPath text NOT NULL // local main repo path + repoProvider, repoOwner, repoName, repoUrl, remoteName + createdAt +} +``` + +`workspaces.projectId` FKs to this. No project row → no workspaces possible on that host. `repoPath` can be stale on disk (cell 3) — handled with a repair CTA. + +--- + +## State dimensions + +Per host, a project has three signals; plus the global cloud signal: + +| Axis | Values | +| --- | --- | +| Cloud `v2_projects` row | exists / missing | +| Host's `host-service.projects` row | exists / missing | +| `repoPath` on disk | valid git root / missing | + +### Cells + +| \# | Cloud | Host row | Disk | Meaning | Action | +| --- | --- | --- | --- | --- | --- | +| 1 | ✓ | ✗ | — | Cloud-only (teammate, other device) | `project.setup` (clone/import) | +| 2 | ✓ | ✓ | valid | Fully backed | — | +| 3 | ✓ | ✓ | missing | Stale path | Repair via `project.setup` | +| 5 | ✗ | — | — | Brand new | `project.create` | + +Wrong-remote drift (host row exists but its remote doesn't match cloud's `repoCloneUrl`) is prevented at entry by `project.setup`'s remote validation. Not modelled here. + +--- + +## Current sidebar (and what's broken) + +The existing v2 sidebar (`useDashboardSidebarData`) is **pin-driven**, not backing-aware. Visibility comes from three per-device localStorage collections: + +| Collection | Role | +| --- | --- | +| `v2SidebarProjects` | Project is in sidebar iff row exists. Holds `projectId`, `tabOrder`, `isCollapsed`. | +| `v2WorkspaceLocalState` | Workspace is in sidebar if row exists. Holds `sidebarState.projectId/tabOrder/sectionId`. | +| `v2SidebarSections` | Optional grouping rows. | + +Nothing in the join consults `host-service.projects`. Pinned projects with no backing still render, and "New workspace" throws deep in the creation flow. This design fills that gap. + +--- + +## Host-service as orchestrator + +Every client calls host-service. Desktop today; web/mobile route through host-service later. The host-service RPC **is the create flow** — cloud-row creation, optional GitHub repo provisioning, local git, local DB insert, cloud backing signal. + +Neither `project.create` nor `project.setup` auto-creates a workspace. A project can exist and be backed on a host with zero workspaces. Workspaces are always explicit user action ("import branch" or "create new with clone"). + +### `project.create` (new) + +User-facing intent: **"clone a new project."** Handles the new-project side — cloud row + local clone. + +```ts +project.create({ + name: string, + visibility: "private" | "public", + mode: + | { kind: "empty"; parentDir: string } + | { kind: "clone"; parentDir: string; url: string } + | { kind: "importLocal"; repoPath: string } // git root of existing local repo + | { kind: "template"; parentDir: string; templateId: string } +}) → { projectId: string; repoPath: string } +``` + +Path semantics are baked into each variant so there's no overloaded meaning: `parentDir` for modes that create a new directory; `repoPath` (git root) for `importLocal`. + +Internal order: + +1. Cloud: create `v2_projects` row (+ GitHub repo for empty/importLocal/template) +2. Local git: clone / init+push / link+push / scaffold+push +3. Upsert `host-service.projects` row with `repoPath` + remote metadata +4. Upsert cloud `v2_host_projects` row for (projectId, currentHostId) — see backing signal below +5. Return + +**GitHub repo creation is in scope** — otherwise `empty` and `template` degrade to `clone`. + +**Always materializes on the calling host.** No "cloud-only" mode. Other hosts use `project.setup`. + +**No rollback on mid-flow failure.** Cloud row created but local clone fails → project is in cell 1. User retries via `project.setup`. Cell 1 is a first-class state, not a failure mode. + +Phase 1 ships `clone` and `importLocal` only; `empty` and `template` throw `not_implemented`. + +### `project.setup` (exists — `packages/host-service/src/trpc/router/project/project.ts:23`) + +User-facing intent: **"import or fix."** Either a cell-1 project that already exists in cloud (clone/import on this host), or a cell-3 repair (re-point the path). + +```ts +project.setup({ + projectId: string, + acknowledgeWorkspaceInvalidation?: boolean, // required when projects row already exists + mode: + | { kind: "clone"; parentDir: string } // host-service clones into parentDir + | { kind: "import"; repoPath: string } // point at an existing git root; remote is validated +}) → { repoPath: string } +``` + +Changes from existing: + +- Also upserts cloud `v2_host_projects` row for (projectId, currentHostId). +- `acknowledgeWorkspaceInvalidation` is the repair-vs-first-time discriminator. Path re-point can invalidate existing workspace rows; caller must ack. + +### `project.list` (new) + +```ts +project.list() → Array<{ + id: string // matches v2_projects.id + repoPath: string +}> +``` + +One row per `host-service.projects` entry on the calling machine. Pure DB read, no filesystem check. + +**Phase 1: no proactive stale-path detection.** Operations that hit a missing path (e.g. `workspace.create`, `git` calls) surface the error at that moment, and their error handlers invalidate `["project", "list"]` so the sidebar can react. Lazy path to repair is good enough until users complain. Phase 4 adds proactive `statSync` + a Stale-path row state + a refetch interval to catch out-of-band disk changes. + +Renderer reads via React Query with invalidation on `["project", "list"]` after `project.create` / `project.setup` / `project.remove` (and on operation errors that indicate a vanished path). No subscription, no polling — local `host-service.projects` only changes via our own mutations. + +### Cloud backing signal: `v2_host_projects` + +Since we no longer auto-seed workspaces, we can't derive "host H backs project P" from the workspaces table. We need a direct signal. + +New cloud table, Electric-synced: + +```ts +v2_host_projects { + id uuid PK + organizationId uuid + projectId uuid → v2_projects.id + hostId uuid → v2_hosts.id + createdAt, updatedAt + unique(projectId, hostId) +} +``` + +One row per (project, host) pair that backs it. Host-service mutations: +- `project.create` / `project.setup` → upsert +- `project.remove` → delete the row for (projectId, currentHostId) + +Both mutations go through `ctx.api` to a new cloud `v2HostProjects` router (authorized against the caller's `v2_users_hosts` membership). + +### Client responsibilities + +Native pickers (`dialog.showOpenDialog`) stay in the client — host-service has no UI. Client collects the path, passes it into `project.create` / `project.setup`. + +--- + +## Existing types — reuse, don't redeclare + +| Need | Source | +| --- | --- | +| Cloud project row | `typeof v2Projects.$inferSelect` (`packages/db/src/schema/schema.ts:380`) | +| Cloud project + clone URL | `v2Projects.get` output (`packages/trpc/src/router/v2-project/v2-project.ts:82`) | +| Cloud project creation | `v2Projects.create` (L113) — takes `{ name, slug, githubRepositoryId }` | +| Workspace (cloud) | `typeof v2Workspaces.$inferSelect` (has `projectId`, `hostId`) | +| Host (cloud) | `typeof v2Hosts.$inferSelect` (has `machineId`, `isOnline`) | +| Host backing (cloud, new) | `typeof v2HostProjects.$inferSelect` (see above) | +| Host-service project row | `typeof projects.$inferSelect` | +| Host-service workspace row | `typeof workspaces.$inferSelect` | +| Current host identity | `useLocalHostService().machineId` + `activeHostUrl` | +| Pinned-in-sidebar rows | `v2SidebarProjects` / `v2WorkspaceLocalState` (localStorage) | + +--- + +## Sidebar integration + +### Visibility rule + +**Pin alone.** A pinned project (`v2SidebarProjects` row) always renders. Backing health shows as row state, never as a filter. Users don't lose their place when a host goes offline. Pin-management (auto-pin, cross-device pin sync, unpin UX) is tuned separately — pin is a binary input to this design. + +### Backing derivation (client-side) + +Two sources, combined in `useDashboardSidebarData`. Nothing new gets synced. + +**Local backing** — authoritative, lag-free. Calls the local daemon: + +```ts +const { data: localBacked } = useQuery({ + queryKey: ["project", "list"], + queryFn: () => activeHostClient.project.list.query(), +}) +// Map +``` + +**Remote backing** — Electric-derived from `v2_host_projects`, tolerates sync lag: + +```ts +const { data: remoteBacked } = useLiveQuery(q => q + .from({ hp: collections.v2HostProjects }) + .innerJoin({ h: collections.v2Hosts }, ({ hp, h }) => eq(hp.hostId, h.id)) + .where(({ h }) => ne(h.machineId, currentMachineId)) +) +// → derived into Map; offline: Set }> +// partitioned by h.isOnline +``` + +Both online and offline remote backings are surfaced — offline is what drives the "Host offline" row state. Direct signal, no workspace-count dependency. + +> ⚠️ **Implementation-time validation.** The two-source split (host-service tRPC for local, Electric live query for remote) is structurally motivated — `pathStatus` needs filesystem access, Electric gives push-based reactivity for remote changes. In practice, the combined hook may surface friction (ordering, invalidation timing, empty-state flashes). Revisit if the split causes pain during wiring. + +### Row state (per pinned project) + +| Row state | Condition | CTA | +| --- | --- | --- | +| Normal | local backing exists, or any `remoteBacked.online` host | open / new workspace | +| Host offline | no local backing, no online remote backing, but `remoteBacked.offline` non-empty | passive; reconnect restores | +| Not set up here | no local backing, no remote backing at all | "Set up here" inline (→ `project.setup`) | + +Phase 4 adds a fourth **Stale path** state, triggered by proactive `statSync` of `repoPath`. Phase 1 doesn't distinguish healthy-on-disk from missing-on-disk — operations fail lazily if the path is gone. + +### Workspace row + +- **Host chip** — `current-host | remote-device | cloud`, from the existing `hostType` derivation (`v2Hosts.machineId === machineId`). +- **New workspace action** — local backing → creates directly; otherwise → inline setup-then-create (see companion doc). +- **Remote-device workspace click** — workspaces are bound to the host they were created on. Opening requires being on that host. Click lands on a "switch host or set up here" stub page (Phase 3; companion doc). + +--- + +## Available — discovery inside the workspaces tab + +Not a separate sidebar surface. Lives as a section inside the existing workspaces tab, alongside the pinned workspaces. + +- **D7.1.** Lists cloud projects in the user's org that aren't pinned locally (`v2_projects` ∖ `v2SidebarProjects`). No backing filter — pinned-and-unbacked stays in the sidebar, not here. +- **D7.2.** Two entry points: + - **"+ New project"** → `project.create` + - **"Pin & set up"** on a row → adds pin + runs `project.setup` +- **D7.3.** Pins never drop back into Available. Once pinned, a project lives in the sidebar forever (or until user unpins — separate gesture, out of scope). +- **D7.4.** No row-state decoration here (Normal / Stale path / Host offline / Not set up here are sidebar concerns). Available lists cloud projects as candidates only. +- **D7.5. Folder-first entry: "Import existing folder."** Alongside "+ New project." User picks a folder; if its git remote matches an existing cloud project, we bind to that project via `project.setup`. If multiple projects match (two cloud projects share a GitHub repo across orgs, etc.), show a picker. If none match, offer to create instead via `project.create` with `mode: importLocal`. + +### Folder-first import — picker flow + +1. User clicks "Import existing folder" → native picker (client). +2. Client calls new host-service `project.findByPath({ repoPath }) → { candidates: Array<{ id, name, slug, organizationId, organizationName }> }`. +3. Host-service validates `repoPath` is a git root, reads the remote URL, forwards to new cloud `v2Projects.findByRemote({ repoCloneUrl })` via `ctx.api`. +4. Cloud filters to projects in orgs the user belongs to, returns matches. +5. Client branches on `candidates.length`: + - **0** → modal offers "No match — create as new project" (pivots to `project.create` `importLocal`). + - **1** → auto-advance to `project.setup({ projectId, mode: { kind: "import", repoPath } })`. + - **>1** → picker modal lists candidates (name + org); user picks; then `project.setup`. + +Decisions baked in: +- **D7.6.** Picker only appears when ambiguity is real (≥2 matches). One match auto-advances. No match offers creation. +- **D7.7.** Candidate list scoped to the user's accessible orgs, not global — respects v2 auth scope. +- **D7.8.** New host-service endpoint `project.findByPath` handles the remote-read + cloud-query in one call (client doesn't fan out). +- **D7.9.** New cloud endpoint `v2Projects.findByRemote` — dedicated matcher, not a `v2Projects.list` filter, so auth is explicit and intent is clear. + +--- + +## User journeys + +**Legend:** laptop + desktop, both connected unless noted. "Pin" = localStorage, per-device. + +### 1. New user, new org — first project + +| Step | Host-service | Cloud `v2_projects` | Cloud `v2_host_projects` | Pin | Sidebar | Available | +| --- | --- | --- | --- | --- | --- | --- | +| start | — | — | — | — | empty | empty | +| "+ New project" → `project.create` | row | row | row | pinned | project, Normal, no workspaces | — | + +Project exists and is backed; user creates workspaces explicitly from the sidebar. + +### 2. Join an org with existing projects + +| Step | Host-service | `v2_host_projects` (this user's hosts) | Pin | Sidebar | Available | +| --- | --- | --- | --- | --- | --- | +| start | — | — | — | empty | every teammate project | +| "Pin & set up" → `project.setup` | row | + row for current host | pinned | the project, Normal, no workspaces | rest | + +Teammates' `v2_host_projects` rows exist but their hosts are in their own `v2_users_hosts`, so they don't contribute remote backing for this user. Available is the path in. + +### 3. Adding a second host + +| Step | Laptop host-svc | Desktop host-svc | Desktop pins | Desktop sidebar | Desktop Available | +| --- | --- | --- | --- | --- | --- | +| before (user on laptop) | A, B | — | — | — | — | +| log into desktop | unchanged | — | — | empty | A, B | +| "Pin & set up" A on desktop | unchanged | A | A | A, Normal | B | + +Desktop starts empty (no pins on this device). Cross-device pin sync is pin-tuning. + +### 4. Same project backed on both hosts + +| Event | `v2_host_projects` | `v2_workspaces` | Laptop sidebar (project P) | Desktop sidebar (project P) | +| --- | --- | --- | --- | --- | +| both backed, no workspaces yet | (P,L), (P,D) | — | Normal, empty | Normal, empty | +| laptop creates α | unchanged | + α (hostId = L) | + α (local) | + α (remote) | +| desktop creates β | unchanged | + β (hostId = D) | + β (remote) | + β (local) | + +Backing is independent of workspaces. Workspaces bind to their creating host. Remote-device rows open the "switch host or set up here" stub, not the workspace directly. + +### 5. A host goes offline + +User on desktop, project pinned there. Laptop is the other host. + +| State | Laptop online | Desktop backs it | Row state | +| --- | --- | --- | --- | +| both backed, both online | ✓ | ✓ | Normal | +| laptop offline, desktop backs it | — | ✓ | Normal | +| laptop offline, desktop doesn't | — | — | Host offline | +| neither host ever backed it | — | — | Not set up here | + +Row state surfaces the problem; the pin stays. + +--- + +## Flow summary + +| Transition | RPC | Entry point | Row state flips | +| --- | --- | --- | --- | +| nothing → cell 2 | `project.create` | Available "+ New project" | Normal immediately | +| cell 1 → cell 2 | `project.setup` | Workspaces-tab Available "Pin & set up", sidebar "Set up here" CTA | Normal immediately | +| cell 3 → cell 2 | `project.setup` (`acknowledgeWorkspaceInvalidation: true`) | Phase 4 Stale-path Repair CTA (or lazy on operation error in Phase 1) | Normal immediately | +| workspace-create on unbacked host | workspace.create throw → inline `project.setup` → retry | New Workspace modal | Normal immediately | + +--- + +## Open questions + +1. ~~**Disambiguation on import.**~~ **Resolved:** Phase 1 ships a folder-first entry point ("Import existing folder") with a picker. See the dedicated section below. +2. **GitHub auth for repo creation.** Likely cloud-side (GitHub App installation), fetched via `ctx.api`. Org-picker UX is a separate design. +3. **Template source.** Cloud records, curated registry, or user-provided? Mode exists in the RPC shape; implementation stubbed until decided. +4. **Mid-flow failure visibility.** With no rollback, a cloud row can exist without any host-service row. Available surfaces this naturally — decide whether the originating client also shows an inline "setup unfinished" recovery path. + +Pin behavior (auto-pin on create/setup, cross-device pin sync, unpin UX) is out of scope here. + +--- + +## Phasing + +Moved to [`plans/20260417-v2-project-create-import-impl.md`](../../plans/20260417-v2-project-create-import-impl.md) — Phase 1 punch list + Phases 2–4 sequencing + deferred/out-of-scope notes. \ No newline at end of file diff --git a/packages/db/drizzle/0034_v2_host_projects.sql b/packages/db/drizzle/0034_v2_host_projects.sql new file mode 100644 index 00000000000..9720058ae4c --- /dev/null +++ b/packages/db/drizzle/0034_v2_host_projects.sql @@ -0,0 +1,16 @@ +CREATE TABLE "v2_host_projects" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "project_id" uuid NOT NULL, + "host_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "v2_host_projects_project_host_unique" UNIQUE("project_id","host_id") +); +--> statement-breakpoint +ALTER TABLE "v2_host_projects" ADD CONSTRAINT "v2_host_projects_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_host_projects" ADD CONSTRAINT "v2_host_projects_project_id_v2_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."v2_projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "v2_host_projects" ADD CONSTRAINT "v2_host_projects_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_host_projects_organization_id_idx" ON "v2_host_projects" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "v2_host_projects_project_id_idx" ON "v2_host_projects" USING btree ("project_id");--> statement-breakpoint +CREATE INDEX "v2_host_projects_host_id_idx" ON "v2_host_projects" USING btree ("host_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0034_snapshot.json b/packages/db/drizzle/meta/0034_snapshot.json new file mode 100644 index 00000000000..fa76acdd3d4 --- /dev/null +++ b/packages/db/drizzle/meta/0034_snapshot.json @@ -0,0 +1,5412 @@ +{ + "id": "a6dc48fe-fa61-4d9b-be8d-02b303471282", + "prevId": "23f7159b-4d98-4245-9fd6-87789de09467", + "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 + }, + "auth_time": { + "name": "auth_time", + "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_host_projects": { + "name": "v2_host_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 + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_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_host_projects_organization_id_idx": { + "name": "v2_host_projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_host_projects_project_id_idx": { + "name": "v2_host_projects_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_host_projects_host_id_idx": { + "name": "v2_host_projects_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_host_projects_organization_id_organizations_id_fk": { + "name": "v2_host_projects_organization_id_organizations_id_fk", + "tableFrom": "v2_host_projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_host_projects_project_id_v2_projects_id_fk": { + "name": "v2_host_projects_project_id_v2_projects_id_fk", + "tableFrom": "v2_host_projects", + "tableTo": "v2_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_host_projects_host_id_v2_hosts_id_fk": { + "name": "v2_host_projects_host_id_v2_hosts_id_fk", + "tableFrom": "v2_host_projects", + "tableTo": "v2_hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_host_projects_project_host_unique": { + "name": "v2_host_projects_project_host_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "host_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": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_online": { + "name": "is_online", + "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 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 1123db1ab67..d56f96b5dc8 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -239,6 +239,13 @@ "when": 1775788950626, "tag": "0033_add_oauth_refresh_token_auth_time", "breakpoints": true + }, + { + "idx": 34, + "version": "7", + "when": 1776449141325, + "tag": "0034_v2_host_projects", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts index fa9400cf189..6594d8ed05a 100644 --- a/packages/db/src/schema/relations.ts +++ b/packages/db/src/schema/relations.ts @@ -27,6 +27,7 @@ import { tasks, usersSlackUsers, v2Clients, + v2HostProjects, v2Hosts, v2Projects, v2UsersHosts, @@ -76,6 +77,7 @@ export const organizationsRelations = relations(organizations, ({ many }) => ({ v2UsersHosts: many(v2UsersHosts), v2Projects: many(v2Projects), v2Workspaces: many(v2Workspaces), + v2HostProjects: many(v2HostProjects), secrets: many(secrets), sandboxImages: many(sandboxImages), workspaces: many(workspaces), @@ -278,6 +280,7 @@ export const v2ProjectsRelations = relations(v2Projects, ({ one, many }) => ({ references: [githubRepositories.id], }), workspaces: many(v2Workspaces), + hostProjects: many(v2HostProjects), })); export const v2HostsRelations = relations(v2Hosts, ({ one, many }) => ({ @@ -291,6 +294,22 @@ export const v2HostsRelations = relations(v2Hosts, ({ one, many }) => ({ }), usersHosts: many(v2UsersHosts), workspaces: many(v2Workspaces), + hostProjects: many(v2HostProjects), +})); + +export const v2HostProjectsRelations = relations(v2HostProjects, ({ one }) => ({ + organization: one(organizations, { + fields: [v2HostProjects.organizationId], + references: [organizations.id], + }), + project: one(v2Projects, { + fields: [v2HostProjects.projectId], + references: [v2Projects.id], + }), + host: one(v2Hosts, { + fields: [v2HostProjects.hostId], + references: [v2Hosts.id], + }), })); export const v2ClientsRelations = relations(v2Clients, ({ one }) => ({ diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index c50c2568ebb..34c974b9e7f 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -546,6 +546,41 @@ export const v2Workspaces = pgTable( export type InsertV2Workspace = typeof v2Workspaces.$inferInsert; export type SelectV2Workspace = typeof v2Workspaces.$inferSelect; +export const v2HostProjects = pgTable( + "v2_host_projects", + { + id: uuid().primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + projectId: uuid("project_id") + .notNull() + .references(() => v2Projects.id, { onDelete: "cascade" }), + hostId: uuid("host_id") + .notNull() + .references(() => v2Hosts.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + index("v2_host_projects_organization_id_idx").on(table.organizationId), + index("v2_host_projects_project_id_idx").on(table.projectId), + index("v2_host_projects_host_id_idx").on(table.hostId), + unique("v2_host_projects_project_host_unique").on( + table.projectId, + table.hostId, + ), + ], +); + +export type InsertV2HostProject = typeof v2HostProjects.$inferInsert; +export type SelectV2HostProject = typeof v2HostProjects.$inferSelect; + export const secrets = pgTable( "secrets", { 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 a64d9f55871..af89211a87f 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.ts @@ -1,11 +1,11 @@ import { randomUUID } from "node:crypto"; import type { Octokit } from "@octokit/rest"; +import { parseGitHubRemote } from "@superset/shared/github-remote"; import { and, eq, inArray } from "drizzle-orm"; import type { HostDb } from "../../db"; import { projects, pullRequests, workspaces } from "../../db/schema"; import type { GitFactory } from "../git"; import { fetchRepositoryPullRequests } from "./utils/github-query"; -import { parseGitHubRemote } from "./utils/parse-github-remote"; import { type ChecksStatus, coerceChecksStatus, diff --git a/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/index.ts b/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/index.ts deleted file mode 100644 index 25ae243201e..00000000000 --- a/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - type ParsedGitHubRemote, - parseGitHubRemote, -} from "./parse-github-remote"; diff --git a/packages/host-service/src/trpc/error-types.ts b/packages/host-service/src/trpc/error-types.ts index 094a3f50d5f..c62ead06fbe 100644 --- a/packages/host-service/src/trpc/error-types.ts +++ b/packages/host-service/src/trpc/error-types.ts @@ -22,3 +22,25 @@ export function isTeardownFailureCause( (value as { kind: unknown }).kind === "TEARDOWN_FAILED" ); } + +/** + * Thrown by host-service procedures that require the project to already + * be set up on this host. The renderer catches this via TRPCClientError + * and opens the Pin & set up modal with the projectId pre-filled, then + * retries the original mutation. + */ +export interface ProjectNotSetupCause { + kind: "PROJECT_NOT_SETUP"; + projectId: string; +} + +export function isProjectNotSetupCause( + value: unknown, +): value is ProjectNotSetupCause { + return ( + !!value && + typeof value === "object" && + "kind" in value && + (value as { kind: unknown }).kind === "PROJECT_NOT_SETUP" + ); +} diff --git a/packages/host-service/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index 7d7aee6ddf9..a74f14b22d7 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -2,7 +2,9 @@ import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import type { HostServiceContext } from "../types"; import { + isProjectNotSetupCause, isTeardownFailureCause, + type ProjectNotSetupCause, type TeardownFailureCause, } from "./error-types"; @@ -24,11 +26,19 @@ const t = initTRPC.context().create({ outputTail: error.cause.outputTail, } : undefined; + const projectNotSetup: ProjectNotSetupCause | undefined = + isProjectNotSetupCause(error.cause) + ? { + kind: "PROJECT_NOT_SETUP", + projectId: error.cause.projectId, + } + : undefined; return { ...shape, data: { ...shape.data, teardownFailure, + projectNotSetup, }, }; }, @@ -47,5 +57,8 @@ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { return next({ ctx }); }); -export type { TeardownFailureCause } from "./error-types"; +export type { + ProjectNotSetupCause, + TeardownFailureCause, +} from "./error-types"; export type { AppRouter } from "./router"; diff --git a/packages/host-service/src/trpc/router/project/handlers.ts b/packages/host-service/src/trpc/router/project/handlers.ts new file mode 100644 index 00000000000..e303886e25a --- /dev/null +++ b/packages/host-service/src/trpc/router/project/handlers.ts @@ -0,0 +1,120 @@ +import { TRPCError } from "@trpc/server"; +import type { HostServiceContext } from "../../../types"; +import { + persistLocalProject, + upsertHostBacking, +} from "./utils/persist-project"; +import { + cloneRepoInto, + resolveMatchingSlug, + resolveWithPrimaryRemote, +} from "./utils/resolve-repo"; + +function slugifyProjectName(name: string): string { + const slug = name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!slug) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Project name must contain at least one alphanumeric character", + }); + } + return slug; +} + +// ============================================================================ +// project.create +// ============================================================================ + +interface CreateResult { + projectId: string; + repoPath: string; +} + +/** + * Create flow — clone mode. User provided a clone URL and a parent directory. + * Cloud row is authoritative; local git is a materialization. Failures after + * cloud create land the project in cell 1 (recoverable via project.setup). + */ +export async function createFromClone( + ctx: HostServiceContext, + args: { name: string; parentDir: string; url: string }, +): Promise { + const cloudProject = await ctx.api.v2Project.create.mutate({ + organizationId: ctx.organizationId, + name: args.name, + slug: slugifyProjectName(args.name), + repoCloneUrl: args.url, + }); + const resolved = await cloneRepoInto(args.url, args.parentDir); + persistLocalProject(ctx, cloudProject.id, resolved); + await upsertHostBacking(ctx, cloudProject.id); + return { projectId: cloudProject.id, repoPath: resolved.repoPath }; +} + +/** + * Create flow — importLocal mode. User picked an existing on-disk git repo. + * We derive the remote URL from the repo, register it with the cloud, then + * register the local row. + */ +export async function createFromImportLocal( + ctx: HostServiceContext, + args: { name: string; repoPath: string }, +): Promise { + const resolved = await resolveWithPrimaryRemote(args.repoPath); + const cloudProject = await ctx.api.v2Project.create.mutate({ + organizationId: ctx.organizationId, + name: args.name, + slug: slugifyProjectName(args.name), + repoCloneUrl: resolved.parsed.url, + }); + persistLocalProject(ctx, cloudProject.id, resolved); + await upsertHostBacking(ctx, cloudProject.id); + return { projectId: cloudProject.id, repoPath: resolved.repoPath }; +} + +// ============================================================================ +// project.setup +// ============================================================================ + +interface SetupContext { + ctx: HostServiceContext; + projectId: string; + cloudRepoCloneUrl: string; + expectedSlug: string; +} + +interface SetupResult { + repoPath: string; +} + +/** + * Setup flow — clone mode. Clone the cloud's authoritative URL into the + * chosen parent directory. + */ +export async function setupFromClone( + setup: SetupContext, + args: { parentDir: string }, +): Promise { + const resolved = await cloneRepoInto(setup.cloudRepoCloneUrl, args.parentDir); + persistLocalProject(setup.ctx, setup.projectId, resolved); + await upsertHostBacking(setup.ctx, setup.projectId); + return { repoPath: resolved.repoPath }; +} + +/** + * Setup flow — import mode. Point at an existing on-disk repo and verify + * one of its remotes matches the cloud's authoritative slug. + */ +export async function setupFromImport( + setup: SetupContext, + args: { repoPath: string }, +): Promise { + const resolved = await resolveMatchingSlug(args.repoPath, setup.expectedSlug); + persistLocalProject(setup.ctx, setup.projectId, resolved); + await upsertHostBacking(setup.ctx, setup.projectId); + return { repoPath: resolved.repoPath }; +} diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 1a9b23b7d41..fca83f8e3c2 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -1,38 +1,134 @@ -import { existsSync, rmSync, statSync } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import { rmSync, statSync } from "node:fs"; +import { parseGitHubRemote } from "@superset/shared/github-remote"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; -import simpleGit from "simple-git"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; -import { parseGitHubRemote } from "../../../runtime/pull-requests/utils/parse-github-remote"; import { protectedProcedure, router } from "../../index"; import { - findMatchingRemote, - getGitHubRemotes, - type ParsedGitHubRemote, -} from "./utils/git-remote"; - -interface ResolvedRepo { - repoPath: string; - matchingRemote: string; - parsed: ParsedGitHubRemote; + createFromClone, + createFromImportLocal, + setupFromClone, + setupFromImport, +} from "./handlers"; +import { deleteHostBacking } from "./utils/persist-project"; +import { resolveWithPrimaryRemote } from "./utils/resolve-repo"; + +// Phase 4: cheap per-row existence check so the sidebar can surface +// "Stale path" without blocking operations. Note this is synchronous fs — +// cheap for a handful of projects, revisit if the list grows large. +function probePathStatus(repoPath: string): "healthy" | "missing" { + try { + const s = statSync(repoPath); + return s.isDirectory() ? "healthy" : "missing"; + } catch { + return "missing"; + } } export const projectRouter = router({ + list: protectedProcedure.query(({ ctx }) => { + const rows = ctx.db + .select({ id: projects.id, repoPath: projects.repoPath }) + .from(projects) + .all(); + return rows.map((row) => ({ + ...row, + pathStatus: probePathStatus(row.repoPath), + })); + }), + + findByPath: protectedProcedure + .input(z.object({ repoPath: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const { parsed } = await resolveWithPrimaryRemote(input.repoPath); + const { candidates } = await ctx.api.v2Project.findByRemote.query({ + repoCloneUrl: parsed.url, + }); + return { candidates }; + }), + + create: protectedProcedure + .input( + z.object({ + name: z.string().min(1), + visibility: z.enum(["private", "public"]), + mode: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("empty"), + parentDir: z.string().min(1), + }), + z.object({ + kind: z.literal("clone"), + parentDir: z.string().min(1), + url: z.string().min(1), + }), + z.object({ + kind: z.literal("importLocal"), + repoPath: z.string().min(1), + }), + z.object({ + kind: z.literal("template"), + parentDir: z.string().min(1), + templateId: z.string().min(1), + }), + ]), + }), + ) + .mutation(async ({ ctx, input }) => { + switch (input.mode.kind) { + case "empty": + case "template": + throw new TRPCError({ + code: "NOT_IMPLEMENTED", + message: `project.create mode="${input.mode.kind}" is not implemented yet`, + }); + case "clone": + return createFromClone(ctx, { + name: input.name, + parentDir: input.mode.parentDir, + url: input.mode.url, + }); + case "importLocal": + return createFromImportLocal(ctx, { + name: input.name, + repoPath: input.mode.repoPath, + }); + } + }), + setup: protectedProcedure .input( z.object({ - projectId: z.string(), - mode: z.enum(["import", "clone"]), - localPath: z.string().min(1), + projectId: z.string().uuid(), + // Required when a host-service.projects row already exists for this + // projectId and we'd be re-pointing `repoPath`. Re-pointing can + // invalidate existing workspace rows under the project; the client + // confirms it has explained that to the user. + acknowledgeWorkspaceInvalidation: z.boolean().optional(), + mode: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("clone"), + parentDir: z.string().min(1), + }), + z.object({ + kind: z.literal("import"), + repoPath: z.string().min(1), + }), + ]), }), ) .mutation(async ({ ctx, input }) => { - if (!ctx.api) { + const existing = ctx.db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + if (existing && !input.acknowledgeWorkspaceInvalidation) { throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Cloud API not configured", + code: "CONFLICT", + message: + "Project is already set up on this host. Re-pointing the path can invalidate existing workspaces — call again with acknowledgeWorkspaceInvalidation: true to proceed.", }); } @@ -40,14 +136,12 @@ export const projectRouter = router({ organizationId: ctx.organizationId, id: input.projectId, }); - if (!cloudProject.repoCloneUrl) { throw new TRPCError({ code: "BAD_REQUEST", message: "Project has no linked GitHub repository — cannot set up", }); } - const expectedParsed = parseGitHubRemote(cloudProject.repoCloneUrl); if (!expectedParsed) { throw new TRPCError({ @@ -56,45 +150,18 @@ export const projectRouter = router({ }); } - const expectedSlug = `${expectedParsed.owner}/${expectedParsed.name}`; - - let resolved: ResolvedRepo; - - if (input.mode === "import") { - resolved = await importExistingRepo(input.localPath, expectedSlug); - } else { - resolved = await cloneRepo( - cloudProject.repoCloneUrl, - input.localPath, - expectedSlug, - ); + const setup = { + ctx, + projectId: input.projectId, + cloudRepoCloneUrl: cloudProject.repoCloneUrl, + expectedSlug: `${expectedParsed.owner}/${expectedParsed.name}`, + }; + switch (input.mode.kind) { + case "clone": + return setupFromClone(setup, { parentDir: input.mode.parentDir }); + case "import": + return setupFromImport(setup, { repoPath: input.mode.repoPath }); } - - ctx.db - .insert(projects) - .values({ - id: input.projectId, - repoPath: resolved.repoPath, - repoProvider: "github", - repoOwner: resolved.parsed.owner, - repoName: resolved.parsed.name, - repoUrl: resolved.parsed.url, - remoteName: resolved.matchingRemote, - }) - .onConflictDoUpdate({ - target: projects.id, - set: { - repoPath: resolved.repoPath, - repoProvider: "github", - repoOwner: resolved.parsed.owner, - repoName: resolved.parsed.name, - repoUrl: resolved.parsed.url, - remoteName: resolved.matchingRemote, - }, - }) - .run(); - - return { repoPath: resolved.repoPath }; }), // TODO: remove @@ -104,10 +171,7 @@ export const projectRouter = router({ const localProject = ctx.db.query.projects .findFirst({ where: eq(projects.id, input.projectId) }) .sync(); - - if (!localProject) { - return { success: true }; - } + if (!localProject) return { success: true }; const localWorkspaces = ctx.db .select() @@ -139,133 +203,8 @@ export const projectRouter = router({ } ctx.db.delete(projects).where(eq(projects.id, input.projectId)).run(); + await deleteHostBacking(ctx, input.projectId); return { success: true }; }), }); - -async function importExistingRepo( - localPath: string, - expectedSlug: string, -): Promise { - if (!existsSync(localPath)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Path does not exist: ${localPath}`, - }); - } - - if (!statSync(localPath).isDirectory()) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Path is not a directory: ${localPath}`, - }); - } - - const git = simpleGit(localPath); - - let gitRoot: string; - try { - gitRoot = (await git.revparse(["--show-toplevel"])).trim(); - } catch { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Not a git repository: ${localPath}`, - }); - } - - const remotes = await getGitHubRemotes(simpleGit(gitRoot)); - const matchingRemote = findMatchingRemote(remotes, expectedSlug); - - if (!matchingRemote) { - const found = [...remotes.entries()] - .map(([name, parsed]) => `${name}: ${parsed.owner}/${parsed.name}`) - .join(", "); - throw new TRPCError({ - code: "BAD_REQUEST", - message: `No remote matches ${expectedSlug}. Found: ${found || "no remotes"}`, - }); - } - - const parsed = remotes.get(matchingRemote); - if (!parsed) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Remote "${matchingRemote}" matched but has no parsed data`, - }); - } - - return { repoPath: gitRoot, matchingRemote, parsed }; -} - -async function cloneRepo( - repoCloneUrl: string, - parentDir: string, - expectedSlug: string, -): Promise { - const resolvedParentDir = resolve(parentDir); - - if (!existsSync(resolvedParentDir)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Parent directory does not exist: ${resolvedParentDir}`, - }); - } - - if (!statSync(resolvedParentDir).isDirectory()) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Parent path is not a directory: ${resolvedParentDir}`, - }); - } - - const repoName = extractRepoNameFromUrl(repoCloneUrl); - const targetPath = join(resolvedParentDir, repoName); - - if (existsSync(targetPath)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Directory already exists: ${targetPath}`, - }); - } - - try { - await simpleGit().clone(repoCloneUrl, targetPath); - } catch (err) { - if (existsSync(targetPath)) { - rmSync(targetPath, { recursive: true, force: true }); - } - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Failed to clone repository: ${err instanceof Error ? err.message : String(err)}`, - }); - } - - const remotes = await getGitHubRemotes(simpleGit(targetPath)); - const matchingRemote = findMatchingRemote(remotes, expectedSlug); - - if (!matchingRemote) { - rmSync(targetPath, { recursive: true, force: true }); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Cloned repo does not match expected GitHub remote", - }); - } - - const parsed = remotes.get(matchingRemote); - if (!parsed) { - rmSync(targetPath, { recursive: true, force: true }); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Remote "${matchingRemote}" matched but has no parsed data`, - }); - } - - return { repoPath: targetPath, matchingRemote, parsed }; -} - -function extractRepoNameFromUrl(url: string): string { - const parsed = parseGitHubRemote(url); - if (parsed) return parsed.name; - return basename(url, ".git"); -} diff --git a/packages/host-service/src/trpc/router/project/utils/git-remote.ts b/packages/host-service/src/trpc/router/project/utils/git-remote.ts index 35d8624f012..cc27dafdcf1 100644 --- a/packages/host-service/src/trpc/router/project/utils/git-remote.ts +++ b/packages/host-service/src/trpc/router/project/utils/git-remote.ts @@ -1,8 +1,8 @@ -import type { SimpleGit } from "simple-git"; import { type ParsedGitHubRemote, parseGitHubRemote, -} from "../../../../runtime/pull-requests/utils/parse-github-remote"; +} from "@superset/shared/github-remote"; +import type { SimpleGit } from "simple-git"; export type { ParsedGitHubRemote }; diff --git a/packages/host-service/src/trpc/router/project/utils/persist-project.ts b/packages/host-service/src/trpc/router/project/utils/persist-project.ts new file mode 100644 index 00000000000..6a18c4301ed --- /dev/null +++ b/packages/host-service/src/trpc/router/project/utils/persist-project.ts @@ -0,0 +1,71 @@ +import { getDeviceName, getHashedDeviceId } from "@superset/shared/device-info"; +import { projects } from "../../../../db/schema"; +import type { HostServiceContext } from "../../../../types"; +import type { ResolvedRepo } from "./resolve-repo"; + +/** + * Inserts or updates the local `host-service.projects` row for `projectId` + * using the resolved GitHub remote metadata. Safe to call on both fresh + * create and setup re-point. + */ +export function persistLocalProject( + ctx: HostServiceContext, + projectId: string, + resolved: ResolvedRepo, +): void { + const repoFields = { + repoPath: resolved.repoPath, + repoProvider: "github" as const, + repoOwner: resolved.parsed.owner, + repoName: resolved.parsed.name, + repoUrl: resolved.parsed.url, + remoteName: resolved.remoteName, + }; + ctx.db + .insert(projects) + .values({ id: projectId, ...repoFields }) + .onConflictDoUpdate({ target: projects.id, set: repoFields }) + .run(); +} + +/** + * Ensures the current machine has a `v2_hosts` row in the cloud and then + * upserts the `v2_host_projects` binding for (projectId, thisHostId). This + * is the cloud-side "host H backs project P" signal consumed by the sidebar. + */ +export async function upsertHostBacking( + ctx: HostServiceContext, + projectId: string, +): Promise { + const host = await ctx.api.device.ensureV2Host.mutate({ + organizationId: ctx.organizationId, + machineId: getHashedDeviceId(), + name: getDeviceName(), + }); + await ctx.api.v2HostProject.upsert.mutate({ projectId, hostId: host.id }); +} + +/** + * Best-effort cloud cleanup of the `v2_host_projects` binding. Used by + * `project.remove` after local teardown. Errors are logged and swallowed — + * local state is already gone, so we never want to fail the RPC because of + * a cloud hiccup. Orphan rows are handled elsewhere. + */ +export async function deleteHostBacking( + ctx: HostServiceContext, + projectId: string, +): Promise { + try { + const host = await ctx.api.device.ensureV2Host.mutate({ + organizationId: ctx.organizationId, + machineId: getHashedDeviceId(), + name: getDeviceName(), + }); + await ctx.api.v2HostProject.delete.mutate({ projectId, hostId: host.id }); + } catch (err) { + console.warn("[project.remove] failed to delete v2_host_projects", { + projectId, + err, + }); + } +} diff --git a/packages/host-service/src/trpc/router/project/utils/resolve-repo.ts b/packages/host-service/src/trpc/router/project/utils/resolve-repo.ts new file mode 100644 index 00000000000..decd214e6c9 --- /dev/null +++ b/packages/host-service/src/trpc/router/project/utils/resolve-repo.ts @@ -0,0 +1,166 @@ +import { existsSync, rmSync, statSync } from "node:fs"; +import { join, resolve as resolvePath } from "node:path"; +import { parseGitHubRemote } from "@superset/shared/github-remote"; +import { TRPCError } from "@trpc/server"; +import simpleGit from "simple-git"; +import { + findMatchingRemote, + getGitHubRemotes, + type ParsedGitHubRemote, +} from "./git-remote"; + +export interface ResolvedRepo { + repoPath: string; + remoteName: string; + parsed: ParsedGitHubRemote; +} + +function validateDirectoryPath(path: string, label: string): void { + if (!existsSync(path)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${label} does not exist: ${path}`, + }); + } + if (!statSync(path).isDirectory()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${label} is not a directory: ${path}`, + }); + } +} + +async function revParseGitRoot(path: string): Promise { + try { + return (await simpleGit(path).revparse(["--show-toplevel"])).trim(); + } catch { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Not a git repository: ${path}`, + }); + } +} + +/** + * Validates that a path is a git working tree and returns the canonical git + * root plus its "primary" GitHub remote — `origin` if present, otherwise + * the first GitHub remote found. Throws if the path isn't a git repo or has + * no GitHub remotes. + * + * Used when the caller doesn't have an authoritative clone URL to match + * against (e.g. `findByPath`, `create mode=importLocal`). + */ +export async function resolveWithPrimaryRemote( + repoPath: string, +): Promise { + validateDirectoryPath(repoPath, "Path"); + const gitRoot = await revParseGitRoot(repoPath); + const remotes = await getGitHubRemotes(simpleGit(gitRoot)); + if (remotes.size === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Repository has no GitHub remotes", + }); + } + const originParsed = remotes.get("origin"); + if (originParsed) { + return { repoPath: gitRoot, remoteName: "origin", parsed: originParsed }; + } + // remotes.size > 0 was asserted above, so iterator.next() must yield one. + const first = remotes.entries().next().value; + if (!first) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Remote iteration produced no entries", + }); + } + const [firstName, firstParsed] = first; + return { repoPath: gitRoot, remoteName: firstName, parsed: firstParsed }; +} + +/** + * Validates that a path is a git working tree and returns the canonical git + * root plus the GitHub remote whose `owner/name` matches `expectedSlug`. + * Throws if no matching remote exists. + * + * Used when the caller has an authoritative clone URL from the cloud and + * wants to confirm this local repo is actually that project (`setup + * mode=import`, post-clone validation). + */ +export async function resolveMatchingSlug( + repoPath: string, + expectedSlug: string, +): Promise { + validateDirectoryPath(repoPath, "Path"); + const gitRoot = await revParseGitRoot(repoPath); + const remotes = await getGitHubRemotes(simpleGit(gitRoot)); + const remoteName = findMatchingRemote(remotes, expectedSlug); + if (!remoteName) { + const found = [...remotes.entries()] + .map(([name, parsed]) => `${name}: ${parsed.owner}/${parsed.name}`) + .join(", "); + throw new TRPCError({ + code: "BAD_REQUEST", + message: `No remote matches ${expectedSlug}. Found: ${found || "no remotes"}`, + }); + } + const parsed = remotes.get(remoteName); + if (!parsed) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Remote "${remoteName}" matched but has no parsed data`, + }); + } + return { repoPath: gitRoot, remoteName, parsed }; +} + +/** + * Clones a GitHub repo into `/` and returns the resolved + * repo. Fails and cleans up the target directory if the clone succeeds but + * the resulting remote doesn't match the URL we cloned from (defensive). + */ +export async function cloneRepoInto( + repoCloneUrl: string, + parentDir: string, +): Promise { + const parsedUrl = parseGitHubRemote(repoCloneUrl); + if (!parsedUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Could not parse GitHub remote from ${repoCloneUrl}`, + }); + } + const expectedSlug = `${parsedUrl.owner}/${parsedUrl.name}`; + + const resolvedParentDir = resolvePath(parentDir); + validateDirectoryPath(resolvedParentDir, "Parent directory"); + + const targetPath = join(resolvedParentDir, parsedUrl.name); + if (existsSync(targetPath)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Directory already exists: ${targetPath}`, + }); + } + + try { + await simpleGit().clone(repoCloneUrl, targetPath); + } catch (err) { + if (existsSync(targetPath)) { + rmSync(targetPath, { recursive: true, force: true }); + } + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Failed to clone repository: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + } + + try { + return await resolveMatchingSlug(targetPath, expectedSlug); + } catch (err) { + rmSync(targetPath, { recursive: true, force: true }); + throw err; + } +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index 69e0923e898..0fd843352c8 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -1,9 +1,8 @@ -import { existsSync, mkdirSync } from "node:fs"; -import { dirname, join, resolve, sep } from "node:path"; +import { existsSync } from "node:fs"; +import { join, resolve, sep } from "node:path"; import { getDeviceName, getHashedDeviceId } from "@superset/shared/device-info"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; -import simpleGit from "simple-git"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; import { @@ -15,6 +14,7 @@ import { } from "../../../runtime/git/refs"; import { createTerminalSessionInternal } from "../../../terminal/terminal"; import type { HostServiceContext } from "../../../types"; +import type { ProjectNotSetupCause } from "../../error-types"; import { protectedProcedure, router } from "../../index"; import { execGh } from "./utils/exec-gh"; import { resolveStartPoint } from "./utils/resolve-start-point"; @@ -84,6 +84,20 @@ function safeResolveWorktreePath(repoPath: string, branchName: string): string { return worktreePath; } +function projectNotSetupError(projectId: string): TRPCError { + // Surfaces the projectId via `cause` so the renderer can open the Pin & + // set up modal pre-filled with it. The code is PRECONDITION_FAILED so the + // renderer can still treat other errors (network, permissions) distinctly. + return new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Project is not set up on this host", + cause: { + kind: "PROJECT_NOT_SETUP", + projectId, + } satisfies ProjectNotSetupCause, + }); +} + async function resolveGithubRepo( ctx: HostServiceContext, projectId: string, @@ -538,37 +552,15 @@ export const workspaceCreationRouter = router({ const deviceName = getDeviceName(); setProgress(input.pendingId, "ensuring_repo"); - // 1. Resolve / ensure project locally - let localProject = ctx.db.query.projects + // 1. Require the project be set up on this host. The renderer + // catches PROJECT_NOT_SETUP and opens Pin & set up so the user + // picks where to clone explicitly (vs the old silent auto-clone + // into ~/.superset/repos/). + const localProject = ctx.db.query.projects .findFirst({ where: eq(projects.id, input.projectId) }) .sync(); - if (!localProject) { - const cloudProject = await ctx.api.v2Project.get.query({ - organizationId: ctx.organizationId, - id: input.projectId, - }); - - if (!cloudProject.repoCloneUrl) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Project has no linked GitHub repository — cannot clone", - }); - } - - const homeDir = process.env.HOME || process.env.USERPROFILE || "/tmp"; - const repoPath = join(homeDir, ".superset", "repos", input.projectId); - - if (!existsSync(repoPath)) { - mkdirSync(dirname(repoPath), { recursive: true }); - await simpleGit().clone(cloudProject.repoCloneUrl, repoPath); - } - - localProject = ctx.db - .insert(projects) - .values({ id: input.projectId, repoPath }) - .returning() - .get(); + throw projectNotSetupError(input.projectId); } setProgress(input.pendingId, "creating_worktree"); @@ -842,33 +834,12 @@ export const workspaceCreationRouter = router({ const deviceName = getDeviceName(); setProgress(input.pendingId, "ensuring_repo"); - // 1. Ensure project locally (clone if missing) — same as create - let localProject = ctx.db.query.projects + // 1. Require the project be set up on this host — same as create. + const localProject = ctx.db.query.projects .findFirst({ where: eq(projects.id, input.projectId) }) .sync(); - if (!localProject) { - const cloudProject = await ctx.api.v2Project.get.query({ - organizationId: ctx.organizationId, - id: input.projectId, - }); - if (!cloudProject.repoCloneUrl) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Project has no linked GitHub repository — cannot clone", - }); - } - const homeDir = process.env.HOME || process.env.USERPROFILE || "/tmp"; - const repoPath = join(homeDir, ".superset", "repos", input.projectId); - if (!existsSync(repoPath)) { - mkdirSync(dirname(repoPath), { recursive: true }); - await simpleGit().clone(cloudProject.repoCloneUrl, repoPath); - } - localProject = ctx.db - .insert(projects) - .values({ id: input.projectId, repoPath }) - .returning() - .get(); + throw projectNotSetupError(input.projectId); } setProgress(input.pendingId, "creating_worktree"); @@ -1092,10 +1063,7 @@ export const workspaceCreationRouter = router({ .findFirst({ where: eq(projects.id, input.projectId) }) .sync(); if (!localProject) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Project is not set up locally", - }); + throw projectNotSetupError(input.projectId); } const branch = input.branch.trim(); diff --git a/packages/shared/package.json b/packages/shared/package.json index ea93057b6a6..33065df0e84 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -64,6 +64,10 @@ "types": "./src/device-info.ts", "default": "./src/device-info.ts" }, + "./github-remote": { + "types": "./src/github-remote.ts", + "default": "./src/github-remote.ts" + }, "./shell-ready-scanner": { "types": "./src/shell-ready-scanner.ts", "default": "./src/shell-ready-scanner.ts" diff --git a/packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/parse-github-remote.ts b/packages/shared/src/github-remote.ts similarity index 100% rename from packages/host-service/src/runtime/pull-requests/utils/parse-github-remote/parse-github-remote.ts rename to packages/shared/src/github-remote.ts diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 6c69679a06f..d8a5cf7cfc1 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -12,6 +12,7 @@ import { organizationRouter } from "./router/organization"; import { projectRouter } from "./router/project"; import { taskRouter } from "./router/task"; import { userRouter } from "./router/user"; +import { v2HostProjectRouter } from "./router/v2-host-project"; import { v2ProjectRouter } from "./router/v2-project"; import { v2WorkspaceRouter } from "./router/v2-workspace"; import { workspaceRouter } from "./router/workspace"; @@ -30,6 +31,7 @@ export const appRouter = createTRPCRouter({ project: projectRouter, task: taskRouter, user: userRouter, + v2HostProject: v2HostProjectRouter, v2Project: v2ProjectRouter, v2Workspace: v2WorkspaceRouter, workspace: workspaceRouter, diff --git a/packages/trpc/src/router/device/device.ts b/packages/trpc/src/router/device/device.ts index 3ec2da7e62f..c4417cbae91 100644 --- a/packages/trpc/src/router/device/device.ts +++ b/packages/trpc/src/router/device/device.ts @@ -8,9 +8,10 @@ import { v2UsersHosts, } from "@superset/db/schema"; import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { z } from "zod"; import { jwtProcedure, protectedProcedure } from "../../trpc"; +import { hasHostAccess, requireHostAccess } from "../utils/host-access"; export const deviceRouter = { ensureV2Host: jwtProcedure @@ -218,32 +219,14 @@ export const deviceRouter = { checkHostAccess: jwtProcedure .input(z.object({ hostId: z.string().uuid() })) .query(async ({ ctx, input }) => { - const row = await db.query.v2UsersHosts.findFirst({ - where: and( - eq(v2UsersHosts.userId, ctx.userId), - eq(v2UsersHosts.hostId, input.hostId), - ), - columns: { id: true }, - }); - return { allowed: !!row }; + const allowed = await hasHostAccess(ctx.userId, input.hostId); + return { allowed }; }), setHostOnline: jwtProcedure .input(z.object({ hostId: z.string().uuid(), isOnline: z.boolean() })) .mutation(async ({ ctx, input }) => { - const access = await db.query.v2UsersHosts.findFirst({ - where: and( - eq(v2UsersHosts.userId, ctx.userId), - eq(v2UsersHosts.hostId, input.hostId), - ), - columns: { id: true }, - }); - if (!access) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "No access to this host", - }); - } + await requireHostAccess(ctx.userId, input.hostId); await db .update(v2Hosts) .set({ isOnline: input.isOnline }) diff --git a/packages/trpc/src/router/utils/host-access.ts b/packages/trpc/src/router/utils/host-access.ts new file mode 100644 index 00000000000..08d10d2cb5c --- /dev/null +++ b/packages/trpc/src/router/utils/host-access.ts @@ -0,0 +1,33 @@ +import { dbWs } from "@superset/db/client"; +import { v2UsersHosts } from "@superset/db/schema"; +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; + +// Checks whether a user has a v2_users_hosts row for the given host. +// Returns the boolean directly — use requireHostAccess when you want the +// throwing variant. +export async function hasHostAccess( + userId: string, + hostId: string, +): Promise { + const row = await dbWs.query.v2UsersHosts.findFirst({ + columns: { id: true }, + where: and( + eq(v2UsersHosts.userId, userId), + eq(v2UsersHosts.hostId, hostId), + ), + }); + return !!row; +} + +export async function requireHostAccess( + userId: string, + hostId: string, +): Promise { + if (!(await hasHostAccess(userId, hostId))) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "No access to this host", + }); + } +} diff --git a/packages/trpc/src/router/v2-host-project/index.ts b/packages/trpc/src/router/v2-host-project/index.ts new file mode 100644 index 00000000000..3828a978de4 --- /dev/null +++ b/packages/trpc/src/router/v2-host-project/index.ts @@ -0,0 +1 @@ +export { v2HostProjectRouter } from "./v2-host-project"; diff --git a/packages/trpc/src/router/v2-host-project/v2-host-project.ts b/packages/trpc/src/router/v2-host-project/v2-host-project.ts new file mode 100644 index 00000000000..43b21dba002 --- /dev/null +++ b/packages/trpc/src/router/v2-host-project/v2-host-project.ts @@ -0,0 +1,107 @@ +import { dbWs } from "@superset/db/client"; +import { v2HostProjects, v2Hosts, v2Projects } from "@superset/db/schema"; +import type { TRPCRouterRecord } from "@trpc/server"; +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { jwtProcedure } from "../../trpc"; +import { requireHostAccess } from "../utils/host-access"; +import { requireOrgScopedResource } from "../utils/org-resource-access"; + +async function resolveProjectHostOrg( + projectId: string, + hostId: string, + organizationIds: string[], +) { + const project = await requireOrgScopedResource( + () => + dbWs.query.v2Projects.findFirst({ + columns: { id: true, organizationId: true }, + where: eq(v2Projects.id, projectId), + }), + { message: "Project not found" }, + ); + if (!organizationIds.includes(project.organizationId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not a member of this organization", + }); + } + await requireOrgScopedResource( + () => + dbWs.query.v2Hosts.findFirst({ + columns: { id: true, organizationId: true }, + where: eq(v2Hosts.id, hostId), + }), + { + code: "BAD_REQUEST", + message: "Host not found in this organization", + organizationId: project.organizationId, + }, + ); + return { organizationId: project.organizationId }; +} + +export const v2HostProjectRouter = { + upsert: jwtProcedure + .input( + z.object({ + projectId: z.string().uuid(), + hostId: z.string().uuid(), + }), + ) + .mutation(async ({ ctx, input }) => { + await requireHostAccess(ctx.userId, input.hostId); + const { organizationId } = await resolveProjectHostOrg( + input.projectId, + input.hostId, + ctx.organizationIds, + ); + + const [row] = await dbWs + .insert(v2HostProjects) + .values({ + organizationId, + projectId: input.projectId, + hostId: input.hostId, + }) + .onConflictDoUpdate({ + target: [v2HostProjects.projectId, v2HostProjects.hostId], + set: { updatedAt: new Date() }, + }) + .returning(); + if (!row) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to upsert host-project binding", + }); + } + return row; + }), + + delete: jwtProcedure + .input( + z.object({ + projectId: z.string().uuid(), + hostId: z.string().uuid(), + }), + ) + .mutation(async ({ ctx, input }) => { + await requireHostAccess(ctx.userId, input.hostId); + await resolveProjectHostOrg( + input.projectId, + input.hostId, + ctx.organizationIds, + ); + + await dbWs + .delete(v2HostProjects) + .where( + and( + eq(v2HostProjects.projectId, input.projectId), + eq(v2HostProjects.hostId, input.hostId), + ), + ); + return { success: true }; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/v2-project/v2-project.ts b/packages/trpc/src/router/v2-project/v2-project.ts index 1359d06981a..66733467fe1 100644 --- a/packages/trpc/src/router/v2-project/v2-project.ts +++ b/packages/trpc/src/router/v2-project/v2-project.ts @@ -1,8 +1,13 @@ import { dbWs } from "@superset/db/client"; -import { githubRepositories, v2Projects } from "@superset/db/schema"; +import { + githubRepositories, + organizations, + v2Projects, +} from "@superset/db/schema"; +import { parseGitHubRemote } from "@superset/shared/github-remote"; import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { z } from "zod"; import { jwtProcedure, protectedProcedure } from "../../trpc"; import { @@ -110,29 +115,91 @@ export const v2ProjectRouter = { return { ...row, repoCloneUrl }; }), - create: protectedProcedure + findByRemote: jwtProcedure + .input(z.object({ repoCloneUrl: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const parsed = parseGitHubRemote(input.repoCloneUrl); + if (!parsed || ctx.organizationIds.length === 0) { + return { candidates: [] }; + } + // GitHub slugs are case-insensitive (github.com/Foo/Bar and + // github.com/foo/bar point to the same repo). Local git remotes + // preserve whatever casing was typed at clone time. Compare in lower + // case so we still match. + const fullNameLower = `${parsed.owner}/${parsed.name}`.toLowerCase(); + + const rows = await dbWs + .select({ + id: v2Projects.id, + name: v2Projects.name, + slug: v2Projects.slug, + organizationId: v2Projects.organizationId, + organizationName: organizations.name, + }) + .from(v2Projects) + .innerJoin( + githubRepositories, + eq(v2Projects.githubRepositoryId, githubRepositories.id), + ) + .innerJoin( + organizations, + eq(v2Projects.organizationId, organizations.id), + ) + .where( + and( + eq(sql`lower(${githubRepositories.fullName})`, fullNameLower), + inArray(v2Projects.organizationId, ctx.organizationIds), + ), + ); + + return { candidates: rows }; + }), + + create: jwtProcedure .input( z.object({ + organizationId: z.string().uuid(), name: z.string().min(1), slug: z.string().min(1), - githubRepositoryId: z.string().uuid(), + repoCloneUrl: z.string().min(1), }), ) .mutation(async ({ ctx, input }) => { - const organizationId = await requireActiveOrgMembership( - ctx.session, - "No active organization", - ); + if (!ctx.organizationIds.includes(input.organizationId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not a member of this organization", + }); + } + const parsed = parseGitHubRemote(input.repoCloneUrl); + if (!parsed) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Could not parse GitHub remote URL", + }); + } + const fullName = `${parsed.owner}/${parsed.name}`; + const fullNameLower = fullName.toLowerCase(); - const repo = await getScopedGithubRepository( - organizationId, - input.githubRepositoryId, - ); + // Case-insensitive match — see findByRemote note. + const repo = await dbWs.query.githubRepositories.findFirst({ + columns: { id: true }, + where: and( + eq(sql`lower(${githubRepositories.fullName})`, fullNameLower), + eq(githubRepositories.organizationId, input.organizationId), + ), + }); + if (!repo) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `GitHub repository ${fullName} is not installed in this organization`, + }); + } const [project] = await dbWs .insert(v2Projects) .values({ - organizationId, + organizationId: input.organizationId, name: input.name, slug: input.slug, githubRepositoryId: repo.id, diff --git a/plans/20260417-v2-project-create-import-impl.md b/plans/20260417-v2-project-create-import-impl.md new file mode 100644 index 00000000000..61d7446ce66 --- /dev/null +++ b/plans/20260417-v2-project-create-import-impl.md @@ -0,0 +1,96 @@ +# V2 Project Create & Import — Implementation Plan + +Companion to [`docs/design/v2-project-create-import.md`](../docs/design/v2-project-create-import.md). This is the execution plan: what to build, in what order, and what each phase leaves stubbed for the next. + +--- + +## Phase 1 — core backing-aware sidebar + create/setup + +The MVP. Intentionally broad — the pieces are tightly coupled (backing signal, sidebar state derivation, and create/setup flows all depend on each other; shipping a subset would leave the sidebar half-wired). + +### Cloud (packages/db, packages/trpc, Electric) + +- [ ] `v2_host_projects` cloud table — Drizzle schema + migration. Columns: `id`, `organizationId`, `projectId`, `hostId`, `createdAt`, `updatedAt`. Unique on `(projectId, hostId)`. +- [ ] Electric sync config for `v2_host_projects` (mirror existing `v2_*` tables). +- [ ] Cloud `v2HostProjects` tRPC router — `upsert`, `delete`. Authorized by `v2_users_hosts` membership. +- [ ] Cloud `v2Projects.findByRemote({ repoCloneUrl })` — returns matching projects scoped to the user's accessible orgs. + +### Host-service (packages/host-service) + +- [ ] `project.list` — returns `Array<{ id, repoPath }>`. Pure DB read, no filesystem check. Proactive `statSync` / Stale-path detection is Phase 4. +- [ ] `project.findByPath({ repoPath })` — validates git root, reads remote, forwards to cloud `v2Projects.findByRemote`. Returns candidate projects. +- [ ] `project.create` — discriminated-union mode (`empty`/`clone`/`importLocal`/`template`); Phase 1 implements `clone` and `importLocal` only, others throw `not_implemented`. Writes local `host-service.projects` + cloud `v2_host_projects`. +- [ ] `project.setup` — discriminated-union mode (`clone`/`import`) with per-variant path semantics. Adds `acknowledgeWorkspaceInvalidation` param. Also upserts cloud `v2_host_projects`. +- [ ] `project.remove` — delete cloud `v2_host_projects` for current host on removal. + +### Desktop renderer (apps/desktop) + +- [ ] Register `v2HostProjects` collection in `CollectionsProvider`. +- [ ] Extend `useDashboardSidebarData`: + - Local backing via React Query against `activeHostClient.project.list` (key `["project", "list"]`). Invalidated after mutations; error handlers on `workspace.create` / git ops invalidate on "vanished path" errors. + - Remote backing via `useLiveQuery` over `v2_host_projects ⋈ v2_hosts`, partitioned online/offline, excluding current machineId. + - Derived row state per pinned project (Normal / Host offline / Not set up here — three states in Phase 1; Stale path is Phase 4). +- [ ] Sidebar project row renders three row states. Phase 1 surfaces Normal fully; Host offline / Not set up here render as visual markers with no inline CTA yet (stubs for Phase 2). +- [ ] Workspaces tab: Available section with three actions — "+ New project", "Pin & set up" (per cloud project row), "Import existing folder." +- [ ] Folder-first picker UI: + - Native picker → `project.findByPath`. + - `candidates.length === 0` → offer "No match — create as new project" (pivots to `project.create importLocal`). + - `=== 1` → auto-advance to `project.setup`. + - `> 1` → chooser modal, user picks projectId. +- [ ] React Query invalidation on `["project", "list"]` after `project.create` / `project.setup` / `project.remove`. + +### Acceptance + +- Creating a project via "+ New project" results in a sidebar row in the Normal state, no workspaces under it, and a `v2_host_projects` row visible to other connected hosts within Electric sync latency. +- Pinning + setting up an existing cloud project from the workspaces tab's Available section produces the same end state. +- Pinning a project whose only backing is on an offline host renders the "Host offline" marker. +- Pinning a project with no backing anywhere renders the "Not set up here" marker (CTA not yet wired). +- Importing a folder whose remote matches multiple projects surfaces the picker. +- Deleting the repo directory out of band is not caught by the sidebar until the user triggers an operation that fails — by design (proactive detection is Phase 4). + +--- + +## Phase 2 — row-state polish + +Un-stub the three non-Normal row states from Phase 1. + +- [ ] "Not set up here" inline CTA → opens the same `project.setup` modal as Available's "Pin & set up" (with the projectId pre-filled from the sidebar row). +- [ ] "Host offline" state: copy + visual treatment; no action required (passive — resolves when a backing host reconnects). +- [ ] Host chips on workspace rows (`current-host | remote-device | cloud`) using the existing `hostType` derivation. + +--- + +## Phase 3 — workspace-create inline setup + +Couple `workspace.create` to the setup flow so unbacked-host workspace creation doesn't fail cold. + +- [ ] `workspace.create` throws `PROJECT_NOT_SETUP` (with projectId in payload) when current host has no `host-service.projects` row for the target project. +- [ ] New Workspace modal catches the throw, opens the inline `project.setup` flow, retries `workspace.create` on success. +- [ ] Remote-device workspace row click → "switch host or set up here" stub page. Design for this page lives outside this plan; link out once written. + +--- + +## Phase 4 — stale-path detection + repair + +Add proactive Stale-path detection and wire the Repair CTA. + +- [ ] `project.list` returns `pathStatus: "healthy" | "missing"` via `statSync` at read time. +- [ ] `useDashboardSidebarData` adds a modest `refetchInterval` (30–60s) to catch out-of-band directory deletions. +- [ ] Fourth row state "Stale path" driven by `pathStatus: "missing"` on local backing. +- [ ] Stale-path sidebar row shows Repair CTA. +- [ ] Repair opens the `project.setup` modal with `acknowledgeWorkspaceInvalidation: true` pre-set. +- [ ] Copy explains that re-pointing the path invalidates existing workspace rows under the project; user confirms. +- [ ] On success, state returns to Normal; downstream workspace rows may need re-creation (out of scope — workspace-level concern). + +--- + +## Deferred / out of scope + +From the design doc's open questions: + +- **GitHub auth for repo creation.** Needed before `project.create` `empty` / `template` modes ship. Likely GitHub App installation fetched via `ctx.api`. Out of Phase 1. +- **Template source.** Where templates live; `project.create` mode stub throws `not_implemented` until decided. +- **Mid-flow failure visibility.** Whether to surface an inline "setup unfinished" prompt on the originating client, or rely on Available. Not critical for Phase 1. +- **Orphaned cloud rows.** `v2_projects` rows with no `v2_host_projects` anywhere, from abandoned retries. Available surfaces them as cell 1. TTL cleanup is a separate decision. +- **Pin behavior.** Auto-pin on create/setup, cross-device pin sync, unpin UX. Binary input to this design; tuned separately. +- **Wrong-remote detection (cell 4).** Rare enough that we don't model it; `project.setup` prevents entry. No repair path.