860 lines
30 KiB
TypeScript
860 lines
30 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|