Skip to content

Commit b58d987

Browse files
refactor: use prefetch module instead of threading promises
Replace the BgOrgDetection type and promise-threading through WizardOptions with a simpler warm/consume prefetch module. init.ts calls warmOrgDetection() to start background work, and local-ops.ts transparently consumes the cached results via resolveOrgPrefetched() and listOrgsPrefetched() — no plumbing needed between the two.
1 parent 5a8d48f commit b58d987

File tree

5 files changed

+118
-97
lines changed

5 files changed

+118
-97
lines changed

src/commands/init.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,14 @@
1919

2020
import path from "node:path";
2121
import type { SentryContext } from "../context.js";
22-
import { listOrganizations } from "../lib/api-client.js";
2322
import { looksLikePath, parseOrgProjectArg } from "../lib/arg-parsing.js";
2423
import { buildCommand } from "../lib/command.js";
2524
import { ContextError } from "../lib/errors.js";
26-
import type { BgOrgDetection } from "../lib/init/types.js";
25+
import { warmOrgDetection } from "../lib/init/prefetch.js";
2726
import { runWizard } from "../lib/init/wizard-runner.js";
2827
import { validateResourceId } from "../lib/input-validation.js";
2928
import { logger } from "../lib/logger.js";
30-
import { resolveOrg, resolveProjectBySlug } from "../lib/resolve-target.js";
29+
import { resolveProjectBySlug } from "../lib/resolve-target.js";
3130

3231
const log = logger.withTag("init");
3332

@@ -231,15 +230,11 @@ export const initCommand = buildCommand<
231230
await resolveTarget(targetArg);
232231

233232
// 5. Start background org detection when org is not yet known.
234-
// These promises run concurrently with the preamble user-interaction
235-
// (experimental confirm, git status check) so the results are ready
236-
// by the time the wizard needs to create a Sentry project.
237-
let bgOrgDetection: BgOrgDetection | undefined;
233+
// The prefetch runs concurrently with the preamble, the wizard startup,
234+
// and all early suspend/resume rounds — by the time the wizard needs the
235+
// org (inside createSentryProject), the result is already cached.
238236
if (!explicitOrg) {
239-
bgOrgDetection = {
240-
orgPromise: resolveOrg({ cwd: targetDir }).catch(() => null),
241-
orgListPromise: listOrganizations().catch(() => []),
242-
};
237+
warmOrgDetection(targetDir);
243238
}
244239

245240
// 6. Run the wizard
@@ -251,7 +246,6 @@ export const initCommand = buildCommand<
251246
team: flags.team,
252247
org: explicitOrg,
253248
project: explicitProject,
254-
bgOrgDetection,
255249
});
256250
},
257251
});

