Spaces:
Build error
Build error
| import { and, asc, desc, eq, inArray } from "drizzle-orm"; | |
| import type { Db } from "@paperclipai/db"; | |
| import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; | |
| import { | |
| PROJECT_COLORS, | |
| deriveProjectUrlKey, | |
| isUuidLike, | |
| normalizeProjectUrlKey, | |
| type ProjectCodebase, | |
| type ProjectExecutionWorkspacePolicy, | |
| type ProjectGoalRef, | |
| type ProjectWorkspace, | |
| type WorkspaceRuntimeService, | |
| } from "@paperclipai/shared"; | |
| import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js"; | |
| import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; | |
| import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; | |
| type ProjectRow = typeof projects.$inferSelect; | |
| type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; | |
| type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; | |
| const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; | |
| type CreateWorkspaceInput = { | |
| name?: string | null; | |
| sourceType?: string | null; | |
| cwd?: string | null; | |
| repoUrl?: string | null; | |
| repoRef?: string | null; | |
| defaultRef?: string | null; | |
| visibility?: string | null; | |
| setupCommand?: string | null; | |
| cleanupCommand?: string | null; | |
| remoteProvider?: string | null; | |
| remoteWorkspaceRef?: string | null; | |
| sharedWorkspaceKey?: string | null; | |
| metadata?: Record<string, unknown> | null; | |
| isPrimary?: boolean; | |
| }; | |
| type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>; | |
| interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> { | |
| urlKey: string; | |
| goalIds: string[]; | |
| goals: ProjectGoalRef[]; | |
| executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null; | |
| codebase: ProjectCodebase; | |
| workspaces: ProjectWorkspace[]; | |
| primaryWorkspace: ProjectWorkspace | null; | |
| } | |
| interface ProjectShortnameRow { | |
| id: string; | |
| name: string; | |
| } | |
| interface ResolveProjectNameOptions { | |
| excludeProjectId?: string | null; | |
| } | |
| /** Batch-load goal refs for a set of projects. */ | |
| async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals[]> { | |
| if (rows.length === 0) return []; | |
| const projectIds = rows.map((r) => r.id); | |
| // Fetch join rows + goal titles in one query | |
| const links = await db | |
| .select({ | |
| projectId: projectGoals.projectId, | |
| goalId: projectGoals.goalId, | |
| goalTitle: goals.title, | |
| }) | |
| .from(projectGoals) | |
| .innerJoin(goals, eq(projectGoals.goalId, goals.id)) | |
| .where(inArray(projectGoals.projectId, projectIds)); | |
| const map = new Map<string, ProjectGoalRef[]>(); | |
| for (const link of links) { | |
| let arr = map.get(link.projectId); | |
| if (!arr) { | |
| arr = []; | |
| map.set(link.projectId, arr); | |
| } | |
| arr.push({ id: link.goalId, title: link.goalTitle }); | |
| } | |
| return rows.map((r) => { | |
| const g = map.get(r.id) ?? []; | |
| return { | |
| ...r, | |
| urlKey: deriveProjectUrlKey(r.name, r.id), | |
| goalIds: g.map((x) => x.id), | |
| goals: g, | |
| executionWorkspacePolicy: parseProjectExecutionWorkspacePolicy(r.executionWorkspacePolicy), | |
| } as ProjectWithGoals; | |
| }); | |
| } | |
| function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeService { | |
| return { | |
| id: row.id, | |
| companyId: row.companyId, | |
| projectId: row.projectId ?? null, | |
| projectWorkspaceId: row.projectWorkspaceId ?? null, | |
| executionWorkspaceId: row.executionWorkspaceId ?? null, | |
| issueId: row.issueId ?? null, | |
| scopeType: row.scopeType as WorkspaceRuntimeService["scopeType"], | |
| scopeId: row.scopeId ?? null, | |
| serviceName: row.serviceName, | |
| status: row.status as WorkspaceRuntimeService["status"], | |
| lifecycle: row.lifecycle as WorkspaceRuntimeService["lifecycle"], | |
| reuseKey: row.reuseKey ?? null, | |
| command: row.command ?? null, | |
| cwd: row.cwd ?? null, | |
| port: row.port ?? null, | |
| url: row.url ?? null, | |
| provider: row.provider as WorkspaceRuntimeService["provider"], | |
| providerRef: row.providerRef ?? null, | |
| ownerAgentId: row.ownerAgentId ?? null, | |
| startedByRunId: row.startedByRunId ?? null, | |
| lastUsedAt: row.lastUsedAt, | |
| startedAt: row.startedAt, | |
| stoppedAt: row.stoppedAt ?? null, | |
| stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null, | |
| healthStatus: row.healthStatus as WorkspaceRuntimeService["healthStatus"], | |
| createdAt: row.createdAt, | |
| updatedAt: row.updatedAt, | |
| }; | |
| } | |
| function toWorkspace( | |
| row: ProjectWorkspaceRow, | |
| runtimeServices: WorkspaceRuntimeService[] = [], | |
| ): ProjectWorkspace { | |
| return { | |
| id: row.id, | |
| companyId: row.companyId, | |
| projectId: row.projectId, | |
| name: row.name, | |
| sourceType: row.sourceType as ProjectWorkspace["sourceType"], | |
| cwd: normalizeWorkspaceCwd(row.cwd), | |
| repoUrl: row.repoUrl ?? null, | |
| repoRef: row.repoRef ?? null, | |
| defaultRef: row.defaultRef ?? row.repoRef ?? null, | |
| visibility: row.visibility as ProjectWorkspace["visibility"], | |
| setupCommand: row.setupCommand ?? null, | |
| cleanupCommand: row.cleanupCommand ?? null, | |
| remoteProvider: row.remoteProvider ?? null, | |
| remoteWorkspaceRef: row.remoteWorkspaceRef ?? null, | |
| sharedWorkspaceKey: row.sharedWorkspaceKey ?? null, | |
| metadata: (row.metadata as Record<string, unknown> | null) ?? null, | |
| isPrimary: row.isPrimary, | |
| runtimeServices, | |
| createdAt: row.createdAt, | |
| updatedAt: row.updatedAt, | |
| }; | |
| } | |
| function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null { | |
| const raw = readNonEmptyString(repoUrl); | |
| if (!raw) return null; | |
| try { | |
| const parsed = new URL(raw); | |
| const cleanedPath = parsed.pathname.replace(/\/+$/, ""); | |
| const repoName = cleanedPath.split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") ?? ""; | |
| return repoName || null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function deriveProjectCodebase(input: { | |
| companyId: string; | |
| projectId: string; | |
| primaryWorkspace: ProjectWorkspace | null; | |
| fallbackWorkspaces: ProjectWorkspace[]; | |
| }): ProjectCodebase { | |
| const primaryWorkspace = input.primaryWorkspace ?? input.fallbackWorkspaces[0] ?? null; | |
| const repoUrl = primaryWorkspace?.repoUrl ?? null; | |
| const repoName = deriveRepoNameFromRepoUrl(repoUrl); | |
| const localFolder = primaryWorkspace?.cwd ?? null; | |
| const managedFolder = resolveManagedProjectWorkspaceDir({ | |
| companyId: input.companyId, | |
| projectId: input.projectId, | |
| repoName, | |
| }); | |
| return { | |
| workspaceId: primaryWorkspace?.id ?? null, | |
| repoUrl, | |
| repoRef: primaryWorkspace?.repoRef ?? null, | |
| defaultRef: primaryWorkspace?.defaultRef ?? null, | |
| repoName, | |
| localFolder, | |
| managedFolder, | |
| effectiveLocalFolder: localFolder ?? managedFolder, | |
| origin: localFolder ? "local_folder" : "managed_checkout", | |
| }; | |
| } | |
| function pickPrimaryWorkspace( | |
| rows: ProjectWorkspaceRow[], | |
| runtimeServicesByWorkspaceId?: Map<string, WorkspaceRuntimeService[]>, | |
| ): ProjectWorkspace | null { | |
| if (rows.length === 0) return null; | |
| const explicitPrimary = rows.find((row) => row.isPrimary); | |
| const primary = explicitPrimary ?? rows[0]; | |
| return toWorkspace(primary, runtimeServicesByWorkspaceId?.get(primary.id) ?? []); | |
| } | |
| /** Batch-load workspace refs for a set of projects. */ | |
| async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<ProjectWithGoals[]> { | |
| if (rows.length === 0) return []; | |
| const projectIds = rows.map((r) => r.id); | |
| const workspaceRows = await db | |
| .select() | |
| .from(projectWorkspaces) | |
| .where(inArray(projectWorkspaces.projectId, projectIds)) | |
| .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); | |
| const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces( | |
| db, | |
| rows[0]!.companyId, | |
| workspaceRows.map((workspace) => workspace.id), | |
| ); | |
| const sharedRuntimeServicesByWorkspaceId = new Map( | |
| Array.from(runtimeServicesByWorkspaceId.entries()).map(([workspaceId, services]) => [ | |
| workspaceId, | |
| services.map(toRuntimeService), | |
| ]), | |
| ); | |
| const map = new Map<string, ProjectWorkspaceRow[]>(); | |
| for (const row of workspaceRows) { | |
| let arr = map.get(row.projectId); | |
| if (!arr) { | |
| arr = []; | |
| map.set(row.projectId, arr); | |
| } | |
| arr.push(row); | |
| } | |
| return rows.map((row) => { | |
| const projectWorkspaceRows = map.get(row.id) ?? []; | |
| const workspaces = projectWorkspaceRows.map((workspace) => | |
| toWorkspace( | |
| workspace, | |
| sharedRuntimeServicesByWorkspaceId.get(workspace.id) ?? [], | |
| ), | |
| ); | |
| const primaryWorkspace = pickPrimaryWorkspace(projectWorkspaceRows, sharedRuntimeServicesByWorkspaceId); | |
| return { | |
| ...row, | |
| codebase: deriveProjectCodebase({ | |
| companyId: row.companyId, | |
| projectId: row.id, | |
| primaryWorkspace, | |
| fallbackWorkspaces: workspaces, | |
| }), | |
| workspaces, | |
| primaryWorkspace, | |
| }; | |
| }); | |
| } | |
| /** Sync the project_goals join table for a single project. */ | |
| async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) { | |
| // Delete existing links | |
| await db.delete(projectGoals).where(eq(projectGoals.projectId, projectId)); | |
| // Insert new links | |
| if (goalIds.length > 0) { | |
| await db.insert(projectGoals).values( | |
| goalIds.map((goalId) => ({ projectId, goalId, companyId })), | |
| ); | |
| } | |
| } | |
| /** Resolve goalIds from input, handling the legacy goalId field. */ | |
| function resolveGoalIds(data: { goalIds?: string[]; goalId?: string | null }): string[] | undefined { | |
| if (data.goalIds !== undefined) return data.goalIds; | |
| if (data.goalId !== undefined) { | |
| return data.goalId ? [data.goalId] : []; | |
| } | |
| return undefined; | |
| } | |
| function readNonEmptyString(value: unknown): string | null { | |
| if (typeof value !== "string") return null; | |
| const trimmed = value.trim(); | |
| return trimmed.length > 0 ? trimmed : null; | |
| } | |
| function normalizeWorkspaceCwd(value: unknown): string | null { | |
| const cwd = readNonEmptyString(value); | |
| if (!cwd) return null; | |
| return cwd === REPO_ONLY_CWD_SENTINEL ? null : cwd; | |
| } | |
| function deriveNameFromCwd(cwd: string): string { | |
| const normalized = cwd.replace(/[\\/]+$/, ""); | |
| const segments = normalized.split(/[\\/]/).filter(Boolean); | |
| return segments[segments.length - 1] ?? "Local folder"; | |
| } | |
| function deriveNameFromRepoUrl(repoUrl: string): string { | |
| try { | |
| const url = new URL(repoUrl); | |
| const cleanedPath = url.pathname.replace(/\/+$/, ""); | |
| const lastSegment = cleanedPath.split("/").filter(Boolean).pop() ?? ""; | |
| const noGitSuffix = lastSegment.replace(/\.git$/i, ""); | |
| return noGitSuffix || repoUrl; | |
| } catch { | |
| return repoUrl; | |
| } | |
| } | |
| function deriveWorkspaceName(input: { | |
| name?: string | null; | |
| cwd?: string | null; | |
| repoUrl?: string | null; | |
| }) { | |
| const explicit = readNonEmptyString(input.name); | |
| if (explicit) return explicit; | |
| const cwd = readNonEmptyString(input.cwd); | |
| if (cwd) return deriveNameFromCwd(cwd); | |
| const repoUrl = readNonEmptyString(input.repoUrl); | |
| if (repoUrl) return deriveNameFromRepoUrl(repoUrl); | |
| return "Workspace"; | |
| } | |
| export function resolveProjectNameForUniqueShortname( | |
| requestedName: string, | |
| existingProjects: ProjectShortnameRow[], | |
| options?: ResolveProjectNameOptions, | |
| ): string { | |
| const requestedShortname = normalizeProjectUrlKey(requestedName); | |
| if (!requestedShortname) return requestedName; | |
| const usedShortnames = new Set( | |
| existingProjects | |
| .filter((project) => !(options?.excludeProjectId && project.id === options.excludeProjectId)) | |
| .map((project) => normalizeProjectUrlKey(project.name)) | |
| .filter((value): value is string => value !== null), | |
| ); | |
| if (!usedShortnames.has(requestedShortname)) return requestedName; | |
| for (let suffix = 2; suffix < 10_000; suffix += 1) { | |
| const candidateName = `${requestedName} ${suffix}`; | |
| const candidateShortname = normalizeProjectUrlKey(candidateName); | |
| if (candidateShortname && !usedShortnames.has(candidateShortname)) { | |
| return candidateName; | |
| } | |
| } | |
| // Fallback guard for pathological naming collisions. | |
| return `${requestedName} ${Date.now()}`; | |
| } | |
| async function ensureSinglePrimaryWorkspace( | |
| dbOrTx: any, | |
| input: { | |
| companyId: string; | |
| projectId: string; | |
| keepWorkspaceId: string; | |
| }, | |
| ) { | |
| await dbOrTx | |
| .update(projectWorkspaces) | |
| .set({ isPrimary: false, updatedAt: new Date() }) | |
| .where( | |
| and( | |
| eq(projectWorkspaces.companyId, input.companyId), | |
| eq(projectWorkspaces.projectId, input.projectId), | |
| ), | |
| ); | |
| await dbOrTx | |
| .update(projectWorkspaces) | |
| .set({ isPrimary: true, updatedAt: new Date() }) | |
| .where( | |
| and( | |
| eq(projectWorkspaces.companyId, input.companyId), | |
| eq(projectWorkspaces.projectId, input.projectId), | |
| eq(projectWorkspaces.id, input.keepWorkspaceId), | |
| ), | |
| ); | |
| } | |
| export function projectService(db: Db) { | |
| return { | |
| list: async (companyId: string): Promise<ProjectWithGoals[]> => { | |
| const rows = await db.select().from(projects).where(eq(projects.companyId, companyId)); | |
| const withGoals = await attachGoals(db, rows); | |
| return attachWorkspaces(db, withGoals); | |
| }, | |
| listByIds: async (companyId: string, ids: string[]): Promise<ProjectWithGoals[]> => { | |
| const dedupedIds = [...new Set(ids)]; | |
| if (dedupedIds.length === 0) return []; | |
| const rows = await db | |
| .select() | |
| .from(projects) | |
| .where(and(eq(projects.companyId, companyId), inArray(projects.id, dedupedIds))); | |
| const withGoals = await attachGoals(db, rows); | |
| const withWorkspaces = await attachWorkspaces(db, withGoals); | |
| const byId = new Map(withWorkspaces.map((project) => [project.id, project])); | |
| return dedupedIds.map((id) => byId.get(id)).filter((project): project is ProjectWithGoals => Boolean(project)); | |
| }, | |
| getById: async (id: string): Promise<ProjectWithGoals | null> => { | |
| const row = await db | |
| .select() | |
| .from(projects) | |
| .where(eq(projects.id, id)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!row) return null; | |
| const [withGoals] = await attachGoals(db, [row]); | |
| if (!withGoals) return null; | |
| const [enriched] = await attachWorkspaces(db, [withGoals]); | |
| return enriched ?? null; | |
| }, | |
| create: async ( | |
| companyId: string, | |
| data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] }, | |
| ): Promise<ProjectWithGoals> => { | |
| const { goalIds: inputGoalIds, ...projectData } = data; | |
| const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); | |
| // Auto-assign a color from the palette if none provided | |
| if (!projectData.color) { | |
| const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId)); | |
| const usedColors = new Set(existing.map((r) => r.color).filter(Boolean)); | |
| const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length]; | |
| projectData.color = nextColor; | |
| } | |
| const existingProjects = await db | |
| .select({ id: projects.id, name: projects.name }) | |
| .from(projects) | |
| .where(eq(projects.companyId, companyId)); | |
| projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects); | |
| // Also write goalId to the legacy column (first goal or null) | |
| const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null; | |
| const row = await db | |
| .insert(projects) | |
| .values({ ...projectData, goalId: legacyGoalId, companyId }) | |
| .returning() | |
| .then((rows) => rows[0]); | |
| if (ids && ids.length > 0) { | |
| await syncGoalLinks(db, row.id, companyId, ids); | |
| } | |
| const [withGoals] = await attachGoals(db, [row]); | |
| const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : []; | |
| return enriched!; | |
| }, | |
| update: async ( | |
| id: string, | |
| data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] }, | |
| ): Promise<ProjectWithGoals | null> => { | |
| const { goalIds: inputGoalIds, ...projectData } = data; | |
| const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); | |
| const existingProject = await db | |
| .select({ id: projects.id, companyId: projects.companyId, name: projects.name }) | |
| .from(projects) | |
| .where(eq(projects.id, id)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!existingProject) return null; | |
| if (projectData.name !== undefined) { | |
| const existingShortname = normalizeProjectUrlKey(existingProject.name); | |
| const nextShortname = normalizeProjectUrlKey(projectData.name); | |
| if (existingShortname !== nextShortname) { | |
| const existingProjects = await db | |
| .select({ id: projects.id, name: projects.name }) | |
| .from(projects) | |
| .where(eq(projects.companyId, existingProject.companyId)); | |
| projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects, { | |
| excludeProjectId: id, | |
| }); | |
| } | |
| } | |
| // Keep legacy goalId column in sync | |
| const updates: Partial<typeof projects.$inferInsert> = { | |
| ...projectData, | |
| updatedAt: new Date(), | |
| }; | |
| if (ids !== undefined) { | |
| updates.goalId = ids.length > 0 ? ids[0] : null; | |
| } | |
| const row = await db | |
| .update(projects) | |
| .set(updates) | |
| .where(eq(projects.id, id)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| if (!row) return null; | |
| if (ids !== undefined) { | |
| await syncGoalLinks(db, id, row.companyId, ids); | |
| } | |
| const [withGoals] = await attachGoals(db, [row]); | |
| const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : []; | |
| return enriched ?? null; | |
| }, | |
| remove: (id: string) => | |
| db | |
| .delete(projects) | |
| .where(eq(projects.id, id)) | |
| .returning() | |
| .then((rows) => { | |
| const row = rows[0] ?? null; | |
| if (!row) return null; | |
| return { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) }; | |
| }), | |
| listWorkspaces: async (projectId: string): Promise<ProjectWorkspace[]> => { | |
| const rows = await db | |
| .select() | |
| .from(projectWorkspaces) | |
| .where(eq(projectWorkspaces.projectId, projectId)) | |
| .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); | |
| if (rows.length === 0) return []; | |
| const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces( | |
| db, | |
| rows[0]!.companyId, | |
| rows.map((workspace) => workspace.id), | |
| ); | |
| return rows.map((row) => | |
| toWorkspace( | |
| row, | |
| (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), | |
| ), | |
| ); | |
| }, | |
| createWorkspace: async ( | |
| projectId: string, | |
| data: CreateWorkspaceInput, | |
| ): Promise<ProjectWorkspace | null> => { | |
| const project = await db | |
| .select() | |
| .from(projects) | |
| .where(eq(projects.id, projectId)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!project) return null; | |
| const cwd = normalizeWorkspaceCwd(data.cwd); | |
| const repoUrl = readNonEmptyString(data.repoUrl); | |
| const sourceType = readNonEmptyString(data.sourceType) ?? (repoUrl ? "git_repo" : cwd ? "local_path" : "remote_managed"); | |
| const remoteWorkspaceRef = readNonEmptyString(data.remoteWorkspaceRef); | |
| if (sourceType === "remote_managed") { | |
| if (!remoteWorkspaceRef && !repoUrl) return null; | |
| } else if (!cwd && !repoUrl) { | |
| return null; | |
| } | |
| const name = deriveWorkspaceName({ | |
| name: data.name, | |
| cwd, | |
| repoUrl, | |
| }); | |
| const existing = await db | |
| .select() | |
| .from(projectWorkspaces) | |
| .where(eq(projectWorkspaces.projectId, projectId)) | |
| .orderBy(asc(projectWorkspaces.createdAt)) | |
| .then((rows) => rows); | |
| const shouldBePrimary = data.isPrimary === true || existing.length === 0; | |
| const created = await db.transaction(async (tx) => { | |
| if (shouldBePrimary) { | |
| await tx | |
| .update(projectWorkspaces) | |
| .set({ isPrimary: false, updatedAt: new Date() }) | |
| .where( | |
| and( | |
| eq(projectWorkspaces.companyId, project.companyId), | |
| eq(projectWorkspaces.projectId, projectId), | |
| ), | |
| ); | |
| } | |
| const row = await tx | |
| .insert(projectWorkspaces) | |
| .values({ | |
| companyId: project.companyId, | |
| projectId, | |
| name, | |
| sourceType, | |
| cwd: cwd ?? null, | |
| repoUrl: repoUrl ?? null, | |
| repoRef: readNonEmptyString(data.repoRef), | |
| defaultRef: readNonEmptyString(data.defaultRef) ?? readNonEmptyString(data.repoRef), | |
| visibility: readNonEmptyString(data.visibility) ?? "default", | |
| setupCommand: readNonEmptyString(data.setupCommand), | |
| cleanupCommand: readNonEmptyString(data.cleanupCommand), | |
| remoteProvider: readNonEmptyString(data.remoteProvider), | |
| remoteWorkspaceRef, | |
| sharedWorkspaceKey: readNonEmptyString(data.sharedWorkspaceKey), | |
| metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null, | |
| isPrimary: shouldBePrimary, | |
| }) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| return row; | |
| }); | |
| return created ? toWorkspace(created) : null; | |
| }, | |
| updateWorkspace: async ( | |
| projectId: string, | |
| workspaceId: string, | |
| data: UpdateWorkspaceInput, | |
| ): Promise<ProjectWorkspace | null> => { | |
| const existing = await db | |
| .select() | |
| .from(projectWorkspaces) | |
| .where( | |
| and( | |
| eq(projectWorkspaces.id, workspaceId), | |
| eq(projectWorkspaces.projectId, projectId), | |
| ), | |
| ) | |
| .then((rows) => rows[0] ?? null); | |
| if (!existing) return null; | |
| const nextCwd = | |
| data.cwd !== undefined | |
| ? normalizeWorkspaceCwd(data.cwd) | |
| : normalizeWorkspaceCwd(existing.cwd); | |
| const nextRepoUrl = | |
| data.repoUrl !== undefined | |
| ? readNonEmptyString(data.repoUrl) | |
| : readNonEmptyString(existing.repoUrl); | |
| const nextSourceType = | |
| data.sourceType !== undefined | |
| ? readNonEmptyString(data.sourceType) | |
| : readNonEmptyString(existing.sourceType); | |
| const nextRemoteWorkspaceRef = | |
| data.remoteWorkspaceRef !== undefined | |
| ? readNonEmptyString(data.remoteWorkspaceRef) | |
| : readNonEmptyString(existing.remoteWorkspaceRef); | |
| if (nextSourceType === "remote_managed") { | |
| if (!nextRemoteWorkspaceRef && !nextRepoUrl) return null; | |
| } else if (!nextCwd && !nextRepoUrl) { | |
| return null; | |
| } | |
| const patch: Partial<typeof projectWorkspaces.$inferInsert> = { | |
| updatedAt: new Date(), | |
| }; | |
| if (data.name !== undefined) patch.name = deriveWorkspaceName({ name: data.name, cwd: nextCwd, repoUrl: nextRepoUrl }); | |
| if (data.name === undefined && (data.cwd !== undefined || data.repoUrl !== undefined)) { | |
| patch.name = deriveWorkspaceName({ cwd: nextCwd, repoUrl: nextRepoUrl }); | |
| } | |
| if (data.cwd !== undefined) patch.cwd = nextCwd ?? null; | |
| if (data.repoUrl !== undefined) patch.repoUrl = nextRepoUrl ?? null; | |
| if (data.repoRef !== undefined) patch.repoRef = readNonEmptyString(data.repoRef); | |
| if (data.sourceType !== undefined && nextSourceType) patch.sourceType = nextSourceType; | |
| if (data.defaultRef !== undefined) patch.defaultRef = readNonEmptyString(data.defaultRef); | |
| if (data.visibility !== undefined && readNonEmptyString(data.visibility)) { | |
| patch.visibility = readNonEmptyString(data.visibility)!; | |
| } | |
| if (data.setupCommand !== undefined) patch.setupCommand = readNonEmptyString(data.setupCommand); | |
| if (data.cleanupCommand !== undefined) patch.cleanupCommand = readNonEmptyString(data.cleanupCommand); | |
| if (data.remoteProvider !== undefined) patch.remoteProvider = readNonEmptyString(data.remoteProvider); | |
| if (data.remoteWorkspaceRef !== undefined) patch.remoteWorkspaceRef = nextRemoteWorkspaceRef; | |
| if (data.sharedWorkspaceKey !== undefined) patch.sharedWorkspaceKey = readNonEmptyString(data.sharedWorkspaceKey); | |
| if (data.metadata !== undefined) patch.metadata = data.metadata; | |
| const updated = await db.transaction(async (tx) => { | |
| if (data.isPrimary === true) { | |
| await tx | |
| .update(projectWorkspaces) | |
| .set({ isPrimary: false, updatedAt: new Date() }) | |
| .where( | |
| and( | |
| eq(projectWorkspaces.companyId, existing.companyId), | |
| eq(projectWorkspaces.projectId, projectId), | |
| ), | |
| ); | |
| patch.isPrimary = true; | |
| } else if (data.isPrimary === false) { | |
| patch.isPrimary = false; | |
| } | |
| const row = await tx | |
| .update(projectWorkspaces) | |
| .set(patch) | |
| .where(eq(projectWorkspaces.id, workspaceId)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| if (!row) return null; | |
| if (row.isPrimary) return row; | |
| const hasPrimary = await tx | |
| .select({ id: projectWorkspaces.id }) | |
| .from(projectWorkspaces) | |
| .where( | |
| and( | |
| eq(projectWorkspaces.companyId, row.companyId), | |
| eq(projectWorkspaces.projectId, row.projectId), | |
| eq(projectWorkspaces.isPrimary, true), | |
| ), | |
| ) | |
| .then((rows) => rows[0] ?? null); | |
| if (!hasPrimary) { | |
| const nextPrimaryCandidate = await tx | |
| .select({ id: projectWorkspaces.id }) | |
| .from(projectWorkspaces) | |
| .where( | |
| and( | |
| eq(projectWorkspaces.companyId, row.companyId), | |
| eq(projectWorkspaces.projectId, row.projectId), | |
| eq(projectWorkspaces.id, row.id), | |
| ), | |
| ) | |
| .then((rows) => rows[0] ?? null); | |
| const alternateCandidate = await tx | |
| .select({ id: projectWorkspaces.id }) | |
| .from(projectWorkspaces) | |
| .where( | |
| and( | |
| eq(projectWorkspaces.companyId, row.companyId), | |
| eq(projectWorkspaces.projectId, row.projectId), | |
| ), | |
| ) | |
| .orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)) | |
| .then((rows) => rows.find((candidate) => candidate.id !== row.id) ?? null); | |
| await ensureSinglePrimaryWorkspace(tx, { | |
| companyId: row.companyId, | |
| projectId: row.projectId, | |
| keepWorkspaceId: alternateCandidate?.id ?? nextPrimaryCandidate?.id ?? row.id, | |
| }); | |
| const refreshed = await tx | |
| .select() | |
| .from(projectWorkspaces) | |
| .where(eq(projectWorkspaces.id, row.id)) | |
| .then((rows) => rows[0] ?? row); | |
| return refreshed; | |
| } | |
| return row; | |
| }); | |
| return updated ? toWorkspace(updated) : null; | |
| }, | |
| removeWorkspace: async (projectId: string, workspaceId: string): Promise<ProjectWorkspace | null> => { | |
| const existing = await db | |
| .select() | |
| .from(projectWorkspaces) | |
| .where( | |
| and( | |
| eq(projectWorkspaces.id, workspaceId), | |
| eq(projectWorkspaces.projectId, projectId), | |
| ), | |
| ) | |
| .then((rows) => rows[0] ?? null); | |
| if (!existing) return null; | |
| const removed = await db.transaction(async (tx) => { | |
| const row = await tx | |
| .delete(projectWorkspaces) | |
| .where(eq(projectWorkspaces.id, workspaceId)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| if (!row) return null; | |
| if (!row.isPrimary) return row; | |
| const next = await tx | |
| .select() | |
| .from(projectWorkspaces) | |
| .where( | |
| and( | |
| eq(projectWorkspaces.companyId, row.companyId), | |
| eq(projectWorkspaces.projectId, row.projectId), | |
| ), | |
| ) | |
| .orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)) | |
| .limit(1) | |
| .then((rows) => rows[0] ?? null); | |
| if (next) { | |
| await ensureSinglePrimaryWorkspace(tx, { | |
| companyId: row.companyId, | |
| projectId: row.projectId, | |
| keepWorkspaceId: next.id, | |
| }); | |
| } | |
| return row; | |
| }); | |
| return removed ? toWorkspace(removed) : null; | |
| }, | |
| resolveByReference: async (companyId: string, reference: string) => { | |
| const raw = reference.trim(); | |
| if (raw.length === 0) { | |
| return { project: null, ambiguous: false } as const; | |
| } | |
| if (isUuidLike(raw)) { | |
| const row = await db | |
| .select({ id: projects.id, companyId: projects.companyId, name: projects.name }) | |
| .from(projects) | |
| .where(and(eq(projects.id, raw), eq(projects.companyId, companyId))) | |
| .then((rows) => rows[0] ?? null); | |
| if (!row) return { project: null, ambiguous: false } as const; | |
| return { | |
| project: { id: row.id, companyId: row.companyId, urlKey: deriveProjectUrlKey(row.name, row.id) }, | |
| ambiguous: false, | |
| } as const; | |
| } | |
| const urlKey = normalizeProjectUrlKey(raw); | |
| if (!urlKey) { | |
| return { project: null, ambiguous: false } as const; | |
| } | |
| const rows = await db | |
| .select({ id: projects.id, companyId: projects.companyId, name: projects.name }) | |
| .from(projects) | |
| .where(eq(projects.companyId, companyId)); | |
| const matches = rows.filter((row) => deriveProjectUrlKey(row.name, row.id) === urlKey); | |
| if (matches.length === 1) { | |
| const match = matches[0]!; | |
| return { | |
| project: { id: match.id, companyId: match.companyId, urlKey: deriveProjectUrlKey(match.name, match.id) }, | |
| ambiguous: false, | |
| } as const; | |
| } | |
| if (matches.length > 1) { | |
| return { project: null, ambiguous: true } as const; | |
| } | |
| return { project: null, ambiguous: false } as const; | |
| }, | |
| }; | |
| } | |