From c80c945fbb40d986d01e897b160783bd73497e6c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:49:00 +0000 Subject: [PATCH 1/6] fix(cli): Keep the local database usable across CLI downgrades Switching from nightly back to stable left cli.db at a newer schema. The older CLI only migrates forward, so its org_regions upsert failed against the newer composite key and its login lookup found an empty auth table. - Before `cli upgrade` installs an older version, drop cache tables, copy the active account into the legacy auth/user_info tables, and set the schema version to 16 so the newer CLI re-migrates when it comes back. - Make the v18 accounts migration refresh an existing account instead of adding a duplicate when it re-runs. - When a newer CLI wrote the database, rebuild cache tables and record this version, or report it in `cli fix` when auto-repair is disabled. - Compare every table's primary key during repair, not only composite keys, and never drop a table whose data cannot be rebuilt. Fixes #1611 --- packages/cli/src/commands/cli/fix.ts | 3 + packages/cli/src/commands/cli/upgrade.ts | 27 +- packages/cli/src/lib/db/schema.ts | 324 +++++++++++++++--- packages/cli/src/lib/env-registry.ts | 2 +- .../cli/test/commands/cli/upgrade.test.ts | 55 +++ .../lib/db/schema-downgrade.focused.test.ts | 232 +++++++++++++ 6 files changed, 588 insertions(+), 55 deletions(-) create mode 100644 packages/cli/test/lib/db/schema-downgrade.focused.test.ts diff --git a/packages/cli/src/commands/cli/fix.ts b/packages/cli/src/commands/cli/fix.ts index 07de427bd..53b9f0986 100644 --- a/packages/cli/src/commands/cli/fix.ts +++ b/packages/cli/src/commands/cli/fix.ts @@ -72,6 +72,9 @@ function formatIssue(issue: SchemaIssue): string { if (issue.type === "missing_column") { return `Missing column: ${issue.table}.${issue.column}`; } + if (issue.type === "newer_schema") { + return `Written by a newer CLI: schema ${issue.version}, this CLI uses ${CURRENT_SCHEMA_VERSION}`; + } return `Wrong primary key: ${issue.table}`; } diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index ee84b5c33..2061a8112 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -30,13 +30,19 @@ import { } from "../../lib/binary.js"; import { buildCommand } from "../../lib/command.js"; import { CLI_VERSION } from "../../lib/constants.js"; +import { getDbPath, getRawDatabase } from "../../lib/db/index.js"; import { getReleaseChannel, type ReleaseChannel, setReleaseChannel, } from "../../lib/db/release-channel.js"; +import { prepareDatabaseForDowngrade } from "../../lib/db/schema.js"; import { getVersionCheckInfo } from "../../lib/db/version-check.js"; -import { UpgradeError, UpgradeTransportError } from "../../lib/errors.js"; +import { + stringifyUnknown, + UpgradeError, + UpgradeTransportError, +} from "../../lib/errors.js"; import { formatUpgradeResult } from "../../lib/formatters/human.js"; import { formatBytes } from "../../lib/formatters/numbers.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -65,6 +71,21 @@ import { whichSync } from "../../lib/which.js"; const log = logger.withTag("cli.upgrade"); +/** + * Leave the local database readable by the older CLI being installed (#1611). + * A failure must not block the downgrade itself. + */ +function prepareLocalDatabaseForDowngrade(): void { + try { + prepareDatabaseForDowngrade(getRawDatabase()); + } catch (error) { + log.warn( + `Could not prepare the local database for an older CLI: ${stringifyUnknown(error)}. ` + + `If the older CLI fails, delete ${getDbPath()} and run \`sentry auth login\`.`, + ); + } +} + /** Special version strings that select a channel rather than a specific release. */ const CHANNEL_VERSIONS = new Set(["nightly", "stable"]); @@ -1108,6 +1129,10 @@ export const upgradeCommand = buildCommand({ } const downgrade = isDowngrade(CLI_VERSION, target); log.debug(`${downgrade ? "Downgrading" : "Upgrading"} to ${target}`); + // Before installing: the older binary runs `cli setup` during the upgrade. + if (downgrade) { + prepareLocalDatabaseForDowngrade(); + } // Perform the actual upgrade let warnings: string[] | undefined; diff --git a/packages/cli/src/lib/db/schema.ts b/packages/cli/src/lib/db/schema.ts index 7ed756c5f..57cffc145 100644 --- a/packages/cli/src/lib/db/schema.ts +++ b/packages/cli/src/lib/db/schema.ts @@ -47,6 +47,11 @@ export type TableSchema = { * should NOT also set `primaryKey: true`. */ compositePrimaryKey?: string[]; + /** + * The table only holds data the CLI can fetch or detect again, so repair + * may drop and recreate it when its shape is wrong or a newer CLI wrote it. + */ + rebuildable?: boolean; }; /** @@ -107,6 +112,7 @@ export const TABLE_SCHEMAS: Record = { }, project_cache: { + rebuildable: true, columns: { cache_key: { type: "TEXT", primaryKey: true }, org_slug: { type: "TEXT", notNull: true }, @@ -127,6 +133,7 @@ export const TABLE_SCHEMAS: Record = { }, }, dsn_cache: { + rebuildable: true, columns: { directory: { type: "TEXT", primaryKey: true }, dsn: { type: "TEXT", notNull: true }, @@ -157,6 +164,7 @@ export const TABLE_SCHEMAS: Record = { }, }, project_aliases: { + rebuildable: true, columns: { alias: { type: "TEXT", primaryKey: true }, org_slug: { type: "TEXT", notNull: true }, @@ -175,6 +183,7 @@ export const TABLE_SCHEMAS: Record = { }, }, pagination_cursors: { + rebuildable: true, columns: { command_key: { type: "TEXT", notNull: true }, context: { type: "TEXT", notNull: true }, @@ -191,6 +200,7 @@ export const TABLE_SCHEMAS: Record = { }, }, org_regions: { + rebuildable: true, columns: { org_slug: { type: "TEXT", notNull: true }, org_id: { type: "TEXT", addedInVersion: 8 }, @@ -221,6 +231,7 @@ export const TABLE_SCHEMAS: Record = { }, }, repo_cache: { + rebuildable: true, columns: { // Composite PK (org_slug) — one row per org caches the full repo list org_slug: { type: "TEXT", primaryKey: true }, @@ -251,6 +262,7 @@ export const TABLE_SCHEMAS: Record = { * permissions). */ issue_org_cache: { + rebuildable: true, columns: { // Numeric issue group ID (e.g., "7413562541"). TEXT to sidestep the // 2^53 JavaScript number limit if Sentry's group IDs ever exceed it. @@ -264,6 +276,7 @@ export const TABLE_SCHEMAS: Record = { }, }, project_root_cache: { + rebuildable: true, columns: { cwd: { type: "TEXT", primaryKey: true }, project_root: { type: "TEXT", notNull: true }, @@ -416,29 +429,37 @@ export function hasColumn( return result.count > 0; } +/** Primary key columns a table schema declares, in key order. */ +function declaredPrimaryKey(schema: TableSchema): string[] { + return ( + schema.compositePrimaryKey ?? + Object.entries(schema.columns) + .filter(([, col]) => col.primaryKey) + .map(([name]) => name) + ); +} + /** - * Check if a table has the expected composite primary key. + * Check if a table's primary key is exactly `expectedColumns`, in order. * - * Inspects the CREATE TABLE DDL stored in sqlite_master to verify - * the table has a table-level PRIMARY KEY constraint matching the - * expected columns. Returns false if the table uses per-column - * PRIMARY KEY instead (e.g., `command_key TEXT PRIMARY KEY`). + * Reads `pragma_table_info`, so single-column and composite keys compare the + * same way. A table written by another CLI version can have a different key + * with the same columns, which makes `ON CONFLICT` upserts fail. */ -function hasCompositePrimaryKey( +function hasPrimaryKey( db: Database, table: string, expectedColumns: string[], ): boolean { - const row = db - .query("SELECT sql FROM sqlite_master WHERE type='table' AND name=?") - .get(table) as { sql: string } | undefined; - - if (!row) { - return false; - } - - const expectedPK = `PRIMARY KEY (${expectedColumns.join(", ")})`; - return row.sql.includes(expectedPK); + const rows = db + .query( + `SELECT name FROM pragma_table_info('${table}') WHERE pk > 0 ORDER BY pk`, + ) + .all() as { name: string }[]; + return ( + rows.length === expectedColumns.length && + rows.every((row, i) => row.name === expectedColumns[i]) + ); } /** Add a column to a table if it doesn't exist */ @@ -457,7 +478,8 @@ function addColumnIfMissing( export type SchemaIssue = | { type: "missing_table"; table: string } | { type: "missing_column"; table: string; column: string } - | { type: "wrong_primary_key"; table: string }; + | { type: "wrong_primary_key"; table: string } + | { type: "newer_schema"; version: number }; function findMissingColumns(db: Database, tableName: string): SchemaIssue[] { const columns = EXPECTED_COLUMNS[tableName]; @@ -475,10 +497,7 @@ function findMissingColumns(db: Database, tableName: string): SchemaIssue[] { function findPrimaryKeyIssues(db: Database, tableName: string): SchemaIssue[] { const schema = TABLE_SCHEMAS[tableName]; - if ( - schema?.compositePrimaryKey && - !hasCompositePrimaryKey(db, tableName, schema.compositePrimaryKey) - ) { + if (schema && !hasPrimaryKey(db, tableName, declaredPrimaryKey(schema))) { return [{ type: "wrong_primary_key", table: tableName }]; } return []; @@ -491,6 +510,11 @@ function findPrimaryKeyIssues(db: Database, tableName: string): SchemaIssue[] { export function getSchemaIssues(db: Database): SchemaIssue[] { const issues: SchemaIssue[] = []; + const storedVersion = getSchemaVersion(db); + if (storedVersion > CURRENT_SCHEMA_VERSION) { + issues.push({ type: "newer_schema", version: storedVersion }); + } + for (const tableName of Object.keys(EXPECTED_TABLES)) { if (!tableExists(db, tableName)) { issues.push({ type: "missing_table", table: tableName }); @@ -548,24 +572,26 @@ function repairMissingColumns(db: Database, result: RepairResult): void { } /** - * Drop and recreate tables that have incorrect primary key constraints. + * Drop and recreate rebuildable tables that have the wrong primary key. * - * This fixes the CLI-72 bug where pagination_cursors was created with a - * single-column PK (`command_key TEXT PRIMARY KEY`) instead of the expected - * composite PK (`PRIMARY KEY (command_key, context)`). SQLite does not - * support ALTER TABLE to change primary keys, so the table must be dropped - * and recreated. The data loss is acceptable since pagination cursors are - * ephemeral (5-minute TTL). + * SQLite cannot ALTER a primary key, so the table must be recreated. This + * covers CLI-72 (pagination_cursors created with a single-column PK) and a + * table whose key changed in another CLI version (#1611: `org_regions` from + * a newer CLI). Tables holding data the CLI cannot rebuild are reported + * instead of dropped. */ function repairWrongPrimaryKeys(db: Database, result: RepairResult): void { for (const [tableName, schema] of Object.entries(TABLE_SCHEMAS)) { - if (!schema.compositePrimaryKey) { + if (!tableExists(db, tableName)) { continue; } - if (!tableExists(db, tableName)) { + if (hasPrimaryKey(db, tableName, declaredPrimaryKey(schema))) { continue; } - if (hasCompositePrimaryKey(db, tableName, schema.compositePrimaryKey)) { + if (!schema.rebuildable) { + result.failed.push( + `Table ${tableName} has an unexpected primary key and holds data that cannot be rebuilt`, + ); continue; } try { @@ -581,9 +607,48 @@ function repairWrongPrimaryKeys(db: Database, result: RepairResult): void { } } +/** Names of tables that only hold data the CLI can fetch or detect again. */ +const REBUILDABLE_TABLES = Object.entries(TABLE_SCHEMAS) + .filter(([, schema]) => schema.rebuildable) + .map(([name]) => name); + +/** + * Rebuild every cache table when a newer CLI last wrote the database. + * + * A newer CLI can change what cached rows mean without changing their + * shape, so this version cannot trust them. Caches refill on demand. + */ +function rebuildCachesFromNewerSchema( + db: Database, + result: RepairResult, +): void { + const storedVersion = getSchemaVersion(db); + if (storedVersion <= CURRENT_SCHEMA_VERSION) { + return; + } + try { + db.transaction(() => { + for (const tableName of REBUILDABLE_TABLES) { + db.exec(`DROP TABLE IF EXISTS ${tableName}`); + db.exec(EXPECTED_TABLES[tableName] as string); + } + })(); + result.fixed.push( + `Rebuilt cached data written by a newer CLI (schema ${storedVersion})`, + ); + } catch (e) { + const msg = stringifyUnknown(e); + result.failed.push(`Failed to rebuild cache tables: ${msg}`); + } +} + /** * Repair schema issues by creating missing tables, adding missing columns, - * and recreating tables with incorrect primary key constraints. + * and recreating tables with incorrect primary key constraints. When a newer + * CLI last wrote the database, every cache table is rebuilt first. + * + * Any fix sets the stored version to {@link CURRENT_SCHEMA_VERSION}, so a + * newer CLI re-runs its later migrations against what this version wrote. * * @param db - The raw database connection (not the traced wrapper) * @returns Lists of fixed and failed repairs @@ -591,6 +656,7 @@ function repairWrongPrimaryKeys(db: Database, result: RepairResult): void { export function repairSchema(db: Database): RepairResult { const result: RepairResult = { fixed: [], failed: [] }; + rebuildCachesFromNewerSchema(db, result); repairMissingTables(db, result); repairMissingColumns(db, result); repairWrongPrimaryKeys(db, result); @@ -744,6 +810,9 @@ export function initSchema(db: Database): void { } function getSchemaVersion(db: Database): number { + if (!tableExists(db, "schema_version")) { + return 0; + } const row = db.query("SELECT version FROM schema_version LIMIT 1").get() as { version: number; } | null; @@ -821,33 +890,180 @@ function migrateSingleAuthToAccounts(db: Database): void { if (auth?.token) { const now = Date.now(); + // A newer CLI hands its active login back through `auth` before a + // downgrade (see prepareDatabaseForDowngrade), so on the way back up the + // login may already exist. Refresh that row instead of duplicating it. + const existing = db + .query( + `SELECT account_id FROM accounts + WHERE token = ? OR (user_id IS NOT NULL AND user_id = ? AND host IS ?) + LIMIT 1`, + ) + .get(auth.token, user?.user_id ?? null, auth.host) as + | { account_id: string } + | undefined; db.query("UPDATE accounts SET is_active = 0").run(); - db.query( - `INSERT INTO accounts (account_id, host, user_id, email, username, name, + if (existing) { + db.query( + `UPDATE accounts SET token = ?, refresh_token = ?, expires_at = ?, + issued_at = ?, is_active = 1, updated_at = ?, last_used_at = ? + WHERE account_id = ?`, + ).run( + auth.token, + auth.refresh_token, + auth.expires_at, + auth.issued_at, + auth.updated_at ?? now, + now, + existing.account_id, + ); + } else { + db.query( + `INSERT INTO accounts (account_id, host, user_id, email, username, name, token, refresh_token, expires_at, issued_at, is_active, created_at, updated_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, - ).run( - uuidv7(), - auth.host, - user?.user_id ?? null, - user?.email ?? null, - user?.username ?? null, - user?.name ?? null, - auth.token, - auth.refresh_token, - auth.expires_at, - auth.issued_at, - now, - auth.updated_at ?? now, - now, - ); + ).run( + uuidv7(), + auth.host, + user?.user_id ?? null, + user?.email ?? null, + user?.username ?? null, + user?.name ?? null, + auth.token, + auth.refresh_token, + auth.expires_at, + auth.issued_at, + now, + auth.updated_at ?? now, + now, + ); + } } db.exec("DROP TABLE IF EXISTS auth"); db.exec("DROP TABLE IF EXISTS user_info"); } +/** + * Newest schema an older CLI can read without knowing about `accounts` or + * org-region provenance: v17 rebuilt `org_regions` with a new key and v18 + * moved the login out of `auth`. + */ +const PRE_ACCOUNTS_SCHEMA_VERSION = 16; + +/** `auth` and `user_info` as CLI versions before schema v18 read them. */ +const LEGACY_AUTH_DDL = `CREATE TABLE auth ( + id INTEGER PRIMARY KEY CHECK (id = 1), + token TEXT, + refresh_token TEXT, + expires_at INTEGER, + issued_at INTEGER, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + host TEXT + )`; +const LEGACY_USER_INFO_DDL = `CREATE TABLE user_info ( + id INTEGER PRIMARY KEY CHECK (id = 1), + user_id TEXT NOT NULL, + email TEXT, + username TEXT, + name TEXT, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + )`; + +/** Copy the active account into the single-row tables older CLIs read. */ +function exportActiveAccountToLegacyTables(db: Database): void { + db.exec("DROP TABLE IF EXISTS auth"); + db.exec("DROP TABLE IF EXISTS user_info"); + db.exec(LEGACY_AUTH_DDL); + db.exec(LEGACY_USER_INFO_DDL); + + const account = db + .query( + `SELECT host, user_id, email, username, name, token, refresh_token, + expires_at, issued_at, updated_at + FROM accounts WHERE is_active = 1 LIMIT 1`, + ) + .get() as (LegacyAuthRow & LegacyUserRow) | undefined; + if (!account?.token) { + return; + } + + db.query( + `INSERT INTO auth (id, token, refresh_token, expires_at, issued_at, updated_at, host) + VALUES (1, ?, ?, ?, ?, ?, ?)`, + ).run( + account.token, + account.refresh_token, + account.expires_at, + account.issued_at, + account.updated_at ?? Date.now(), + account.host, + ); + if (account.user_id) { + db.query( + "INSERT INTO user_info (id, user_id, email, username, name) VALUES (1, ?, ?, ?, ?)", + ).run(account.user_id, account.email, account.username, account.name); + } +} + +/** + * Leave the database readable by an older CLI before `sentry cli upgrade` + * installs one (#1611). + * + * Older CLIs only migrate forward and create missing tables with + * `CREATE TABLE IF NOT EXISTS`, so this: + * - drops every cache table, letting the older CLI recreate them in its own + * shape (v17 changed the `org_regions` key, which breaks older upserts); + * - copies the active login into the pre-v18 `auth`/`user_info` tables, so + * the older CLI stays logged in; + * - sets the stored version to {@link PRE_ACCOUNTS_SCHEMA_VERSION}, so this + * CLI re-runs its later migrations when it comes back and picks up any + * login or token refresh the older CLI wrote to `auth`. + * + * Every migration after that version must therefore be safe to re-run. + */ +export function prepareDatabaseForDowngrade(db: Database): void { + db.transaction(() => { + for (const tableName of REBUILDABLE_TABLES) { + db.exec(`DROP TABLE IF EXISTS ${tableName}`); + } + if (tableExists(db, "accounts")) { + exportActiveAccountToLegacyTables(db); + } + db.query("UPDATE schema_version SET version = ?").run( + PRE_ACCOUNTS_SCHEMA_VERSION, + ); + })(); +} + +/** + * Make a database last written by a newer CLI usable by this version. + * + * Older CLIs never ran this check, so a downgrade left upserts failing + * against tables whose key had changed (#1611). Repair rebuilds the caches + * and records this version, so the newer CLI re-migrates when it returns. + */ +function adaptNewerSchema(db: Database, storedVersion: number): void { + const versions = `schema ${storedVersion}; this CLI uses ${CURRENT_SCHEMA_VERSION}`; + if (getEnv()[NO_AUTO_REPAIR_ENV] === "1") { + logger.warn( + `The local database was written by a newer Sentry CLI (${versions}). Run \`sentry cli fix\` to adapt it.`, + ); + return; + } + const { failed } = repairSchema(db); + if (failed.length > 0) { + logger.warn( + `Could not adapt the local database written by a newer Sentry CLI (${versions}): ${failed.join("; ")}. Run \`sentry cli fix\`.`, + ); + return; + } + logger.info( + `The local database was written by a newer Sentry CLI (${versions}). Cleared cached data so this version can use it.`, + ); +} + /** * Run migrations for schema changes between versions. * @@ -861,6 +1077,11 @@ function migrateSingleAuthToAccounts(db: Database): void { export function runMigrations(db: Database): void { const currentVersion = getSchemaVersion(db); + if (currentVersion > CURRENT_SCHEMA_VERSION) { + adaptNewerSchema(db, currentVersion); + return; + } + // Migration 1 -> 2: Add org_regions and instance_info tables. The // `user_info` table this step also created was folded into `accounts` in v18. if (currentVersion < 2) { @@ -899,10 +1120,7 @@ export function runMigrations(db: Database): void { if ( currentVersion < 6 && tableExists(db, "pagination_cursors") && - !hasCompositePrimaryKey(db, "pagination_cursors", [ - "command_key", - "context", - ]) + !hasPrimaryKey(db, "pagination_cursors", ["command_key", "context"]) ) { db.exec("DROP TABLE pagination_cursors"); db.exec(EXPECTED_TABLES.pagination_cursors as string); diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index f033386a0..a2462aa99 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -299,7 +299,7 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ { name: "SENTRY_CLI_NO_AUTO_REPAIR", description: - "Disable automatic database schema repair. By default, the CLI automatically repairs its SQLite database when it detects schema drift. Set this to `1` to prevent auto-repair.", + "Disable automatic database schema repair. By default, the CLI automatically repairs its SQLite database when it detects schema drift, including clearing cached data a newer CLI version wrote. Set this to `1` to prevent auto-repair; `sentry cli fix` still repairs on demand.", example: "1", }, ]; diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index ccc2d2112..94f0a052b 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -24,6 +24,11 @@ vi.mock("node:child_process", async (importOriginal) => { const orig = await importOriginal(); return { ...orig }; }); +vi.mock("../../../src/lib/binary.js", async (importOriginal) => { + const orig = + await importOriginal(); + return { ...orig }; +}); import { app } from "../../../src/app.js"; import { @@ -31,7 +36,10 @@ import { resolveUpgradeInstallDir, } from "../../../src/commands/cli/upgrade.js"; import type { SentryContext } from "../../../src/context.js"; +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as binary from "../../../src/lib/binary.js"; import { CLI_VERSION } from "../../../src/lib/constants.js"; +import { getRawDatabase } from "../../../src/lib/db/index.js"; import { clearInstallInfo, setInstallInfo, @@ -40,10 +48,18 @@ import { getReleaseChannel, setReleaseChannel, } from "../../../src/lib/db/release-channel.js"; +import { CURRENT_SCHEMA_VERSION } from "../../../src/lib/db/schema.js"; import { setVersionCheckInfo } from "../../../src/lib/db/version-check.js"; import { extractInputUrl } from "../../../src/lib/fetch-input.js"; import { TEST_TMP_DIR, useTestConfigDir } from "../../helpers.js"; +function readSchemaVersion(): number { + const row = getRawDatabase() + .query("SELECT version FROM schema_version") + .get() as { version: number }; + return row.version; +} + /** Store original fetch for restoration */ let originalFetch: typeof globalThis.fetch; @@ -960,6 +976,45 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy expect(setupCall?.args).toContain("--ensure-auth-scopes"); }); + test("hands the database to the older CLI before it runs setup", async () => { + mockBinaryDownloadWithVersion("99.99.99"); + const downgradeSpy = vi.spyOn(binary, "isDowngrade").mockReturnValue(true); + let versionAtSetup: number | undefined; + spawnSpy.mockImplementation((cmd: string, args?: readonly string[]) => { + spawnedArgs.push({ cmd, args: [...(args ?? [])] }); + versionAtSetup = readSchemaVersion(); + return fakeChildProcess(0); + }); + + const { context, getOutput, restore } = createMockContext({ + homeDir: testDir, + }); + restoreStderr = restore; + + try { + await run(app, ["cli", "upgrade", "--method", "curl"], context); + } finally { + downgradeSpy.mockRestore(); + } + + expect(getOutput()).toContain("Downgraded to"); + expect(spawnedArgs.some((entry) => entry.args.includes("setup"))).toBe( + true, + ); + expect(versionAtSetup).toBe(16); + }); + + test("leaves the database alone when upgrading", async () => { + mockBinaryDownloadWithVersion("99.99.99"); + + const { context, restore } = createMockContext({ homeDir: testDir }); + restoreStderr = restore; + + await run(app, ["cli", "upgrade", "--method", "curl"], context); + + expect(readSchemaVersion()).toBe(CURRENT_SCHEMA_VERSION); + }); + test("does not launch interactive auth from JSON upgrades", async () => { mockBinaryDownloadWithVersion("99.99.99"); diff --git a/packages/cli/test/lib/db/schema-downgrade.focused.test.ts b/packages/cli/test/lib/db/schema-downgrade.focused.test.ts new file mode 100644 index 000000000..cac9758d5 --- /dev/null +++ b/packages/cli/test/lib/db/schema-downgrade.focused.test.ts @@ -0,0 +1,232 @@ +/** + * Switching between CLI versions with different schemas (#1611). + * + * Covers a database last written by a newer CLI, and the hand-off + * `sentry cli upgrade` performs before installing an older CLI. + */ + +import { describe, expect, test } from "vitest"; +import { listAccounts } from "../../../src/lib/db/accounts.js"; +import { + getAuthConfig, + resetAuthRowCache, + resetAuthTokenCache, + setAuthToken, +} from "../../../src/lib/db/auth.js"; +import { + closeDatabase, + getDatabase, + getRawDatabase, +} from "../../../src/lib/db/index.js"; +import { + CURRENT_SCHEMA_VERSION, + getSchemaIssues, + prepareDatabaseForDowngrade, + repairSchema, + tableExists, +} from "../../../src/lib/db/schema.js"; +import { setUserInfo } from "../../../src/lib/db/user.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("schema-downgrade-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_CLI_NO_AUTO_REPAIR", + "SENTRY_HOST", + "SENTRY_URL", +]); + +const HOST = "https://sentry.io"; + +/** `org_regions` as CLI 0.47.0 (schema 16) creates and upserts it. */ +const V16_ORG_REGIONS_DDL = `CREATE TABLE IF NOT EXISTS org_regions ( + org_slug TEXT PRIMARY KEY, org_id TEXT, org_name TEXT, org_role TEXT, + region_url TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) +)`; +const V16_ORG_REGIONS_UPSERT = `INSERT INTO org_regions (org_slug, region_url) + VALUES ('acme', 'https://us.sentry.io') + ON CONFLICT(org_slug) DO UPDATE SET region_url = excluded.region_url`; + +function storedVersion(): number { + const row = getRawDatabase() + .query("SELECT version FROM schema_version") + .get() as { version: number }; + return row.version; +} + +function reopen(): void { + closeDatabase(); + resetAuthTokenCache(); + resetAuthRowCache(); + getDatabase(); +} + +function primaryKey(table: string): string[] { + return ( + getRawDatabase() + .query( + `SELECT name FROM pragma_table_info('${table}') WHERE pk > 0 ORDER BY pk`, + ) + .all() as { name: string }[] + ).map((row) => row.name); +} + +/** Leave the DB as a newer CLI would: a changed cache key, a higher version. */ +function writeAsNewerCli(): void { + const db = getRawDatabase(); + db.exec("DROP TABLE org_regions"); + db.exec(V16_ORG_REGIONS_DDL); + db.exec(V16_ORG_REGIONS_UPSERT); + db.query("UPDATE schema_version SET version = ?").run( + CURRENT_SCHEMA_VERSION + 1, + ); +} + +describe("database written by a newer CLI", () => { + test("rebuilds caches, keeps the login, and records this version", () => { + setAuthToken("newer-token", undefined, undefined, { host: HOST }); + writeAsNewerCli(); + + reopen(); + + expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION); + expect(primaryKey("org_regions")).toEqual([ + "credential_identity", + "source_origin", + "org_slug", + ]); + expect( + getRawDatabase().query("SELECT COUNT(*) AS n FROM org_regions").get(), + ).toEqual({ n: 0 }); + expect(getAuthConfig()?.token).toBe("newer-token"); + expect(getSchemaIssues(getRawDatabase())).toEqual([]); + }); + + test("only reports the mismatch when auto-repair is disabled", () => { + writeAsNewerCli(); + process.env.SENTRY_CLI_NO_AUTO_REPAIR = "1"; + + reopen(); + + expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION + 1); + expect(getSchemaIssues(getRawDatabase())).toEqual( + expect.arrayContaining([ + { type: "newer_schema", version: CURRENT_SCHEMA_VERSION + 1 }, + { type: "wrong_primary_key", table: "org_regions" }, + ]), + ); + + const result = repairSchema(getRawDatabase()); + expect(result.failed).toEqual([]); + expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION); + expect(getSchemaIssues(getRawDatabase())).toEqual([]); + }); +}); + +describe("repairSchema: primary keys", () => { + test("recreates a cache table whose single-column key differs", () => { + const db = getRawDatabase(); + db.exec("DROP TABLE org_regions"); + db.exec(V16_ORG_REGIONS_DDL); + + expect(getSchemaIssues(db)).toContainEqual({ + type: "wrong_primary_key", + table: "org_regions", + }); + const result = repairSchema(db); + + expect(result.failed).toEqual([]); + expect(primaryKey("org_regions")).toEqual([ + "credential_identity", + "source_origin", + "org_slug", + ]); + }); + + test("reports instead of dropping a table it cannot rebuild", () => { + const db = getRawDatabase(); + db.exec("DROP TABLE instance_info"); + db.exec( + "CREATE TABLE instance_info (id INTEGER NOT NULL, instance_id TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (id, instance_id))", + ); + db.exec("INSERT INTO instance_info (id, instance_id) VALUES (1, 'keep')"); + + const result = repairSchema(db); + + expect(result.failed).toEqual([ + "Table instance_info has an unexpected primary key and holds data that cannot be rebuilt", + ]); + expect(db.query("SELECT instance_id FROM instance_info").get()).toEqual({ + instance_id: "keep", + }); + }); +}); + +describe("prepareDatabaseForDowngrade", () => { + test("leaves a database CLI 0.47.0 can use while logged in", () => { + setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); + setUserInfo({ userId: "7", email: "dev@example.com", name: "Dev" }); + const db = getRawDatabase(); + + prepareDatabaseForDowngrade(db); + + expect(storedVersion()).toBe(16); + expect(tableExists(db, "org_regions")).toBe(false); + // What 0.47.0 does on startup and when listing organizations. + db.exec(V16_ORG_REGIONS_DDL); + expect(() => db.exec(V16_ORG_REGIONS_UPSERT)).not.toThrow(); + expect( + db.query("SELECT token, refresh_token, host FROM auth").get(), + ).toEqual({ + token: "active-token", + refresh_token: "active-refresh", + host: HOST, + }); + expect( + db.query("SELECT user_id, email, name FROM user_info").get(), + ).toEqual({ user_id: "7", email: "dev@example.com", name: "Dev" }); + }); + + test("picks up the older CLI's token refresh without duplicating the account", () => { + setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); + setUserInfo({ userId: "7", email: "dev@example.com" }); + prepareDatabaseForDowngrade(getRawDatabase()); + // The older CLI refreshes the token and recreates the cache in its shape. + const db = getRawDatabase(); + db.exec(V16_ORG_REGIONS_DDL); + db.exec(V16_ORG_REGIONS_UPSERT); + db.query( + "UPDATE auth SET token = 'refreshed-token', refresh_token = 'refreshed-refresh'", + ).run(); + + reopen(); + + expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION); + expect(tableExists(getRawDatabase(), "auth")).toBe(false); + expect(primaryKey("org_regions")).toEqual([ + "credential_identity", + "source_origin", + "org_slug", + ]); + const accounts = listAccounts(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ active: true, userId: "7" }); + expect(getAuthConfig()).toMatchObject({ + token: "refreshed-token", + refreshToken: "refreshed-refresh", + }); + }); + + test("leaves an empty auth table when nobody is logged in", () => { + const db = getRawDatabase(); + + prepareDatabaseForDowngrade(db); + + expect(db.query("SELECT COUNT(*) AS n FROM auth").get()).toEqual({ n: 0 }); + reopen(); + expect(listAccounts()).toEqual([]); + expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION); + }); +}); From 1a9e55b7be2cd5db81c5b4df699de279b249d14c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:01:15 +0000 Subject: [PATCH 2/6] fix(cli): Redo the downgrade hand-off and keep older-CLI logouts Another invocation of the newer CLI during the download re-migrates the database and undoes the hand-off, so `cli upgrade` now runs it again after installing. It skips a database already at schema 16, which keeps any login the older CLI wrote. When the older CLI logged out, `auth` comes back empty. The v18 migration now drops the handed-down active account instead of logging back in. --- packages/cli/src/commands/cli/upgrade.ts | 6 +++ packages/cli/src/lib/db/schema.ts | 14 +++++++ .../cli/test/commands/cli/upgrade.test.ts | 1 + .../lib/db/schema-downgrade.focused.test.ts | 40 +++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 2061a8112..26a1d04aa 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -1164,6 +1164,12 @@ export const upgradeCommand = buildCommand({ }); } + // Again after installing: another invocation of this CLI during the + // download re-migrates the database. No-op when nothing undid it. + if (downgrade) { + prepareLocalDatabaseForDowngrade(); + } + const changelog = await changelogPromise; yield new CommandOutput({ action: downgrade ? "downgraded" : "upgraded", diff --git a/packages/cli/src/lib/db/schema.ts b/packages/cli/src/lib/db/schema.ts index 57cffc145..a6fff35fc 100644 --- a/packages/cli/src/lib/db/schema.ts +++ b/packages/cli/src/lib/db/schema.ts @@ -939,6 +939,11 @@ function migrateSingleAuthToAccounts(db: Database): void { now, ); } + } else if (auth || tableExists(db, "auth")) { + // No stored token: the older CLI logged out (it deletes the `auth` row). + // Only an account handed down by prepareDatabaseForDowngrade can be + // active here, so drop it rather than silently logging back in. + db.query("DELETE FROM accounts WHERE is_active = 1").run(); } db.exec("DROP TABLE IF EXISTS auth"); @@ -1021,9 +1026,18 @@ function exportActiveAccountToLegacyTables(db: Database): void { * CLI re-runs its later migrations when it comes back and picks up any * login or token refresh the older CLI wrote to `auth`. * + * Does nothing when the stored version is already that low, so callers can + * run it again after installing: another invocation of the newer CLI during + * the download re-migrates the database and undoes the first run. + * * Every migration after that version must therefore be safe to re-run. */ export function prepareDatabaseForDowngrade(db: Database): void { + if (getSchemaVersion(db) <= PRE_ACCOUNTS_SCHEMA_VERSION) { + // Already prepared: preparing again would overwrite a login the older + // CLI wrote to `auth`. + return; + } db.transaction(() => { for (const tableName of REBUILDABLE_TABLES) { db.exec(`DROP TABLE IF EXISTS ${tableName}`); diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index 94f0a052b..501e45c6a 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -1002,6 +1002,7 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy true, ); expect(versionAtSetup).toBe(16); + expect(readSchemaVersion()).toBe(16); }); test("leaves the database alone when upgrading", async () => { diff --git a/packages/cli/test/lib/db/schema-downgrade.focused.test.ts b/packages/cli/test/lib/db/schema-downgrade.focused.test.ts index cac9758d5..3367e1d16 100644 --- a/packages/cli/test/lib/db/schema-downgrade.focused.test.ts +++ b/packages/cli/test/lib/db/schema-downgrade.focused.test.ts @@ -219,6 +219,46 @@ describe("prepareDatabaseForDowngrade", () => { }); }); + test("logs out on the way back up when the older CLI logged out", () => { + setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); + prepareDatabaseForDowngrade(getRawDatabase()); + // What 0.47.0's `sentry auth logout` does. + getRawDatabase().query("DELETE FROM auth WHERE id = 1").run(); + + reopen(); + + expect(listAccounts()).toEqual([]); + expect(getAuthConfig()).toBeUndefined(); + }); + + test("redoes the hand-off after another invocation re-migrated", () => { + setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); + prepareDatabaseForDowngrade(getRawDatabase()); + // Another newer-CLI invocation during the download. + reopen(); + expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION); + + prepareDatabaseForDowngrade(getRawDatabase()); + + expect(storedVersion()).toBe(16); + expect(getRawDatabase().query("SELECT token FROM auth").get()).toEqual({ + token: "active-token", + }); + }); + + test("keeps a login the older CLI wrote when run again", () => { + setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); + const db = getRawDatabase(); + prepareDatabaseForDowngrade(db); + db.query("UPDATE auth SET token = 'older-cli-login'").run(); + + prepareDatabaseForDowngrade(db); + + expect(db.query("SELECT token FROM auth").get()).toEqual({ + token: "older-cli-login", + }); + }); + test("leaves an empty auth table when nobody is logged in", () => { const db = getRawDatabase(); From 81ccd97263795b116bdb79436cee2587585b577f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:10:32 +0000 Subject: [PATCH 3/6] fix(cli): Only redo the downgrade hand-off for this CLI's schema The post-install pass treated any version above 16 as a re-migration by the newer CLI, so it also undid forward migrations the older CLI's own `cli setup` ran. Only this CLI migrates to its own version, so skip everything below it. --- packages/cli/src/commands/cli/upgrade.ts | 3 ++- packages/cli/src/lib/db/schema.ts | 13 +++++++------ .../lib/db/schema-downgrade.focused.test.ts | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 26a1d04aa..942bdcbf2 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -1165,7 +1165,8 @@ export const upgradeCommand = buildCommand({ } // Again after installing: another invocation of this CLI during the - // download re-migrates the database. No-op when nothing undid it. + // download re-migrates the database. No-op unless the stored version is + // this CLI's own, so the older CLI's forward migrations are kept. if (downgrade) { prepareLocalDatabaseForDowngrade(); } diff --git a/packages/cli/src/lib/db/schema.ts b/packages/cli/src/lib/db/schema.ts index a6fff35fc..ef3506045 100644 --- a/packages/cli/src/lib/db/schema.ts +++ b/packages/cli/src/lib/db/schema.ts @@ -1026,16 +1026,17 @@ function exportActiveAccountToLegacyTables(db: Database): void { * CLI re-runs its later migrations when it comes back and picks up any * login or token refresh the older CLI wrote to `auth`. * - * Does nothing when the stored version is already that low, so callers can - * run it again after installing: another invocation of the newer CLI during - * the download re-migrates the database and undoes the first run. + * Does nothing unless the stored version is this CLI's own, so callers can + * run it again after installing: only this CLI migrates to that version, so + * it means another invocation re-migrated the database during the download. + * An older CLI migrating forward from 16 stops below it and is left alone. * * Every migration after that version must therefore be safe to re-run. */ export function prepareDatabaseForDowngrade(db: Database): void { - if (getSchemaVersion(db) <= PRE_ACCOUNTS_SCHEMA_VERSION) { - // Already prepared: preparing again would overwrite a login the older - // CLI wrote to `auth`. + if (getSchemaVersion(db) < CURRENT_SCHEMA_VERSION) { + // Already prepared, or the older CLI has since migrated forward on its + // own. Preparing again would overwrite a login it wrote to `auth`. return; } db.transaction(() => { diff --git a/packages/cli/test/lib/db/schema-downgrade.focused.test.ts b/packages/cli/test/lib/db/schema-downgrade.focused.test.ts index 3367e1d16..d845eeac6 100644 --- a/packages/cli/test/lib/db/schema-downgrade.focused.test.ts +++ b/packages/cli/test/lib/db/schema-downgrade.focused.test.ts @@ -259,6 +259,23 @@ describe("prepareDatabaseForDowngrade", () => { }); }); + test("keeps migrations the older CLI ran when run again", () => { + setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); + const db = getRawDatabase(); + prepareDatabaseForDowngrade(db); + // An older CLI between schema 16 and this one migrates forward on setup. + db.query("UPDATE schema_version SET version = ?").run( + CURRENT_SCHEMA_VERSION - 1, + ); + + prepareDatabaseForDowngrade(db); + + expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION - 1); + expect(db.query("SELECT token FROM auth").get()).toEqual({ + token: "active-token", + }); + }); + test("leaves an empty auth table when nobody is logged in", () => { const db = getRawDatabase(); From 1e7b35fe91bc4a230d81e33543c544fee02a9704 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:20:25 +0000 Subject: [PATCH 4/6] fix(cli): Drop stored accounts on downgrade instead of exporting them Per review: rather than keep the pre-v18 `auth`/`user_info` schema in newer CLIs just for downgrades, the hand-off now clears stored accounts and the user logs in again in the older CLI. Coming back up, the v18 migration folds any login made there into `accounts` as before, so the token-matching and logout handling it gained in this PR are reverted. The hand-off only runs when the target predates 0.48.0, the first release that adapts a newer CLI's database itself. --- packages/cli/src/commands/cli/upgrade.ts | 17 +- packages/cli/src/lib/db/schema.ts | 150 ++++-------------- .../cli/test/commands/cli/upgrade.test.ts | 20 ++- .../lib/db/schema-downgrade.focused.test.ts | 92 +++-------- 4 files changed, 84 insertions(+), 195 deletions(-) diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 942bdcbf2..08e7ca78f 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -20,6 +20,7 @@ import { dirname, join } from "node:path"; import { setTimeout } from "node:timers/promises"; import type { SentryContext } from "../../context.js"; import { + compareVersions, determineInstallDir, isDowngrade, isNightlyVersion, @@ -72,8 +73,14 @@ import { whichSync } from "../../lib/which.js"; const log = logger.withTag("cli.upgrade"); /** - * Leave the local database readable by the older CLI being installed (#1611). - * A failure must not block the downgrade itself. + * First release that adapts a database written by a newer CLI on its own + * (#1611). Older releases (schema 16 and below) need the hand-off. + */ +const SELF_ADAPTING_CLI_VERSION = "0.48.0"; + +/** + * Leave the local database readable by an older CLI that cannot adapt it + * itself. A failure must not block the downgrade. */ function prepareLocalDatabaseForDowngrade(): void { try { @@ -1129,8 +1136,10 @@ export const upgradeCommand = buildCommand({ } const downgrade = isDowngrade(CLI_VERSION, target); log.debug(`${downgrade ? "Downgrading" : "Upgrading"} to ${target}`); + const handOffDatabase = + downgrade && compareVersions(target, SELF_ADAPTING_CLI_VERSION) < 0; // Before installing: the older binary runs `cli setup` during the upgrade. - if (downgrade) { + if (handOffDatabase) { prepareLocalDatabaseForDowngrade(); } @@ -1167,7 +1176,7 @@ export const upgradeCommand = buildCommand({ // Again after installing: another invocation of this CLI during the // download re-migrates the database. No-op unless the stored version is // this CLI's own, so the older CLI's forward migrations are kept. - if (downgrade) { + if (handOffDatabase) { prepareLocalDatabaseForDowngrade(); } diff --git a/packages/cli/src/lib/db/schema.ts b/packages/cli/src/lib/db/schema.ts index ef3506045..60d7fd677 100644 --- a/packages/cli/src/lib/db/schema.ts +++ b/packages/cli/src/lib/db/schema.ts @@ -890,141 +890,49 @@ function migrateSingleAuthToAccounts(db: Database): void { if (auth?.token) { const now = Date.now(); - // A newer CLI hands its active login back through `auth` before a - // downgrade (see prepareDatabaseForDowngrade), so on the way back up the - // login may already exist. Refresh that row instead of duplicating it. - const existing = db - .query( - `SELECT account_id FROM accounts - WHERE token = ? OR (user_id IS NOT NULL AND user_id = ? AND host IS ?) - LIMIT 1`, - ) - .get(auth.token, user?.user_id ?? null, auth.host) as - | { account_id: string } - | undefined; db.query("UPDATE accounts SET is_active = 0").run(); - if (existing) { - db.query( - `UPDATE accounts SET token = ?, refresh_token = ?, expires_at = ?, - issued_at = ?, is_active = 1, updated_at = ?, last_used_at = ? - WHERE account_id = ?`, - ).run( - auth.token, - auth.refresh_token, - auth.expires_at, - auth.issued_at, - auth.updated_at ?? now, - now, - existing.account_id, - ); - } else { - db.query( - `INSERT INTO accounts (account_id, host, user_id, email, username, name, + db.query( + `INSERT INTO accounts (account_id, host, user_id, email, username, name, token, refresh_token, expires_at, issued_at, is_active, created_at, updated_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, - ).run( - uuidv7(), - auth.host, - user?.user_id ?? null, - user?.email ?? null, - user?.username ?? null, - user?.name ?? null, - auth.token, - auth.refresh_token, - auth.expires_at, - auth.issued_at, - now, - auth.updated_at ?? now, - now, - ); - } - } else if (auth || tableExists(db, "auth")) { - // No stored token: the older CLI logged out (it deletes the `auth` row). - // Only an account handed down by prepareDatabaseForDowngrade can be - // active here, so drop it rather than silently logging back in. - db.query("DELETE FROM accounts WHERE is_active = 1").run(); + ).run( + uuidv7(), + auth.host, + user?.user_id ?? null, + user?.email ?? null, + user?.username ?? null, + user?.name ?? null, + auth.token, + auth.refresh_token, + auth.expires_at, + auth.issued_at, + now, + auth.updated_at ?? now, + now, + ); } db.exec("DROP TABLE IF EXISTS auth"); db.exec("DROP TABLE IF EXISTS user_info"); } -/** - * Newest schema an older CLI can read without knowing about `accounts` or - * org-region provenance: v17 rebuilt `org_regions` with a new key and v18 - * moved the login out of `auth`. - */ +/** Schema of CLI 0.47.0 and earlier, before `accounts` (v18) existed. */ const PRE_ACCOUNTS_SCHEMA_VERSION = 16; -/** `auth` and `user_info` as CLI versions before schema v18 read them. */ -const LEGACY_AUTH_DDL = `CREATE TABLE auth ( - id INTEGER PRIMARY KEY CHECK (id = 1), - token TEXT, - refresh_token TEXT, - expires_at INTEGER, - issued_at INTEGER, - updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), - host TEXT - )`; -const LEGACY_USER_INFO_DDL = `CREATE TABLE user_info ( - id INTEGER PRIMARY KEY CHECK (id = 1), - user_id TEXT NOT NULL, - email TEXT, - username TEXT, - name TEXT, - updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) - )`; - -/** Copy the active account into the single-row tables older CLIs read. */ -function exportActiveAccountToLegacyTables(db: Database): void { - db.exec("DROP TABLE IF EXISTS auth"); - db.exec("DROP TABLE IF EXISTS user_info"); - db.exec(LEGACY_AUTH_DDL); - db.exec(LEGACY_USER_INFO_DDL); - - const account = db - .query( - `SELECT host, user_id, email, username, name, token, refresh_token, - expires_at, issued_at, updated_at - FROM accounts WHERE is_active = 1 LIMIT 1`, - ) - .get() as (LegacyAuthRow & LegacyUserRow) | undefined; - if (!account?.token) { - return; - } - - db.query( - `INSERT INTO auth (id, token, refresh_token, expires_at, issued_at, updated_at, host) - VALUES (1, ?, ?, ?, ?, ?, ?)`, - ).run( - account.token, - account.refresh_token, - account.expires_at, - account.issued_at, - account.updated_at ?? Date.now(), - account.host, - ); - if (account.user_id) { - db.query( - "INSERT INTO user_info (id, user_id, email, username, name) VALUES (1, ?, ?, ?, ?)", - ).run(account.user_id, account.email, account.username, account.name); - } -} - /** - * Leave the database readable by an older CLI before `sentry cli upgrade` - * installs one (#1611). + * Leave the database readable by a CLI from before schema v18, ahead of + * `sentry cli upgrade` installing one (#1611). * - * Older CLIs only migrate forward and create missing tables with + * Those CLIs only migrate forward and create missing tables with * `CREATE TABLE IF NOT EXISTS`, so this: * - drops every cache table, letting the older CLI recreate them in its own * shape (v17 changed the `org_regions` key, which breaks older upserts); - * - copies the active login into the pre-v18 `auth`/`user_info` tables, so - * the older CLI stays logged in; + * - deletes every stored account: the older CLI cannot read `accounts`, so + * the user logs in again there; * - sets the stored version to {@link PRE_ACCOUNTS_SCHEMA_VERSION}, so this - * CLI re-runs its later migrations when it comes back and picks up any - * login or token refresh the older CLI wrote to `auth`. + * CLI re-runs its later migrations when it comes back and folds a login + * made in the older CLI into `accounts`. * * Does nothing unless the stored version is this CLI's own, so callers can * run it again after installing: only this CLI migrates to that version, so @@ -1035,17 +943,15 @@ function exportActiveAccountToLegacyTables(db: Database): void { */ export function prepareDatabaseForDowngrade(db: Database): void { if (getSchemaVersion(db) < CURRENT_SCHEMA_VERSION) { - // Already prepared, or the older CLI has since migrated forward on its - // own. Preparing again would overwrite a login it wrote to `auth`. return; } db.transaction(() => { for (const tableName of REBUILDABLE_TABLES) { db.exec(`DROP TABLE IF EXISTS ${tableName}`); } - if (tableExists(db, "accounts")) { - exportActiveAccountToLegacyTables(db); - } + // DELETE, not DROP: later reads in this process must not hit a missing + // table, whose auto-repair would restore this CLI's schema version. + db.query("DELETE FROM accounts").run(); db.query("UPDATE schema_version SET version = ?").run( PRE_ACCOUNTS_SCHEMA_VERSION, ); diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index 501e45c6a..4e90b7950 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -976,8 +976,8 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy expect(setupCall?.args).toContain("--ensure-auth-scopes"); }); - test("hands the database to the older CLI before it runs setup", async () => { - mockBinaryDownloadWithVersion("99.99.99"); + test("hands the database to an older CLI before it runs setup", async () => { + mockBinaryDownloadWithVersion("0.47.0"); const downgradeSpy = vi.spyOn(binary, "isDowngrade").mockReturnValue(true); let versionAtSetup: number | undefined; spawnSpy.mockImplementation((cmd: string, args?: readonly string[]) => { @@ -1005,6 +1005,22 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy expect(readSchemaVersion()).toBe(16); }); + test("leaves the database alone for an older CLI that adapts it", async () => { + mockBinaryDownloadWithVersion("99.99.99"); + const downgradeSpy = vi.spyOn(binary, "isDowngrade").mockReturnValue(true); + + const { context, restore } = createMockContext({ homeDir: testDir }); + restoreStderr = restore; + + try { + await run(app, ["cli", "upgrade", "--method", "curl"], context); + } finally { + downgradeSpy.mockRestore(); + } + + expect(readSchemaVersion()).toBe(CURRENT_SCHEMA_VERSION); + }); + test("leaves the database alone when upgrading", async () => { mockBinaryDownloadWithVersion("99.99.99"); diff --git a/packages/cli/test/lib/db/schema-downgrade.focused.test.ts b/packages/cli/test/lib/db/schema-downgrade.focused.test.ts index d845eeac6..beb1af142 100644 --- a/packages/cli/test/lib/db/schema-downgrade.focused.test.ts +++ b/packages/cli/test/lib/db/schema-downgrade.focused.test.ts @@ -20,6 +20,7 @@ import { } from "../../../src/lib/db/index.js"; import { CURRENT_SCHEMA_VERSION, + EXPECTED_TABLES, getSchemaIssues, prepareDatabaseForDowngrade, repairSchema, @@ -45,6 +46,12 @@ const V16_ORG_REGIONS_DDL = `CREATE TABLE IF NOT EXISTS org_regions ( region_url TEXT NOT NULL, updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) )`; +/** `auth` as CLI 0.47.0 creates it. */ +const V16_AUTH_DDL = `CREATE TABLE IF NOT EXISTS auth ( + id INTEGER PRIMARY KEY CHECK (id = 1), token TEXT, refresh_token TEXT, + expires_at INTEGER, issued_at INTEGER, + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), host TEXT +)`; const V16_ORG_REGIONS_UPSERT = `INSERT INTO org_regions (org_slug, region_url) VALUES ('acme', 'https://us.sentry.io') ON CONFLICT(org_slug) DO UPDATE SET region_url = excluded.region_url`; @@ -165,7 +172,7 @@ describe("repairSchema: primary keys", () => { }); describe("prepareDatabaseForDowngrade", () => { - test("leaves a database CLI 0.47.0 can use while logged in", () => { + test("leaves a database CLI 0.47.0 can use, logged out", () => { setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); setUserInfo({ userId: "7", email: "dev@example.com", name: "Dev" }); const db = getRawDatabase(); @@ -174,32 +181,25 @@ describe("prepareDatabaseForDowngrade", () => { expect(storedVersion()).toBe(16); expect(tableExists(db, "org_regions")).toBe(false); + expect(db.query("SELECT COUNT(*) AS n FROM accounts").get()).toEqual({ + n: 0, + }); // What 0.47.0 does on startup and when listing organizations. db.exec(V16_ORG_REGIONS_DDL); expect(() => db.exec(V16_ORG_REGIONS_UPSERT)).not.toThrow(); - expect( - db.query("SELECT token, refresh_token, host FROM auth").get(), - ).toEqual({ - token: "active-token", - refresh_token: "active-refresh", - host: HOST, - }); - expect( - db.query("SELECT user_id, email, name FROM user_info").get(), - ).toEqual({ user_id: "7", email: "dev@example.com", name: "Dev" }); }); - test("picks up the older CLI's token refresh without duplicating the account", () => { + test("picks up a login made in the older CLI on the way back up", () => { setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); - setUserInfo({ userId: "7", email: "dev@example.com" }); - prepareDatabaseForDowngrade(getRawDatabase()); - // The older CLI refreshes the token and recreates the cache in its shape. const db = getRawDatabase(); + prepareDatabaseForDowngrade(db); + // 0.47.0 recreates its tables, then the user logs in there. db.exec(V16_ORG_REGIONS_DDL); db.exec(V16_ORG_REGIONS_UPSERT); + db.exec(V16_AUTH_DDL); db.query( - "UPDATE auth SET token = 'refreshed-token', refresh_token = 'refreshed-refresh'", - ).run(); + "INSERT INTO auth (id, token, refresh_token, host) VALUES (1, 'older-cli-token', 'older-cli-refresh', ?)", + ).run(HOST); reopen(); @@ -210,30 +210,16 @@ describe("prepareDatabaseForDowngrade", () => { "source_origin", "org_slug", ]); - const accounts = listAccounts(); - expect(accounts).toHaveLength(1); - expect(accounts[0]).toMatchObject({ active: true, userId: "7" }); + expect(listAccounts()).toHaveLength(1); expect(getAuthConfig()).toMatchObject({ - token: "refreshed-token", - refreshToken: "refreshed-refresh", + token: "older-cli-token", + refreshToken: "older-cli-refresh", }); }); - test("logs out on the way back up when the older CLI logged out", () => { - setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); - prepareDatabaseForDowngrade(getRawDatabase()); - // What 0.47.0's `sentry auth logout` does. - getRawDatabase().query("DELETE FROM auth WHERE id = 1").run(); - - reopen(); - - expect(listAccounts()).toEqual([]); - expect(getAuthConfig()).toBeUndefined(); - }); - test("redoes the hand-off after another invocation re-migrated", () => { - setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); - prepareDatabaseForDowngrade(getRawDatabase()); + const db = getRawDatabase(); + prepareDatabaseForDowngrade(db); // Another newer-CLI invocation during the download. reopen(); expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION); @@ -241,29 +227,14 @@ describe("prepareDatabaseForDowngrade", () => { prepareDatabaseForDowngrade(getRawDatabase()); expect(storedVersion()).toBe(16); - expect(getRawDatabase().query("SELECT token FROM auth").get()).toEqual({ - token: "active-token", - }); - }); - - test("keeps a login the older CLI wrote when run again", () => { - setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); - const db = getRawDatabase(); - prepareDatabaseForDowngrade(db); - db.query("UPDATE auth SET token = 'older-cli-login'").run(); - - prepareDatabaseForDowngrade(db); - - expect(db.query("SELECT token FROM auth").get()).toEqual({ - token: "older-cli-login", - }); + expect(tableExists(getRawDatabase(), "org_regions")).toBe(false); }); test("keeps migrations the older CLI ran when run again", () => { - setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); const db = getRawDatabase(); prepareDatabaseForDowngrade(db); // An older CLI between schema 16 and this one migrates forward on setup. + db.exec(EXPECTED_TABLES.org_regions as string); db.query("UPDATE schema_version SET version = ?").run( CURRENT_SCHEMA_VERSION - 1, ); @@ -271,19 +242,6 @@ describe("prepareDatabaseForDowngrade", () => { prepareDatabaseForDowngrade(db); expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION - 1); - expect(db.query("SELECT token FROM auth").get()).toEqual({ - token: "active-token", - }); - }); - - test("leaves an empty auth table when nobody is logged in", () => { - const db = getRawDatabase(); - - prepareDatabaseForDowngrade(db); - - expect(db.query("SELECT COUNT(*) AS n FROM auth").get()).toEqual({ n: 0 }); - reopen(); - expect(listAccounts()).toEqual([]); - expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION); + expect(tableExists(db, "org_regions")).toBe(true); }); }); From 3bf3e34e69eb287259f2fb827c4c7216c692d0be Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:36:04 +0000 Subject: [PATCH 5/6] fix(cli): Hand off the database only once the older CLI is downloaded Running the downgrade hand-off before the download meant a failed download left the current CLI logged out. It now runs right before the older binary's `cli setup`, and again in a `finally` after it, so a setup failure still redoes it if another invocation re-migrated the database in the meantime. --- packages/cli/src/commands/cli/upgrade.ts | 51 +++++++++++++----- .../cli/test/commands/cli/upgrade.test.ts | 53 +++++++++++++++++++ 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 08e7ca78f..735c67da0 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -581,6 +581,8 @@ type SetupOptions = { ensureAuthScopes: boolean; /** Skip agent skill installation during setup. */ noAgentSkills: boolean; + /** Hand the local database to an older CLI around setup (#1611). */ + handOffDatabase: boolean; }; /** @@ -594,6 +596,11 @@ type SetupOptions = { * * For package manager upgrades: the binary is already in place, so setup only * updates completions, agent skills, and records metadata. + * + * With `handOffDatabase`, the database is handed off only once the older + * binary is downloaded, so a failed download keeps the current login, and + * again after setup even when it fails, in case another invocation of this + * CLI re-migrated the database meanwhile. */ async function runSetupOnNewBinary(opts: SetupOptions): Promise { const { @@ -604,6 +611,7 @@ async function runSetupOnNewBinary(opts: SetupOptions): Promise { installDir, ensureAuthScopes, noAgentSkills, + handOffDatabase, } = opts; const args = [ "cli", @@ -629,7 +637,17 @@ async function runSetupOnNewBinary(opts: SetupOptions): Promise { ? { ...process.env, SENTRY_INSTALL_DIR: installDir } : undefined; - const exitCode = await spawnWithRetry(binaryPath, args, env); + if (handOffDatabase) { + prepareLocalDatabaseForDowngrade(); + } + let exitCode: number; + try { + exitCode = await spawnWithRetry(binaryPath, args, env); + } finally { + if (handOffDatabase) { + prepareLocalDatabaseForDowngrade(); + } + } if (exitCode !== 0) { throw new UpgradeError( "execution_failed", @@ -697,6 +715,7 @@ async function executeStandardUpgrade(opts: { json?: boolean; noAgentSkills: boolean; source?: UpgradeSource; + handOffDatabase: boolean; }): Promise { const { method, @@ -710,6 +729,7 @@ async function executeStandardUpgrade(opts: { json, noAgentSkills, source, + handOffDatabase, } = opts; // Use the rolling "nightly" tag only when upgrading to latest nightly @@ -759,6 +779,7 @@ async function executeStandardUpgrade(opts: { installDir, ensureAuthScopes: !json, noAgentSkills, + handOffDatabase, }); } finally { releaseLock(downloadResult.lockPath); @@ -774,6 +795,7 @@ async function executeStandardUpgrade(opts: { install: false, ensureAuthScopes: !json, noAgentSkills, + handOffDatabase, }); } } @@ -803,9 +825,18 @@ async function migrateToStandaloneForNightly(opts: { json?: boolean; source?: UpgradeSource; channel: ReleaseChannel; + handOffDatabase: boolean; }): Promise { - const { method, target, versionArg, noAgentSkills, json, source, channel } = - opts; + const { + method, + target, + versionArg, + noAgentSkills, + json, + source, + channel, + handOffDatabase, + } = opts; log.info("Nightly builds are only available as standalone binaries."); log.info("Migrating to standalone installation..."); @@ -849,6 +880,7 @@ async function migrateToStandaloneForNightly(opts: { installDir, ensureAuthScopes: !json, noAgentSkills, + handOffDatabase, }); } finally { releaseLock(downloadResult.lockPath); @@ -1138,10 +1170,6 @@ export const upgradeCommand = buildCommand({ log.debug(`${downgrade ? "Downgrading" : "Upgrading"} to ${target}`); const handOffDatabase = downgrade && compareVersions(target, SELF_ADAPTING_CLI_VERSION) < 0; - // Before installing: the older binary runs `cli setup` during the upgrade. - if (handOffDatabase) { - prepareLocalDatabaseForDowngrade(); - } // Perform the actual upgrade let warnings: string[] | undefined; @@ -1156,6 +1184,7 @@ export const upgradeCommand = buildCommand({ json: flags.json, source, channel, + handOffDatabase, }); } else { await executeStandardUpgrade({ @@ -1170,16 +1199,10 @@ export const upgradeCommand = buildCommand({ json: flags.json, noAgentSkills: flags["no-agent-skills"], source, + handOffDatabase, }); } - // Again after installing: another invocation of this CLI during the - // download re-migrates the database. No-op unless the stored version is - // this CLI's own, so the older CLI's forward migrations are kept. - if (handOffDatabase) { - prepareLocalDatabaseForDowngrade(); - } - const changelog = await changelogPromise; yield new CommandOutput({ action: downgrade ? "downgraded" : "upgraded", diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index 4e90b7950..8662fd1ba 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -1005,6 +1005,59 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy expect(readSchemaVersion()).toBe(16); }); + test("keeps the database when downloading the older CLI fails", async () => { + mockFetch(async (url) => { + const urlStr = extractInputUrl(url); + if (urlStr.includes("getsentry/toolkit/releases?per_page=100")) { + return new Response(JSON.stringify([{ tag_name: "cli@0.47.0" }]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("Server Error", { status: 500 }); + }); + const downgradeSpy = vi.spyOn(binary, "isDowngrade").mockReturnValue(true); + + const { context, restore } = createMockContext({ homeDir: testDir }); + restoreStderr = restore; + + try { + await run(app, ["cli", "upgrade", "--method", "curl"], context); + } finally { + downgradeSpy.mockRestore(); + } + + expect(spawnedArgs).toEqual([]); + expect(readSchemaVersion()).toBe(CURRENT_SCHEMA_VERSION); + }); + + test("redoes the hand-off after setup fails", async () => { + mockBinaryDownloadWithVersion("0.47.0"); + const downgradeSpy = vi.spyOn(binary, "isDowngrade").mockReturnValue(true); + spawnSpy.mockImplementation((cmd: string, args?: readonly string[]) => { + spawnedArgs.push({ cmd, args: [...(args ?? [])] }); + // Another invocation of this CLI re-migrates the database meanwhile. + getRawDatabase() + .query("UPDATE schema_version SET version = ?") + .run(CURRENT_SCHEMA_VERSION); + return fakeChildProcess(1); + }); + + const { context, restore } = createMockContext({ homeDir: testDir }); + restoreStderr = restore; + + try { + await run(app, ["cli", "upgrade", "--method", "curl"], context); + } finally { + downgradeSpy.mockRestore(); + } + + expect(spawnedArgs.some((entry) => entry.args.includes("setup"))).toBe( + true, + ); + expect(readSchemaVersion()).toBe(16); + }); + test("leaves the database alone for an older CLI that adapts it", async () => { mockBinaryDownloadWithVersion("99.99.99"); const downgradeSpy = vi.spyOn(binary, "isDowngrade").mockReturnValue(true); From d814a7d455a4b4840f08e4f7396ec3732a01574c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:15:02 +0000 Subject: [PATCH 6/6] ref(cli): Mark persistent tables instead of rebuildable ones Tables now count as caches unless they set persistent: true, so only logins, settings, and other data the CLI cannot fetch again need a flag. A test lists the cache tables so adding a table is a deliberate choice. --- packages/cli/src/lib/db/schema.ts | 34 ++++++++++++------------- packages/cli/test/lib/db/schema.test.ts | 23 +++++++++++++++++ 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/lib/db/schema.ts b/packages/cli/src/lib/db/schema.ts index 60d7fd677..5cea02704 100644 --- a/packages/cli/src/lib/db/schema.ts +++ b/packages/cli/src/lib/db/schema.ts @@ -48,10 +48,11 @@ export type TableSchema = { */ compositePrimaryKey?: string[]; /** - * The table only holds data the CLI can fetch or detect again, so repair - * may drop and recreate it when its shape is wrong or a newer CLI wrote it. + * The table holds data the CLI can't fetch or detect again, such as + * logins or settings. Every other table is a cache, which repair may drop + * and recreate when its shape is wrong or a newer CLI wrote it. */ - rebuildable?: boolean; + persistent?: boolean; }; /** @@ -60,6 +61,7 @@ export type TableSchema = { */ export const TABLE_SCHEMAS: Record = { schema_version: { + persistent: true, columns: { version: { type: "INTEGER", primaryKey: true }, }, @@ -75,6 +77,7 @@ export const TABLE_SCHEMAS: Record = { * same account updates it instead of adding a duplicate. */ accounts: { + persistent: true, columns: { account_id: { type: "TEXT", primaryKey: true }, // Origin URL (scheme://host[:port]) this token was issued against. @@ -112,7 +115,6 @@ export const TABLE_SCHEMAS: Record = { }, project_cache: { - rebuildable: true, columns: { cache_key: { type: "TEXT", primaryKey: true }, org_slug: { type: "TEXT", notNull: true }, @@ -133,7 +135,6 @@ export const TABLE_SCHEMAS: Record = { }, }, dsn_cache: { - rebuildable: true, columns: { directory: { type: "TEXT", primaryKey: true }, dsn: { type: "TEXT", notNull: true }, @@ -164,7 +165,6 @@ export const TABLE_SCHEMAS: Record = { }, }, project_aliases: { - rebuildable: true, columns: { alias: { type: "TEXT", primaryKey: true }, org_slug: { type: "TEXT", notNull: true }, @@ -183,7 +183,6 @@ export const TABLE_SCHEMAS: Record = { }, }, pagination_cursors: { - rebuildable: true, columns: { command_key: { type: "TEXT", notNull: true }, context: { type: "TEXT", notNull: true }, @@ -194,13 +193,13 @@ export const TABLE_SCHEMAS: Record = { compositePrimaryKey: ["command_key", "context"], }, metadata: { + persistent: true, columns: { key: { type: "TEXT", primaryKey: true }, value: { type: "TEXT", notNull: true }, }, }, org_regions: { - rebuildable: true, columns: { org_slug: { type: "TEXT", notNull: true }, org_id: { type: "TEXT", addedInVersion: 8 }, @@ -220,6 +219,7 @@ export const TABLE_SCHEMAS: Record = { compositePrimaryKey: ["credential_identity", "source_origin", "org_slug"], }, instance_info: { + persistent: true, columns: { id: { type: "INTEGER", primaryKey: true, check: "id = 1" }, instance_id: { type: "TEXT", notNull: true }, @@ -231,7 +231,6 @@ export const TABLE_SCHEMAS: Record = { }, }, repo_cache: { - rebuildable: true, columns: { // Composite PK (org_slug) — one row per org caches the full repo list org_slug: { type: "TEXT", primaryKey: true }, @@ -262,7 +261,6 @@ export const TABLE_SCHEMAS: Record = { * permissions). */ issue_org_cache: { - rebuildable: true, columns: { // Numeric issue group ID (e.g., "7413562541"). TEXT to sidestep the // 2^53 JavaScript number limit if Sentry's group IDs ever exceed it. @@ -276,7 +274,6 @@ export const TABLE_SCHEMAS: Record = { }, }, project_root_cache: { - rebuildable: true, columns: { cwd: { type: "TEXT", primaryKey: true }, project_root: { type: "TEXT", notNull: true }, @@ -297,6 +294,7 @@ export const TABLE_SCHEMAS: Record = { * The next normal CLI run flushes entries as Sentry metrics and deletes them. */ completion_telemetry_queue: { + persistent: true, columns: { id: { type: "INTEGER", primaryKey: true }, created_at: { @@ -572,7 +570,7 @@ function repairMissingColumns(db: Database, result: RepairResult): void { } /** - * Drop and recreate rebuildable tables that have the wrong primary key. + * Drop and recreate cache tables that have the wrong primary key. * * SQLite cannot ALTER a primary key, so the table must be recreated. This * covers CLI-72 (pagination_cursors created with a single-column PK) and a @@ -588,7 +586,7 @@ function repairWrongPrimaryKeys(db: Database, result: RepairResult): void { if (hasPrimaryKey(db, tableName, declaredPrimaryKey(schema))) { continue; } - if (!schema.rebuildable) { + if (schema.persistent) { result.failed.push( `Table ${tableName} has an unexpected primary key and holds data that cannot be rebuilt`, ); @@ -607,9 +605,9 @@ function repairWrongPrimaryKeys(db: Database, result: RepairResult): void { } } -/** Names of tables that only hold data the CLI can fetch or detect again. */ -const REBUILDABLE_TABLES = Object.entries(TABLE_SCHEMAS) - .filter(([, schema]) => schema.rebuildable) +/** Tables not marked persistent: caches the CLI can fetch or detect again. */ +const CACHE_TABLES = Object.entries(TABLE_SCHEMAS) + .filter(([, schema]) => !schema.persistent) .map(([name]) => name); /** @@ -628,7 +626,7 @@ function rebuildCachesFromNewerSchema( } try { db.transaction(() => { - for (const tableName of REBUILDABLE_TABLES) { + for (const tableName of CACHE_TABLES) { db.exec(`DROP TABLE IF EXISTS ${tableName}`); db.exec(EXPECTED_TABLES[tableName] as string); } @@ -946,7 +944,7 @@ export function prepareDatabaseForDowngrade(db: Database): void { return; } db.transaction(() => { - for (const tableName of REBUILDABLE_TABLES) { + for (const tableName of CACHE_TABLES) { db.exec(`DROP TABLE IF EXISTS ${tableName}`); } // DELETE, not DROP: later reads in this process must not hit a missing diff --git a/packages/cli/test/lib/db/schema.test.ts b/packages/cli/test/lib/db/schema.test.ts index 52dd3b4a8..4edc39a2e 100644 --- a/packages/cli/test/lib/db/schema.test.ts +++ b/packages/cli/test/lib/db/schema.test.ts @@ -15,6 +15,7 @@ import { isReadonlyError, repairSchema, runMigrations, + TABLE_SCHEMAS, tableExists, } from "../../../src/lib/db/schema.js"; import { Database } from "../../../src/lib/db/sqlite.js"; @@ -240,6 +241,28 @@ describe("EXPECTED_TABLES", () => { }); }); +describe("TABLE_SCHEMAS persistence", () => { + // Tables without `persistent: true` are caches that repair and downgrade + // may drop. Listing them here makes adding a table a deliberate choice. + test("only known cache tables are droppable", () => { + const cacheTables = Object.entries(TABLE_SCHEMAS) + .filter(([, schema]) => !schema.persistent) + .map(([name]) => name) + .sort(); + + expect(cacheTables).toEqual([ + "dsn_cache", + "issue_org_cache", + "org_regions", + "pagination_cursors", + "project_aliases", + "project_cache", + "project_root_cache", + "repo_cache", + ]); + }); +}); + describe("EXPECTED_COLUMNS", () => { test("dsn_cache includes v4 columns", () => { const dsnCacheColumns = EXPECTED_COLUMNS.dsn_cache;