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..735c67da0 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, @@ -30,13 +31,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 +72,27 @@ import { whichSync } from "../../lib/which.js"; const log = logger.withTag("cli.upgrade"); +/** + * 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 { + 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"]); @@ -553,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; }; /** @@ -566,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 { @@ -576,6 +611,7 @@ async function runSetupOnNewBinary(opts: SetupOptions): Promise { installDir, ensureAuthScopes, noAgentSkills, + handOffDatabase, } = opts; const args = [ "cli", @@ -601,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", @@ -669,6 +715,7 @@ async function executeStandardUpgrade(opts: { json?: boolean; noAgentSkills: boolean; source?: UpgradeSource; + handOffDatabase: boolean; }): Promise { const { method, @@ -682,6 +729,7 @@ async function executeStandardUpgrade(opts: { json, noAgentSkills, source, + handOffDatabase, } = opts; // Use the rolling "nightly" tag only when upgrading to latest nightly @@ -731,6 +779,7 @@ async function executeStandardUpgrade(opts: { installDir, ensureAuthScopes: !json, noAgentSkills, + handOffDatabase, }); } finally { releaseLock(downloadResult.lockPath); @@ -746,6 +795,7 @@ async function executeStandardUpgrade(opts: { install: false, ensureAuthScopes: !json, noAgentSkills, + handOffDatabase, }); } } @@ -775,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..."); @@ -821,6 +880,7 @@ async function migrateToStandaloneForNightly(opts: { installDir, ensureAuthScopes: !json, noAgentSkills, + handOffDatabase, }); } finally { releaseLock(downloadResult.lockPath); @@ -1108,6 +1168,8 @@ 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; // Perform the actual upgrade let warnings: string[] | undefined; @@ -1122,6 +1184,7 @@ export const upgradeCommand = buildCommand({ json: flags.json, source, channel, + handOffDatabase, }); } else { await executeStandardUpgrade({ @@ -1136,6 +1199,7 @@ export const upgradeCommand = buildCommand({ json: flags.json, noAgentSkills: flags["no-agent-skills"], source, + handOffDatabase, }); } diff --git a/packages/cli/src/lib/db/schema.ts b/packages/cli/src/lib/db/schema.ts index 7ed756c5f..5cea02704 100644 --- a/packages/cli/src/lib/db/schema.ts +++ b/packages/cli/src/lib/db/schema.ts @@ -47,6 +47,12 @@ export type TableSchema = { * should NOT also set `primaryKey: true`. */ compositePrimaryKey?: string[]; + /** + * 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. + */ + persistent?: boolean; }; /** @@ -55,6 +61,7 @@ export type TableSchema = { */ export const TABLE_SCHEMAS: Record = { schema_version: { + persistent: true, columns: { version: { type: "INTEGER", primaryKey: true }, }, @@ -70,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. @@ -185,6 +193,7 @@ export const TABLE_SCHEMAS: Record = { compositePrimaryKey: ["command_key", "context"], }, metadata: { + persistent: true, columns: { key: { type: "TEXT", primaryKey: true }, value: { type: "TEXT", notNull: true }, @@ -210,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 }, @@ -284,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: { @@ -416,29 +427,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 +476,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 +495,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 +508,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 +570,26 @@ function repairMissingColumns(db: Database, result: RepairResult): void { } /** - * Drop and recreate tables that have incorrect primary key constraints. + * Drop and recreate cache 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.persistent) { + result.failed.push( + `Table ${tableName} has an unexpected primary key and holds data that cannot be rebuilt`, + ); continue; } try { @@ -581,9 +605,48 @@ function repairWrongPrimaryKeys(db: Database, result: RepairResult): void { } } +/** 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); + +/** + * 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 CACHE_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 +654,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 +808,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; @@ -848,6 +915,74 @@ function migrateSingleAuthToAccounts(db: Database): void { db.exec("DROP TABLE IF EXISTS user_info"); } +/** Schema of CLI 0.47.0 and earlier, before `accounts` (v18) existed. */ +const PRE_ACCOUNTS_SCHEMA_VERSION = 16; + +/** + * Leave the database readable by a CLI from before schema v18, ahead of + * `sentry cli upgrade` installing one (#1611). + * + * 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); + * - 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 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 + * 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) < CURRENT_SCHEMA_VERSION) { + return; + } + db.transaction(() => { + 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 + // 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, + ); + })(); +} + +/** + * 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 +996,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 +1039,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..8662fd1ba 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,115 @@ describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy expect(setupCall?.args).toContain("--ensure-auth-scopes"); }); + 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[]) => { + 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); + 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); + + 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"); + + 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..beb1af142 --- /dev/null +++ b/packages/cli/test/lib/db/schema-downgrade.focused.test.ts @@ -0,0 +1,247 @@ +/** + * 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, + EXPECTED_TABLES, + 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) +)`; +/** `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`; + +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, logged out", () => { + 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); + 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(); + }); + + test("picks up a login made in the older CLI on the way back up", () => { + setAuthToken("active-token", 3600, "active-refresh", { host: HOST }); + 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( + "INSERT INTO auth (id, token, refresh_token, host) VALUES (1, 'older-cli-token', 'older-cli-refresh', ?)", + ).run(HOST); + + reopen(); + + expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION); + expect(tableExists(getRawDatabase(), "auth")).toBe(false); + expect(primaryKey("org_regions")).toEqual([ + "credential_identity", + "source_origin", + "org_slug", + ]); + expect(listAccounts()).toHaveLength(1); + expect(getAuthConfig()).toMatchObject({ + token: "older-cli-token", + refreshToken: "older-cli-refresh", + }); + }); + + test("redoes the hand-off after another invocation re-migrated", () => { + const db = getRawDatabase(); + prepareDatabaseForDowngrade(db); + // Another newer-CLI invocation during the download. + reopen(); + expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION); + + prepareDatabaseForDowngrade(getRawDatabase()); + + expect(storedVersion()).toBe(16); + expect(tableExists(getRawDatabase(), "org_regions")).toBe(false); + }); + + test("keeps migrations the older CLI ran when run again", () => { + 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, + ); + + prepareDatabaseForDowngrade(db); + + expect(storedVersion()).toBe(CURRENT_SCHEMA_VERSION - 1); + expect(tableExists(db, "org_regions")).toBe(true); + }); +}); 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;