Skip to content

Commit 0bef71c

Browse files
committed
refactor: generalize org listing cache into listOrganizations itself
Instead of patching findProjectsBySlug with a per-callsite cache check, move the caching into listOrganizations() so all 12 callers benefit: - Add org_name column to org_regions table (schema v9) - Split listOrganizations into cached + uncached variants: - listOrganizations() returns from SQLite cache when populated - listOrganizationsUncached() always hits the API (for org list, auth status, resolveEffectiveOrg) - Add getCachedOrganizations() to db/regions.ts - Simplify findProjectsBySlug back to single-path (no getAllOrgRegions fast-path needed — listOrganizations handles it) - Update org list and auth status to use uncached variant - Update resolveEffectiveOrg to use uncached (it explicitly refreshes)
1 parent 580e733 commit 0bef71c

File tree

11 files changed

+168
-123
lines changed

11 files changed

+168
-123
lines changed

AGENTS.md

Lines changed: 35 additions & 46 deletions
Large diffs are not rendered by default.

src/commands/auth/status.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import type { SentryContext } from "../../context.js";
8-
import { listOrganizations } from "../../lib/api-client.js";
8+
import { listOrganizationsUncached } from "../../lib/api-client.js";
99
import { buildCommand } from "../../lib/command.js";
1010
import {
1111
type AuthConfig,
@@ -124,7 +124,7 @@ async function collectDefaults(): Promise<AuthStatusData["defaults"]> {
124124
*/
125125
async function verifyCredentials(): Promise<AuthStatusData["verification"]> {
126126
try {
127-
const orgs = await listOrganizations();
127+
const orgs = await listOrganizationsUncached();
128128
return {
129129
success: true,
130130
organizations: orgs.map((o) => ({ name: o.name, slug: o.slug })),

src/commands/org/list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import type { SentryContext } from "../../context.js";
8-
import { listOrganizations } from "../../lib/api-client.js";
8+
import { listOrganizationsUncached } from "../../lib/api-client.js";
99
import { buildCommand } from "../../lib/command.js";
1010
import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js";
1111
import { getAllOrgRegions } from "../../lib/db/regions.js";
@@ -130,7 +130,7 @@ export const listCommand = buildCommand({
130130
async *func(this: SentryContext, flags: ListFlags) {
131131
applyFreshFlag(flags);
132132

133-
const orgs = await listOrganizations();
133+
const orgs = await listOrganizationsUncached();
134134
const limitedOrgs = orgs.slice(0, flags.limit);
135135

136136
// Check if user has orgs in multiple regions

src/lib/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export {
5656
getUserRegions,
5757
listOrganizations,
5858
listOrganizationsInRegion,
59+
listOrganizationsUncached,
5960
} from "./api/organizations.js";
6061
export {
6162
createProject,

src/lib/api/organizations.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,44 @@ export async function listOrganizationsInRegion(
6565
}
6666

6767
/**
68-
* List all organizations the user has access to across all regions.
69-
* Performs a fan-out to each region and combines results.
70-
* Also caches the region URL for each organization.
68+
* List all organizations, returning cached data when available.
69+
*
70+
* On first call (cold cache), fetches from the API and populates the cache.
71+
* On subsequent calls, returns organizations from the SQLite cache without
72+
* any HTTP requests. This avoids the expensive getUserRegions() +
73+
* listOrganizationsInRegion() fan-out (~800ms) on every command.
74+
*
75+
* Callers that need guaranteed-fresh data (e.g., `org list`, `auth status`)
76+
* should use {@link listOrganizationsUncached} instead.
7177
*/
7278
export async function listOrganizations(): Promise<SentryOrganization[]> {
79+
const { getCachedOrganizations } = await import("../db/regions.js");
80+
81+
const cached = await getCachedOrganizations();
82+
if (cached.length > 0) {
83+
return cached.map((org) => ({
84+
id: org.id,
85+
slug: org.slug,
86+
name: org.name,
87+
}));
88+
}
89+
90+
// Cache miss — fetch from API (also populates cache for next time)
91+
return listOrganizationsUncached();
92+
}
93+
94+
/**
95+
* List all organizations by fetching from the API, bypassing the cache.
96+
*
97+
* Performs a fan-out to each region and combines results.
98+
* Populates the org_regions cache with slug, region URL, org ID, and org name.
99+
*
100+
* Use this when you need guaranteed-fresh data (e.g., `org list`, `auth status`).
101+
* Most callers should use {@link listOrganizations} instead.
102+
*/
103+
export async function listOrganizationsUncached(): Promise<
104+
SentryOrganization[]
105+
> {
73106
const { setOrgRegions } = await import("../db/regions.js");
74107

75108
// Self-hosted instances may not have the regions endpoint (404)
@@ -102,6 +135,7 @@ export async function listOrganizations(): Promise<SentryOrganization[]> {
102135
slug: r.org.slug,
103136
regionUrl: r.regionUrl,
104137
orgId: r.org.id,
138+
orgName: r.org.name,
105139
}));
106140
await setOrgRegions(regionEntries);
107141

src/lib/api/projects.ts

Lines changed: 26 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import type {
1818
SentryProject,
1919
} from "../../types/index.js";
2020

21-
import { getAllOrgRegions } from "../db/regions.js";
2221
import { type AuthGuardSuccess, withAuthGuard } from "../errors.js";
2322
import { logger } from "../logger.js";
2423
import { getApiBaseUrl } from "../sentry-client.js";
@@ -179,62 +178,35 @@ export async function findProjectsBySlug(
179178
): Promise<ProjectSearchResult> {
180179
const isNumericId = isAllDigits(projectSlug);
181180

182-
/** Search for the project in a list of org slugs (parallel getProject per org). */
183-
const searchOrgs = (orgSlugs: string[]) =>
184-
Promise.all(
185-
orgSlugs.map((orgSlug) =>
186-
withAuthGuard(async () => {
187-
const project = await getProject(orgSlug, projectSlug);
188-
// The API accepts project_id_or_slug, so a numeric input could
189-
// resolve by ID instead of slug. When the input is all digits,
190-
// accept the match (the user passed a numeric project ID).
191-
// For non-numeric inputs, verify the slug actually matches to
192-
// avoid false positives from coincidental ID collisions.
193-
// Note: Sentry enforces that project slugs must start with a letter,
194-
// so an all-digits input can only ever be a numeric ID, never a slug.
195-
if (!isNumericId && project.slug !== projectSlug) {
196-
return null;
197-
}
198-
return { ...project, orgSlug };
199-
})
200-
)
201-
);
202-
203-
const extractProjects = (
204-
results: Awaited<ReturnType<typeof searchOrgs>>
205-
): ProjectWithOrg[] =>
206-
results
207-
.filter((r): r is AuthGuardSuccess<ProjectWithOrg | null> => r.ok)
208-
.map((r) => r.value)
209-
.filter((v): v is ProjectWithOrg => v !== null);
210-
211-
// Fast path: use cached org slugs to skip the expensive listOrganizations()
212-
// round-trip (getUserRegions + listOrganizationsInRegion).
213-
// The cache is expected to be complete: callers reach this function via the
214-
// project-search format (e.g. "buybridge-5BS") whose short suffix only comes
215-
// from `sentry issue list` output — which already called listOrganizations()
216-
// and populated org_regions with all accessible orgs.
217-
const cachedOrgRegions = await getAllOrgRegions();
218-
const cachedSlugs = [...cachedOrgRegions.keys()];
219-
if (cachedSlugs.length > 0) {
220-
const cachedResults = await searchOrgs(cachedSlugs);
221-
const cachedProjects = extractProjects(cachedResults);
222-
if (cachedProjects.length > 0) {
223-
return { projects: cachedProjects, orgs: [] };
224-
}
225-
// Fall through: project might be in a new org not yet cached
226-
}
227-
228-
// Full listing: fetch all orgs from API, then skip already-searched cached orgs
181+
// listOrganizations() returns from cache when populated, avoiding
182+
// the expensive getUserRegions() + listOrganizationsInRegion() fan-out.
229183
const orgs = await listOrganizations();
230-
const cachedSet = new Set(cachedSlugs);
231-
const newOrgSlugs = orgs
232-
.filter((o) => !cachedSet.has(o.slug))
233-
.map((o) => o.slug);
234-
const searchResults = await searchOrgs(newOrgSlugs);
184+
185+
// Direct lookup in parallel — one API call per org instead of paginating all projects
186+
const searchResults = await Promise.all(
187+
orgs.map((org) =>
188+
withAuthGuard(async () => {
189+
const project = await getProject(org.slug, projectSlug);
190+
// The API accepts project_id_or_slug, so a numeric input could
191+
// resolve by ID instead of slug. When the input is all digits,
192+
// accept the match (the user passed a numeric project ID).
193+
// For non-numeric inputs, verify the slug actually matches to
194+
// avoid false positives from coincidental ID collisions.
195+
// Note: Sentry enforces that project slugs must start with a letter,
196+
// so an all-digits input can only ever be a numeric ID, never a slug.
197+
if (!isNumericId && project.slug !== projectSlug) {
198+
return null;
199+
}
200+
return { ...project, orgSlug: org.slug };
201+
})
202+
)
203+
);
235204

236205
return {
237-
projects: extractProjects(searchResults),
206+
projects: searchResults
207+
.filter((r): r is AuthGuardSuccess<ProjectWithOrg | null> => r.ok)
208+
.map((r) => r.value)
209+
.filter((v): v is ProjectWithOrg => v !== null),
238210
orgs,
239211
};
240212
}

src/lib/db/regions.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,17 @@ const TABLE = "org_regions";
1818
type OrgRegionRow = {
1919
org_slug: string;
2020
org_id: string | null;
21+
org_name: string | null;
2122
region_url: string;
2223
updated_at: number;
2324
};
2425

25-
/** Entry for batch-caching org regions with optional numeric ID. */
26+
/** Entry for batch-caching org regions with optional metadata. */
2627
export type OrgRegionEntry = {
2728
slug: string;
2829
regionUrl: string;
2930
orgId?: string;
31+
orgName?: string;
3032
};
3133

3234
/**
@@ -119,6 +121,9 @@ export async function setOrgRegions(entries: OrgRegionEntry[]): Promise<void> {
119121
if (entry.orgId) {
120122
row.org_id = entry.orgId;
121123
}
124+
if (entry.orgName) {
125+
row.org_name = entry.orgName;
126+
}
122127
runUpsert(db, TABLE, row, ["org_slug"]);
123128
}
124129
})();
@@ -147,3 +152,35 @@ export async function getAllOrgRegions(): Promise<Map<string, string>> {
147152

148153
return new Map(rows.map((row) => [row.org_slug, row.region_url]));
149154
}
155+
156+
/** Cached org entry with the fields needed to reconstruct a SentryOrganization. */
157+
export type CachedOrg = {
158+
slug: string;
159+
id: string;
160+
name: string;
161+
};
162+
163+
/**
164+
* Get all cached organizations with id, slug, and name.
165+
*
166+
* Returns organizations that have all three fields populated in the cache.
167+
* Rows with missing `org_id` or `org_name` (from before schema v9) are
168+
* excluded — callers should fall back to the API when the result is empty.
169+
*
170+
* @returns Array of cached org entries, or empty if cache is cold/incomplete
171+
*/
172+
export async function getCachedOrganizations(): Promise<CachedOrg[]> {
173+
const db = getDatabase();
174+
const rows = db
175+
.query(
176+
`SELECT org_slug, org_id, org_name FROM ${TABLE} WHERE org_id IS NOT NULL AND org_name IS NOT NULL`
177+
)
178+
.all() as Pick<OrgRegionRow, "org_slug" | "org_id" | "org_name">[];
179+
180+
return rows.map((row) => ({
181+
slug: row.org_slug,
182+
// org_id and org_name are guaranteed non-null by the WHERE clause
183+
id: row.org_id as string,
184+
name: row.org_name as string,
185+
}));
186+
}

src/lib/db/schema.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { Database } from "bun:sqlite";
1515
import { stringifyUnknown } from "../errors.js";
1616
import { logger } from "../logger.js";
1717

18-
export const CURRENT_SCHEMA_VERSION = 8;
18+
export const CURRENT_SCHEMA_VERSION = 9;
1919

2020
/** Environment variable to disable auto-repair */
2121
const NO_AUTO_REPAIR_ENV = "SENTRY_CLI_NO_AUTO_REPAIR";
@@ -166,6 +166,7 @@ export const TABLE_SCHEMAS: Record<string, TableSchema> = {
166166
columns: {
167167
org_slug: { type: "TEXT", primaryKey: true },
168168
org_id: { type: "TEXT", addedInVersion: 8 },
169+
org_name: { type: "TEXT", addedInVersion: 9 },
169170
region_url: { type: "TEXT", notNull: true },
170171
updated_at: {
171172
type: "INTEGER",
@@ -721,6 +722,11 @@ export function runMigrations(db: Database): void {
721722
addColumnIfMissing(db, "org_regions", "org_id", "TEXT");
722723
}
723724

725+
// Migration 8 -> 9: Add org_name column to org_regions for cached org listing
726+
if (currentVersion < 9) {
727+
addColumnIfMissing(db, "org_regions", "org_name", "TEXT");
728+
}
729+
724730
if (currentVersion < CURRENT_SCHEMA_VERSION) {
725731
db.query("UPDATE schema_version SET version = ?").run(
726732
CURRENT_SCHEMA_VERSION

src/lib/region.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,13 @@ export async function resolveEffectiveOrg(orgSlug: string): Promise<string> {
162162
return fromCache;
163163
}
164164

165-
// Cache is cold or identifier is unknown — refresh the org list.
166-
// listOrganizations() populates org_regions with slug, region, and org_id.
165+
// Cache is cold or identifier is unknown — refresh the org list from API.
166+
// listOrganizationsUncached() populates org_regions with slug, region, org_id, and name.
167167
// Any error (auth failure, network error, etc.) falls back to the original
168168
// slug; the downstream API call will produce a relevant error if needed.
169169
try {
170-
const { listOrganizations } = await import("./api-client.js");
171-
await listOrganizations();
170+
const { listOrganizationsUncached } = await import("./api-client.js");
171+
await listOrganizationsUncached();
172172
} catch {
173173
return orgSlug;
174174
}

test/commands/auth/status.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ describe("statusCommand.func", () => {
8181
getDefaultOrgSpy = spyOn(dbDefaults, "getDefaultOrganization");
8282
getDefaultProjectSpy = spyOn(dbDefaults, "getDefaultProject");
8383
getDbPathSpy = spyOn(dbIndex, "getDbPath");
84-
listOrgsSpy = spyOn(apiClient, "listOrganizations");
84+
listOrgsSpy = spyOn(apiClient, "listOrganizationsUncached");
8585

8686
// Defaults that most tests override
8787
getUserInfoSpy.mockReturnValue(null);

0 commit comments

Comments
 (0)