From e0f416e06e3234711a1015599b57f19cb6a6fa74 Mon Sep 17 00:00:00 2001 From: Musti7even Date: Thu, 18 Dec 2025 18:28:10 -0800 Subject: [PATCH 1/3] fix(db): apply migrations by hash + recover workspace->task rename --- src/main/db/path.ts | 24 ++- src/main/entry.ts | 9 ++ src/main/main.ts | 31 +++- src/main/services/DatabaseService.ts | 218 +++++++++++++++++++++++++-- src/main/telemetry.ts | 7 +- 5 files changed, 274 insertions(+), 15 deletions(-) diff --git a/src/main/db/path.ts b/src/main/db/path.ts index c811c3d2..851cda89 100644 --- a/src/main/db/path.ts +++ b/src/main/db/path.ts @@ -1,5 +1,5 @@ import { existsSync, renameSync } from 'fs'; -import { join } from 'path'; +import { dirname, join } from 'path'; import { app } from 'electron'; const CURRENT_DB_FILENAME = 'emdash.db'; @@ -17,6 +17,28 @@ export function resolveDatabasePath(options: ResolveDatabasePathOptions = {}): s return currentPath; } + // Dev safety: prior versions sometimes resolved userData under the default Electron app + // (e.g. ~/Library/Application Support/Electron). If we now have a new app name directory, + // migrate the old DB over so users don't "lose" their data when running from source. + try { + const userDataParent = dirname(userDataPath); + const legacyDirs = ['Electron', 'emdash', 'Emdash']; + for (const dirName of legacyDirs) { + const candidateDir = join(userDataParent, dirName); + const candidateCurrent = join(candidateDir, CURRENT_DB_FILENAME); + if (existsSync(candidateCurrent)) { + try { + renameSync(candidateCurrent, currentPath); + return currentPath; + } catch { + return candidateCurrent; + } + } + } + } catch { + // best-effort only + } + for (const legacyName of LEGACY_DB_FILENAMES) { const legacyPath = join(userDataPath, legacyName); if (existsSync(legacyPath)) { diff --git a/src/main/entry.ts b/src/main/entry.ts index 847f2a42..7e44a36d 100644 --- a/src/main/entry.ts +++ b/src/main/entry.ts @@ -3,6 +3,15 @@ // We point aliases to the compiled dist tree rather than TS sources. import path from 'node:path'; +// Ensure app name is set BEFORE any module reads app.getPath('userData'). +// In dev builds, if userData is resolved before app name is set, Electron defaults to +// ~/Library/Application Support/Electron which leads to confusing "missing DB/migrations" behavior. +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { app } = require('electron'); + app.setName('Emdash'); +} catch {} + // Install minimal path alias resolver without external deps. // Maps: // @shared/* -> dist/main/shared/* diff --git a/src/main/main.ts b/src/main/main.ts index 0b1644ac..0a004703 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -136,15 +136,42 @@ if (process.platform === 'darwin' && !app.isPackaged) { // App bootstrap app.whenReady().then(async () => { // Initialize database + let dbInitOk = false; + let dbInitErrorType: string | undefined; try { await databaseService.initialize(); - // console.log('Database initialized successfully'); + dbInitOk = true; + console.log('Database initialized successfully'); } catch (error) { - // console.error('Failed to initialize database:', error); + const err = error as unknown; + const asObj = typeof err === 'object' && err !== null ? (err as Record) : null; + const code = asObj && typeof asObj.code === 'string' ? asObj.code : undefined; + const name = asObj && typeof asObj.name === 'string' ? asObj.name : undefined; + dbInitErrorType = code || name || 'unknown'; + console.error('Failed to initialize database:', error); + // Don't prevent app startup, but log the error clearly } // Initialize telemetry (privacy-first, anonymous) telemetry.init({ installSource: app.isPackaged ? 'dmg' : 'dev' }); + try { + const summary = databaseService.getLastMigrationSummary(); + const toBucket = (n: number) => (n === 0 ? '0' : n === 1 ? '1' : n <= 3 ? '2-3' : '>3'); + telemetry.capture('db_setup', { + outcome: dbInitOk ? 'success' : 'failure', + ...(dbInitOk + ? { + applied_migrations: summary?.appliedCount ?? 0, + applied_migrations_bucket: toBucket(summary?.appliedCount ?? 0), + recovered: summary?.recovered === true, + } + : { + error_type: dbInitErrorType ?? 'unknown', + }), + }); + } catch { + // telemetry must never crash the app + } // Best-effort: capture a coarse snapshot of project/task counts (no names/paths) try { diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index bc548489..70548abb 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -1,6 +1,6 @@ import type sqlite3Type from 'sqlite3'; import { asc, desc, eq, sql } from 'drizzle-orm'; -import { migrate } from 'drizzle-orm/sqlite-proxy/migrator'; +import { readMigrationFiles } from 'drizzle-orm/migrator'; import { resolveDatabasePath, resolveMigrationsPath } from '../db/path'; import { getDrizzleClient } from '../db/drizzleClient'; import { @@ -62,12 +62,19 @@ export interface Message { metadata?: string; // JSON string for additional data } +export interface MigrationSummary { + appliedCount: number; + totalMigrations: number; + recovered: boolean; +} + export class DatabaseService { private static migrationsApplied = false; private db: sqlite3Type.Database | null = null; private sqlite3: typeof sqlite3Type | null = null; private dbPath: string; private disabled: boolean = false; + private lastMigrationSummary: MigrationSummary | null = null; constructor() { if (process.env.EMDASH_DISABLE_NATIVE_DB === '1') { @@ -102,6 +109,10 @@ export class DatabaseService { }); } + getLastMigrationSummary(): MigrationSummary | null { + return this.lastMigrationSummary; + } + async saveProject(project: Omit): Promise { if (this.disabled) return; const { db } = await getDrizzleClient(); @@ -551,20 +562,205 @@ export class DatabaseService { throw new Error('Drizzle migrations folder not found'); } - const { db } = await getDrizzleClient(); - await migrate( - db, - async (queries) => { - for (const statement of queries) { - await this.execSql(statement); + // We run schema migrations with foreign_keys disabled. + // Many dev DBs were created with foreign_keys=OFF, so legacy data can contain orphans. + // Enabling FK enforcement mid-migration can cause schema transitions (table rebuilds) to fail. + await this.execSql('PRAGMA foreign_keys=OFF;'); + + // IMPORTANT: + // Drizzle's built-in migrator for sqlite-proxy decides what to run based on the latest + // `created_at` timestamp in __drizzle_migrations. If a migration is added later but has an + // earlier timestamp than the latest applied migration, Drizzle will skip it forever. + // + // To make migrations robust for dev DBs (and for any DB that may have extra migrations), + // we apply migrations by missing hash instead of timestamp ordering. + const migrations = readMigrationFiles({ migrationsFolder: migrationsPath }); + const tagByWhen = await this.tryLoadMigrationTagByWhen(migrationsPath); + + await this.execSql(` + CREATE TABLE IF NOT EXISTS "__drizzle_migrations" ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at numeric + ) + `); + + const appliedRows = await this.allSql<{ hash: string }>( + `SELECT hash FROM "__drizzle_migrations"` + ); + const applied = new Set(appliedRows.map((r) => r.hash)); + + // Recovery: if a previous run partially applied the workspace->task migration, finish it. + // Symptom: `tasks` exists, `conversations` still has `workspace_id`, and `__new_conversations` exists. + let recovered = false; + if ( + (await this.tableExists('tasks')) && + (await this.tableExists('conversations')) && + (await this.tableExists('__new_conversations')) && + (await this.tableHasColumn('conversations', 'workspace_id')) && + !(await this.tableHasColumn('conversations', 'task_id')) + ) { + // Populate new conversations table from the old one (FK enforcement is OFF, so orphans won't block) + await this.execSql(` + INSERT INTO "__new_conversations"("id", "task_id", "title", "created_at", "updated_at") + SELECT "id", "workspace_id", "title", "created_at", "updated_at" FROM "conversations" + `); + await this.execSql(`DROP TABLE "conversations";`); + await this.execSql(`ALTER TABLE "__new_conversations" RENAME TO "conversations";`); + await this.execSql(`CREATE INDEX IF NOT EXISTS "idx_conversations_task_id" ON "conversations" ("task_id");`); + + // Mark the workspace->task migration as applied (even if it wasn't tracked). + // This prevents the hash-based runner from attempting to re-run it against a partially-migrated DB. + await this.ensureMigrationMarkedApplied(migrationsPath, applied, '0002_lyrical_impossible_man'); + recovered = true; + } + + let appliedCount = 0; + for (const migration of migrations) { + if (applied.has(migration.hash)) continue; + + const tag = tagByWhen?.get(migration.folderMillis); + // If the DB already reflects the workspace->task rename (e.g. user manually fixed their DB) + // but the migration hash wasn't recorded, mark it as applied and move on. + if ( + tag === '0002_lyrical_impossible_man' && + (await this.tableExists('tasks')) && + !(await this.tableExists('workspaces')) && + (await this.tableExists('conversations')) && + (await this.tableHasColumn('conversations', 'task_id')) + ) { + await this.execSql( + `INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES('${migration.hash}', '${migration.folderMillis}')` + ); + applied.add(migration.hash); + continue; + } + + // Execute each statement chunk (drizzle-kit uses '--> statement-breakpoint') + for (const statement of migration.sql) { + // We manage FK enforcement ourselves during migrations. + const trimmed = statement.trim().toUpperCase(); + if (trimmed.startsWith('PRAGMA FOREIGN_KEYS=')) continue; + await this.execSql(statement); + } + + // Record as applied (same schema as Drizzle uses) + await this.execSql( + `INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES('${migration.hash}', '${migration.folderMillis}')` + ); + + applied.add(migration.hash); + appliedCount += 1; + } + + this.lastMigrationSummary = { + appliedCount, + totalMigrations: migrations.length, + recovered, + }; + + // Restore FK enforcement for normal operation. + await this.execSql('PRAGMA foreign_keys=ON;'); + + DatabaseService.migrationsApplied = true; + } + + private async tryLoadMigrationTagByWhen( + migrationsFolder: string + ): Promise | null> { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fs = require('node:fs'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const path = require('node:path'); + const journalPath = path.join(migrationsFolder, 'meta', '_journal.json'); + if (!fs.existsSync(journalPath)) return null; + const parsed: unknown = JSON.parse(fs.readFileSync(journalPath, 'utf8')); + if (!parsed || typeof parsed !== 'object') return null; + const entries = (parsed as { entries?: unknown }).entries; + if (!Array.isArray(entries)) return null; + + const map = new Map(); + for (const e of entries) { + if (!e || typeof e !== 'object') continue; + const when = (e as { when?: unknown }).when; + const tag = (e as { tag?: unknown }).tag; + if (typeof when === 'number' && typeof tag === 'string') { + map.set(when, tag); } - }, - { - migrationsFolder: migrationsPath, } + return map; + } catch { + return null; + } + } + + private async ensureMigrationMarkedApplied( + migrationsFolder: string, + applied: Set, + tag: string + ): Promise { + // Only mark if the SQL file + journal entry exist. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fs = require('node:fs'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const path = require('node:path'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const crypto = require('node:crypto'); + + const journalPath = path.join(migrationsFolder, 'meta', '_journal.json'); + if (!fs.existsSync(journalPath)) return; + const journalParsed: unknown = JSON.parse(fs.readFileSync(journalPath, 'utf8')); + const entries = (journalParsed as { entries?: unknown }).entries; + if (!Array.isArray(entries)) return; + const entry = entries.find((e) => { + if (!e || typeof e !== 'object') return false; + return (e as { tag?: unknown }).tag === tag; + }) as { when?: unknown } | undefined; + if (!entry) return; + + const sqlPath = path.join(migrationsFolder, `${tag}.sql`); + if (!fs.existsSync(sqlPath)) return; + const contents = fs.readFileSync(sqlPath, 'utf8'); + const hash = crypto.createHash('sha256').update(contents).digest('hex'); + + if (applied.has(hash)) return; + const createdAt = typeof entry.when === 'number' ? entry.when : Date.now(); + await this.execSql( + `INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES('${hash}', '${createdAt}')` ); + applied.add(hash); + } - DatabaseService.migrationsApplied = true; + private async tableExists(name: string): Promise { + const rows = await this.allSql<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name='${name.replace(/'/g, "''")}' LIMIT 1` + ); + return rows.length > 0; + } + + private async tableHasColumn(tableName: string, columnName: string): Promise { + if (!(await this.tableExists(tableName))) return false; + const rows = await this.allSql<{ name: string }>( + `PRAGMA table_info("${tableName.replace(/"/g, '""')}")` + ); + return rows.some((r) => r.name === columnName); + } + + private async allSql(query: string): Promise { + if (!this.db) throw new Error('Database not initialized'); + const trimmed = query.trim(); + if (!trimmed) return []; + + return await new Promise((resolve, reject) => { + this.db!.all(trimmed, (err, rows) => { + if (err) { + reject(err); + } else { + resolve((rows ?? []) as T[]); + } + }); + }); } private async execSql(statement: string): Promise { diff --git a/src/main/telemetry.ts b/src/main/telemetry.ts index dc077621..65b1d5c4 100644 --- a/src/main/telemetry.ts +++ b/src/main/telemetry.ts @@ -98,7 +98,9 @@ type TelemetryEvent = | 'app_session' // Agent usage (provider-level only) | 'agent_run_start' - | 'agent_run_finish'; + | 'agent_run_finish' + // DB setup (privacy-safe) + | 'db_setup'; interface InitOptions { installSource?: string; @@ -219,6 +221,9 @@ function sanitizeEventAndProps(event: TelemetryEvent, props: Record 'duration_ms', 'session_duration_ms', 'outcome', + 'applied_migrations', + 'applied_migrations_bucket', + 'recovered', 'task_count', 'task_count_bucket', 'project_count', From e7a2782b3cdf4366b4ac393a23af96ed9d6af5d9 Mon Sep 17 00:00:00 2001 From: Musti7even Date: Thu, 18 Dec 2025 18:40:10 -0800 Subject: [PATCH 2/3] npm run format --- src/main/services/DatabaseService.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index 70548abb..86cda58c 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -607,11 +607,17 @@ export class DatabaseService { `); await this.execSql(`DROP TABLE "conversations";`); await this.execSql(`ALTER TABLE "__new_conversations" RENAME TO "conversations";`); - await this.execSql(`CREATE INDEX IF NOT EXISTS "idx_conversations_task_id" ON "conversations" ("task_id");`); + await this.execSql( + `CREATE INDEX IF NOT EXISTS "idx_conversations_task_id" ON "conversations" ("task_id");` + ); // Mark the workspace->task migration as applied (even if it wasn't tracked). // This prevents the hash-based runner from attempting to re-run it against a partially-migrated DB. - await this.ensureMigrationMarkedApplied(migrationsPath, applied, '0002_lyrical_impossible_man'); + await this.ensureMigrationMarkedApplied( + migrationsPath, + applied, + '0002_lyrical_impossible_man' + ); recovered = true; } From 8231d54d3895531f02d7fc5638463b9e3bac4800 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:23:58 +0100 Subject: [PATCH 3/3] fix: always re-enable FK enforcement after migrations --- src/main/services/DatabaseService.ts | 169 ++++++++++++++------------- 1 file changed, 85 insertions(+), 84 deletions(-) diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index 86cda58c..c3538657 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -566,109 +566,110 @@ export class DatabaseService { // Many dev DBs were created with foreign_keys=OFF, so legacy data can contain orphans. // Enabling FK enforcement mid-migration can cause schema transitions (table rebuilds) to fail. await this.execSql('PRAGMA foreign_keys=OFF;'); + try { + // IMPORTANT: + // Drizzle's built-in migrator for sqlite-proxy decides what to run based on the latest + // `created_at` timestamp in __drizzle_migrations. If a migration is added later but has an + // earlier timestamp than the latest applied migration, Drizzle will skip it forever. + // + // To make migrations robust for dev DBs (and for any DB that may have extra migrations), + // we apply migrations by missing hash instead of timestamp ordering. + const migrations = readMigrationFiles({ migrationsFolder: migrationsPath }); + const tagByWhen = await this.tryLoadMigrationTagByWhen(migrationsPath); - // IMPORTANT: - // Drizzle's built-in migrator for sqlite-proxy decides what to run based on the latest - // `created_at` timestamp in __drizzle_migrations. If a migration is added later but has an - // earlier timestamp than the latest applied migration, Drizzle will skip it forever. - // - // To make migrations robust for dev DBs (and for any DB that may have extra migrations), - // we apply migrations by missing hash instead of timestamp ordering. - const migrations = readMigrationFiles({ migrationsFolder: migrationsPath }); - const tagByWhen = await this.tryLoadMigrationTagByWhen(migrationsPath); - - await this.execSql(` - CREATE TABLE IF NOT EXISTS "__drizzle_migrations" ( - id SERIAL PRIMARY KEY, - hash text NOT NULL, - created_at numeric - ) - `); - - const appliedRows = await this.allSql<{ hash: string }>( - `SELECT hash FROM "__drizzle_migrations"` - ); - const applied = new Set(appliedRows.map((r) => r.hash)); - - // Recovery: if a previous run partially applied the workspace->task migration, finish it. - // Symptom: `tasks` exists, `conversations` still has `workspace_id`, and `__new_conversations` exists. - let recovered = false; - if ( - (await this.tableExists('tasks')) && - (await this.tableExists('conversations')) && - (await this.tableExists('__new_conversations')) && - (await this.tableHasColumn('conversations', 'workspace_id')) && - !(await this.tableHasColumn('conversations', 'task_id')) - ) { - // Populate new conversations table from the old one (FK enforcement is OFF, so orphans won't block) await this.execSql(` - INSERT INTO "__new_conversations"("id", "task_id", "title", "created_at", "updated_at") - SELECT "id", "workspace_id", "title", "created_at", "updated_at" FROM "conversations" + CREATE TABLE IF NOT EXISTS "__drizzle_migrations" ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at numeric + ) `); - await this.execSql(`DROP TABLE "conversations";`); - await this.execSql(`ALTER TABLE "__new_conversations" RENAME TO "conversations";`); - await this.execSql( - `CREATE INDEX IF NOT EXISTS "idx_conversations_task_id" ON "conversations" ("task_id");` - ); - // Mark the workspace->task migration as applied (even if it wasn't tracked). - // This prevents the hash-based runner from attempting to re-run it against a partially-migrated DB. - await this.ensureMigrationMarkedApplied( - migrationsPath, - applied, - '0002_lyrical_impossible_man' + const appliedRows = await this.allSql<{ hash: string }>( + `SELECT hash FROM "__drizzle_migrations"` ); - recovered = true; - } - - let appliedCount = 0; - for (const migration of migrations) { - if (applied.has(migration.hash)) continue; + const applied = new Set(appliedRows.map((r) => r.hash)); - const tag = tagByWhen?.get(migration.folderMillis); - // If the DB already reflects the workspace->task rename (e.g. user manually fixed their DB) - // but the migration hash wasn't recorded, mark it as applied and move on. + // Recovery: if a previous run partially applied the workspace->task migration, finish it. + // Symptom: `tasks` exists, `conversations` still has `workspace_id`, and `__new_conversations` exists. + let recovered = false; if ( - tag === '0002_lyrical_impossible_man' && (await this.tableExists('tasks')) && - !(await this.tableExists('workspaces')) && (await this.tableExists('conversations')) && - (await this.tableHasColumn('conversations', 'task_id')) + (await this.tableExists('__new_conversations')) && + (await this.tableHasColumn('conversations', 'workspace_id')) && + !(await this.tableHasColumn('conversations', 'task_id')) ) { + // Populate new conversations table from the old one (FK enforcement is OFF, so orphans won't block) + await this.execSql(` + INSERT INTO "__new_conversations"("id", "task_id", "title", "created_at", "updated_at") + SELECT "id", "workspace_id", "title", "created_at", "updated_at" FROM "conversations" + `); + await this.execSql(`DROP TABLE "conversations";`); + await this.execSql(`ALTER TABLE "__new_conversations" RENAME TO "conversations";`); await this.execSql( - `INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES('${migration.hash}', '${migration.folderMillis}')` + `CREATE INDEX IF NOT EXISTS "idx_conversations_task_id" ON "conversations" ("task_id");` ); - applied.add(migration.hash); - continue; - } - // Execute each statement chunk (drizzle-kit uses '--> statement-breakpoint') - for (const statement of migration.sql) { - // We manage FK enforcement ourselves during migrations. - const trimmed = statement.trim().toUpperCase(); - if (trimmed.startsWith('PRAGMA FOREIGN_KEYS=')) continue; - await this.execSql(statement); + // Mark the workspace->task migration as applied (even if it wasn't tracked). + // This prevents the hash-based runner from attempting to re-run it against a partially-migrated DB. + await this.ensureMigrationMarkedApplied( + migrationsPath, + applied, + '0002_lyrical_impossible_man' + ); + recovered = true; } - // Record as applied (same schema as Drizzle uses) - await this.execSql( - `INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES('${migration.hash}', '${migration.folderMillis}')` - ); + let appliedCount = 0; + for (const migration of migrations) { + if (applied.has(migration.hash)) continue; + + const tag = tagByWhen?.get(migration.folderMillis); + // If the DB already reflects the workspace->task rename (e.g. user manually fixed their DB) + // but the migration hash wasn't recorded, mark it as applied and move on. + if ( + tag === '0002_lyrical_impossible_man' && + (await this.tableExists('tasks')) && + !(await this.tableExists('workspaces')) && + (await this.tableExists('conversations')) && + (await this.tableHasColumn('conversations', 'task_id')) + ) { + await this.execSql( + `INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES('${migration.hash}', '${migration.folderMillis}')` + ); + applied.add(migration.hash); + continue; + } - applied.add(migration.hash); - appliedCount += 1; - } + // Execute each statement chunk (drizzle-kit uses '--> statement-breakpoint') + for (const statement of migration.sql) { + // We manage FK enforcement ourselves during migrations. + const trimmed = statement.trim().toUpperCase(); + if (trimmed.startsWith('PRAGMA FOREIGN_KEYS=')) continue; + await this.execSql(statement); + } - this.lastMigrationSummary = { - appliedCount, - totalMigrations: migrations.length, - recovered, - }; + // Record as applied (same schema as Drizzle uses) + await this.execSql( + `INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES('${migration.hash}', '${migration.folderMillis}')` + ); - // Restore FK enforcement for normal operation. - await this.execSql('PRAGMA foreign_keys=ON;'); + applied.add(migration.hash); + appliedCount += 1; + } - DatabaseService.migrationsApplied = true; + this.lastMigrationSummary = { + appliedCount, + totalMigrations: migrations.length, + recovered, + }; + + DatabaseService.migrationsApplied = true; + } finally { + // Restore FK enforcement for normal operation (and ensure it's re-enabled on failure). + await this.execSql('PRAGMA foreign_keys=ON;'); + } } private async tryLoadMigrationTagByWhen(