Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 | 257x 257x 257x 257x 3608x 20395x 20395x 3351x 20395x 775x 20395x 10575x 20395x 3871x 20395x 3608x 257x 3608x 3598x 10x 10x 10x 132x 10x 10x 257x 3598x 257x 3598x 20303x 3084x 3598x 3598x 684x 684x 549x 549x 50x 50x 50x 50x 44x 447x 447x 288x 159x 378x 34x 447x 447x 3x 444x 32x 32x 448x 1x 1x 447x 447x 32x 10x 140x 114x 26x 26x 26x 10x 50x 50x 120x 93x 27x 27x 27x 10x 140x 130x 10x 10x 8x 2x 2x 2x 2x 10x 10x 10x 10x 10x 8x 8x 10x 257x 4x 4x 4x 10x 3x 7x 4x 4x 3x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 2047x 2047x 2047x 2047x 2031x 2036x 2036x 2036x 2036x 2036x 2036x 2036x 6x 2036x 1x 1x 2036x 8x 2036x 8x 2036x 8x 2036x 8x 2036x 8x 2036x 8x 8x 8x 2036x 3x 3x 2x 3x 1x 3x 2036x 12x 2036x 12x 2036x 12x 2036x 12x | /**
* Database schema DDL and version management.
*
* This module defines the canonical schema for the CLI's SQLite database,
* handles migrations between versions, and provides repair utilities for
* fixing schema inconsistencies.
*
* Schema is defined once in TABLE_SCHEMAS and used to generate:
* - DDL statements for table creation
* - Column lists for schema repair
* - Migration checks
*/
import { createRequire } from "node:module";
import { getEnv } from "../env.js";
import { stringifyUnknown } from "../errors.js";
import { logger } from "../logger.js";
import type { Database } from "./sqlite.js";
const _require = createRequire(import.meta.url);
export const CURRENT_SCHEMA_VERSION = 16;
/** Environment variable to disable auto-repair */
const NO_AUTO_REPAIR_ENV = "SENTRY_CLI_NO_AUTO_REPAIR";
type SqliteType = "TEXT" | "INTEGER";
export type ColumnDef = {
type: SqliteType;
primaryKey?: boolean;
notNull?: boolean;
default?: string;
check?: string;
/** Schema version when this column was added (for repair tracking) */
addedInVersion?: number;
};
export type TableSchema = {
columns: Record<string, ColumnDef>;
/**
* Composite primary key columns. When set, the DDL generator emits a
* table-level `PRIMARY KEY (col1, col2, ...)` constraint instead of
* per-column `PRIMARY KEY` attributes. Individual columns listed here
* should NOT also set `primaryKey: true`.
*/
compositePrimaryKey?: string[];
};
/**
* Canonical schema definitions for all tables.
* DDL and repair info are generated from this single source of truth.
*/
export const TABLE_SCHEMAS: Record<string, TableSchema> = {
schema_version: {
columns: {
version: { type: "INTEGER", primaryKey: true },
},
},
auth: {
columns: {
id: { type: "INTEGER", primaryKey: true, check: "id = 1" },
token: { type: "TEXT" },
refresh_token: { type: "TEXT" },
expires_at: { type: "INTEGER" },
issued_at: { type: "INTEGER" },
updated_at: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
// Origin URL (scheme://host[:port]) this token was issued against.
// Enforced at the fetch layer: credentials are only attached to requests
// whose origin matches this host (with SaaS equivalence). Nullable for
// rows created before schema v16; migrated lazily in getAuthConfig.
host: { type: "TEXT", addedInVersion: 16 },
},
},
project_cache: {
columns: {
cache_key: { type: "TEXT", primaryKey: true },
org_slug: { type: "TEXT", notNull: true },
org_name: { type: "TEXT", notNull: true },
project_slug: { type: "TEXT", notNull: true },
project_name: { type: "TEXT", notNull: true },
project_id: { type: "TEXT", addedInVersion: 7 },
cached_at: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
last_accessed: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
},
},
dsn_cache: {
columns: {
directory: { type: "TEXT", primaryKey: true },
dsn: { type: "TEXT", notNull: true },
project_id: { type: "TEXT", notNull: true },
org_id: { type: "TEXT" },
source: { type: "TEXT", notNull: true },
source_path: { type: "TEXT" },
resolved_org_slug: { type: "TEXT" },
resolved_org_name: { type: "TEXT" },
resolved_project_slug: { type: "TEXT" },
resolved_project_name: { type: "TEXT" },
fingerprint: { type: "TEXT", addedInVersion: 4 },
all_dsns_json: { type: "TEXT", addedInVersion: 4 },
source_mtimes_json: { type: "TEXT", addedInVersion: 4 },
dir_mtimes_json: { type: "TEXT", addedInVersion: 4 },
root_dir_mtime: { type: "INTEGER", addedInVersion: 4 },
ttl_expires_at: { type: "INTEGER", addedInVersion: 4 },
cached_at: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
last_accessed: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
},
},
project_aliases: {
columns: {
alias: { type: "TEXT", primaryKey: true },
org_slug: { type: "TEXT", notNull: true },
project_slug: { type: "TEXT", notNull: true },
dsn_fingerprint: { type: "TEXT" },
cached_at: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
last_accessed: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
},
},
pagination_cursors: {
columns: {
command_key: { type: "TEXT", notNull: true },
context: { type: "TEXT", notNull: true },
cursor_stack: { type: "TEXT", notNull: true },
page_index: { type: "INTEGER", notNull: true, default: "0" },
expires_at: { type: "INTEGER", notNull: true },
},
compositePrimaryKey: ["command_key", "context"],
},
metadata: {
columns: {
key: { type: "TEXT", primaryKey: true },
value: { type: "TEXT", notNull: true },
},
},
org_regions: {
columns: {
org_slug: { type: "TEXT", primaryKey: true },
org_id: { type: "TEXT", addedInVersion: 8 },
org_name: { type: "TEXT", addedInVersion: 9 },
org_role: { type: "TEXT", addedInVersion: 10 },
region_url: { type: "TEXT", notNull: true },
updated_at: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
},
},
user_info: {
columns: {
id: { type: "INTEGER", primaryKey: true, check: "id = 1" },
user_id: { type: "TEXT", notNull: true },
email: { type: "TEXT" },
username: { type: "TEXT" },
name: { type: "TEXT", addedInVersion: 3 },
updated_at: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
},
},
instance_info: {
columns: {
id: { type: "INTEGER", primaryKey: true, check: "id = 1" },
instance_id: { type: "TEXT", notNull: true },
created_at: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
},
},
repo_cache: {
columns: {
// Composite PK (org_slug) — one row per org caches the full repo list
org_slug: { type: "TEXT", primaryKey: true },
// JSON array of SentryRepository — stored as blob since we re-hydrate
// the whole list on cache hit (no per-repo lookups). Keeps the cache
// simple and matches the typical usage pattern (match git origin →
// find one repo by name/url).
repos_json: { type: "TEXT", notNull: true },
cached_at: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
},
},
/**
* Mapping from numeric issue group IDs to organization slugs.
*
* Populated after `sentry issue view <numeric-id>` falls back to the legacy
* unscoped `/api/0/issues/{id}/` endpoint and extracts the org from the
* response permalink. Subsequent runs consult this cache to route directly
* via the org-scoped endpoint, avoiding the redundant unscoped lookup
* flagged as a "Consecutive HTTP" performance issue in Sentry.
*
* Entries are best-effort: a stale mapping (issue moved / deleted / access
* revoked) causes a 404 on the cached org call, which the caller evicts and
* falls back from. Cleared on logout (scoped to the current user's
* permissions).
*/
issue_org_cache: {
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.
issue_id: { type: "TEXT", primaryKey: true },
org_slug: { type: "TEXT", notNull: true },
cached_at: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
},
},
project_root_cache: {
columns: {
cwd: { type: "TEXT", primaryKey: true },
project_root: { type: "TEXT", notNull: true },
reason: { type: "TEXT", notNull: true },
cwd_mtime: { type: "INTEGER", notNull: true },
cached_at: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
ttl_expires_at: { type: "INTEGER", notNull: true },
},
},
/**
* Queue for deferred completion telemetry.
*
* Shell completions write timing data here (zero Sentry SDK overhead).
* The next normal CLI run flushes entries as Sentry metrics and deletes them.
*/
completion_telemetry_queue: {
columns: {
id: { type: "INTEGER", primaryKey: true },
created_at: {
type: "INTEGER",
notNull: true,
default: "(unixepoch() * 1000)",
},
command_path: { type: "TEXT", notNull: true },
duration_ms: { type: "INTEGER", notNull: true },
result_count: { type: "INTEGER", notNull: true },
},
},
};
/** Generate CREATE TABLE DDL from column definitions */
function columnDefsToDDL(
tableName: string,
columns: [string, ColumnDef][],
compositePrimaryKey?: string[]
): string {
const columnDefs = columns.map(([name, col]) => {
const parts = [name, col.type];
if (col.primaryKey) {
parts.push("PRIMARY KEY");
}
if (col.check) {
parts.push(`CHECK (${col.check})`);
}
if (col.notNull) {
parts.push("NOT NULL");
}
if (col.default) {
parts.push(`DEFAULT ${col.default}`);
}
return parts.join(" ");
});
if (compositePrimaryKey && compositePrimaryKey.length > 0) {
columnDefs.push(`PRIMARY KEY (${compositePrimaryKey.join(", ")})`);
}
return `CREATE TABLE IF NOT EXISTS ${tableName} (\n ${columnDefs.join(",\n ")}\n )`;
}
/** Generate CREATE TABLE DDL from a table schema */
export function generateTableDDL(
tableName: string,
schema: TableSchema
): string {
return columnDefsToDDL(
tableName,
Object.entries(schema.columns),
schema.compositePrimaryKey
);
}
/**
* Generate CREATE TABLE DDL excluding columns added in migrations.
* Useful for testing schema repair by creating "pre-migration" tables.
*
* @throws Error if table has no base columns (all columns were added in migrations)
*/
export function generatePreMigrationTableDDL(tableName: string): string {
const schema = TABLE_SCHEMAS[tableName];
Iif (!schema) {
throw new Error(`Unknown table: ${tableName}`);
}
const baseColumns = Object.entries(schema.columns).filter(
([, col]) => col.addedInVersion === undefined
);
Iif (baseColumns.length === 0) {
throw new Error(
`Table ${tableName} has no base columns (all columns were added in migrations)`
);
}
return columnDefsToDDL(tableName, baseColumns, schema.compositePrimaryKey);
}
/** Generated DDL statements for all tables (used for repair and init) */
export const EXPECTED_TABLES: Record<string, string> = Object.fromEntries(
Object.entries(TABLE_SCHEMAS).map(([name, schema]) => [
name,
generateTableDDL(name, schema),
])
);
/** Column info for repair operations */
type RepairColumnDef = { name: string; type: SqliteType };
/**
* Columns that may need repair (added in migrations).
* Generated from TABLE_SCHEMAS where addedInVersion is set.
*/
export const EXPECTED_COLUMNS: Record<string, RepairColumnDef[]> =
Object.fromEntries(
Object.entries(TABLE_SCHEMAS)
.map(([tableName, schema]) => {
const migratedColumns = Object.entries(schema.columns)
.filter(([, col]) => col.addedInVersion !== undefined)
.map(([name, col]) => ({ name, type: col.type }));
return [tableName, migratedColumns] as const;
})
.filter(([, cols]) => cols.length > 0)
);
/** Check if a table exists in the database */
export function tableExists(db: Database, table: string): boolean {
const result = db
.query(
"SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name=?"
)
.get(table) as { count: number };
return result.count > 0;
}
/** Check if a column exists in a table */
export function hasColumn(
db: Database,
table: string,
column: string
): boolean {
const result = db
.query(
`SELECT COUNT(*) as count FROM pragma_table_info('${table}') WHERE name='${column}'`
)
.get() as { count: number };
return result.count > 0;
}
/**
* Check if a table has the expected composite primary key.
*
* 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`).
*/
function hasCompositePrimaryKey(
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;
Iif (!row) {
return false;
}
const expectedPK = `PRIMARY KEY (${expectedColumns.join(", ")})`;
return row.sql.includes(expectedPK);
}
/** Add a column to a table if it doesn't exist */
function addColumnIfMissing(
db: Database,
table: string,
column: string,
type: string
): void {
Iif (!hasColumn(db, table, column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
}
}
/** Schema issue types for diagnostics */
export type SchemaIssue =
| { type: "missing_table"; table: string }
| { type: "missing_column"; table: string; column: string }
| { type: "wrong_primary_key"; table: string };
function findMissingColumns(db: Database, tableName: string): SchemaIssue[] {
const columns = EXPECTED_COLUMNS[tableName];
if (!columns) {
return [];
}
return columns
.filter((col) => !hasColumn(db, tableName, col.name))
.map((col) => ({
type: "missing_column" as const,
table: tableName,
column: col.name,
}));
}
function findPrimaryKeyIssues(db: Database, tableName: string): SchemaIssue[] {
const schema = TABLE_SCHEMAS[tableName];
if (
schema?.compositePrimaryKey &&
!hasCompositePrimaryKey(db, tableName, schema.compositePrimaryKey)
) {
return [{ type: "wrong_primary_key", table: tableName }];
}
return [];
}
/**
* Check schema and return list of issues.
* Used for diagnostics and dry-run mode in `sentry cli fix`.
*/
export function getSchemaIssues(db: Database): SchemaIssue[] {
const issues: SchemaIssue[] = [];
for (const tableName of Object.keys(EXPECTED_TABLES)) {
if (!tableExists(db, tableName)) {
issues.push({ type: "missing_table", table: tableName });
continue;
}
issues.push(...findMissingColumns(db, tableName));
issues.push(...findPrimaryKeyIssues(db, tableName));
}
return issues;
}
/** Result of a schema repair operation */
export type RepairResult = {
fixed: string[];
failed: string[];
};
function repairMissingTables(db: Database, result: RepairResult): void {
for (const [tableName, ddl] of Object.entries(EXPECTED_TABLES)) {
if (tableExists(db, tableName)) {
continue;
}
try {
db.exec(ddl);
result.fixed.push(`Created table ${tableName}`);
} catch (e) {
const msg = stringifyUnknown(e);
result.failed.push(`Failed to create table ${tableName}: ${msg}`);
}
}
}
function repairMissingColumns(db: Database, result: RepairResult): void {
for (const [tableName, columns] of Object.entries(EXPECTED_COLUMNS)) {
Iif (!tableExists(db, tableName)) {
continue;
}
for (const col of columns) {
if (hasColumn(db, tableName, col.name)) {
continue;
}
try {
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${col.name} ${col.type}`);
result.fixed.push(`Added column ${tableName}.${col.name}`);
} catch (e) {
const msg = stringifyUnknown(e);
result.failed.push(
`Failed to add column ${tableName}.${col.name}: ${msg}`
);
}
}
}
}
/**
* Drop and recreate tables that have incorrect primary key constraints.
*
* 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).
*/
function repairWrongPrimaryKeys(db: Database, result: RepairResult): void {
for (const [tableName, schema] of Object.entries(TABLE_SCHEMAS)) {
if (!schema.compositePrimaryKey) {
continue;
}
Iif (!tableExists(db, tableName)) {
continue;
}
if (hasCompositePrimaryKey(db, tableName, schema.compositePrimaryKey)) {
continue;
}
try {
db.exec(`DROP TABLE ${tableName}`);
db.exec(EXPECTED_TABLES[tableName] as string);
result.fixed.push(
`Recreated table ${tableName} with correct primary key`
);
} catch (e) {
const msg = stringifyUnknown(e);
result.failed.push(`Failed to recreate table ${tableName}: ${msg}`);
}
}
}
/**
* Repair schema issues by creating missing tables, adding missing columns,
* and recreating tables with incorrect primary key constraints.
*
* @param db - The raw database connection (not the traced wrapper)
* @returns Lists of fixed and failed repairs
*/
export function repairSchema(db: Database): RepairResult {
const result: RepairResult = { fixed: [], failed: [] };
repairMissingTables(db, result);
repairMissingColumns(db, result);
repairWrongPrimaryKeys(db, result);
if (result.fixed.length > 0) {
try {
db.query("UPDATE schema_version SET version = ?").run(
CURRENT_SCHEMA_VERSION
);
} catch {
// Ignore version update failures - schema is still fixed
}
}
return result;
}
/** Track if we're currently repairing to prevent infinite loops */
let isRepairing = false;
/**
* Check if an error is a schema-related SQLite error that can be auto-repaired.
*
* Matches by message content rather than error name because `bun:sqlite`
* throws `SQLiteError` while `node:sqlite` throws plain `Error` — the
* message strings are identical across both runtimes.
*/
function isSchemaError(error: unknown): boolean {
Iif (!(error instanceof Error)) {
return false;
}
const msg = error.message.toLowerCase();
return (
msg.includes("no such column") ||
msg.includes("no such table") ||
msg.includes("has no column named") ||
msg.includes("on conflict clause does not match")
);
}
/**
* Check if an error is a SQLite "readonly database" error.
*
* This happens when the CLI's local database file or its containing directory
* lacks write permissions (e.g., installed globally in a protected path,
* read-only filesystem, or changed permissions).
*
* Matches by message content rather than error name because `bun:sqlite`
* throws `SQLiteError` while `node:sqlite` throws plain `Error`.
*/
export function isReadonlyError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return error.message
.toLowerCase()
.includes("attempt to write a readonly database");
}
/** Result of a repair attempt */
export type RepairAttemptResult<T> =
| { attempted: false }
| { attempted: true; result: T };
/**
* Attempt to repair the database schema and retry a failed operation.
*
* This function is the core of the auto-repair system. When a database operation
* fails due to a schema error (missing table/column), this function:
* 1. Checks if auto-repair is enabled and applicable
* 2. Runs repairSchema() to fix missing tables/columns
* 3. Retries the original operation
*
* @param operation - The failed operation to retry after repair
* @param error - The error that triggered the repair attempt
* @returns An object indicating whether repair was attempted and the result.
* When `attempted` is false, the caller should re-throw the original error.
* When `attempted` is true, use `result` (which may be undefined for queries
* like stmt.get() that legitimately return undefined).
*/
export function tryRepairAndRetry<T>(
operation: () => T,
error: unknown
): RepairAttemptResult<T> {
// Skip repair if disabled via environment variable
Iif (getEnv()[NO_AUTO_REPAIR_ENV] === "1") {
return { attempted: false };
}
// Only repair schema-related errors
if (!isSchemaError(error)) {
return { attempted: false };
}
// Prevent infinite loops if repair itself causes errors
Iif (isRepairing) {
return { attempted: false };
}
isRepairing = true;
let repairSucceeded = false;
try {
// Dynamic imports to avoid circular dependencies with db/index.js
const { getRawDatabase } = _require("./index.js") as {
getRawDatabase: () => Database;
};
const rawDb = getRawDatabase();
const { fixed } = repairSchema(rawDb);
Iif (fixed.length > 0) {
logger.info(`Auto-repaired database: ${fixed.join(", ")}`);
repairSucceeded = true;
}
} catch {
// Repair failed - caller will re-throw original error
} finally {
isRepairing = false;
}
// Retry operation AFTER try-catch so any new error from operation() propagates
// instead of being swallowed and replaced with the original error
Iif (repairSucceeded) {
return { attempted: true, result: operation() };
}
return { attempted: false };
}
export function initSchema(db: Database): void {
const ddlStatements = Object.values(EXPECTED_TABLES).join(";\n\n");
db.exec(ddlStatements);
const versionRow = db
.query("SELECT version FROM schema_version LIMIT 1")
.get() as { version: number } | null;
if (!versionRow) {
db.query("INSERT OR IGNORE INTO schema_version (version) VALUES (?)").run(
CURRENT_SCHEMA_VERSION
);
}
}
function getSchemaVersion(db: Database): number {
const row = db.query("SELECT version FROM schema_version LIMIT 1").get() as {
version: number;
} | null;
return row?.version ?? 0;
}
/**
* Run migrations for schema changes between versions.
*
* Note: Auto-repair handles missing tables/columns as a safety net, but explicit
* migrations are still needed for:
* - Data transformations (e.g., splitting a column)
* - Column renames (requires data copy in SQLite)
* - Complex constraints
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: sequential migration steps are inherently linear
export function runMigrations(db: Database): void {
const currentVersion = getSchemaVersion(db);
// Migration 1 -> 2: Add org_regions, user_info, and instance_info tables
Iif (currentVersion < 2) {
db.exec(`
${EXPECTED_TABLES.org_regions};
${EXPECTED_TABLES.user_info};
${EXPECTED_TABLES.instance_info};
`);
}
// Migration 2 -> 3: Add name column to user_info table
Iif (currentVersion < 3) {
addColumnIfMissing(db, "user_info", "name", "TEXT");
}
// Migration 3 -> 4: Add detection caching columns to dsn_cache and project_root_cache table
Iif (currentVersion < 4) {
addColumnIfMissing(db, "dsn_cache", "fingerprint", "TEXT");
addColumnIfMissing(db, "dsn_cache", "all_dsns_json", "TEXT");
addColumnIfMissing(db, "dsn_cache", "source_mtimes_json", "TEXT");
addColumnIfMissing(db, "dsn_cache", "dir_mtimes_json", "TEXT");
addColumnIfMissing(db, "dsn_cache", "root_dir_mtime", "INTEGER");
addColumnIfMissing(db, "dsn_cache", "ttl_expires_at", "INTEGER");
db.exec(EXPECTED_TABLES.project_root_cache as string);
}
// Migration 4 -> 5: Add pagination_cursors table for --cursor last support
if (currentVersion < 5) {
db.exec(EXPECTED_TABLES.pagination_cursors as string);
}
// Migration 5 -> 6: Repair pagination_cursors if created with wrong PK (CLI-72)
// Earlier versions could create the table with a single-column PK instead of
// the composite PK (command_key, context). DROP + CREATE is safe because
// pagination cursors are ephemeral (5-minute TTL).
if (
currentVersion < 6 &&
tableExists(db, "pagination_cursors") &&
!hasCompositePrimaryKey(db, "pagination_cursors", [
"command_key",
"context",
])
) {
db.exec("DROP TABLE pagination_cursors");
db.exec(EXPECTED_TABLES.pagination_cursors as string);
}
// Migration 6 -> 7: Add project_id column to project_cache for numeric project filtering
if (currentVersion < 7) {
addColumnIfMissing(db, "project_cache", "project_id", "TEXT");
}
// Migration 7 -> 8: Add org_id column to org_regions for numeric ID lookups
if (currentVersion < 8) {
addColumnIfMissing(db, "org_regions", "org_id", "TEXT");
}
// Migration 8 -> 9: Add org_name column to org_regions for cached org listing
if (currentVersion < 9) {
addColumnIfMissing(db, "org_regions", "org_name", "TEXT");
}
// Migration 9 -> 10: Add org_role column to org_regions for cached role lookups
if (currentVersion < 10) {
addColumnIfMissing(db, "org_regions", "org_role", "TEXT");
}
// Migration 10 -> 11: Add completion_telemetry_queue table
if (currentVersion < 11) {
db.exec(EXPECTED_TABLES.completion_telemetry_queue as string);
}
// Migration 11 -> 12: Replace pagination_cursors with cursor-stack schema.
// The old table stored a single cursor string; the new schema stores a JSON
// array (cursor_stack) + page_index for bidirectional navigation.
// Cursors are ephemeral (5-min TTL), so DROP + CREATE loses nothing.
if (currentVersion < 12) {
Eif (tableExists(db, "pagination_cursors")) {
db.exec("DROP TABLE pagination_cursors");
}
db.exec(EXPECTED_TABLES.pagination_cursors as string);
}
// Migration 12 -> 13: Consolidate defaults into metadata KV table.
// The single-row `defaults` table was never written by production code.
// Move any data (from JSON migration or manual DB edits) to metadata,
// then drop the table. New defaults use metadata keys: defaults.org,
// defaults.project, defaults.telemetry, defaults.url.
if (currentVersion < 13 && tableExists(db, "defaults")) {
const row = db
.query("SELECT organization, project FROM defaults WHERE id = 1")
.get() as { organization: string | null; project: string | null } | null;
if (row?.organization) {
db.query(
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)"
).run("defaults.org", row.organization);
}
if (row?.project) {
db.query(
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)"
).run("defaults.project", row.project);
}
db.exec("DROP TABLE defaults");
}
// Migration 13 -> 14: Add repo_cache table for offline Sentry repository
// lookups (used by `issue resolve --in @commit` to match git origin →
// Sentry-registered repo without an extra API round trip).
if (currentVersion < 14) {
db.exec(EXPECTED_TABLES.repo_cache as string);
}
// Migration 14 -> 15: Add issue_org_cache table for numeric-id → org
// mappings used by `issue view <numeric-id>` to skip the legacy unscoped
// `/api/0/issues/{id}/` endpoint on repeat runs.
if (currentVersion < 15) {
db.exec(EXPECTED_TABLES.issue_org_cache as string);
}
// Migration 15 -> 16: Add host column to auth table for host-scoped tokens.
// The column is NULL for existing rows; getAuthConfig lazily backfills it
// with the currently-configured host on first access after upgrade, so
// users who already have SENTRY_HOST/SENTRY_URL set at upgrade time are
// migrated cleanly to the host-scoped model.
if (currentVersion < 16) {
addColumnIfMissing(db, "auth", "host", "TEXT");
}
if (currentVersion < CURRENT_SCHEMA_VERSION) {
db.query("UPDATE schema_version SET version = ?").run(
CURRENT_SCHEMA_VERSION
);
}
}
|