Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 67 additions & 51 deletions backend/src/db/migration-runner.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,38 @@
import { Database } from 'bun:sqlite'
import { logger } from '../utils/logger'

/**
* A schema change applied once per database. Migrations run in the order they appear in
* `allMigrations`, and each is recorded by its `id`, which must never change once released.
*
* New migrations use a UTC timestamp id, `YYYYMMDDHHmm-short-name` (for example
* `202610061345-schedule-workspace-mode`), in a file of the same name, so migrations written on
* different branches cannot claim the same identity. Make `up` safe to run on a database that
* already has the change, because merged branches may apply migrations in a different order.
*
* `legacy` only exists on the numbered migrations written before ids; it lets databases that
* recorded them by version number in `schema_migrations` recognise them as applied.
*/
export interface Migration {
version: number
name: string
id: string
legacy?: { version: number; name: string }
up(db: Database): void
down(db: Database): void
}

interface MigrationRecord {
interface LegacyMigrationRecord {
version: number
name: string
applied_at: number
}

function ensureMigrationsTable(db: Database): void {
db.run(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at INTEGER NOT NULL
)
`)
}

function getAppliedMigrations(db: Database): Map<number, string> {
const rows = db.prepare('SELECT version, name FROM schema_migrations ORDER BY version').all() as MigrationRecord[]
return new Map(rows.map(r => [r.version, r.name]))
}

/**
* Surfaces version-number collisions where a recorded migration's name differs
* from the code migration registered under the same version. The runner keys on
* version number alone, so a reused version silently skips the real migration
* (e.g. an ADD COLUMN), producing later runtime errors that are hard to trace.
* This converts that silent skip into a loud, actionable warning.
*/
function warnOnVersionNameMismatch(applied: Map<number, string>, migrations: Migration[]): void {
for (const migration of migrations) {
const recordedName = applied.get(migration.version)
if (recordedName !== undefined && recordedName !== migration.name) {
logger.warn(
`Migration version ${migration.version} is recorded as "${recordedName}" but the code defines "${migration.name}". ` +
`This migration was skipped; its schema changes may be missing. Verify the database schema and apply the changes manually if needed.`,
)
}
}
}

function markApplied(db: Database, migration: Migration): void {
db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)')
.run(migration.version, migration.name, Date.now())
}

export function migrate(db: Database, migrations: Migration[]): void {
ensureMigrationsTable(db)
assertUniqueIds(migrations)
db.run('CREATE TABLE IF NOT EXISTS applied_migrations (id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)')
adoptLegacyMigrations(db, migrations)

const applied = getAppliedMigrations(db)
warnOnVersionNameMismatch(applied, migrations)
const sorted = [...migrations].sort((a, b) => a.version - b.version)
const pending = sorted.filter(m => !applied.has(m.version))
const applied = new Set((db.prepare('SELECT id FROM applied_migrations').all() as Array<{ id: string }>).map((row) => row.id))
const pending = migrations.filter((migration) => !applied.has(migration.id))

if (pending.length === 0) {
logger.info('Database schema is up to date')
Expand All @@ -69,21 +42,64 @@ export function migrate(db: Database, migrations: Migration[]): void {
logger.info(`Running ${pending.length} pending migration(s)`)

for (const migration of pending) {
logger.info(`Applying migration ${migration.version}: ${migration.name}`)
logger.info(`Applying migration ${migration.id}`)
db.run('BEGIN TRANSACTION')
try {
migration.up(db)
markApplied(db, migration)
db.prepare('INSERT INTO applied_migrations (id, applied_at) VALUES (?, ?)').run(migration.id, Date.now())
db.run('COMMIT')
logger.info(`Migration ${migration.version} applied successfully`)
logger.info(`Migration ${migration.id} applied successfully`)
} catch (error) {
db.run('ROLLBACK')
logger.error(`Migration ${migration.version} failed:`, error)
logger.error(`Migration ${migration.id} failed:`, error)
throw error
}
}

logger.info('All migrations applied successfully')
}

function assertUniqueIds(migrations: Migration[]): void {
const seen = new Set<string>()
for (const migration of migrations) {
if (seen.has(migration.id)) throw new Error(`Duplicate migration id "${migration.id}"`)
seen.add(migration.id)
}
}

/**
* Records numbered migrations that a database applied under the old version-keyed table.
* A version recorded under a different name is still treated as applied, as before, but warned
* about because its schema change may be missing.
*/
function adoptLegacyMigrations(db: Database, migrations: Migration[]): void {
const hasLegacyTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'").get()
if (!hasLegacyTable) return

const records = new Map(
(db.prepare('SELECT version, name, applied_at FROM schema_migrations').all() as LegacyMigrationRecord[])
.map((record) => [record.version, record] as const),
)
const adopted = migrations.flatMap((migration) => {
const record = migration.legacy ? records.get(migration.legacy.version) : undefined
if (!migration.legacy || !record) return []
if (record.name !== migration.legacy.name) {
logger.warn(
`Migration version ${migration.legacy.version} is recorded as "${record.name}" but the code defines "${migration.legacy.name}". ` +
`This migration was skipped; its schema changes may be missing. Verify the database schema and apply the changes manually if needed.`,
)
}
return [{ id: migration.id, appliedAt: record.applied_at }]
})
if (adopted.length === 0) return

const insert = db.prepare('INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)')
db.run('BEGIN TRANSACTION')
try {
adopted.forEach((entry) => insert.run(entry.id, entry.appliedAt))
db.run('COMMIT')
} catch (error) {
db.run('ROLLBACK')
throw error
}
}
4 changes: 2 additions & 2 deletions backend/src/db/migrations/001-base-schema.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Migration } from '../migration-runner'

const migration: Migration = {
version: 1,
name: 'base-schema',
id: '001-base-schema',
legacy: { version: 1, name: 'base-schema' },

up(db) {
db.run(`
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/002-repos-nullable-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ interface ColumnInfo {
}

const migration: Migration = {
version: 2,
name: 'repos-nullable-url',
id: '002-repos-nullable-url',
legacy: { version: 2, name: 'repos-nullable-url' },

up(db) {
const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[]
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/003-repos-add-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ const COLUMNS = [
]

const migration: Migration = {
version: 3,
name: 'repos-add-columns',
id: '003-repos-add-columns',
legacy: { version: 3, name: 'repos-add-columns' },

up(db) {
const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[]
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/004-repos-indexes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Migration } from '../migration-runner'

const migration: Migration = {
version: 4,
name: 'repos-indexes',
id: '004-repos-indexes',
legacy: { version: 4, name: 'repos-indexes' },

up(db) {
db.run(`
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/005-repos-local-path-prefix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { Migration } from '../migration-runner'
import { logger } from '../../utils/logger'

const migration: Migration = {
version: 5,
name: 'repos-local-path-prefix',
id: '005-repos-local-path-prefix',
legacy: { version: 5, name: 'repos-local-path-prefix' },

up(db) {
const repos = db.prepare("SELECT id, local_path FROM repos WHERE local_path LIKE 'repos/%'").all() as Array<{
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/006-git-token-to-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { Migration } from '../migration-runner'
import { logger } from '../../utils/logger'

const migration: Migration = {
version: 6,
name: 'git-token-to-credentials',
id: '006-git-token-to-credentials',
legacy: { version: 6, name: 'git-token-to-credentials' },

up(db) {
const rows = db.prepare('SELECT user_id, preferences FROM user_preferences').all() as Array<{
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/007-schedules.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Migration } from '../migration-runner'

const migration: Migration = {
version: 7,
name: 'schedules',
id: '007-schedules',
legacy: { version: 7, name: 'schedules' },

up(db) {
db.run(`
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/008-schedule-cron-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ interface ColumnInfo {
}

const migration: Migration = {
version: 8,
name: 'schedule-cron-support',
id: '008-schedule-cron-support',
legacy: { version: 8, name: 'schedule-cron-support' },

up(db) {
const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schedule_jobs'").get()
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/009-prompt-templates.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Migration } from '../migration-runner'

const migration: Migration = {
version: 10,
name: '010-prompt-templates',
id: '009-prompt-templates',
legacy: { version: 10, name: '010-prompt-templates' },

up(db) {
db.run(`
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/009-repo-source-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ interface ColumnInfo {
}

const migration: Migration = {
version: 9,
name: 'repo-source-path',
id: '009-repo-source-path',
legacy: { version: 9, name: 'repo-source-path' },

up(db) {
const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[]
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/011-repo-last-accessed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ interface ColumnInfo {
}

const migration: Migration = {
version: 11,
name: 'repo-last-accessed',
id: '011-repo-last-accessed',
legacy: { version: 11, name: 'repo-last-accessed' },

up(db) {
const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[]
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/012-opencode-model-state.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Migration } from '../migration-runner'

const migration: Migration = {
version: 12,
name: 'opencode-model-state',
id: '012-opencode-model-state',
legacy: { version: 12, name: 'opencode-model-state' },
up(db) {
db.run(`
CREATE TABLE IF NOT EXISTS opencode_model_state (
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/013-app-secrets.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Migration } from '../migration-runner'

const migration: Migration = {
version: 13,
name: 'app-secrets',
id: '013-app-secrets',
legacy: { version: 13, name: 'app-secrets' },
up(db) {
db.run(`
CREATE TABLE IF NOT EXISTS app_secrets (
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/014-repos-add-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ interface ColumnInfo {
}

const migration: Migration = {
version: 14,
name: 'repos-add-name',
id: '014-repos-add-name',
legacy: { version: 14, name: 'repos-add-name' },

up(db) {
const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[]
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/015-schedule-worktree-isolation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ interface ColumnInfo {
}

const migration: Migration = {
version: 15,
name: 'schedule-worktree-isolation',
id: '015-schedule-worktree-isolation',
legacy: { version: 15, name: 'schedule-worktree-isolation' },

up(db) {
const jobColumns = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[]
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/016-schedule-permission-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ interface ColumnInfo {
}

const migration: Migration = {
version: 16,
name: 'schedule-permission-config',
id: '016-schedule-permission-config',
legacy: { version: 16, name: 'schedule-permission-config' },

up(db) {
const cols = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[]
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/017-schedule-run-workspace-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ interface ColumnInfo {
}

const migration: Migration = {
version: 17,
name: 'schedule-run-workspace-id',
id: '017-schedule-run-workspace-id',
legacy: { version: 17, name: 'schedule-run-workspace-id' },

up(db) {
const cols = db.prepare('PRAGMA table_info(schedule_runs)').all() as ColumnInfo[]
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/018-session-pins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { Migration } from '../migration-runner'
import { ensureSessionPinsTable } from '../session-pins'

const migration: Migration = {
version: 18,
name: 'session-pins',
id: '018-session-pins',
legacy: { version: 18, name: 'session-pins' },
up(db) {
ensureSessionPinsTable(db)
},
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/019-drop-opencode-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ function archiveConfigs(rows: Array<{ config_name: string; config_content: strin
}

const migration: Migration = {
version: 19,
name: 'drop-opencode-configs',
id: '019-drop-opencode-configs',
legacy: { version: 19, name: 'drop-opencode-configs' },

up(db) {
const rows = db.prepare('SELECT config_name, config_content, is_default FROM opencode_configs').all() as Array<{ config_name: string; config_content: string; is_default: number | null }>
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/020-drop-opencode-model-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ function restoreModelStateFile(row: ModelStateRow): void {
}

const migration: Migration = {
version: 20,
name: 'drop-opencode-model-state',
id: '020-drop-opencode-model-state',
legacy: { version: 20, name: 'drop-opencode-model-state' },

up(db) {
const row = readDefaultRow(db)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ interface ColumnInfo {
}

const migration: Migration = {
version: 21,
name: 'drop-schedule-run-workspace-id',
id: '021-drop-schedule-run-workspace-id',
legacy: { version: 21, name: 'drop-schedule-run-workspace-id' },

up(db) {
const cols = db.prepare('PRAGMA table_info(schedule_runs)').all() as ColumnInfo[]
Expand Down
4 changes: 2 additions & 2 deletions backend/src/db/migrations/022-schedule-runs-session-index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Migration } from '../migration-runner'

const migration: Migration = {
version: 22,
name: 'schedule-runs-session-index',
id: '022-schedule-runs-session-index',
legacy: { version: 22, name: 'schedule-runs-session-index' },

up(db) {
db.run(`
Expand Down
Loading