Skip to content

Commit 85309f1

Browse files
committed
perf(delta-upgrade): lazy chain walk, manifest cache, GHCR retry
Three performance optimizations for the nightly delta upgrade system: ## Fix 1: Lazy chain resolution (replaces eager graph build) Replace buildNightlyPatchGraph + walkNightlyChain with a lazy approach in resolveNightlyChain: - List all patch tags (1 HTTP call, kept) - Filter tags to only those between currentVersion and targetVersion using Bun.semver.order() — since patches are sequential, this gives the exact chain without fetching any manifests - Sort filtered tags by version - Fetch only the 1-2 manifests in the chain (instead of all N) This reduces manifest fetches from ~14 to 1-2 for typical upgrades. New exported function filterAndSortChainTags is the core pure logic, with comprehensive tests replacing the old walkNightlyChain tests. ## Fix 2: Manifest caching in SQLite Add manifest_cache table (schema v8→v9) to cache immutable GHCR patch manifests. On repeat upgrades or retries, cached manifests are served instantly from SQLite. - New module: src/lib/db/manifest-cache.ts (getCachedManifest, setCachedManifest, getCachedManifests) - Cache reads are wrapped in try-catch so failures don't block upgrades - Cache writes are fire-and-forget (non-blocking) - Uses tryRepairAndRetry pattern for auto-repair on schema errors Tracing span attributes: chain.tags_total, chain.tags_filtered, chain.cached_count, chain.fetched_count. ## Fix 3: Retry with timeout for GHCR requests Add fetchWithRetry helper in ghcr.ts: - 10s timeout per request (AbortSignal.timeout) - 1 retry on transient errors (timeout, network, connection reset) - Applied to getAnonymousToken, fetchManifest, fetchTagPage - downloadNightlyBlob gets 30s timeout (no retry — large downloads) Keeps worst-case latency at ~20s instead of 30s+ for cold GHCR instances.
1 parent bb04650 commit 85309f1

File tree

9 files changed

+626
-528
lines changed

9 files changed

+626
-528
lines changed

