From 29838701a443aafe69ba6385672198f575afa587 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 11 Mar 2026 09:10:34 -0700 Subject: [PATCH 1/7] WIP --- apps/desktop/electron-builder.ts | 6 + apps/desktop/src/main/host-service/index.ts | 2 + .../src/main/lib/host-service-manager.ts | 6 + .../CollectionsProvider/collections.ts | 57 ++++++++ .../HostServiceStatus/HostServiceStatus.tsx | 117 +++++++++++++++ apps/desktop/vite/helpers.ts | 4 + bun.lock | 10 +- packages/db/src/schema/index.ts | 1 + packages/db/src/schema/relations.ts | 43 ++++++ packages/db/src/schema/v2.ts | 99 +++++++++++++ packages/host-service/drizzle.config.ts | 7 + .../0000_initial_projects_workspaces.sql | 18 +++ .../drizzle/meta/0000_snapshot.json | 131 +++++++++++++++++ .../host-service/drizzle/meta/_journal.json | 13 ++ packages/host-service/package.json | 9 ++ packages/host-service/src/app.ts | 9 +- packages/host-service/src/db/db.ts | 64 +++++++++ packages/host-service/src/db/index.ts | 2 + packages/host-service/src/db/schema.ts | 32 +++++ packages/host-service/src/index.ts | 1 + packages/host-service/src/serve.ts | 3 +- .../host-service/src/trpc/context/context.ts | 3 + .../src/trpc/router/project/index.ts | 1 + .../src/trpc/router/project/project.ts | 48 +++++++ .../host-service/src/trpc/router/router.ts | 4 + .../src/trpc/router/workspace/index.ts | 1 + .../src/trpc/router/workspace/workspace.ts | 136 ++++++++++++++++++ packages/host-service/src/types.ts | 2 + packages/trpc/src/root.ts | 4 + packages/trpc/src/router/v2-project/index.ts | 1 + .../trpc/src/router/v2-project/v2-project.ts | 130 +++++++++++++++++ .../trpc/src/router/v2-workspace/index.ts | 1 + .../src/router/v2-workspace/v2-workspace.ts | 96 +++++++++++++ 33 files changed, 1058 insertions(+), 3 deletions(-) create mode 100644 packages/db/src/schema/v2.ts create mode 100644 packages/host-service/drizzle.config.ts create mode 100644 packages/host-service/drizzle/0000_initial_projects_workspaces.sql create mode 100644 packages/host-service/drizzle/meta/0000_snapshot.json create mode 100644 packages/host-service/drizzle/meta/_journal.json create mode 100644 packages/host-service/src/db/db.ts create mode 100644 packages/host-service/src/db/index.ts create mode 100644 packages/host-service/src/db/schema.ts create mode 100644 packages/host-service/src/trpc/router/project/index.ts create mode 100644 packages/host-service/src/trpc/router/project/project.ts create mode 100644 packages/host-service/src/trpc/router/workspace/index.ts create mode 100644 packages/host-service/src/trpc/router/workspace/workspace.ts create mode 100644 packages/trpc/src/router/v2-project/index.ts create mode 100644 packages/trpc/src/router/v2-project/v2-project.ts create mode 100644 packages/trpc/src/router/v2-workspace/index.ts create mode 100644 packages/trpc/src/router/v2-workspace/v2-workspace.ts diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 439fb5b705f..0297ed11805 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -60,6 +60,12 @@ const config: Configuration = { to: "resources/migrations", filter: ["**/*"], }, + // Host-service SQLite migrations + { + 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..9c86f43e047 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -1,5 +1,6 @@ import { type ChildProcess, spawn } from "node:child_process"; import path from "node:path"; +import { SUPERSET_HOME_DIR } from "./app-environment"; type HostServiceStatus = "starting" | "running" | "crashed"; @@ -69,6 +70,11 @@ 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: path.join( + __dirname, + "../../../packages/host-service/drizzle", + ), }; if (this.authToken) { env.AUTH_TOKEN = this.authToken; 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 81b970ad944..55b231bdc79 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -15,6 +15,9 @@ import type { SelectTask, SelectTaskStatus, SelectUser, + SelectV2Device, + SelectV2Project, + SelectV2Workspace, SelectWorkspace, } from "@superset/db/schema"; import type { AppRouter } from "@superset/trpc"; @@ -63,6 +66,9 @@ interface OrgCollections { sessionHosts: Collection; githubRepositories: Collection; githubPullRequests: Collection; + v2Projects: Collection; + v2Workspaces: Collection; + v2Devices: Collection; } // Per-org collections cache @@ -385,6 +391,54 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); + const v2Projects = createCollection( + electricCollectionOptions({ + id: `v2_projects-${organizationId}`, + shapeOptions: { + url: electricUrl, + params: { + table: "v2_projects", + organizationId, + }, + headers: electricHeaders, + columnMapper, + }, + getKey: (item) => item.id, + }), + ); + + const v2Workspaces = createCollection( + electricCollectionOptions({ + id: `v2_workspaces-${organizationId}`, + shapeOptions: { + url: electricUrl, + params: { + table: "v2_workspaces", + organizationId, + }, + headers: electricHeaders, + columnMapper, + }, + getKey: (item) => item.id, + }), + ); + + const v2Devices = createCollection( + electricCollectionOptions({ + id: `v2_devices-${organizationId}`, + shapeOptions: { + url: electricUrl, + params: { + table: "v2_devices", + organizationId, + }, + headers: electricHeaders, + columnMapper, + }, + getKey: (item) => item.id, + }), + ); + return { tasks, taskStatuses, @@ -402,6 +456,9 @@ function createOrgCollections(organizationId: string): OrgCollections { sessionHosts, githubRepositories, githubPullRequests, + v2Projects, + v2Workspaces, + v2Devices, }; } 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..96321ba2230 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,14 @@ export function HostServiceStatus() { const [cloudLoading, setCloudLoading] = useState(false); const [cloudError, setCloudError] = useState(null); + // V2 Operations state + 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 +226,115 @@ export function HostServiceStatus() { )} + {/* V2 Operations */} +
+ 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 1346a0cbced..be7dedf0efc 100644 --- a/bun.lock +++ b/bun.lock @@ -109,7 +109,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.1.4", + "version": "1.1.5", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36", @@ -735,6 +735,8 @@ "@superset/trpc": "workspace:*", "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", + "better-sqlite3": "^11.8.1", + "drizzle-orm": "^0.44.2", "hono": "^4.8.5", "simple-git": "^3.30.0", "superjson": "^2.2.5", @@ -742,7 +744,9 @@ }, "devDependencies": { "@superset/typescript": "workspace:*", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.9.1", + "drizzle-kit": "^0.31.4", "typescript": "^5.9.3", }, }, @@ -5937,6 +5941,10 @@ "@slack/web-api/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + "@superset/host-service/better-sqlite3": ["better-sqlite3@11.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="], + + "@superset/host-service/drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], + "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 3710ee6afd9..30d3eb226ee 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -5,4 +5,5 @@ export * from "./ingest"; export * from "./relations"; export * from "./schema"; export * from "./types"; +export * from "./v2"; export * from "./zod"; diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts index ed4d0d97228..0319947dbd7 100644 --- a/packages/db/src/schema/relations.ts +++ b/packages/db/src/schema/relations.ts @@ -28,6 +28,7 @@ import { usersSlackUsers, workspaces, } from "./schema"; +import { v2Devices, v2Projects, v2Workspaces } from "./v2"; export const usersRelations = relations(users, ({ many }) => ({ sessions: many(sessions), @@ -74,6 +75,9 @@ export const organizationsRelations = relations(organizations, ({ many }) => ({ devicePresence: many(devicePresence), agentCommands: many(agentCommands), chatSessions: many(chatSessions), + v2Projects: many(v2Projects), + v2Devices: many(v2Devices), + v2Workspaces: many(v2Workspaces), })); export const membersRelations = relations(members, ({ one }) => ({ @@ -324,3 +328,42 @@ export const sessionHostsRelations = relations(sessionHosts, ({ one }) => ({ references: [organizations.id], }), })); + +// V2 relations +export const v2ProjectsRelations = relations(v2Projects, ({ one, many }) => ({ + organization: one(organizations, { + fields: [v2Projects.organizationId], + references: [organizations.id], + }), + githubRepository: one(githubRepositories, { + fields: [v2Projects.githubRepositoryId], + references: [githubRepositories.id], + }), + v2Workspaces: many(v2Workspaces), +})); + +export const v2DevicesRelations = relations(v2Devices, ({ one }) => ({ + organization: one(organizations, { + fields: [v2Devices.organizationId], + references: [organizations.id], + }), +})); + +export const v2WorkspacesRelations = relations(v2Workspaces, ({ one }) => ({ + organization: one(organizations, { + fields: [v2Workspaces.organizationId], + references: [organizations.id], + }), + project: one(v2Projects, { + fields: [v2Workspaces.projectId], + references: [v2Projects.id], + }), + device: one(v2Devices, { + fields: [v2Workspaces.deviceId], + references: [v2Devices.id], + }), + createdBy: one(users, { + fields: [v2Workspaces.createdByUserId], + references: [users.id], + }), +})); diff --git a/packages/db/src/schema/v2.ts b/packages/db/src/schema/v2.ts new file mode 100644 index 00000000000..c0f20d4c609 --- /dev/null +++ b/packages/db/src/schema/v2.ts @@ -0,0 +1,99 @@ +import { + index, + pgTable, + text, + timestamp, + unique, + uuid, +} from "drizzle-orm/pg-core"; +import { organizations, users } from "./auth"; +import { githubRepositories } from "./github"; + +export const v2Projects = pgTable( + "v2_projects", + { + id: uuid().primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + name: text().notNull(), + slug: text().notNull(), + githubRepositoryId: uuid("github_repository_id").references( + () => githubRepositories.id, + { onDelete: "set null" }, + ), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at") + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + index("v2_projects_organization_id_idx").on(table.organizationId), + unique("v2_projects_org_slug_unique").on(table.organizationId, table.slug), + ], +); + +export type InsertV2Project = typeof v2Projects.$inferInsert; +export type SelectV2Project = typeof v2Projects.$inferSelect; + +export const v2Devices = pgTable( + "v2_devices", + { + id: uuid().primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + name: text().notNull(), + type: text().notNull(), // "host" | "cloud" | "viewer" + hashedDeviceId: text("hashed_device_id").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at") + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + index("v2_devices_organization_id_idx").on(table.organizationId), + unique("v2_devices_org_hashed_device_id_unique").on( + table.organizationId, + table.hashedDeviceId, + ), + ], +); + +export type InsertV2Device = typeof v2Devices.$inferInsert; +export type SelectV2Device = typeof v2Devices.$inferSelect; + +export const v2Workspaces = pgTable( + "v2_workspaces", + { + id: uuid().primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + projectId: uuid("project_id") + .notNull() + .references(() => v2Projects.id, { onDelete: "cascade" }), + name: text().notNull(), + branch: text().notNull().default("main"), + deviceId: uuid("device_id").references(() => v2Devices.id, { + onDelete: "set null", + }), + createdByUserId: uuid("created_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at") + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + index("v2_workspaces_project_id_idx").on(table.projectId), + index("v2_workspaces_organization_id_idx").on(table.organizationId), + ], +); + +export type InsertV2Workspace = typeof v2Workspaces.$inferInsert; +export type SelectV2Workspace = typeof v2Workspaces.$inferSelect; 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..a25990cf86b 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": "^11.8.1", + "drizzle-orm": "^0.44.2", "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.4", "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..d7558eb1c19 --- /dev/null +++ b/packages/host-service/src/db/db.ts @@ -0,0 +1,64 @@ +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; + +/** + * Resolves the migrations folder for host-service's local SQLite DB. + * + * - Production (Electron packaged): process.resourcesPath/resources/host-migrations + * - Dev (ELECTRON_RUN_AS_NODE child process): HOST_MIGRATIONS_PATH env var + * - Standalone dev (serve.ts): relative from src/db/ to package root drizzle/ + * - Fallback: __dirname-based resolution + */ +function getMigrationsFolder(): string { + // Electron packaged app (resourcesPath is Electron-specific) + 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"); + } + + // Dev child process: explicit env var from desktop + if (process.env.HOST_MIGRATIONS_PATH) { + return process.env.HOST_MIGRATIONS_PATH; + } + + // Standalone dev (serve.ts) — import.meta.dirname = src/db/ + if (typeof import.meta.dirname === "string") { + const candidate = join(import.meta.dirname, "../../drizzle"); + if (existsSync(candidate)) { + return candidate; + } + } + + // Fallback + 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..e40f681ff61 --- /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(), // = cloud v2_projects.id (set by caller) + 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(), // = cloud v2_workspaces.id + 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..24e1baac725 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; +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..c036d12c4ac --- /dev/null +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -0,0 +1,48 @@ +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({ + 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 }; + } + + // Find all local workspaces for this project + const localWorkspaces = ctx.db + .select() + .from(workspaces) + .where(eq(workspaces.projectId, input.projectId)) + .all(); + + // Best-effort remove each worktree + for (const ws of localWorkspaces) { + try { + const git = await ctx.git(localProject.repoPath); + await git.raw(["worktree", "remove", ws.worktreePath]); + } catch { + // Best-effort + } + } + + // Best-effort remove cloned repo directory + try { + rmSync(localProject.repoPath, { recursive: true, force: true }); + } catch { + // Best-effort + } + + // Delete local project row (cascades to local workspace rows) + 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..41cd6c8ac6c --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -0,0 +1,136 @@ +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", + }); + } + + // Check if project exists locally + let localProject = ctx.db.query.projects + .findFirst({ where: eq(projects.id, input.projectId) }) + .sync(); + + // If not found locally, fetch from cloud and clone + 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", + }); + } + + // Create worktree + const worktreePath = join( + localProject.repoPath, + ".worktrees", + input.branch, + ); + + const git = await ctx.git(localProject.repoPath); + await git.raw(["worktree", "add", worktreePath, input.branch]); + + // Create cloud workspace (orgId implicit from auth session) + const cloudRow = await ctx.api.v2Workspace.create.mutate({ + projectId: input.projectId, + name: input.name, + branch: input.branch, + }); + + // Track locally + 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", + }); + } + + // Look up local workspace + 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 { + // Best-effort worktree removal + } + } + } + + // Delete from cloud (orgId implicit from auth session) + await ctx.api.v2Workspace.delete.mutate({ id: input.id }); + + // Delete local row + 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 0c2aab74526..6c69679a06f 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -12,6 +12,8 @@ import { organizationRouter } from "./router/organization"; import { projectRouter } from "./router/project"; 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 { createCallerFactory, createTRPCRouter } from "./trpc"; @@ -28,6 +30,8 @@ export const appRouter = createTRPCRouter({ project: projectRouter, task: taskRouter, user: userRouter, + v2Project: v2ProjectRouter, + v2Workspace: v2WorkspaceRouter, workspace: workspaceRouter, }); 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..3bbedd1622f --- /dev/null +++ b/packages/trpc/src/router/v2-project/v2-project.ts @@ -0,0 +1,130 @@ +import { dbWs } from "@superset/db/client"; +import { 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); + 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); + const { id, ...data } = input; + const [updated] = await dbWs + .update(v2Projects) + .set(data) + .where( + and( + eq(v2Projects.id, id), + eq(v2Projects.organizationId, organizationId), + ), + ) + .returning(); + 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..0534708f83e --- /dev/null +++ b/packages/trpc/src/router/v2-workspace/v2-workspace.ts @@ -0,0 +1,96 @@ +import { dbWs } from "@superset/db/client"; +import { 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().optional(), + 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 [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().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); + const { id, ...data } = input; + const [updated] = await dbWs + .update(v2Workspaces) + .set(data) + .where( + and( + eq(v2Workspaces.id, id), + eq(v2Workspaces.organizationId, organizationId), + ), + ) + .returning(); + 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; From 20d1ea1b6cb6341720fbfcf1f1388bf89c1cdcc1 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 11 Mar 2026 09:22:29 -0700 Subject: [PATCH 2/7] fix: align host-service dep versions with workspace (sherif) Update better-sqlite3 to 12.6.2, drizzle-orm to 0.45.1, and drizzle-kit to 0.31.8 to match the rest of the monorepo and pass sherif's multiple-dependency-versions check. --- bun.lock | 12 ++++-------- packages/host-service/package.json | 6 +++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/bun.lock b/bun.lock index ac6de683370..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,8 +741,8 @@ "@superset/trpc": "workspace:*", "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", - "better-sqlite3": "^11.8.1", - "drizzle-orm": "^0.44.2", + "better-sqlite3": "12.6.2", + "drizzle-orm": "0.45.1", "hono": "^4.8.5", "simple-git": "^3.30.0", "superjson": "^2.2.5", @@ -752,7 +752,7 @@ "@superset/typescript": "workspace:*", "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.9.1", - "drizzle-kit": "^0.31.4", + "drizzle-kit": "0.31.8", "typescript": "^5.9.3", }, }, @@ -5947,10 +5947,6 @@ "@slack/web-api/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], - "@superset/host-service/better-sqlite3": ["better-sqlite3@11.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="], - - "@superset/host-service/drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], - "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], diff --git a/packages/host-service/package.json b/packages/host-service/package.json index a25990cf86b..423aa69baba 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -33,8 +33,8 @@ "@superset/trpc": "workspace:*", "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", - "better-sqlite3": "^11.8.1", - "drizzle-orm": "^0.44.2", + "better-sqlite3": "12.6.2", + "drizzle-orm": "0.45.1", "hono": "^4.8.5", "simple-git": "^3.30.0", "superjson": "^2.2.5", @@ -44,7 +44,7 @@ "@superset/typescript": "workspace:*", "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.9.1", - "drizzle-kit": "^0.31.4", + "drizzle-kit": "0.31.8", "typescript": "^5.9.3" } } From fddc50f109c9f1d331ec439d93a86d4364149055 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 11 Mar 2026 09:26:32 -0700 Subject: [PATCH 3/7] chore: remove unnecessary comments --- apps/desktop/electron-builder.ts | 1 - .../HostServiceStatus/HostServiceStatus.tsx | 2 -- packages/db/src/schema/relations.ts | 1 - packages/host-service/src/db/schema.ts | 4 ++-- .../host-service/src/trpc/router/project/project.ts | 12 ++---------- .../src/trpc/router/workspace/workspace.ts | 12 +----------- 6 files changed, 5 insertions(+), 27 deletions(-) diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 0297ed11805..8b08ddd17ba 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -60,7 +60,6 @@ const config: Configuration = { to: "resources/migrations", filter: ["**/*"], }, - // Host-service SQLite migrations { from: "dist/resources/host-migrations", to: "resources/host-migrations", 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 96321ba2230..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,7 +56,6 @@ export function HostServiceStatus() { const [cloudLoading, setCloudLoading] = useState(false); const [cloudError, setCloudError] = useState(null); - // V2 Operations state const [v2ProjectId, setV2ProjectId] = useState(""); const [v2WorkspaceId, setV2WorkspaceId] = useState(""); const [v2Branch, setV2Branch] = useState("main"); @@ -226,7 +225,6 @@ export function HostServiceStatus() { )}
- {/* V2 Operations */}
V2 Operations
diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts index 0319947dbd7..0d9329aeab2 100644 --- a/packages/db/src/schema/relations.ts +++ b/packages/db/src/schema/relations.ts @@ -329,7 +329,6 @@ export const sessionHostsRelations = relations(sessionHosts, ({ one }) => ({ }), })); -// V2 relations export const v2ProjectsRelations = relations(v2Projects, ({ one, many }) => ({ organization: one(organizations, { fields: [v2Projects.organizationId], diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts index e40f681ff61..d75d66607bd 100644 --- a/packages/host-service/src/db/schema.ts +++ b/packages/host-service/src/db/schema.ts @@ -3,7 +3,7 @@ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const projects = sqliteTable( "projects", { - id: text().primaryKey(), // = cloud v2_projects.id (set by caller) + id: text().primaryKey(), repoPath: text("repo_path").notNull(), createdAt: integer("created_at") .notNull() @@ -15,7 +15,7 @@ export const projects = sqliteTable( export const workspaces = sqliteTable( "workspaces", { - id: text().primaryKey(), // = cloud v2_workspaces.id + id: text().primaryKey(), projectId: text("project_id") .notNull() .references(() => projects.id, { onDelete: "cascade" }), diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index c036d12c4ac..954f8c304dd 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -16,31 +16,23 @@ export const projectRouter = router({ return { success: true }; } - // Find all local workspaces for this project const localWorkspaces = ctx.db .select() .from(workspaces) .where(eq(workspaces.projectId, input.projectId)) .all(); - // Best-effort remove each worktree for (const ws of localWorkspaces) { try { const git = await ctx.git(localProject.repoPath); await git.raw(["worktree", "remove", ws.worktreePath]); - } catch { - // Best-effort - } + } catch {} } - // Best-effort remove cloned repo directory try { rmSync(localProject.repoPath, { recursive: true, force: true }); - } catch { - // Best-effort - } + } catch {} - // Delete local project row (cascades to local workspace rows) ctx.db.delete(projects).where(eq(projects.id, input.projectId)).run(); return { success: true }; diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts index 41cd6c8ac6c..b060b6c7ba8 100644 --- a/packages/host-service/src/trpc/router/workspace/workspace.ts +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -22,12 +22,10 @@ export const workspaceRouter = router({ }); } - // Check if project exists locally let localProject = ctx.db.query.projects .findFirst({ where: eq(projects.id, input.projectId) }) .sync(); - // If not found locally, fetch from cloud and clone if (!localProject) { const cloudProject = await ctx.api.v2Project.get.query({ id: input.projectId, @@ -62,7 +60,6 @@ export const workspaceRouter = router({ }); } - // Create worktree const worktreePath = join( localProject.repoPath, ".worktrees", @@ -72,14 +69,12 @@ export const workspaceRouter = router({ const git = await ctx.git(localProject.repoPath); await git.raw(["worktree", "add", worktreePath, input.branch]); - // Create cloud workspace (orgId implicit from auth session) const cloudRow = await ctx.api.v2Workspace.create.mutate({ projectId: input.projectId, name: input.name, branch: input.branch, }); - // Track locally if (cloudRow) { ctx.db .insert(workspaces) @@ -105,7 +100,6 @@ export const workspaceRouter = router({ }); } - // Look up local workspace const localWorkspace = ctx.db.query.workspaces .findFirst({ where: eq(workspaces.id, input.id) }) .sync(); @@ -119,16 +113,12 @@ export const workspaceRouter = router({ try { const git = await ctx.git(localProject.repoPath); await git.raw(["worktree", "remove", localWorkspace.worktreePath]); - } catch { - // Best-effort worktree removal - } + } catch {} } } - // Delete from cloud (orgId implicit from auth session) await ctx.api.v2Workspace.delete.mutate({ id: input.id }); - // Delete local row ctx.db.delete(workspaces).where(eq(workspaces.id, input.id)).run(); return { success: true }; From 27935378fa2f16ab6e9652083562ea84ad789d03 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 11 Mar 2026 09:47:17 -0700 Subject: [PATCH 4/7] fix: address PR review feedback - Validate githubRepositoryId org ownership in v2-project create/update - Validate projectId and deviceId org ownership in v2-workspace create/update - Add console.warn to all best-effort catch blocks for observability - Reorder workspace.delete: cloud first, then local cleanup - Rollback worktree on cloud-create failure in workspace.create - Normalize HOST_DB_PATH with trim/fallback in serve.ts - Remove unnecessary comments --- packages/db/src/schema/v2.ts | 2 +- packages/host-service/src/db/db.ts | 12 ----- packages/host-service/src/serve.ts | 2 +- .../src/trpc/router/project/project.ts | 16 ++++++- .../src/trpc/router/workspace/workspace.ts | 34 ++++++++++---- .../trpc/src/router/v2-project/v2-project.ts | 34 +++++++++++++- .../src/router/v2-workspace/v2-workspace.ts | 47 ++++++++++++++++++- 7 files changed, 121 insertions(+), 26 deletions(-) diff --git a/packages/db/src/schema/v2.ts b/packages/db/src/schema/v2.ts index c0f20d4c609..a2454533e29 100644 --- a/packages/db/src/schema/v2.ts +++ b/packages/db/src/schema/v2.ts @@ -45,7 +45,7 @@ export const v2Devices = pgTable( .notNull() .references(() => organizations.id, { onDelete: "cascade" }), name: text().notNull(), - type: text().notNull(), // "host" | "cloud" | "viewer" + type: text().notNull(), hashedDeviceId: text("hashed_device_id").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at") diff --git a/packages/host-service/src/db/db.ts b/packages/host-service/src/db/db.ts index d7558eb1c19..37a96413e44 100644 --- a/packages/host-service/src/db/db.ts +++ b/packages/host-service/src/db/db.ts @@ -7,28 +7,17 @@ import * as schema from "./schema"; export type HostDb = ReturnType; -/** - * Resolves the migrations folder for host-service's local SQLite DB. - * - * - Production (Electron packaged): process.resourcesPath/resources/host-migrations - * - Dev (ELECTRON_RUN_AS_NODE child process): HOST_MIGRATIONS_PATH env var - * - Standalone dev (serve.ts): relative from src/db/ to package root drizzle/ - * - Fallback: __dirname-based resolution - */ function getMigrationsFolder(): string { - // Electron packaged app (resourcesPath is Electron-specific) 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"); } - // Dev child process: explicit env var from desktop if (process.env.HOST_MIGRATIONS_PATH) { return process.env.HOST_MIGRATIONS_PATH; } - // Standalone dev (serve.ts) — import.meta.dirname = src/db/ if (typeof import.meta.dirname === "string") { const candidate = join(import.meta.dirname, "../../drizzle"); if (existsSync(candidate)) { @@ -36,7 +25,6 @@ function getMigrationsFolder(): string { } } - // Fallback return join(__dirname, "../../drizzle"); } diff --git a/packages/host-service/src/serve.ts b/packages/host-service/src/serve.ts index 24e1baac725..924a354f4d0 100644 --- a/packages/host-service/src/serve.ts +++ b/packages/host-service/src/serve.ts @@ -1,7 +1,7 @@ import { serve } from "@hono/node-server"; import { createApp } from "./app"; -const dbPath = process.env.HOST_DB_PATH; +const dbPath = process.env.HOST_DB_PATH?.trim() || undefined; const app = createApp({ dbPath }); const port = Number(process.env.PORT) || 4879; diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 954f8c304dd..3eb02d2f23c 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -26,12 +26,24 @@ export const projectRouter = router({ try { const git = await ctx.git(localProject.repoPath); await git.raw(["worktree", "remove", ws.worktreePath]); - } catch {} + } 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 {} + } 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(); diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts index b060b6c7ba8..dfea8710eec 100644 --- a/packages/host-service/src/trpc/router/workspace/workspace.ts +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -69,11 +69,23 @@ export const workspaceRouter = router({ 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, - }); + 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 @@ -100,6 +112,8 @@ export const workspaceRouter = router({ }); } + await ctx.api.v2Workspace.delete.mutate({ id: input.id }); + const localWorkspace = ctx.db.query.workspaces .findFirst({ where: eq(workspaces.id, input.id) }) .sync(); @@ -113,12 +127,16 @@ export const workspaceRouter = router({ try { const git = await ctx.git(localProject.repoPath); await git.raw(["worktree", "remove", localWorkspace.worktreePath]); - } catch {} + } catch (err) { + console.warn("[workspace.delete] failed to remove worktree", { + workspaceId: input.id, + worktreePath: localWorkspace.worktreePath, + err, + }); + } } } - await ctx.api.v2Workspace.delete.mutate({ id: input.id }); - ctx.db.delete(workspaces).where(eq(workspaces.id, input.id)).run(); return { success: true }; diff --git a/packages/trpc/src/router/v2-project/v2-project.ts b/packages/trpc/src/router/v2-project/v2-project.ts index 3bbedd1622f..af88adbfbad 100644 --- a/packages/trpc/src/router/v2-project/v2-project.ts +++ b/packages/trpc/src/router/v2-project/v2-project.ts @@ -1,5 +1,5 @@ import { dbWs } from "@superset/db/client"; -import { v2Projects } from "@superset/db/schema"; +import { githubRepositories, v2Projects } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; import { and, eq } from "drizzle-orm"; @@ -56,6 +56,22 @@ export const v2ProjectRouter = { }); } 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({ @@ -92,6 +108,22 @@ export const v2ProjectRouter = { }); } 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; const [updated] = await dbWs .update(v2Projects) diff --git a/packages/trpc/src/router/v2-workspace/v2-workspace.ts b/packages/trpc/src/router/v2-workspace/v2-workspace.ts index 0534708f83e..d3c97b57cf9 100644 --- a/packages/trpc/src/router/v2-workspace/v2-workspace.ts +++ b/packages/trpc/src/router/v2-workspace/v2-workspace.ts @@ -1,5 +1,5 @@ import { dbWs } from "@superset/db/client"; -import { v2Workspaces } from "@superset/db/schema"; +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"; @@ -26,6 +26,35 @@ export const v2WorkspaceRouter = { }); } 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({ @@ -58,6 +87,22 @@ export const v2WorkspaceRouter = { }); } 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; const [updated] = await dbWs .update(v2Workspaces) From eca7d42812bf82ea7185daa0f6adc9f7eee5d6b5 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 11 Mar 2026 12:35:23 -0700 Subject: [PATCH 5/7] fix: address second round of PR review feedback - Fix HOST_MIGRATIONS_PATH for prod builds (use process.resourcesPath) - Pass remoteUrl to GitFactory for cold-start clone (no existing repo) - Add branch min(1) validation in v2-workspace create/update - Require at least one mutable field in update, throw NOT_FOUND on miss --- .../src/main/lib/host-service-manager.ts | 8 ++++---- .../git/createGitFactory/createGitFactory.ts | 6 +++--- packages/host-service/src/git/types.ts | 5 ++++- .../src/trpc/router/workspace/workspace.ts | 2 +- .../trpc/src/router/v2-project/v2-project.ts | 16 +++++++++++++++ .../src/router/v2-workspace/v2-workspace.ts | 20 +++++++++++++++++-- 6 files changed, 46 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index 9c86f43e047..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,6 @@ 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"; @@ -71,10 +72,9 @@ class HostServiceManager { ELECTRON_RUN_AS_NODE: "1", ORGANIZATION_ID: organizationId, HOST_DB_PATH: path.join(SUPERSET_HOME_DIR, "host.db"), - HOST_MIGRATIONS_PATH: path.join( - __dirname, - "../../../packages/host-service/drizzle", - ), + 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/packages/host-service/src/git/createGitFactory/createGitFactory.ts b/packages/host-service/src/git/createGitFactory/createGitFactory.ts index 09698c5b318..bf0d7cde902 100644 --- a/packages/host-service/src/git/createGitFactory/createGitFactory.ts +++ b/packages/host-service/src/git/createGitFactory/createGitFactory.ts @@ -3,10 +3,10 @@ import type { CredentialProvider, GitFactory } from "../types"; import { getRemoteUrl } from "./utils/utils"; export function createGitFactory(provider: CredentialProvider): GitFactory { - return async (repoPath: string) => { + return async (repoPath: string, remoteUrl?: string) => { const git = simpleGit(repoPath); - const remoteUrl = await getRemoteUrl(git); - const creds = await provider.getCredentials(remoteUrl); + const resolvedUrl = remoteUrl ?? (await getRemoteUrl(git)); + const creds = await provider.getCredentials(resolvedUrl); return git.env(creds.env); }; } diff --git a/packages/host-service/src/git/types.ts b/packages/host-service/src/git/types.ts index f7e26b1d055..f91330b5f4d 100644 --- a/packages/host-service/src/git/types.ts +++ b/packages/host-service/src/git/types.ts @@ -6,4 +6,7 @@ export interface CredentialProvider { ): Promise<{ env: Record }>; } -export type GitFactory = (path: string) => Promise; +export type GitFactory = ( + path: string, + remoteUrl?: string, +) => Promise; diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts index dfea8710eec..4336a7db228 100644 --- a/packages/host-service/src/trpc/router/workspace/workspace.ts +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -41,7 +41,7 @@ export const workspaceRouter = router({ const homeDir = process.env.HOME || process.env.USERPROFILE || "/tmp"; const repoPath = join(homeDir, ".superset", "repos", input.projectId); - const git = await ctx.git(repoPath); + const git = await ctx.git(repoPath, cloudProject.repoCloneUrl); await git.clone(cloudProject.repoCloneUrl, repoPath); const inserted = ctx.db diff --git a/packages/trpc/src/router/v2-project/v2-project.ts b/packages/trpc/src/router/v2-project/v2-project.ts index af88adbfbad..90f08083ca0 100644 --- a/packages/trpc/src/router/v2-project/v2-project.ts +++ b/packages/trpc/src/router/v2-project/v2-project.ts @@ -125,6 +125,16 @@ export const v2ProjectRouter = { } 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) @@ -135,6 +145,12 @@ export const v2ProjectRouter = { ), ) .returning(); + if (!updated) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } return updated; }), diff --git a/packages/trpc/src/router/v2-workspace/v2-workspace.ts b/packages/trpc/src/router/v2-workspace/v2-workspace.ts index d3c97b57cf9..1208d4ed69a 100644 --- a/packages/trpc/src/router/v2-workspace/v2-workspace.ts +++ b/packages/trpc/src/router/v2-workspace/v2-workspace.ts @@ -13,7 +13,7 @@ export const v2WorkspaceRouter = { z.object({ projectId: z.string().uuid(), name: z.string().min(1), - branch: z.string().optional(), + branch: z.string().min(1).optional(), deviceId: z.string().uuid().optional(), }), ) @@ -74,7 +74,7 @@ export const v2WorkspaceRouter = { z.object({ id: z.string().uuid(), name: z.string().min(1).optional(), - branch: z.string().optional(), + branch: z.string().min(1).optional(), deviceId: z.string().uuid().nullish(), }), ) @@ -104,6 +104,16 @@ export const v2WorkspaceRouter = { } 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) @@ -114,6 +124,12 @@ export const v2WorkspaceRouter = { ), ) .returning(); + if (!updated) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found", + }); + } return updated; }), From 2efcc56742829647339516a595e620de3db39d16 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 11 Mar 2026 12:43:29 -0700 Subject: [PATCH 6/7] fix: revert GitFactory remoteUrl param, keep clone simple ctx.git(repoPath) already handles non-existent repos gracefully. Authenticated cloning for private repos is a separate concern. --- .../src/git/createGitFactory/createGitFactory.ts | 6 +++--- packages/host-service/src/git/types.ts | 5 +---- .../host-service/src/trpc/router/workspace/workspace.ts | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/host-service/src/git/createGitFactory/createGitFactory.ts b/packages/host-service/src/git/createGitFactory/createGitFactory.ts index bf0d7cde902..09698c5b318 100644 --- a/packages/host-service/src/git/createGitFactory/createGitFactory.ts +++ b/packages/host-service/src/git/createGitFactory/createGitFactory.ts @@ -3,10 +3,10 @@ import type { CredentialProvider, GitFactory } from "../types"; import { getRemoteUrl } from "./utils/utils"; export function createGitFactory(provider: CredentialProvider): GitFactory { - return async (repoPath: string, remoteUrl?: string) => { + return async (repoPath: string) => { const git = simpleGit(repoPath); - const resolvedUrl = remoteUrl ?? (await getRemoteUrl(git)); - const creds = await provider.getCredentials(resolvedUrl); + const remoteUrl = await getRemoteUrl(git); + const creds = await provider.getCredentials(remoteUrl); return git.env(creds.env); }; } diff --git a/packages/host-service/src/git/types.ts b/packages/host-service/src/git/types.ts index f91330b5f4d..f7e26b1d055 100644 --- a/packages/host-service/src/git/types.ts +++ b/packages/host-service/src/git/types.ts @@ -6,7 +6,4 @@ export interface CredentialProvider { ): Promise<{ env: Record }>; } -export type GitFactory = ( - path: string, - remoteUrl?: string, -) => Promise; +export type GitFactory = (path: string) => Promise; diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts index 4336a7db228..dfea8710eec 100644 --- a/packages/host-service/src/trpc/router/workspace/workspace.ts +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -41,7 +41,7 @@ export const workspaceRouter = router({ const homeDir = process.env.HOME || process.env.USERPROFILE || "/tmp"; const repoPath = join(homeDir, ".superset", "repos", input.projectId); - const git = await ctx.git(repoPath, cloudProject.repoCloneUrl); + const git = await ctx.git(repoPath); await git.clone(cloudProject.repoCloneUrl, repoPath); const inserted = ctx.db From 8894ddc10d6e185cd0f442b5d0ce4c3c84b623e3 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 11 Mar 2026 14:03:22 -0700 Subject: [PATCH 7/7] add TODO: remove note to removeFromDevice --- packages/host-service/src/trpc/router/project/project.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 3eb02d2f23c..2894d0ef007 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -5,6 +5,7 @@ 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 }) => {