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 | null; isPrimary?: boolean; }; type UpdateWorkspaceInput = Partial; interface ProjectWithGoals extends Omit { 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 { 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(); 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 | 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 | 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, ): 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 { 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(); 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 => { 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 => { 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 => { 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 & { goalIds?: string[] }, ): Promise => { 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 & { goalIds?: string[] }, ): Promise => { 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 = { ...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 => { 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 => { 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 | 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 => { 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 = { 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 => { 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; }, }; }