AGENTS.md

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -628,19 +628,71 @@ mock.module("./some-module", () => ({
628628

629629
### Architecture
630630

631-
<!-- lore:019cbeba-e4d3-748c-ad50-fe3c3d5c0a0d -->
632-
* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Authentication in \`src/lib/db/auth.ts\` follows a layered precedence: \`SENTRY\_AUTH\_TOKEN\` env var > \`SENTRY\_TOKEN\` env var > SQLite-stored OAuth token. The \`getEnvToken()\` helper reads env vars with \`.trim()\` (empty/whitespace treated as unset). \`AuthSource\` type tracks provenance: \`"env:SENTRY\_AUTH\_TOKEN"\` | \`"env:SENTRY\_TOKEN"\` | \`"oauth"\`. Env tokens bypass all refresh/expiry logic — \`refreshToken()\` returns immediately, \`handleUnauthorized()\` in sentry-client.ts skips 401 retry. \`isEnvTokenActive()\` is the guard used by auth commands (login, logout, refresh, status) to branch behavior. \`AuthConfig.source\` is always populated so callers can distinguish env vs stored tokens without re-checking env vars.
631+
<!-- lore:019cb8ea-c6f0-75d8-bda7-e32b4e217f92 -->
632+
* **CLI telemetry DSN is public write-onlysafe to embed in install script**: The CLI's Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key already baked into every binary. Safe to hardcode in install scripts. Opt-out: \`SENTRY\_CLI\_NO\_TELEMETRY=1\`.
633633

634-
<!-- lore:019cbaa2-e4a2-76c0-8f64-917a97ae20c5 -->
635-
* **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: Consola chosen as CLI logger with Sentry createConsolaReporter integration. Two reporters: FancyReporter (stderr) and Sentry reporter for structured logs. Level controlled by \`SENTRY\_LOG\_LEVEL\` env var mapped to consola numeric levels: error=0, warn=1, info=3 (default), debug=4, trace=5. \`buildCommand\` in \`src/lib/command.ts\` wraps Stricli's \`buildCommand\` to inject hidden \`--log-level\` and \`--verbose\` flags, intercepts them before calling original func via \`setLogLevel()\`, then strips them. When a command already defines \`--verbose\` (e.g. \`api\`), the injected one is skipped. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates to all children via a registry. Respects \`NO\_COLOR\` natively.
634+
<!-- lore:019c978a-18b5-7a0d-a55f-b72f7789bdac -->
635+
* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: \`cli.sentry.dev\` is served from gh-pages branch via GitHub Pages. Craft's gh-pages target runs \`git rm -r -f .\` before extracting docs — persist extra files via \`postReleaseCommand\` in \`.craft.yml\`. Install script supports \`--channel nightly\`, downloading from the \`nightly\` release tag directly. version.json is only used by upgrade/version-check flow.
636+
637+
<!-- lore:019cbe93-19b8-7776-9705-20bbde226599 -->
638+
* **Nightly delta upgrade buildNightlyPatchGraph fetches ALL patch tags — O(N) HTTP calls**: \`buildNightlyPatchGraph()\` in \`src/lib/delta-upgrade.ts\` fetches ALL \`patch-\*\` tag manifests from GHCR in parallel. With 14 tags, observed 1.5s–22s variance due to GHCR latency (not tag count). \`fetch-target-manifest\` shows 126ms–30s for single requests. Custom span attributes use \`tags\[name,number]\` syntax in Discover queries (not \`span.data.name\` or \`tags\[name]\`). Fix priorities: (1) lazy graph walk — fetch only chain-path manifests, reducing 14→1-2 fetches; (2) local manifest caching — patch graph is immutable; (3) GHCR request timeout+retry for cold-start spikes. Tag pruning is preventive but not yet the bottleneck. \`graph.tag\_count\`/\`graph.entry\_count\` attributes on \`build-patch-graph\` span (PR #355) confirmed working.
639+
640+
<!-- lore:2c3eb7ab-1341-4392-89fd-d81095cfe9c4 -->
641+
* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError.
642+
643+
<!-- lore:019c972c-9f0f-75cd-9e24-9bdbb1ac03d6 -->
644+
* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution in \`resolveNumericIssue()\`: (1) try DSN/env/config for org, (2) if found use \`getIssueInOrg(org, id)\` with region routing, (3) else fall back to unscoped \`getIssue(id)\`, (4) extract org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. The \`explicit-org-numeric\` case uses \`getIssueInOrg\`. \`resolveOrgAndIssueId\` no longer throws for bare numeric IDs when permalink contains the org slug.
645+
646+
<!-- lore:019c972c-9f0d-7c8e-95b1-7beda99c36a8 -->
647+
* **parseSentryUrl does not handle subdomain-style SaaS URLs**: parseSentryUrl in src/lib/sentry-url-parser.ts handles both path-based (\`/organizations/{org}/...\`) and subdomain-style (\`https://{org}.sentry.io/issues/123/\`) URLs. \`matchSubdomainOrg()\` extracts org from hostname ending in \`.sentry.io\`. Region subdomains (\`us\`, \`de\`) filtered by requiring org slug length > 2. Supports \`/issues/{id}/\`, \`/issues/{id}/events/{eventId}/\`, and \`/traces/{traceId}/\` paths. Self-hosted uses path-based only.
648+
649+
### Decision
650+
651+
<!-- lore:019c99d5-69f2-74eb-8c86-411f8512801d -->
652+
* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions.
653+
654+
<!-- lore:00166785-609d-4ab5-911e-ee205d17b90c -->
655+
* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\`\`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing.
636656
637657
### Gotcha
638658
639-
<!-- lore:019cbe0d-d03e-716c-b372-b09998c07ed6 -->
640-
* **Stricli rejects unknown flags — pre-parsed global flags must be consumed from argv**: Stricli's arg parser is strict: any \`--flag\` not registered on a command throws \`No flag registered for --flag\`. Global flags (parsed before Stricli in bin.ts) MUST be spliced out of argv. \`--log-level\` was correctly consumed but \`--verbose\` was intentionally left in (for the \`api\` command's own \`--verbose\`). This breaks every other command. Also, \`argv.indexOf('--flag')\` doesn't match \`--flag=value\` form — must check both space-separated and equals-sign forms when pre-parsing. A Biome \`noRestrictedImports\` lint rule in \`biome.jsonc\` now blocks \`import { buildCommand } from "@stricli/core"\` at error level — only \`src/lib/command.ts\` is exempted. Other \`@stricli/core\` exports (\`buildRouteMap\`, \`run\`, etc.) are allowed.
659+
<!-- lore:019c8ab6-d119-7365-9359-98ecf464b704 -->
660+
* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: @sentry/api SDK calls \`\_fetch(request)\` with no init object. In \`authenticatedFetch\`, \`init\` is undefined so \`prepareHeaders\` creates empty headers — on Node.js this strips Content-Type (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access the Response's Link header for pagination. \`per\_page\` is not in SDK types; cast query to pass it at runtime.
661+
662+
<!-- lore:019c9e98-7af4-7e25-95f4-fc06f7abf564 -->
663+
* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\`
664+
665+
<!-- lore:019c9776-e3dd-7632-88b8-358a19506218 -->
666+
* **GitHub immutable releases prevent rolling nightly tag pattern**: getsentry/cli has immutable GitHub releases — assets can't be modified and tags can NEVER be reused. Nightly builds are published to GHCR with versioned tags like \`nightly-0.14.0-dev.1772661724\`, NOT to GitHub Releases or npm. \`versionExists()\` routes nightly versions to GHCR manifest checks. \`downloadNightlyToPath()\` accepts optional \`version\` param for pinned versioned tags vs rolling \`:nightly\`. \`fetchManifest()\` throws \`UpgradeError("network\_error")\` for both network failures and non-200 — callers must check message for HTTP 404/403 to distinguish "not found" from real errors. Craft with no \`preReleaseCommand\` silently skips \`bump-version.sh\` if only target is \`github\` — must explicitly set it.
667+
668+
<!-- lore:019cb8c2-d7b5-780c-8a9f-d20001bc198f -->
669+
* **Install script: BSD sed and awk JSON parsing breaks OCI digest extraction**: The install script parses OCI manifests with awk (no jq). Key trap: BSD sed \`\n\` is literal, not newline. Fix: single awk pass tracking last-seen \`"digest"\`, printing when \`"org.opencontainers.image.title"\` matches target. The config digest (\`sha256:44136fa...\`) is a 2-byte \`{}\` blob — downloading it instead of the real binary causes \`gunzip: unexpected end of file\`.
670+
671+
<!-- lore:019cb963-cb63-722d-9365-b34336f4766d -->
672+
* **macOS SIGKILL on MAP\_SHARED mmap of signed Mach-O binaries**: Bun.mmap() always opens files with PROT\_WRITE/O\_RDWR regardless of the \`shared\` flag. This kills the process on the running binary: macOS sends SIGKILL (AMFI rejects writable mappings on signed Mach-O), Linux returns ETXTBSY. Fix: use \`new Uint8Array(await Bun.file(oldPath).arrayBuffer())\` unconditionally in bspatch.ts. Costs ~100MB heap but is the only cross-platform approach.
673+
674+
<!-- lore:019c969a-1c90-7041-88a8-4e4d9a51ebed -->
675+
* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: Bun test mocking gotchas: (1) \`mockFetch()\` replaces \`globalThis.fetch\` — calling it twice replaces the first mock. Use a single unified fetch mock dispatching by URL pattern. (2) \`mock.module()\` pollutes the module registry for ALL subsequent test files. Tests using it must live in \`test/isolated/\` and run via \`test:isolated\`. This also causes \`delta-upgrade.test.ts\` to fail when run alongside \`test/isolated/delta-upgrade.test.ts\` — the isolated test's \`mock.module()\` replaces \`CLI\_VERSION\` for all subsequent files. (3) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`.
676+
677+
<!-- lore:019c9741-d78e-73b1-87c2-e360ef6c7475 -->
678+
* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` in the repo tree. Without \`{ isolateProjectRoot: true }\`, \`findProjectRoot\` walks up and finds the repo's \`.git\`, causing DSN detection to scan real source code and trigger network calls against test mocks (timeouts). Always pass \`isolateProjectRoot: true\` when tests exercise \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\`.
641679
642680
### Pattern
643681
644-
<!-- lore:019cbe44-7687-7288-81a2-662feefc28ea -->
645-
* **SKILL.md generator must filter hidden Stricli flags**: \`script/generate-skill.ts\` introspects Stricli's route tree to auto-generate \`plugins/sentry-cli/skills/sentry-cli/SKILL.md\`. The \`FlagDef\` type must include \`hidden?: boolean\` and \`extractFlags\` must propagate it to \`FlagInfo\`. The filter in \`generateCommandDoc\` must exclude \`f.hidden\` alongside \`help\`/\`helpAll\`. Without this, hidden flags injected by \`buildCommand\` (like \`--log-level\`, \`--verbose\`) appear on every command in the AI agent skill file. Global flags should instead be documented once in \`docs/src/content/docs/commands/index.md\` Global Options section, which the generator pulls into SKILL.md via \`loadCommandsOverview\`.
682+
<!-- lore:019c972c-9f11-7c0d-96ce-3f8cc2641175 -->
683+
* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts: (1) call \`getOrgSdkConfig(orgSlug)\` for regional URL + SDK config, (2) spread into SDK function: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass to \`unwrapResult(result, errorContext)\`. Shared helpers \`resolveAllTargets\`/\`resolveOrgAndProject\` must NOT call \`fetchProjectId\` — commands that need it enrich targets themselves.
684+
685+
<!-- lore:5ac4e219-ea1f-41cb-8e97-7e946f5848c0 -->
686+
* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as required or advisory checks. Both typically take 2-3 minutes. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \<PR> --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`.
687+
688+
<!-- lore:019cb162-d3ad-7b05-ab4f-f87892d517a6 -->
689+
* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: List commands with cursor pagination use \`buildPaginationContextKey(type, identifier, flags)\` for composite context keys and \`parseCursorFlag(value)\` accepting \`"last"\` magic value. Critical: \`resolveCursor()\` must be called inside the \`org-all\` override closure, not before \`dispatchOrgScopedList\` — otherwise cursor validation errors fire before the correct mode-specific error.
690+
691+
<!-- lore:019cbd5f-ec35-7e2d-8386-6d3a67adf0cf -->
692+
* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` so it's a no-op without active transaction. When returning \`withTracingSpan(...)\` directly, drop \`async\` and use \`Promise.resolve(null)\` for early returns. User-visible fallbacks should use \`log.warn()\` not \`log.debug()\` — debug is invisible at default level. Also: several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\`. Affected: trace/list, trace/view, log/view, api.ts, help.ts.
693+
694+
### Preference
695+
696+
<!-- lore:019c9700-0fc3-730c-82c3-a290d5ecc2ea -->
697+
* **CI scripts: prefer jq/sed over node -e for JSON manipulation**: Prefer \`jq\`/\`sed\`/\`awk\` over \`node -e\` for JSON manipulation in CI scripts. Example: \`jq -r .version package.json\` to read, \`jq --arg v "$NEW" '.version = $v' package.json > tmp && mv tmp package.json\` to write.
646698
<!-- End lore-managed section -->

src/lib/db/manifest-cache.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* GHCR Manifest Cache
3+
*
4+
* Caches OCI manifests from GHCR to avoid redundant HTTP calls.
5+
* Patch manifests are immutable — once published, they never change.
6+
* Only versioned nightly/patch manifests are cached (not the rolling `:nightly` tag).
7+
*/
8+
9+
import type { OciManifest } from "../ghcr.js";
10+
import { getDatabase } from "./index.js";
11+
import { tryRepairAndRetry } from "./schema.js";
12+
13+
/**
14+
* Get a cached manifest by tag.
15+
*
16+
* @param tag - GHCR tag (e.g., "patch-0.14.0-dev.1772732047")
17+
* @returns Parsed OCI manifest, or null if not cached
18+
*/
19+
export function getCachedManifest(tag: string): OciManifest | null {
20+
const db = getDatabase();
21+
const get = () =>
22+
db
23+
.query("SELECT manifest_json FROM manifest_cache WHERE tag = ?")
24+
.get(tag) as { manifest_json: string } | null;
25+
26+
let row: { manifest_json: string } | null;
27+
try {
28+
row = get();
29+
} catch (error) {
30+
const repair = tryRepairAndRetry(get, error);
31+
if (!repair.attempted) {
32+
throw error;
33+
}
34+
row = repair.result ?? null;
35+
}
36+
if (!row) {
37+
return null;
38+
}
39+
return JSON.parse(row.manifest_json) as OciManifest;
40+
}
41+
42+
/**
43+
* Cache a manifest by tag.
44+
*
45+
* Uses INSERT OR REPLACE to handle both new entries and updates.
46+
*
47+
* @param tag - GHCR tag
48+
* @param manifest - OCI manifest to cache
49+
*/
50+
export function setCachedManifest(tag: string, manifest: OciManifest): void {
51+
const db = getDatabase();
52+
const run = () =>
53+
db
54+
.query(
55+
"INSERT OR REPLACE INTO manifest_cache (tag, manifest_json) VALUES (?, ?)"
56+
)
57+
.run(tag, JSON.stringify(manifest));
58+
59+
try {
60+
run();
61+
} catch (error) {
62+
const repair = tryRepairAndRetry(run, error);
63+
if (!repair.attempted) {
64+
throw error;
65+
}
66+
}
67+
}
68+
69+
/**
70+
* Get multiple cached manifests at once.
71+
*
72+
* Uses a single SQL query with IN clause for efficiency.
73+
*
74+
* @param tags - Array of GHCR tags to look up
75+
* @returns Map from tag → OciManifest for tags that are cached
76+
*/
77+
export function getCachedManifests(tags: string[]): Map<string, OciManifest> {
78+
const result = new Map<string, OciManifest>();
79+
if (tags.length === 0) {
80+
return result;
81+
}
82+
83+
const db = getDatabase();
84+
const placeholders = tags.map(() => "?").join(",");
85+
const query = `SELECT tag, manifest_json FROM manifest_cache WHERE tag IN (${placeholders})`;
86+
87+
const get = () =>
88+
db.query(query).all(...tags) as {
89+
tag: string;
90+
manifest_json: string;
91+
}[];
92+
93+
let rows: { tag: string; manifest_json: string }[];
94+
try {
95+
rows = get();
96+
} catch (error) {
97+
const repair = tryRepairAndRetry(get, error);
98+
if (!repair.attempted) {
99+
throw error;
100+
}
101+
rows = (repair.result as { tag: string; manifest_json: string }[]) ?? [];
102+
}
103+
104+
for (const row of rows) {
105+
result.set(row.tag, JSON.parse(row.manifest_json) as OciManifest);
106+
}
107+
return result;
108+
}

src/lib/db/schema.ts

Lines changed: 17 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";
@@ -199,6 +199,17 @@ export const TABLE_SCHEMAS: Record<string, TableSchema> = {
199199
},
200200
},
201201
},
202+
manifest_cache: {
203+
columns: {
204+
tag: { type: "TEXT", primaryKey: true },
205+
manifest_json: { type: "TEXT", notNull: true },
206+
cached_at: {
207+
type: "INTEGER",
208+
notNull: true,
209+
default: "(unixepoch() * 1000)",
210+
},
211+
},
212+
},
202213
project_root_cache: {
203214
columns: {
204215
cwd: { type: "TEXT", primaryKey: true },
@@ -721,6 +732,11 @@ export function runMigrations(db: Database): void {
721732
addColumnIfMissing(db, "org_regions", "org_id", "TEXT");
722733
}
723734

735+
// Migration 8 -> 9: Add manifest_cache table for GHCR manifest caching
736+
if (currentVersion < 9) {
737+
db.exec(EXPECTED_TABLES.manifest_cache as string);
738+
}
739+
724740
if (currentVersion < CURRENT_SCHEMA_VERSION) {
725741
db.query("UPDATE schema_version SET version = ?").run(
726742
CURRENT_SCHEMA_VERSION

0 commit comments

Comments
 (0)