src/lib/init/local-ops.ts

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,8 @@ import { spawn } from "node:child_process";
99
import fs from "node:fs";
1010
import path from "node:path";
1111
import { isCancel, select } from "@clack/prompts";
12-
import {
13-
createProject,
14-
getProject,
15-
listOrganizations,
16-
tryGetPrimaryDsn,
17-
} from "../api-client.js";
12+
import { createProject, getProject, tryGetPrimaryDsn } from "../api-client.js";
1813
import { ApiError } from "../errors.js";
19-
import { resolveOrg } from "../resolve-target.js";
2014
import { resolveOrCreateTeam } from "../resolve-team.js";
2115
import { buildProjectUrl } from "../sentry-urls.js";
2216
import { slugify } from "../utils.js";
@@ -25,9 +19,9 @@ import {
2519
MAX_FILE_BYTES,
2620
MAX_OUTPUT_BYTES,
2721
} from "./constants.js";
22+
import { listOrgsPrefetched, resolveOrgPrefetched } from "./prefetch.js";
2823
import type {
2924
ApplyPatchsetPayload,
30-
BgOrgDetection,
3125
CreateSentryProjectPayload,
3226
DirEntry,
3327
FileExistsBatchPayload,
@@ -660,26 +654,24 @@ function applyPatchset(
660654
* Resolve the org slug from local config, env vars, or by listing the user's
661655
* organizations from the API as a fallback.
662656
*
663-
* When `bgDetection` is provided, its pre-started promises are awaited instead
664-
* of making fresh calls — this eliminates the 2-5 s cold-start DSN scan that
665-
* would otherwise block here, because the scan was already running in the
666-
* background during the preamble user-interaction phase.
657+
* Uses the prefetch-aware helpers from `./prefetch.ts` — if
658+
* {@link warmOrgDetection} was called earlier (by `init.ts`), the results are
659+
* already cached and this function returns near-instantly. Otherwise it falls
660+
* back to live calls transparently.
667661
*
668662
* @returns The org slug on success, or a {@link LocalOpResult} error to return early.
669663
*/
670664
async function resolveOrgSlug(
671665
cwd: string,
672-
yes: boolean,
673-
bgDetection?: BgOrgDetection
666+
yes: boolean
674667
): Promise<string | LocalOpResult> {
675-
// Use the pre-fetched org detection if available, otherwise run live
676-
const resolved = await (bgDetection?.orgPromise ?? resolveOrg({ cwd }));
668+
const resolved = await resolveOrgPrefetched(cwd);
677669
if (resolved) {
678670
return resolved.org;
679671
}
680672

681-
// Fallback: use pre-fetched org list if available, otherwise fetch live
682-
const orgs = await (bgDetection?.orgListPromise ?? listOrganizations());
673+
// Fallback: list user's organizations from API (prefetch-aware)
674+
const orgs = await listOrgsPrefetched();
683675
if (orgs.length === 0) {
684676
return {
685677
ok: false,
@@ -779,11 +771,7 @@ async function createSentryProject(
779771
if (options.org) {
780772
orgSlug = options.org;
781773
} else {
782-
const orgResult = await resolveOrgSlug(
783-
payload.cwd,
784-
options.yes,
785-
options.bgOrgDetection
786-
);
774+
const orgResult = await resolveOrgSlug(payload.cwd, options.yes);
787775
if (typeof orgResult !== "string") {
788776
return orgResult;
789777
}

src/lib/init/prefetch.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Background Org Detection Prefetch
3+
*
4+
* Provides a warm/consume pattern for org resolution during `sentry init`.
5+
* Call {@link warmOrgDetection} early (before the preamble) to start DSN
6+
* scanning and org-list fetching in the background. Later, call
7+
* {@link resolveOrgPrefetched} or {@link listOrgsPrefetched} — they return
8+
* the cached result instantly if the background work has finished, or
9+
* fall back to a live call if it hasn't been warmed.
10+
*
11+
* This keeps the hot path (inside the wizard's `createSentryProject`)
12+
* free of explicit promise-threading — callers just swap in the
13+
* prefetch-aware functions.
14+
*/
15+
16+
import { listOrganizations } from "../api-client.js";
17+
import type { ResolvedOrg } from "../resolve-target.js";
18+
import { resolveOrg } from "../resolve-target.js";
19+
20+
type OrgResult = ResolvedOrg | null;
21+
type OrgListResult = Array<{ id: string; slug: string; name: string }>;
22+
23+
let orgPromise: Promise<OrgResult> | undefined;
24+
let orgListPromise: Promise<OrgListResult> | undefined;
25+
26+
/**
27+
* Kick off background org detection and org-list fetching.
28+
*
29+
* Safe to call multiple times — subsequent calls are no-ops.
30+
* Errors are silently swallowed so the foreground path can retry.
31+
*/
32+
export function warmOrgDetection(cwd: string): void {
33+
if (!orgPromise) {
34+
orgPromise = resolveOrg({ cwd }).catch(() => null);
35+
}
36+
if (!orgListPromise) {
37+
orgListPromise = listOrganizations().catch(() => []);
38+
}
39+
}
40+
41+
/**
42+
* Resolve the org, using the prefetched result if available.
43+
* Falls back to a live call when {@link warmOrgDetection} was not called.
44+
*/
45+
export function resolveOrgPrefetched(cwd: string): Promise<OrgResult> {
46+
if (orgPromise) {
47+
return orgPromise;
48+
}
49+
return resolveOrg({ cwd }).catch(() => null);
50+
}
51+
52+
/**
53+
* List organizations, using the prefetched result if available.
54+
* Falls back to a live call when {@link warmOrgDetection} was not called.
55+
*/
56+
export function listOrgsPrefetched(): Promise<OrgListResult> {
57+
if (orgListPromise) {
58+
return orgListPromise;
59+
}
60+
return listOrganizations().catch(() => []);
61+
}
62+
63+
/**
64+
* Reset prefetch state. Used by tests to prevent cross-test leakage.
65+
*/
66+
export function resetPrefetch(): void {
67+
orgPromise = undefined;
68+
orgListPromise = undefined;
69+
}

src/lib/init/types.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,6 @@ export type DirEntry = {
44
type: "file" | "directory";
55
};
66

7-
/**
8-
* Pre-started background promises for org detection.
9-
*
10-
* Fired early in the `sentry init` flow (before the preamble user-interaction
11-
* phase) so that DSN scanning and org-list fetching overlap with the user
12-
* confirming the experimental warning and git status checks. When the wizard
13-
* later needs the org (inside `createSentryProject`), the results are already
14-
* available — eliminating 2-5 s of cold-start latency.
15-
*/
16-
export type BgOrgDetection = {
17-
/** Resolves to the auto-detected org (from DSN/env/config), or `null` if none found. */
18-
orgPromise: Promise<{ org: string; detectedFrom?: string } | null>;
19-
/** Resolves to the user's accessible organizations, or `[]` on failure. */
20-
orgListPromise: Promise<Array<{ id: string; slug: string; name: string }>>;
21-
};
22-
237
export type WizardOptions = {
248
directory: string;
259
yes: boolean;
@@ -31,8 +15,6 @@ export type WizardOptions = {
3115
org?: string;
3216
/** Explicit project name from CLI arg (e.g., "my-app" from "acme/my-app"). Overrides wizard-detected name. */
3317
project?: string;
34-
/** Pre-started org detection promises. Skipped when org is already explicit. */
35-
bgOrgDetection?: BgOrgDetection;
3618
};
3719

3820
// Local-op suspend payloads

test/commands/init.test.ts

Lines changed: 32 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
1010
import path from "node:path";
1111
import { initCommand } from "../../src/commands/init.js";
12-
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
13-
import * as apiClient from "../../src/lib/api-client.js";
1412
import { ContextError } from "../../src/lib/errors.js";
1513
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
14+
import * as prefetchNs from "../../src/lib/init/prefetch.js";
15+
import { resetPrefetch } from "../../src/lib/init/prefetch.js";
16+
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
1617
import * as wizardRunner from "../../src/lib/init/wizard-runner.js";
1718
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
1819
import * as resolveTarget from "../../src/lib/resolve-target.js";
@@ -21,8 +22,7 @@ import * as resolveTarget from "../../src/lib/resolve-target.js";
2122
let capturedArgs: Record<string, unknown> | undefined;
2223
let runWizardSpy: ReturnType<typeof spyOn>;
2324
let resolveProjectSpy: ReturnType<typeof spyOn>;
24-
let resolveOrgSpy: ReturnType<typeof spyOn>;
25-
let listOrgsSpy: ReturnType<typeof spyOn>;
25+
let warmSpy: ReturnType<typeof spyOn>;
2626

2727
const func = (await initCommand.loader()) as unknown as (
2828
this: {
@@ -49,6 +49,7 @@ const DEFAULT_FLAGS = { yes: true, "dry-run": false } as const;
4949

5050
beforeEach(() => {
5151
capturedArgs = undefined;
52+
resetPrefetch();
5253
runWizardSpy = spyOn(wizardRunner, "runWizard").mockImplementation(
5354
(args: Record<string, unknown>) => {
5455
capturedArgs = args;
@@ -63,21 +64,19 @@ beforeEach(() => {
6364
org: "resolved-org",
6465
project: slug,
6566
}));
66-
// Mock resolveOrg and listOrganizations to prevent real DSN scans / API calls
67-
// from the background detection promises fired in init's func()
68-
resolveOrgSpy = spyOn(resolveTarget, "resolveOrg").mockImplementation(
69-
async () => null
70-
);
71-
listOrgsSpy = spyOn(apiClient, "listOrganizations").mockImplementation(
72-
async () => []
67+
// Spy on warmOrgDetection to verify it's called/skipped appropriately.
68+
// The mock prevents real DSN scans and API calls from the background.
69+
warmSpy = spyOn(prefetchNs, "warmOrgDetection").mockImplementation(
70+
// biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op mock
71+
() => {}
7372
);
7473
});
7574

7675
afterEach(() => {
7776
runWizardSpy.mockRestore();
7877
resolveProjectSpy.mockRestore();
79-
resolveOrgSpy.mockRestore();
80-
listOrgsSpy.mockRestore();
78+
warmSpy.mockRestore();
79+
resetPrefetch();
8180
});
8281

8382
describe("init command func", () => {
@@ -342,55 +341,44 @@ describe("init command func", () => {
342341
// ── Background org detection ──────────────────────────────────────────
343342

344343
describe("background org detection", () => {
345-
test("passes bgOrgDetection when org is not explicit", async () => {
344+
test("warms prefetch when org is not explicit", async () => {
346345
const ctx = makeContext();
347346
await func.call(ctx, DEFAULT_FLAGS);
348-
const bg = capturedArgs?.bgOrgDetection as
349-
| { orgPromise: Promise<unknown>; orgListPromise: Promise<unknown> }
350-
| undefined;
351-
expect(bg).toBeDefined();
352-
expect(bg?.orgPromise).toBeInstanceOf(Promise);
353-
expect(bg?.orgListPromise).toBeInstanceOf(Promise);
347+
expect(warmSpy).toHaveBeenCalledTimes(1);
348+
expect(warmSpy).toHaveBeenCalledWith("/projects/app");
354349
});
355350

356-
test("fires resolveOrg for background detection", async () => {
357-
const ctx = makeContext();
358-
await func.call(ctx, DEFAULT_FLAGS);
359-
expect(resolveOrgSpy).toHaveBeenCalled();
360-
});
361-
362-
test("fires listOrganizations for background detection", async () => {
363-
const ctx = makeContext();
364-
await func.call(ctx, DEFAULT_FLAGS);
365-
expect(listOrgsSpy).toHaveBeenCalled();
366-
});
367-
368-
test("omits bgOrgDetection when org is explicit", async () => {
351+
test("skips prefetch when org is explicit", async () => {
369352
const ctx = makeContext();
370353
await func.call(ctx, DEFAULT_FLAGS, "acme/my-app");
371-
expect(capturedArgs?.bgOrgDetection).toBeUndefined();
354+
expect(warmSpy).not.toHaveBeenCalled();
372355
});
373356

374-
test("omits bgOrgDetection when org-only is explicit", async () => {
357+
test("skips prefetch when org-only is explicit", async () => {
375358
const ctx = makeContext();
376359
await func.call(ctx, DEFAULT_FLAGS, "acme/");
377-
expect(capturedArgs?.bgOrgDetection).toBeUndefined();
360+
expect(warmSpy).not.toHaveBeenCalled();
378361
});
379362

380-
test("passes bgOrgDetection for bare slug (project-search resolves org)", async () => {
381-
// Even though resolveProjectBySlug finds an org, the bgDetection
382-
// is skipped because explicitOrg is set from the resolved result
363+
test("skips prefetch for bare slug (project-search resolves org)", async () => {
383364
const ctx = makeContext();
384365
await func.call(ctx, DEFAULT_FLAGS, "my-app");
385-
// resolveProjectBySlug returns { org: "resolved-org", project: "my-app" }
386-
// so explicitOrg is set → bgOrgDetection should be undefined
387-
expect(capturedArgs?.bgOrgDetection).toBeUndefined();
366+
// resolveProjectBySlug returns { org: "resolved-org" } → org is known
367+
expect(warmSpy).not.toHaveBeenCalled();
388368
});
389369

390-
test("passes bgOrgDetection for path-only arg", async () => {
370+
test("warms prefetch for path-only arg", async () => {
391371
const ctx = makeContext();
392372
await func.call(ctx, DEFAULT_FLAGS, "./subdir");
393-
expect(capturedArgs?.bgOrgDetection).toBeDefined();
373+
expect(warmSpy).toHaveBeenCalledTimes(1);
374+
});
375+
376+
test("warms prefetch with resolved directory path", async () => {
377+
const ctx = makeContext("/projects/app");
378+
await func.call(ctx, DEFAULT_FLAGS, "./subdir");
379+
expect(warmSpy).toHaveBeenCalledWith(
380+
path.resolve("/projects/app", "./subdir")
381+
);
394382
});
395383
});
396384
});

0 commit comments

Comments
 (0)