diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 439fb5b705f..8b08ddd17ba 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -60,6 +60,11 @@ const config: Configuration = { to: "resources/migrations", filter: ["**/*"], }, + { + from: "dist/resources/host-migrations", + to: "resources/host-migrations", + filter: ["**/*"], + }, ], files: [ diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index e3831b4ec10..a7ac8e1f0ed 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -16,6 +16,7 @@ import { const authToken = process.env.AUTH_TOKEN; const cloudApiUrl = process.env.CLOUD_API_URL; +const dbPath = process.env.HOST_DB_PATH; const auth = authToken && cloudApiUrl ? new JwtAuthProvider(authToken) : undefined; @@ -24,6 +25,7 @@ const app = createApp({ credentials: new LocalCredentialProvider(), auth, cloudApiUrl, + dbPath, }); const server = serve( diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index e556302d742..c9190b01640 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -1,5 +1,7 @@ import { type ChildProcess, spawn } from "node:child_process"; import path from "node:path"; +import { app } from "electron"; +import { SUPERSET_HOME_DIR } from "./app-environment"; type HostServiceStatus = "starting" | "running" | "crashed"; @@ -69,6 +71,10 @@ class HostServiceManager { ...process.env, ELECTRON_RUN_AS_NODE: "1", ORGANIZATION_ID: organizationId, + HOST_DB_PATH: path.join(SUPERSET_HOME_DIR, "host.db"), + HOST_MIGRATIONS_PATH: app.isPackaged + ? path.join(process.resourcesPath, "resources/host-migrations") + : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), }; if (this.authToken) { env.AUTH_TOKEN = this.authToken; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/HostServiceStatus/HostServiceStatus.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/HostServiceStatus/HostServiceStatus.tsx index e5ee7aef29a..3553b8536ce 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/HostServiceStatus/HostServiceStatus.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/HostServiceStatus/HostServiceStatus.tsx @@ -56,6 +56,13 @@ export function HostServiceStatus() { const [cloudLoading, setCloudLoading] = useState(false); const [cloudError, setCloudError] = useState(null); + const [v2ProjectId, setV2ProjectId] = useState(""); + const [v2WorkspaceId, setV2WorkspaceId] = useState(""); + const [v2Branch, setV2Branch] = useState("main"); + const [v2Loading, setV2Loading] = useState(false); + const [v2Result, setV2Result] = useState(null); + const [v2Error, setV2Error] = useState(null); + const checkHealth = useCallback(async () => { if (!service) { setStatus("unknown"); @@ -218,6 +225,114 @@ export function HostServiceStatus() { )} +
+ V2 Operations +
+ setV2ProjectId(e.target.value)} + placeholder="Project ID" + className="flex-1 rounded-md border border-input bg-background px-3 py-1.5 text-sm" + /> + setV2Branch(e.target.value)} + placeholder="Branch" + className="w-32 rounded-md border border-input bg-background px-3 py-1.5 text-sm" + /> +
+
+ + + +
+ {v2Loading && ( +
Loading...
+ )} + {v2Error && ( +
+ {v2Error} +
+ )} + {v2Result && ( +
+								{v2Result}
+							
+ )} +
+ {/* Git Operations */}
diff --git a/apps/desktop/vite/helpers.ts b/apps/desktop/vite/helpers.ts index 4b6292822ad..93aada3766f 100644 --- a/apps/desktop/vite/helpers.ts +++ b/apps/desktop/vite/helpers.ts @@ -40,6 +40,10 @@ const RESOURCES_TO_COPY = [ src: resolve(__dirname, "../../../packages/local-db/drizzle"), dest: resolve(__dirname, "..", devPath, "resources/migrations"), }, + { + src: resolve(__dirname, "../../../packages/host-service/drizzle"), + dest: resolve(__dirname, "..", devPath, "resources/host-migrations"), + }, { src: resolve(__dirname, "../src/main/lib/agent-setup/templates"), dest: resolve(__dirname, "..", devPath, "main/templates"), diff --git a/bun.lock b/bun.lock index 87572659bdf..6c0506180fa 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.1.5", + "version": "1.1.6", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36", @@ -741,6 +741,8 @@ "@superset/trpc": "workspace:*", "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", + "better-sqlite3": "12.6.2", + "drizzle-orm": "0.45.1", "hono": "^4.8.5", "simple-git": "^3.30.0", "superjson": "^2.2.5", @@ -748,7 +750,9 @@ }, "devDependencies": { "@superset/typescript": "workspace:*", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.9.1", + "drizzle-kit": "0.31.8", "typescript": "^5.9.3", }, }, diff --git a/packages/host-service/drizzle.config.ts b/packages/host-service/drizzle.config.ts new file mode 100644 index 00000000000..b7e2180f770 --- /dev/null +++ b/packages/host-service/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/db/schema.ts", + out: "./drizzle", + dialect: "sqlite", +}); diff --git a/packages/host-service/drizzle/0000_initial_projects_workspaces.sql b/packages/host-service/drizzle/0000_initial_projects_workspaces.sql new file mode 100644 index 00000000000..b453b1093e9 --- /dev/null +++ b/packages/host-service/drizzle/0000_initial_projects_workspaces.sql @@ -0,0 +1,18 @@ +CREATE TABLE `projects` ( + `id` text PRIMARY KEY NOT NULL, + `repo_path` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `projects_repo_path_idx` ON `projects` (`repo_path`);--> statement-breakpoint +CREATE TABLE `workspaces` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `worktree_path` text NOT NULL, + `branch` text NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `workspaces_project_id_idx` ON `workspaces` (`project_id`);--> statement-breakpoint +CREATE INDEX `workspaces_branch_idx` ON `workspaces` (`branch`); \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/0000_snapshot.json b/packages/host-service/drizzle/meta/0000_snapshot.json new file mode 100644 index 00000000000..6fbeadf0dc0 --- /dev/null +++ b/packages/host-service/drizzle/meta/0000_snapshot.json @@ -0,0 +1,131 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6af1c5ed-ea02-45ae-8582-299afac9295e", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_repo_path_idx": { + "name": "projects_repo_path_idx", + "columns": [ + "repo_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_branch_idx": { + "name": "workspaces_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json new file mode 100644 index 00000000000..fbab491061a --- /dev/null +++ b/packages/host-service/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1773188543980, + "tag": "0000_initial_projects_workspaces", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/host-service/package.json b/packages/host-service/package.json index f3c55308f04..423aa69baba 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -8,6 +8,10 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, + "./db": { + "types": "./src/db/index.ts", + "default": "./src/db/index.ts" + }, "./git": { "types": "./src/git/index.ts", "default": "./src/git/index.ts" @@ -20,6 +24,7 @@ "scripts": { "clean": "git clean -xdf .cache .turbo dist node_modules", "dev": "bun run src/serve.ts", + "generate": "drizzle-kit generate", "typecheck": "tsc --noEmit --emitDeclarationOnly false" }, "dependencies": { @@ -28,6 +33,8 @@ "@superset/trpc": "workspace:*", "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", + "better-sqlite3": "12.6.2", + "drizzle-orm": "0.45.1", "hono": "^4.8.5", "simple-git": "^3.30.0", "superjson": "^2.2.5", @@ -35,7 +42,9 @@ }, "devDependencies": { "@superset/typescript": "workspace:*", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.9.1", + "drizzle-kit": "0.31.8", "typescript": "^5.9.3" } } diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index cadd7abd84f..ce5d1d3dfae 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -1,8 +1,11 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; import { trpcServer } from "@hono/trpc-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { createApiClient } from "./api"; import type { AuthProvider } from "./auth/types"; +import { createDb } from "./db"; import { LocalCredentialProvider } from "./git/providers"; import type { CredentialProvider } from "./git/types"; import { createContextFactory } from "./trpc/context"; @@ -12,6 +15,7 @@ export interface CreateAppOptions { credentials?: CredentialProvider; auth?: AuthProvider; cloudApiUrl?: string; + dbPath?: string; } export function createApp(options?: CreateAppOptions) { @@ -22,7 +26,10 @@ export function createApp(options?: CreateAppOptions) { ? createApiClient(options.cloudApiUrl, options.auth) : null; - const createContext = createContextFactory({ credentials, api }); + const dbPath = options?.dbPath ?? join(homedir(), ".superset", "host.db"); + const db = createDb(dbPath); + + const createContext = createContextFactory({ credentials, api, db }); const app = new Hono(); app.use("*", cors()); diff --git a/packages/host-service/src/db/db.ts b/packages/host-service/src/db/db.ts new file mode 100644 index 00000000000..37a96413e44 --- /dev/null +++ b/packages/host-service/src/db/db.ts @@ -0,0 +1,52 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import * as schema from "./schema"; + +export type HostDb = ReturnType; + +function getMigrationsFolder(): string { + const resourcesPath = (process as unknown as Record) + .resourcesPath as string | undefined; + if (resourcesPath && !process.env.ELECTRON_RUN_AS_NODE) { + return join(resourcesPath, "resources/host-migrations"); + } + + if (process.env.HOST_MIGRATIONS_PATH) { + return process.env.HOST_MIGRATIONS_PATH; + } + + if (typeof import.meta.dirname === "string") { + const candidate = join(import.meta.dirname, "../../drizzle"); + if (existsSync(candidate)) { + return candidate; + } + } + + return join(__dirname, "../../drizzle"); +} + +export function createDb(dbPath: string) { + mkdirSync(dirname(dbPath), { recursive: true }); + + const sqlite = new Database(dbPath); + sqlite.pragma("journal_mode = WAL"); + sqlite.pragma("foreign_keys = ON"); + + const db = drizzle(sqlite, { schema }); + + const migrationsFolder = getMigrationsFolder(); + console.log( + `[host-service:db] Initialized at ${dbPath}, migrations from ${migrationsFolder}`, + ); + + try { + migrate(db, { migrationsFolder }); + } catch (error) { + console.error("[host-service:db] Migration failed:", error); + } + + return db; +} diff --git a/packages/host-service/src/db/index.ts b/packages/host-service/src/db/index.ts new file mode 100644 index 00000000000..e6cb0767895 --- /dev/null +++ b/packages/host-service/src/db/index.ts @@ -0,0 +1,2 @@ +export { createDb, type HostDb } from "./db"; +export * from "./schema"; diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts new file mode 100644 index 00000000000..d75d66607bd --- /dev/null +++ b/packages/host-service/src/db/schema.ts @@ -0,0 +1,32 @@ +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const projects = sqliteTable( + "projects", + { + id: text().primaryKey(), + repoPath: text("repo_path").notNull(), + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + }, + (table) => [index("projects_repo_path_idx").on(table.repoPath)], +); + +export const workspaces = sqliteTable( + "workspaces", + { + id: text().primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + worktreePath: text("worktree_path").notNull(), + branch: text().notNull(), + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + }, + (table) => [ + index("workspaces_project_id_idx").on(table.projectId), + index("workspaces_branch_idx").on(table.branch), + ], +); diff --git a/packages/host-service/src/index.ts b/packages/host-service/src/index.ts index 7a9eb955ee6..8e6b991e99b 100644 --- a/packages/host-service/src/index.ts +++ b/packages/host-service/src/index.ts @@ -2,6 +2,7 @@ export { createApiClient } from "./api"; export { type CreateAppOptions, createApp } from "./app"; export type { AuthProvider } from "./auth"; export { DeviceKeyAuthProvider, JwtAuthProvider } from "./auth"; +export type { HostDb } from "./db"; export type { CredentialProvider, GitFactory } from "./git"; export { CloudCredentialProvider, LocalCredentialProvider } from "./git"; export type { AppRouter } from "./trpc/router"; diff --git a/packages/host-service/src/serve.ts b/packages/host-service/src/serve.ts index d7e7559187a..924a354f4d0 100644 --- a/packages/host-service/src/serve.ts +++ b/packages/host-service/src/serve.ts @@ -1,7 +1,8 @@ import { serve } from "@hono/node-server"; import { createApp } from "./app"; -const app = createApp(); +const dbPath = process.env.HOST_DB_PATH?.trim() || undefined; +const app = createApp({ dbPath }); const port = Number(process.env.PORT) || 4879; serve({ fetch: app.fetch, port }, (info) => { diff --git a/packages/host-service/src/trpc/context/context.ts b/packages/host-service/src/trpc/context/context.ts index d35b912353f..0774fc2d7e8 100644 --- a/packages/host-service/src/trpc/context/context.ts +++ b/packages/host-service/src/trpc/context/context.ts @@ -1,3 +1,4 @@ +import type { HostDb } from "../../db"; import { createGitFactory } from "../../git/createGitFactory"; import type { CredentialProvider } from "../../git/types"; import type { ApiClient, HostServiceContext } from "../../types"; @@ -5,9 +6,11 @@ import type { ApiClient, HostServiceContext } from "../../types"; export function createContextFactory(opts: { credentials: CredentialProvider; api: ApiClient | null; + db: HostDb; }): () => Promise { return async () => ({ git: createGitFactory(opts.credentials), api: opts.api, + db: opts.db, }); } diff --git a/packages/host-service/src/trpc/router/project/index.ts b/packages/host-service/src/trpc/router/project/index.ts new file mode 100644 index 00000000000..5e3fcb59a71 --- /dev/null +++ b/packages/host-service/src/trpc/router/project/index.ts @@ -0,0 +1 @@ +export { projectRouter } from "./project"; diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts new file mode 100644 index 00000000000..2894d0ef007 --- /dev/null +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -0,0 +1,53 @@ +import { rmSync } from "node:fs"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { projects, workspaces } from "../../../db/schema"; +import { publicProcedure, router } from "../../index"; + +export const projectRouter = router({ + // TODO: remove + removeFromDevice: publicProcedure + .input(z.object({ projectId: z.string() })) + .mutation(async ({ ctx, input }) => { + const localProject = ctx.db.query.projects + .findFirst({ where: eq(projects.id, input.projectId) }) + .sync(); + + if (!localProject) { + return { success: true }; + } + + const localWorkspaces = ctx.db + .select() + .from(workspaces) + .where(eq(workspaces.projectId, input.projectId)) + .all(); + + for (const ws of localWorkspaces) { + try { + const git = await ctx.git(localProject.repoPath); + await git.raw(["worktree", "remove", ws.worktreePath]); + } catch (err) { + console.warn("[project.removeFromDevice] failed to remove worktree", { + projectId: input.projectId, + worktreePath: ws.worktreePath, + err, + }); + } + } + + try { + rmSync(localProject.repoPath, { recursive: true, force: true }); + } catch (err) { + console.warn("[project.removeFromDevice] failed to remove repo dir", { + projectId: input.projectId, + repoPath: localProject.repoPath, + err, + }); + } + + ctx.db.delete(projects).where(eq(projects.id, input.projectId)).run(); + + return { success: true }; + }), +}); diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts index 1307caee5bd..0337b40d7ec 100644 --- a/packages/host-service/src/trpc/router/router.ts +++ b/packages/host-service/src/trpc/router/router.ts @@ -2,11 +2,15 @@ import { router } from "../index"; import { cloudRouter } from "./cloud"; import { gitRouter } from "./git"; import { healthRouter } from "./health"; +import { projectRouter } from "./project"; +import { workspaceRouter } from "./workspace"; export const appRouter = router({ health: healthRouter, git: gitRouter, cloud: cloudRouter, + project: projectRouter, + workspace: workspaceRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/host-service/src/trpc/router/workspace/index.ts b/packages/host-service/src/trpc/router/workspace/index.ts new file mode 100644 index 00000000000..02f5cb103dc --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace/index.ts @@ -0,0 +1 @@ +export { workspaceRouter } from "./workspace"; diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts new file mode 100644 index 00000000000..dfea8710eec --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -0,0 +1,144 @@ +import { join } from "node:path"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { projects, workspaces } from "../../../db/schema"; +import { publicProcedure, router } from "../../index"; + +export const workspaceRouter = router({ + create: publicProcedure + .input( + z.object({ + projectId: z.string(), + name: z.string().min(1), + branch: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + if (!ctx.api) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Cloud API not configured", + }); + } + + let localProject = ctx.db.query.projects + .findFirst({ where: eq(projects.id, input.projectId) }) + .sync(); + + if (!localProject) { + const cloudProject = await ctx.api.v2Project.get.query({ + 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); + + const git = await ctx.git(repoPath); + await git.clone(cloudProject.repoCloneUrl, repoPath); + + const inserted = ctx.db + .insert(projects) + .values({ id: input.projectId, repoPath }) + .returning() + .get(); + + localProject = inserted; + } + + if (!localProject) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to resolve local project", + }); + } + + const worktreePath = join( + localProject.repoPath, + ".worktrees", + input.branch, + ); + + const git = await ctx.git(localProject.repoPath); + await git.raw(["worktree", "add", worktreePath, input.branch]); + + const cloudRow = await ctx.api.v2Workspace.create + .mutate({ + projectId: input.projectId, + name: input.name, + branch: input.branch, + }) + .catch(async (err) => { + try { + await git.raw(["worktree", "remove", worktreePath]); + } catch (cleanupErr) { + console.warn("[workspace.create] failed to rollback worktree", { + worktreePath, + cleanupErr, + }); + } + throw err; + }); + + if (cloudRow) { + ctx.db + .insert(workspaces) + .values({ + id: cloudRow.id, + projectId: input.projectId, + worktreePath, + branch: input.branch, + }) + .run(); + } + + return cloudRow; + }), + + delete: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + if (!ctx.api) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Cloud API not configured", + }); + } + + await ctx.api.v2Workspace.delete.mutate({ id: input.id }); + + const localWorkspace = ctx.db.query.workspaces + .findFirst({ where: eq(workspaces.id, input.id) }) + .sync(); + + if (localWorkspace) { + const localProject = ctx.db.query.projects + .findFirst({ where: eq(projects.id, localWorkspace.projectId) }) + .sync(); + + if (localProject) { + try { + const git = await ctx.git(localProject.repoPath); + await git.raw(["worktree", "remove", localWorkspace.worktreePath]); + } catch (err) { + console.warn("[workspace.delete] failed to remove worktree", { + workspaceId: input.id, + worktreePath: localWorkspace.worktreePath, + err, + }); + } + } + } + + ctx.db.delete(workspaces).where(eq(workspaces.id, input.id)).run(); + + return { success: true }; + }), +}); diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index 4c2cc118fc4..690f6d0fe0e 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -1,5 +1,6 @@ import type { AppRouter } from "@superset/trpc"; import type { TRPCClient } from "@trpc/client"; +import type { HostDb } from "./db"; import type { GitFactory } from "./git/types"; export type ApiClient = TRPCClient; @@ -7,4 +8,5 @@ export type ApiClient = TRPCClient; export interface HostServiceContext { git: GitFactory; api: ApiClient | null; + db: HostDb; } diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 8e045c06789..6c69679a06f 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -10,11 +10,11 @@ import { deviceRouter } from "./router/device"; import { integrationRouter } from "./router/integration"; import { organizationRouter } from "./router/organization"; import { projectRouter } from "./router/project"; -import { projectsV2Router } from "./router/projects-v2"; import { taskRouter } from "./router/task"; import { userRouter } from "./router/user"; +import { v2ProjectRouter } from "./router/v2-project"; +import { v2WorkspaceRouter } from "./router/v2-workspace"; import { workspaceRouter } from "./router/workspace"; -import { workspacesV2Router } from "./router/workspaces-v2"; import { createCallerFactory, createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ @@ -28,11 +28,11 @@ export const appRouter = createTRPCRouter({ integration: integrationRouter, organization: organizationRouter, project: projectRouter, - projectsV2: projectsV2Router, task: taskRouter, user: userRouter, + v2Project: v2ProjectRouter, + v2Workspace: v2WorkspaceRouter, workspace: workspaceRouter, - workspacesV2: workspacesV2Router, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/src/router/projects-v2/index.ts b/packages/trpc/src/router/projects-v2/index.ts deleted file mode 100644 index 32589714a33..00000000000 --- a/packages/trpc/src/router/projects-v2/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { projectsV2Router } from "./projects-v2"; diff --git a/packages/trpc/src/router/projects-v2/projects-v2.ts b/packages/trpc/src/router/projects-v2/projects-v2.ts deleted file mode 100644 index 18917f94ae2..00000000000 --- a/packages/trpc/src/router/projects-v2/projects-v2.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { dbWs } from "@superset/db/client"; -import { type SelectV2Project, 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 { protectedProcedure } from "../../trpc"; -import { verifyOrgAdmin, verifyOrgMembership } from "../integration/utils"; - -function isUniqueViolation( - error: unknown, - constraintName?: string, -): error is { code: string; constraint?: string; constraint_name?: string } { - if ( - typeof error !== "object" || - error === null || - !("code" in error) || - error.code !== "23505" - ) { - return false; - } - - if (!constraintName) { - return true; - } - - return ( - ("constraint" in error && error.constraint === constraintName) || - ("constraint_name" in error && error.constraint_name === constraintName) - ); -} - -export const projectsV2Router = { - create: protectedProcedure - .input( - z.object({ - organizationId: z.string().uuid(), - name: z.string().min(1), - slug: z.string().min(1), - githubRepositoryId: z.string().uuid().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - let project: SelectV2Project | undefined; - - try { - [project] = await dbWs - .insert(v2Projects) - .values({ - organizationId: input.organizationId, - name: input.name, - slug: input.slug, - githubRepositoryId: input.githubRepositoryId, - }) - .returning(); - } catch (error) { - if (isUniqueViolation(error, "v2_projects_org_slug_unique")) { - throw new TRPCError({ - code: "CONFLICT", - message: - "A V2 project with this slug already exists in the organization", - }); - } - - throw error; - } - - if (!project) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to create V2 project", - }); - } - - return project; - }), - - rename: protectedProcedure - .input( - z.object({ - id: z.string().uuid(), - organizationId: z.string().uuid(), - name: z.string().min(1), - }), - ) - .mutation(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const [project] = await dbWs - .update(v2Projects) - .set({ name: input.name }) - .where( - and( - eq(v2Projects.id, input.id), - eq(v2Projects.organizationId, input.organizationId), - ), - ) - .returning(); - - if (!project) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "V2 project not found", - }); - } - - return project; - }), - - delete: protectedProcedure - .input( - z.object({ - id: z.string().uuid(), - organizationId: z.string().uuid(), - }), - ) - .mutation(async ({ ctx, input }) => { - await verifyOrgAdmin(ctx.session.user.id, input.organizationId); - - await dbWs - .delete(v2Projects) - .where( - and( - eq(v2Projects.id, input.id), - eq(v2Projects.organizationId, input.organizationId), - ), - ); - - return { success: true }; - }), -} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/v2-project/index.ts b/packages/trpc/src/router/v2-project/index.ts new file mode 100644 index 00000000000..067d8d4ea88 --- /dev/null +++ b/packages/trpc/src/router/v2-project/index.ts @@ -0,0 +1 @@ +export { v2ProjectRouter } from "./v2-project"; diff --git a/packages/trpc/src/router/v2-project/v2-project.ts b/packages/trpc/src/router/v2-project/v2-project.ts new file mode 100644 index 00000000000..90f08083ca0 --- /dev/null +++ b/packages/trpc/src/router/v2-project/v2-project.ts @@ -0,0 +1,178 @@ +import { dbWs } from "@superset/db/client"; +import { githubRepositories, 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 { protectedProcedure } from "../../trpc"; +import { verifyOrgAdmin, verifyOrgMembership } from "../integration/utils"; + +export const v2ProjectRouter = { + get: protectedProcedure + .input(z.object({ id: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No active organization", + }); + } + await verifyOrgMembership(ctx.session.user.id, organizationId); + + const row = await dbWs.query.v2Projects.findFirst({ + where: and( + eq(v2Projects.id, input.id), + eq(v2Projects.organizationId, organizationId), + ), + with: { githubRepository: true }, + }); + if (!row) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + const repoCloneUrl = row.githubRepository + ? `https://github.com/${row.githubRepository.fullName}.git` + : null; + return { ...row, repoCloneUrl }; + }), + + create: protectedProcedure + .input( + z.object({ + name: z.string().min(1), + slug: z.string().min(1), + githubRepositoryId: z.string().uuid().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No active organization", + }); + } + await verifyOrgMembership(ctx.session.user.id, organizationId); + + if (input.githubRepositoryId) { + const repo = await dbWs.query.githubRepositories.findFirst({ + where: and( + eq(githubRepositories.id, input.githubRepositoryId), + eq(githubRepositories.organizationId, organizationId), + ), + }); + if (!repo) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "GitHub repository not found in this organization", + }); + } + } + + const [project] = await dbWs + .insert(v2Projects) + .values({ + organizationId, + name: input.name, + slug: input.slug, + githubRepositoryId: input.githubRepositoryId, + }) + .returning(); + if (!project) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create project", + }); + } + return project; + }), + + update: protectedProcedure + .input( + z.object({ + id: z.string().uuid(), + name: z.string().min(1).optional(), + slug: z.string().min(1).optional(), + githubRepositoryId: z.string().uuid().nullish(), + }), + ) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No active organization", + }); + } + await verifyOrgMembership(ctx.session.user.id, organizationId); + + if (input.githubRepositoryId) { + const repo = await dbWs.query.githubRepositories.findFirst({ + where: and( + eq(githubRepositories.id, input.githubRepositoryId), + eq(githubRepositories.organizationId, organizationId), + ), + }); + if (!repo) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "GitHub repository not found in this organization", + }); + } + } + + const { id, ...data } = input; + if ( + Object.keys(data).every( + (k) => data[k as keyof typeof data] === undefined, + ) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No fields to update", + }); + } + const [updated] = await dbWs + .update(v2Projects) + .set(data) + .where( + and( + eq(v2Projects.id, id), + eq(v2Projects.organizationId, organizationId), + ), + ) + .returning(); + if (!updated) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + return updated; + }), + + delete: protectedProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No active organization", + }); + } + await verifyOrgAdmin(ctx.session.user.id, organizationId); + await dbWs + .delete(v2Projects) + .where( + and( + eq(v2Projects.id, input.id), + eq(v2Projects.organizationId, organizationId), + ), + ); + return { success: true }; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/v2-workspace/index.ts b/packages/trpc/src/router/v2-workspace/index.ts new file mode 100644 index 00000000000..72a38a2b2c8 --- /dev/null +++ b/packages/trpc/src/router/v2-workspace/index.ts @@ -0,0 +1 @@ +export { v2WorkspaceRouter } from "./v2-workspace"; diff --git a/packages/trpc/src/router/v2-workspace/v2-workspace.ts b/packages/trpc/src/router/v2-workspace/v2-workspace.ts new file mode 100644 index 00000000000..a65ab12337c --- /dev/null +++ b/packages/trpc/src/router/v2-workspace/v2-workspace.ts @@ -0,0 +1,157 @@ +import { dbWs } from "@superset/db/client"; +import { v2Devices, v2Projects, v2Workspaces } 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 { protectedProcedure } from "../../trpc"; +import { verifyOrgAdmin, verifyOrgMembership } from "../integration/utils"; + +export const v2WorkspaceRouter = { + create: protectedProcedure + .input( + z.object({ + projectId: z.string().uuid(), + name: z.string().min(1), + branch: z.string().min(1), + deviceId: z.string().uuid().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No active organization", + }); + } + await verifyOrgMembership(ctx.session.user.id, organizationId); + + const project = await dbWs.query.v2Projects.findFirst({ + where: and( + eq(v2Projects.id, input.projectId), + eq(v2Projects.organizationId, organizationId), + ), + }); + if (!project) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Project not found in this organization", + }); + } + + if (input.deviceId) { + const device = await dbWs.query.v2Devices.findFirst({ + where: and( + eq(v2Devices.id, input.deviceId), + eq(v2Devices.organizationId, organizationId), + ), + }); + if (!device) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Device not found in this organization", + }); + } + } + + const [workspace] = await dbWs + .insert(v2Workspaces) + .values({ + organizationId, + projectId: input.projectId, + name: input.name, + branch: input.branch, + deviceId: input.deviceId, + createdByUserId: ctx.session.user.id, + }) + .returning(); + return workspace; + }), + + update: protectedProcedure + .input( + z.object({ + id: z.string().uuid(), + name: z.string().min(1).optional(), + branch: z.string().min(1).optional(), + deviceId: z.string().uuid().nullish(), + }), + ) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No active organization", + }); + } + await verifyOrgMembership(ctx.session.user.id, organizationId); + + if (input.deviceId) { + const device = await dbWs.query.v2Devices.findFirst({ + where: and( + eq(v2Devices.id, input.deviceId), + eq(v2Devices.organizationId, organizationId), + ), + }); + if (!device) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Device not found in this organization", + }); + } + } + + const { id, ...data } = input; + if ( + Object.keys(data).every( + (k) => data[k as keyof typeof data] === undefined, + ) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No fields to update", + }); + } + const [updated] = await dbWs + .update(v2Workspaces) + .set(data) + .where( + and( + eq(v2Workspaces.id, id), + eq(v2Workspaces.organizationId, organizationId), + ), + ) + .returning(); + if (!updated) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found", + }); + } + return updated; + }), + + delete: protectedProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No active organization", + }); + } + await verifyOrgAdmin(ctx.session.user.id, organizationId); + await dbWs + .delete(v2Workspaces) + .where( + and( + eq(v2Workspaces.id, input.id), + eq(v2Workspaces.organizationId, organizationId), + ), + ); + return { success: true }; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/workspaces-v2/index.ts b/packages/trpc/src/router/workspaces-v2/index.ts deleted file mode 100644 index d31c142555c..00000000000 --- a/packages/trpc/src/router/workspaces-v2/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { workspacesV2Router } from "./workspaces-v2"; diff --git a/packages/trpc/src/router/workspaces-v2/workspaces-v2.ts b/packages/trpc/src/router/workspaces-v2/workspaces-v2.ts deleted file mode 100644 index c8948c3b2e9..00000000000 --- a/packages/trpc/src/router/workspaces-v2/workspaces-v2.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { dbWs } from "@superset/db/client"; -import { - v2DevicePresence, - v2Devices, - v2UsersDevices, - v2Workspaces, -} 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 { protectedProcedure } from "../../trpc"; -import { verifyOrgAdmin, verifyOrgMembership } from "../integration/utils"; - -async function resolveDeviceId( - input: { - deviceId?: string; - organizationId: string; - }, - user: { - id: string; - name?: string | null; - }, -): Promise { - if (input.deviceId) { - const existing = await dbWs.query.v2Devices.findFirst({ - where: and( - eq(v2Devices.id, input.deviceId), - eq(v2Devices.organizationId, input.organizationId), - ), - columns: { id: true }, - }); - - if (!existing) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Device not found in organization", - }); - } - - return existing.id; - } - - const [existingDevice] = await dbWs - .select({ id: v2Devices.id }) - .from(v2UsersDevices) - .innerJoin(v2Devices, eq(v2UsersDevices.deviceId, v2Devices.id)) - .where( - and( - eq(v2UsersDevices.organizationId, input.organizationId), - eq(v2UsersDevices.userId, user.id), - eq(v2Devices.organizationId, input.organizationId), - ), - ) - .limit(1); - - if (existingDevice) { - return existingDevice.id; - } - - return dbWs.transaction(async (tx) => { - const [device] = await tx - .insert(v2Devices) - .values({ - organizationId: input.organizationId, - name: user.name ? `${user.name}'s host` : "This device", - type: "host", - createdByUserId: user.id, - }) - .returning({ id: v2Devices.id }); - - if (!device) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to create V2 device", - }); - } - - await tx.insert(v2UsersDevices).values({ - organizationId: input.organizationId, - userId: user.id, - deviceId: device.id, - role: "owner", - }); - - await tx.insert(v2DevicePresence).values({ - deviceId: device.id, - organizationId: input.organizationId, - }); - - return device.id; - }); -} - -export const workspacesV2Router = { - create: protectedProcedure - .input( - z.object({ - organizationId: z.string().uuid(), - projectId: z.string().uuid(), - name: z.string().min(1), - branch: z.string().min(1), - deviceId: z.string().uuid().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const deviceId = await resolveDeviceId( - { - deviceId: input.deviceId, - organizationId: input.organizationId, - }, - ctx.session.user, - ); - - const [workspace] = await dbWs - .insert(v2Workspaces) - .values({ - organizationId: input.organizationId, - projectId: input.projectId, - deviceId, - name: input.name, - branch: input.branch, - createdByUserId: ctx.session.user.id, - }) - .returning(); - - if (!workspace) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to create V2 workspace", - }); - } - - return workspace; - }), - - rename: protectedProcedure - .input( - z.object({ - id: z.string().uuid(), - organizationId: z.string().uuid(), - name: z.string().min(1), - }), - ) - .mutation(async ({ ctx, input }) => { - await verifyOrgMembership(ctx.session.user.id, input.organizationId); - - const [workspace] = await dbWs - .update(v2Workspaces) - .set({ name: input.name }) - .where( - and( - eq(v2Workspaces.id, input.id), - eq(v2Workspaces.organizationId, input.organizationId), - ), - ) - .returning(); - - if (!workspace) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "V2 workspace not found", - }); - } - - return workspace; - }), - - delete: protectedProcedure - .input( - z.object({ - id: z.string().uuid(), - organizationId: z.string().uuid(), - }), - ) - .mutation(async ({ ctx, input }) => { - await verifyOrgAdmin(ctx.session.user.id, input.organizationId); - - await dbWs - .delete(v2Workspaces) - .where( - and( - eq(v2Workspaces.id, input.id), - eq(v2Workspaces.organizationId, input.organizationId), - ), - ); - - return { success: true }; - }), -} satisfies TRPCRouterRecord;