diff --git a/apps/desktop/src/main/lib/build-channel.ts b/apps/desktop/src/main/lib/build-channel.ts index db19aaf938e..6f1a2b12423 100644 --- a/apps/desktop/src/main/lib/build-channel.ts +++ b/apps/desktop/src/main/lib/build-channel.ts @@ -9,12 +9,3 @@ export function isPrereleaseBuild(): boolean { const prereleaseComponents = prerelease(app.getVersion()); return prereleaseComponents !== null && prereleaseComponents.length > 0; } - -/** - * True on builds that ship to the team, not the public: canary releases and - * unpackaged dev runs (`bun dev` carries a stable-looking version). Gates - * pre-release features without a user-facing setting. - */ -export function isInternalBuild(): boolean { - return isPrereleaseBuild() || !app.isPackaged; -} diff --git a/apps/desktop/src/renderer/lib/v1-migration/projects.ts b/apps/desktop/src/renderer/lib/v1-migration/projects.ts index 7f4c478c195..b98b81aab8a 100644 --- a/apps/desktop/src/renderer/lib/v1-migration/projects.ts +++ b/apps/desktop/src/renderer/lib/v1-migration/projects.ts @@ -124,6 +124,13 @@ export async function importV1Project({ try { const result = await hostClient.project.setup.mutate({ projectId: targetCandidate.id, + // Coordinates from the candidate the host just handed us: a + // `remote` candidate has no local row on this host, so setup + // has no other way to learn the repo. + origin: { + repoCloneUrl: targetCandidate.repoCloneUrl, + name: targetCandidate.name, + }, mode: { kind: "import", repoPath: project.mainRepoPath, diff --git a/packages/cli/src/commands/projects/setup/command.ts b/packages/cli/src/commands/projects/setup/command.ts index 7d0f029d4a3..5449c918098 100644 --- a/packages/cli/src/commands/projects/setup/command.ts +++ b/packages/cli/src/commands/projects/setup/command.ts @@ -1,6 +1,16 @@ import { boolean, CLIError, positional, string } from "@superset/cli-framework"; +import { getHostId } from "@superset/shared/host-info"; import { command } from "../../../lib/command"; -import { requireHostTarget, resolveHostTarget } from "../../../lib/host-target"; +import { + type HostServiceClient, + requireHostTarget, + resolveHostTarget, +} from "../../../lib/host-target"; + +async function findProject(client: HostServiceClient, projectId: string) { + const projects = await client.project.list.query(); + return projects.find((project) => project.id === projectId) ?? null; +} export default command({ description: @@ -22,6 +32,12 @@ export default command({ allowRelocate: boolean().desc( "Permit re-importing at a different path if the project is already set up here", ), + repoUrl: string().desc( + "Repo clone URL, when the target host doesn't know the project yet", + ), + name: string().desc( + "Project name, when the target host doesn't know it yet", + ), }, run: async ({ ctx, args, options }) => { if (options.project !== undefined && args.id !== undefined) { @@ -85,8 +101,40 @@ export default command({ allowRelocate: options.allowRelocate ?? false, }; + // Projects are host-owned: a host that has never seen this project has + // no way to learn its repo. Prefer explicit flags, then this machine's + // own row for the project (the usual "adopt my project onto another + // box" case), and only skip origin when the target already has it. + let origin: { repoCloneUrl: string | null; name?: string } | undefined; + if (options.repoUrl || options.name) { + origin = { + repoCloneUrl: options.repoUrl ?? null, + name: options.name ?? undefined, + }; + } else if (!(await findProject(target.client, projectId))) { + const source = + target.hostId === getHostId() + ? null + : await resolveHostTarget({ + requestedHostId: getHostId(), + organizationId, + userJwt: ctx.bearer, + api: ctx.api, + }) + .then((local) => findProject(local.client, projectId)) + .catch(() => null); + if (!source) { + throw new CLIError( + `Host ${target.hostId} doesn't have project ${projectId}`, + "Pass --repo-url (and --name ) so the host knows what to set up.", + ); + } + origin = { repoCloneUrl: source.repoUrl, name: source.name }; + } + const result = await target.client.project.setup.mutate({ projectId, + origin, mode, }); diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 18088f7faac..4467b237b13 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -198,10 +198,8 @@ export function createApp(options: CreateAppOptions): CreateAppResult { // on pre-existing rows before the main-workspace sweep touches them. void (async () => { await runProjectBackfill({ - api, db, eventBus, - organizationId: config.organizationId, }).catch((err) => { console.warn("[host-service] project backfill failed:", err); }); diff --git a/packages/host-service/src/runtime/project-backfill.ts b/packages/host-service/src/runtime/project-backfill.ts index 9152c5d4f4e..f3ad86e8bea 100644 --- a/packages/host-service/src/runtime/project-backfill.ts +++ b/packages/host-service/src/runtime/project-backfill.ts @@ -7,25 +7,17 @@ import { emitProjectChanged, getLocalProject, } from "../projects/local-project-store"; -import type { ApiClient } from "../types"; export interface ProjectBackfillContext { - api: ApiClient; db: HostDb; eventBus: EventBus; - organizationId: string; } /** * One-time-per-row backfill of `name` for rows that predate host ownership. * Targets rows with the empty-name sentinel; steady-state boots are a single - * indexed query and no cloud calls. - * - * - Legacy cloud row found → copy its name (that's where names lived before - * projects went local-first). - * - Cloud row NOT_FOUND → name from the folder basename. Never delete — - * the repo on disk is the truth for existence. - * - Cloud unreachable → leave the sentinel; retried next boot. + * indexed query. The folder basename is the name — the repo on disk is the + * truth for both existence and identity. */ export async function runProjectBackfill( ctx: ProjectBackfillContext, @@ -39,35 +31,11 @@ export async function runProjectBackfill( let filled = 0; for (const row of pending) { - let name: string; - try { - const cloud = await ctx.api.v2Project.get.query({ - organizationId: ctx.organizationId, - id: row.id, - }); - name = cloud.name; - } catch (err) { - const code = - typeof err === "object" && err !== null - ? ((err as { data?: { code?: string } }).data?.code ?? null) - : null; - if (code !== "NOT_FOUND") { - // Skip rather than abort: one row with a persistent cloud error - // must not starve the rest of the sweep. Retried next boot. - console.warn( - "[project-backfill] cloud lookup failed; retrying next boot", - { projectId: row.id, err }, - ); - continue; - } - name = basename(row.repoPath); - } - - // CAS on the empty-name sentinel: a rename that landed while the - // cloud lookup was in flight must not be clobbered by a stale name. + // CAS on the empty-name sentinel: a rename that landed since the + // select must not be clobbered by a stale name. const result = ctx.db .update(projects) - .set({ name, updatedAt: Date.now() }) + .set({ name: basename(row.repoPath), updatedAt: Date.now() }) .where(and(eq(projects.id, row.id), eq(projects.name, ""))) .run(); if (result.changes === 0) continue; diff --git a/packages/host-service/src/trpc/router/project/project-import.test.ts b/packages/host-service/src/trpc/router/project/project-import.test.ts index 34c7969a175..b97b6c62f69 100644 --- a/packages/host-service/src/trpc/router/project/project-import.test.ts +++ b/packages/host-service/src/trpc/router/project/project-import.test.ts @@ -56,16 +56,6 @@ function createRecordingApiStub() { const calls: string[] = []; const api = { v2Project: { - get: { - query: async () => { - calls.push("v2Project.get"); - const err = new Error("Project not found") as Error & { - data?: { code?: string }; - }; - err.data = { code: "NOT_FOUND" }; - throw err; - }, - }, findByGitHubRemote: { query: async () => { calls.push("v2Project.findByGitHubRemote"); diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index a43cc6ede2b..fb1716eb5e2 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -15,7 +15,6 @@ import { updateLocalProject, } from "../../../projects/local-project-store"; import { createUserSimpleGit } from "../../../runtime/git/simple-git"; -import type { HostServiceContext } from "../../../types"; import { deleteLocalWorkspace } from "../../../workspaces/local-workspace-store"; import { protectedProcedure, router } from "../../index"; import { @@ -62,48 +61,6 @@ export interface FindByPathCandidate { matchesExpected: boolean; } -interface CloudProjectNotFoundCause { - kind: "CLOUD_PROJECT_NOT_FOUND"; -} - -/** Upstream tRPC client rejections cross a bundle boundary and lose their - * prototype, so match on `name` rather than `instanceof`. */ -function isCloudProjectNotFound(error: unknown): boolean { - if (!error || typeof error !== "object") return false; - if ((error as { name?: unknown }).name !== "TRPCClientError") return false; - const code = (error as { data?: { code?: unknown } }).data?.code; - if (typeof code === "string") return code === "NOT_FOUND"; - const message = (error as { message?: unknown }).message; - return typeof message === "string" && /not found/i.test(message); -} - -/** Legacy cloud lookup for `setup`. A not-found upstream is an ordinary - * outcome — the project was deleted cloud-side, or an older client is trying - * to adopt one it cannot see — so it is classified as NOT_FOUND here instead - * of reaching the Sentry middleware as a 500. Network failures, cloud 5xx and - * auth errors still propagate untouched. */ -async function fetchCloudProjectForSetup( - ctx: HostServiceContext, - projectId: string, -) { - try { - return await ctx.api.v2Project.get.query({ - organizationId: ctx.organizationId, - id: projectId, - }); - } catch (error) { - if (!isCloudProjectNotFound(error)) throw error; - const message = (error as { message?: unknown }).message; - throw new TRPCError({ - code: "NOT_FOUND", - message: typeof message === "string" && message ? message : "Not found", - cause: { - kind: "CLOUD_PROJECT_NOT_FOUND", - } satisfies CloudProjectNotFoundCause, - }); - } -} - export const projectRouter = router({ list: protectedProcedure.query(({ ctx }) => { return ctx.db @@ -486,7 +443,9 @@ export const projectRouter = router({ const byId = new Map(); - // Cloud lookup for every URL we know about. + // Cloud lookup for every URL we know about. This is the last + // consumer of the cloud v2_projects table; it dies with the v1 + // importer (the only caller that sets walkAllRemotes). const cloudErrors: { url: string; message: string }[] = []; for (const parsed of urlsToQuery.values()) { try { @@ -608,9 +567,9 @@ export const projectRouter = router({ projectId: z.string().uuid(), /** * Repo coordinates supplied by the caller (from the host - * fan-out) so a local-first project created on ANOTHER host — - * which has no cloud row — can be set up on this device. When - * present, the legacy cloud lookup is skipped entirely. + * fan-out) so a local-first project created on ANOTHER host can + * be set up on this device. Required whenever this host has no + * local row for the project — nothing else knows the repo. */ origin: z .object({ @@ -638,20 +597,23 @@ export const projectRouter = router({ .where(eq(projects.id, input.projectId)) .get(); - // Local-first rows complete setup without any cloud round-trip. - // Caller-supplied origin coordinates come next (cross-host setup - // of local-first projects); the legacy cloud lookup only serves - // old clients adopting a cloud-created project. - const cloudProject = - existing || input.origin - ? null - : await fetchCloudProjectForSetup(ctx, input.projectId); - const origin = cloudProject - ? { repoCloneUrl: cloudProject.repoCloneUrl, name: cloudProject.name } - : { - repoCloneUrl: input.origin?.repoCloneUrl ?? null, - name: input.origin?.name, - }; + // Projects are host-owned, so a local row is this host's only + // self-contained source of repo coordinates. Without one the + // caller must supply them from the host fan-out — there is no + // registry left to ask. + if (!existing && !input.origin) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + input.mode.kind === "clone" + ? `Project ${input.projectId} is not set up on this host. Pass origin.repoCloneUrl to clone it.` + : `Project ${input.projectId} is not set up on this host. Pass origin to import it.`, + }); + } + const origin = { + repoCloneUrl: input.origin?.repoCloneUrl ?? null, + name: input.origin?.name, + }; const allowRelocate = input.mode.kind === "import" && input.mode.allowRelocate; @@ -765,15 +727,6 @@ export const projectRouter = router({ }; } - // Legacy adopt only: tell the old cloud row about the remote - // it was missing. Local-first rows never write cloud-side. - if (cloudProject && !cloudProject.repoCloneUrl && resolved.parsed) { - await ctx.api.v2Project.linkRepoCloneUrl.mutate({ - organizationId: ctx.organizationId, - id: input.projectId, - repoCloneUrl: resolved.parsed.url, - }); - } persistLocalProject(ctx, input.projectId, resolved, { name: origin.name, }); @@ -804,10 +757,6 @@ export const projectRouter = router({ * an error — the local table is what the UI lists from, so a * swallowed failure would toast "Deleted" over a surviving row. * - * 4. Fire-and-forget legacy-cloud v2Project.delete (cascades old cloud - * workspace mirrors). Never awaited — a blackholed network must not - * stall the local commit point. - * * The on-disk repo directory is NEVER auto-removed. The user's code is * their code; deletion of the working tree must be an explicit action, * not a side-effect of project removal. Returns repoPath so a future @@ -861,18 +810,6 @@ export const projectRouter = router({ }); } - void ctx.api.v2Project.delete - .mutate({ - organizationId: ctx.organizationId, - id: input.projectId, - }) - .catch((err) => { - console.warn( - "[project.remove] legacy cloud cleanup failed; frozen mirror row may remain", - { projectId: input.projectId, err }, - ); - }); - return { success: true, repoPath: localProject.repoPath }; }), }); diff --git a/packages/host-service/test/integration/bug-hunt.integration.test.ts b/packages/host-service/test/integration/bug-hunt.integration.test.ts index 2f995105164..afdce9956ae 100644 --- a/packages/host-service/test/integration/bug-hunt.integration.test.ts +++ b/packages/host-service/test/integration/bug-hunt.integration.test.ts @@ -276,11 +276,7 @@ describe("bug-hunt: idempotency + double-fire", () => { }); test("project.remove is idempotent across two calls", async () => { - host = await createTestHost({ - apiOverrides: { - "v2Project.delete.mutate": () => ({ success: true }), - }, - }); + host = await createTestHost(); const id = randomUUID(); const a = await host.trpc.project.remove.mutate({ projectId: id }); const b = await host.trpc.project.remove.mutate({ projectId: id }); diff --git a/packages/host-service/test/integration/project-setup.integration.test.ts b/packages/host-service/test/integration/project-setup.integration.test.ts index 59571e98435..b03020b7b4e 100644 --- a/packages/host-service/test/integration/project-setup.integration.test.ts +++ b/packages/host-service/test/integration/project-setup.integration.test.ts @@ -21,49 +21,43 @@ describe("project.setup error paths", () => { repo.dispose(); }); - test("rejects clone when cloud project has no repoCloneUrl", async () => { - host = await createTestHost({ - apiOverrides: { - "v2Project.get.query": () => ({ id: randomUUID(), repoCloneUrl: null }), - }, - }); + test("rejects clone when origin has no repoCloneUrl", async () => { + host = await createTestHost(); await expect( host.trpc.project.setup.mutate({ projectId: randomUUID(), + origin: { repoCloneUrl: null, name: "No Remote" }, mode: { kind: "clone", parentDir: "/tmp/parent-does-not-matter" }, }), ).rejects.toThrow(/no linked GitHub repository/i); }); - test("rejects clone when cloud repoCloneUrl is unparseable", async () => { - host = await createTestHost({ - apiOverrides: { - "v2Project.get.query": () => ({ - id: randomUUID(), - repoCloneUrl: "not-a-github-url", - }), - }, - }); + test("rejects an unknown project with no origin, in both modes", async () => { + host = await createTestHost(); await expect( host.trpc.project.setup.mutate({ projectId: randomUUID(), mode: { kind: "clone", parentDir: "/tmp/parent-does-not-matter" }, }), - ).rejects.toThrow(/Could not parse GitHub remote/i); + ).rejects.toThrow(/not set up on this host/i); + + await expect( + host.trpc.project.setup.mutate({ + projectId: randomUUID(), + mode: { kind: "import", repoPath: repo.repoPath }, + }), + ).rejects.toThrow(/not set up on this host/i); + + expect( + host.apiCalls.filter((c) => c.path.startsWith("v2Project.")), + ).toEqual([]); }); test("rejects re-pointing existing project to a different path without allowRelocate", async () => { const projectId = randomUUID(); - host = await createTestHost({ - apiOverrides: { - "v2Project.get.query": () => ({ - id: projectId, - repoCloneUrl: "https://github.com/octocat/hello.git", - }), - }, - }); + host = await createTestHost(); // project already set up at repo.repoPath host.db @@ -90,11 +84,7 @@ describe("project.setup error paths", () => { }); test("remove() is idempotent when project doesn't exist", async () => { - host = await createTestHost({ - apiOverrides: { - "v2Project.delete.mutate": () => ({ success: true }), - }, - }); + host = await createTestHost(); const result = await host.trpc.project.remove.mutate({ projectId: randomUUID(), }); diff --git a/packages/host-service/test/integration/workspace-creation-github.integration.test.ts b/packages/host-service/test/integration/workspace-creation-github.integration.test.ts index 342eec67a37..02456cad03a 100644 --- a/packages/host-service/test/integration/workspace-creation-github.integration.test.ts +++ b/packages/host-service/test/integration/workspace-creation-github.integration.test.ts @@ -441,17 +441,10 @@ describe("resolveGithubRepo trusts the live local remote, never the cloud", () = }; beforeEach(async () => { - // Cloud says `somewhere/else`; local remote is `cli/cli`. The - // resolver MUST follow the local remote. + // The resolver MUST follow the local remote (`cli/cli`). Any cloud + // call at all fails the test — the fake throws on unmocked procedures. host = await createTestHost({ githubFactory: async () => fakeOctokit, - apiOverrides: { - "v2Project.get.query": () => ({ - id: projectId, - githubRepository: null, - repoCloneUrl: "https://github.com/somewhere/else.git", - }), - }, }); repoDir = await seedRepoFixture( host, @@ -607,15 +600,7 @@ describe("resolveGithubRepo throws PROJECT_NOT_SETUP when no local clone", () => const projectId = randomUUID(); beforeEach(async () => { - host = await createTestHost({ - apiOverrides: { - "v2Project.get.query": () => ({ - id: projectId, - githubRepository: { owner: "octocat", name: "hello" }, - repoCloneUrl: "https://github.com/octocat/hello.git", - }), - }, - }); + host = await createTestHost(); }); afterEach(async () => { diff --git a/packages/trpc/src/router/automation/automation.ts b/packages/trpc/src/router/automation/automation.ts index d6d35695d72..16a287aacb1 100644 --- a/packages/trpc/src/router/automation/automation.ts +++ b/packages/trpc/src/router/automation/automation.ts @@ -3,7 +3,6 @@ import { automationRuns, automations, v2Hosts, - v2Projects, v2UsersHosts, v2Workspaces, } from "@superset/db/schema"; @@ -108,28 +107,6 @@ async function verifyWorkspaceInOrg( }; } -async function verifyProjectInOrg(organizationId: string, projectId: string) { - const [project] = await db - .select({ id: v2Projects.id, organizationId: v2Projects.organizationId }) - .from(v2Projects) - .where(eq(v2Projects.id, projectId)) - .limit(1); - - // Local-first projects live only in host.db — no cloud row exists and - // none ever will, so absence is not an error. The pin is metadata: real - // authorization is the per-user host access check, and a dangling pin - // surfaces as a readable host-side error at dispatch (PR #5741 model). - // When a legacy cloud row DOES exist, still enforce the org match. - if (!project) return; - - if (project.organizationId !== organizationId) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } -} - export const automationRouter = { versions: automationVersionsRouter, @@ -222,18 +199,12 @@ export const automationRouter = { let targetHostId = input.targetHostId ?? null; let v2ProjectId = input.v2ProjectId ?? null; - if (input.v2WorkspaceId && targetHostId) { - // Denormalized pin: the client resolved the workspace on its host - // and supplies hostId (and projectId, when the workspace has one) - // alongside the id — no workspace registry lookup (hosts own - // workspace records). A null project means the pin is a session - // workspace. Host access and project scoping are still verified - // below; a stale pin surfaces as a host-side error at run time, - // same as today. - if (v2ProjectId) { - await verifyProjectInOrg(organizationId, v2ProjectId); - } - } else if (input.v2WorkspaceId) { + // Denormalized pin: a client that supplies hostId (and projectId, when + // the workspace has one) alongside the workspace id needs no registry + // lookup — hosts own workspace records. A null project means the pin + // is a session workspace. Host access is still verified below; a + // stale pin surfaces as a host-side error at run time, same as today. + if (input.v2WorkspaceId && !targetHostId) { // Legacy clients (pre-denormalization) — resolve via the cloud // table while it still exists; this branch is deleted in R3. const workspace = await verifyWorkspaceInOrg( @@ -254,8 +225,6 @@ export const automationRouter = { }); } v2ProjectId = workspace.projectId; - } else if (v2ProjectId) { - await verifyProjectInOrg(organizationId, v2ProjectId); } // No project and no pin = session automation: each run creates a // project-less session workspace on the host. @@ -364,9 +333,6 @@ export const automationRouter = { // Denormalized pin (see create): the client supplies host (and // project, when the workspace has one) with the workspace id; no // workspace registry lookup. A null project = session pin. - if (input.v2ProjectId) { - await verifyProjectInOrg(organizationId, input.v2ProjectId); - } nextProjectId = input.v2ProjectId ?? null; nextTargetHostId = input.targetHostId; } else if (input.v2WorkspaceId) { @@ -403,11 +369,6 @@ export const automationRouter = { }); } nextTargetHostId = workspace.hostId; - } else if ( - input.v2ProjectId != null && - input.v2ProjectId !== existing.v2ProjectId - ) { - await verifyProjectInOrg(organizationId, input.v2ProjectId); } if ( nextTargetHostId && diff --git a/packages/trpc/src/router/v2-project/v2-project.ts b/packages/trpc/src/router/v2-project/v2-project.ts index a24fff62d44..f9082d173f6 100644 --- a/packages/trpc/src/router/v2-project/v2-project.ts +++ b/packages/trpc/src/router/v2-project/v2-project.ts @@ -4,95 +4,17 @@ import { organizations, v2Projects, } from "@superset/db/schema"; -import { getCurrentTxid } from "@superset/db/utils"; import { parseGitHubRemote } from "@superset/shared/github-remote"; import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; import { del } from "@vercel/blob"; import { and, eq, isNull, sql } from "drizzle-orm"; import { z } from "zod"; -import { posthog } from "../../lib/analytics"; -import { jwtProcedure, protectedProcedure } from "../../trpc"; +import { jwtProcedure } from "../../trpc"; import { verifyOrgOwner } from "../integration/utils"; -import { requireActiveOrgId } from "../utils/active-org"; -import { - requireOrgResourceAccess, - requireOrgScopedResource, -} from "../utils/org-resource-access"; - -async function getScopedGithubRepository( - organizationId: string, - githubRepositoryId: string, -) { - return requireOrgScopedResource( - () => - dbWs.query.githubRepositories.findFirst({ - columns: { - id: true, - organizationId: true, - }, - where: eq(githubRepositories.id, githubRepositoryId), - }), - { - code: "BAD_REQUEST", - message: "GitHub repository not found in this organization", - organizationId, - }, - ); -} - -async function getProjectAccess( - userId: string, - projectId: string, - options?: { - access?: "admin" | "member"; - organizationId?: string; - }, -) { - return requireOrgResourceAccess( - userId, - () => - dbWs.query.v2Projects.findFirst({ - columns: { - id: true, - organizationId: true, - }, - where: eq(v2Projects.id, projectId), - }), - { - access: options?.access, - message: "Project not found", - organizationId: options?.organizationId, - }, - ); -} +import { requireOrgScopedResource } from "../utils/org-resource-access"; export const v2ProjectRouter = { - list: jwtProcedure - .input( - z.object({ - organizationId: z.string().uuid(), - }), - ) - .query(async ({ ctx, input }) => { - if (!ctx.organizationIds.includes(input.organizationId)) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Not a member of this organization", - }); - } - return dbWs - .select({ - id: v2Projects.id, - name: v2Projects.name, - slug: v2Projects.slug, - repoCloneUrl: v2Projects.repoCloneUrl, - githubRepositoryId: v2Projects.githubRepositoryId, - }) - .from(v2Projects) - .where(eq(v2Projects.organizationId, input.organizationId)); - }), - get: jwtProcedure .input( z.object({ @@ -164,126 +86,6 @@ export const v2ProjectRouter = { return { candidates: rows }; }), - create: jwtProcedure - .input( - z.object({ - organizationId: z.string().uuid(), - // Optional client-supplied id. Cloud-last create pipelines - // generate the UUID locally so they can persist - // downstream rows that reference the project before this - // commit-point insert runs. - id: z.string().uuid().optional(), - name: z.string().min(1), - slug: z.string().min(1), - // Optional — empty-mode and local-only imports have no - // remote yet. When provided we store the canonical https - // URL and try to link a matching github_repositories row. - repoCloneUrl: z.string().min(1).optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - if (!ctx.organizationIds.includes(input.organizationId)) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Not a member of this organization", - }); - } - - let canonicalUrl: string | null = null; - let linkedRepoId: string | null = null; - if (input.repoCloneUrl) { - const parsed = parseGitHubRemote(input.repoCloneUrl); - if (!parsed) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Could not parse GitHub remote URL", - }); - } - canonicalUrl = parsed.url; - const fullNameLower = `${parsed.owner}/${parsed.name}`.toLowerCase(); - const repo = await dbWs.query.githubRepositories.findFirst({ - columns: { id: true }, - where: and( - eq(sql`lower(${githubRepositories.fullName})`, fullNameLower), - eq(githubRepositories.organizationId, input.organizationId), - ), - }); - linkedRepoId = repo?.id ?? null; - } - - let project: typeof v2Projects.$inferSelect | undefined; - let txid: number | null = null; - try { - const result = await dbWs.transaction(async (tx) => { - const [inserted] = await tx - .insert(v2Projects) - .values({ - ...(input.id ? { id: input.id } : {}), - organizationId: input.organizationId, - name: input.name, - slug: input.slug, - repoCloneUrl: canonicalUrl, - githubRepositoryId: linkedRepoId, - }) - .returning(); - - if (!inserted) { - return { project: undefined, txid: null }; - } - - const currentTxid = await getCurrentTxid(tx); - return { project: inserted, txid: currentTxid }; - }); - project = result.project; - txid = result.txid; - } catch (err) { - // Drizzle wraps pg errors in a "Failed query:" envelope; the - // real constraint name lives on the underlying cause. Walk - // the chain to find it. - let cur: unknown = err; - let constraint: string | null = null; - while (cur && constraint === null) { - const c = (cur as { constraint?: unknown }).constraint; - if (typeof c === "string") constraint = c; - cur = (cur as { cause?: unknown }).cause; - } - if (constraint === "v2_projects_pkey") { - throw new TRPCError({ - code: "CONFLICT", - message: "Project id already in use", - cause: err, - }); - } - if (constraint === "v2_projects_org_slug_unique") { - throw new TRPCError({ - code: "CONFLICT", - message: "Project slug already exists", - cause: err, - }); - } - throw err; - } - if (!project) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to create project", - }); - } - - posthog.capture({ - distinctId: ctx.userId, - event: "project_opened", - properties: { - project_id: project.id, - organization_id: project.organizationId, - method: input.repoCloneUrl ? "github" : "empty", - surface: "v2", - }, - }); - - return { ...project, txid }; - }), - linkRepoCloneUrl: jwtProcedure .input( z.object({ @@ -353,94 +155,6 @@ export const v2ProjectRouter = { return updated; }), - 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().nullable().optional(), - repoCloneUrl: z.string().min(1).nullable().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - const organizationId = requireActiveOrgId(ctx, "No active organization"); - const project = await getProjectAccess(ctx.session.user.id, input.id, { - organizationId, - }); - - if (input.githubRepositoryId) { - await getScopedGithubRepository( - project.organizationId, - input.githubRepositoryId, - ); - } - - let canonicalRepoCloneUrl: string | null | undefined; - let resolvedGithubRepositoryId: string | null | undefined = - input.githubRepositoryId; - if (input.repoCloneUrl === null) { - canonicalRepoCloneUrl = null; - resolvedGithubRepositoryId = null; - } else if (input.repoCloneUrl !== undefined) { - const parsed = parseGitHubRemote(input.repoCloneUrl); - if (!parsed) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Could not parse GitHub remote URL", - }); - } - canonicalRepoCloneUrl = parsed.url; - if (input.githubRepositoryId === undefined) { - const fullNameLower = `${parsed.owner}/${parsed.name}`.toLowerCase(); - const repo = await dbWs.query.githubRepositories.findFirst({ - columns: { id: true }, - where: and( - eq(sql`lower(${githubRepositories.fullName})`, fullNameLower), - eq(githubRepositories.organizationId, project.organizationId), - ), - }); - resolvedGithubRepositoryId = repo?.id ?? null; - } - } - - const data = { - githubRepositoryId: resolvedGithubRepositoryId, - name: input.name, - slug: input.slug, - repoCloneUrl: canonicalRepoCloneUrl, - }; - 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 result = await dbWs.transaction(async (tx) => { - const [updated] = await tx - .update(v2Projects) - .set(data) - .where(eq(v2Projects.id, project.id)) - .returning(); - - const txid = await getCurrentTxid(tx); - - return { updated, txid }; - }); - const { updated, txid } = result; - if (!updated) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } - return { ...updated, txid }; - }), - delete: jwtProcedure .input( z.object({ diff --git a/plans/host-integration-test.md b/plans/done/host-integration-test.md similarity index 100% rename from plans/host-integration-test.md rename to plans/done/host-integration-test.md