Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions apps/desktop/src/main/lib/build-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer/lib/v1-migration/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 49 additions & 1 deletion packages/cli/src/commands/projects/setup/command.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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) {
Expand Down Expand Up @@ -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 <url> (and --name <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,
});

Expand Down
2 changes: 0 additions & 2 deletions packages/host-service/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
42 changes: 5 additions & 37 deletions packages/host-service/src/runtime/project-backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
109 changes: 23 additions & 86 deletions packages/host-service/src/trpc/router/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -486,7 +443,9 @@ export const projectRouter = router({

const byId = new Map<string, FindByPathCandidate>();

// 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 {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 };
}),
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading
Loading