#!/usr/bin/env bun import { Database } from "bun:sqlite"; import { readFileSync } from "fs"; import path from "path"; import { repairDuplicateSsoColumns, repairStrandedBetterAuth17Schema, restoreSsoDataAfter0013, } from "../src/lib/db/migration-repairs"; type JournalEntry = { idx: number; tag: string; when: number; breakpoints: boolean; }; type Migration = { entry: JournalEntry; statements: string[]; }; type UpgradeFixture = { seed: (db: Database) => void; verify: (db: Database) => void; }; type TableInfoRow = { cid: number; name: string; type: string; notnull: number; dflt_value: string | null; pk: number; }; const migrationsFolder = path.join(process.cwd(), "drizzle"); const migrations = loadMigrations(); const latestMigration = migrations.at(-1); /** * Known SQLite limitations that Drizzle-kit's auto-generated migrations * can violate. Each rule is checked against every SQL statement. */ const SQLITE_LINT_RULES: { pattern: RegExp; message: string }[] = [ { pattern: /ALTER\s+TABLE\s+\S+\s+ADD\s+(?:COLUMN\s+)?\S+[^;]*DEFAULT\s*\(/i, message: "ALTER TABLE ADD COLUMN with an expression default (e.g. DEFAULT (unixepoch())) " + "is not allowed in SQLite. Use the table-recreation pattern instead " + "(CREATE new table, INSERT SELECT, DROP old, RENAME).", }, { pattern: /ALTER\s+TABLE\s+\S+\s+ADD\s+(?:COLUMN\s+)?\S+[^;]*DEFAULT\s+CURRENT_(TIME|DATE|TIMESTAMP)\b/i, message: "ALTER TABLE ADD COLUMN with DEFAULT CURRENT_TIME/CURRENT_DATE/CURRENT_TIMESTAMP " + "is not allowed in SQLite. Use the table-recreation pattern instead.", }, ]; function loadMigrations(): Migration[] { const journalPath = path.join(migrationsFolder, "meta", "_journal.json"); const journal = JSON.parse(readFileSync(journalPath, "utf8")) as { entries: JournalEntry[]; }; return journal.entries.map((entry) => { const migrationPath = path.join(migrationsFolder, `${entry.tag}.sql`); const statements = readFileSync(migrationPath, "utf8") .split("--> statement-breakpoint") .map((statement) => statement.trim()) .filter(Boolean); return { entry, statements }; }); } function assert(condition: unknown, message: string): asserts condition { if (!condition) { throw new Error(message); } } function runMigration(db: Database, migration: Migration) { db.run("BEGIN"); try { for (const statement of migration.statements) { db.run(statement); } db.run("COMMIT"); } catch (error) { try { db.run("ROLLBACK"); } catch { // Ignore rollback errors so the original failure is preserved. } throw error; } } function runMigrations(db: Database, selectedMigrations: Migration[]) { for (const migration of selectedMigrations) { runMigration(db, migration); } } function seedPre0009Database(db: Database) { // Seed every existing table so ALTER TABLE paths run against non-empty data. db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'user1', 'User One')"); db.run("INSERT INTO configs (id, user_id, name, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('c1', 'u1', 'Default', '{}', '{}', '{}', '{}')"); db.run("INSERT INTO accounts (id, account_id, user_id, provider_id, access_token, refresh_token, id_token, access_token_expires_at, refresh_token_expires_at, scope) VALUES ('acct1', 'acct-1', 'u1', 'github', 'access-token', 'refresh-token', 'id-token', 2000, 3000, 'repo')"); db.run("INSERT INTO events (id, user_id, channel, payload) VALUES ('evt1', 'u1', 'sync', '{\"status\":\"queued\"}')"); db.run("INSERT INTO mirror_jobs (id, user_id, repository_id, repository_name, status, message, timestamp) VALUES ('job1', 'u1', 'r1', 'owner/repo', 'imported', 'Imported repository', 900)"); db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, public_repository_count, private_repository_count, fork_repository_count) VALUES ('org1', 'u1', 'c1', 'Example Org', 'https://example.com/org.png', 1, 0, 0)"); db.run("INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, organization, default_branch, created_at, updated_at, metadata) VALUES ('r1', 'u1', 'c1', 'repo', 'owner/repo', 'owner/repo', 'https://example.com/repo', 'https://example.com/repo.git', 'owner', 'Example Org', 'main', 1000, 1100, '{\"issues\":true}')"); db.run("INSERT INTO sessions (id, token, user_id, expires_at) VALUES ('sess1', 'session-token', 'u1', 4000)"); db.run("INSERT INTO verification_tokens (id, token, identifier, type, expires_at) VALUES ('vt1', 'verify-token', 'u1@example.com', 'email', 5000)"); db.run("INSERT INTO verifications (id, identifier, value, expires_at) VALUES ('ver1', 'u1@example.com', '123456', 6000)"); db.run("INSERT INTO oauth_applications (id, client_id, client_secret, name, redirect_urls, type, user_id) VALUES ('app1', 'client-1', 'secret-1', 'Example App', '[\"https://example.com/callback\"]', 'confidential', 'u1')"); db.run("INSERT INTO oauth_access_tokens (id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, client_id, user_id, scopes) VALUES ('oat1', 'oauth-access-token', 'oauth-refresh-token', 7000, 8000, 'client-1', 'u1', '[\"repo\"]')"); db.run("INSERT INTO oauth_consent (id, user_id, client_id, scopes, consent_given) VALUES ('consent1', 'u1', 'client-1', '[\"repo\"]', true)"); db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso1', 'https://issuer.example.com', 'example.com', '{}', 'u1', 'provider-1')"); db.run("INSERT INTO rate_limits (id, user_id, provider, `limit`, remaining, used, reset, retry_after, status, last_checked) VALUES ('rl1', 'u1', 'github', 5000, 4999, 1, 9000, NULL, 'ok', 8500)"); } function verify0009Migration(db: Database) { const repositoryColumns = db.query("PRAGMA table_info(repositories)").all() as TableInfoRow[]; const importedAtColumn = repositoryColumns.find((column) => column.name === "imported_at"); assert(importedAtColumn, "Expected repositories.imported_at column to exist after migration"); assert(importedAtColumn.notnull === 1, "Expected repositories.imported_at to be NOT NULL"); assert(importedAtColumn.dflt_value === "unixepoch()", `Expected repositories.imported_at default to be unixepoch(), got ${importedAtColumn.dflt_value ?? "null"}`); const existingRepo = db.query("SELECT imported_at FROM repositories WHERE id = 'r1'").get() as { imported_at: number } | null; assert(existingRepo?.imported_at === 900, `Expected existing repository imported_at to backfill from mirror_jobs timestamp 900, got ${existingRepo?.imported_at ?? "null"}`); db.run("INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) VALUES ('r2', 'u1', 'c1', 'repo-two', 'owner/repo-two', 'owner/repo-two', 'https://example.com/repo-two', 'https://example.com/repo-two.git', 'owner', 'main')"); const newRepo = db.query("SELECT imported_at FROM repositories WHERE id = 'r2'").get() as { imported_at: number } | null; assert(typeof newRepo?.imported_at === "number" && newRepo.imported_at > 0, "Expected new repository insert to receive imported_at from the column default"); const importedAtIndex = db .query("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'repositories' AND name = 'idx_repositories_user_imported_at'") .get() as { name: string } | null; assert(importedAtIndex?.name === "idx_repositories_user_imported_at", "Expected repositories imported_at index to exist after migration"); } function seedPre0010Database(db: any) { // Seed a repo row to verify index creation doesn't break existing data seedPre0009Database(db); } function verify0010Migration(db: any) { const indexes = db.prepare( "SELECT name FROM sqlite_master WHERE type='index' AND name='uniq_repositories_user_mirrored_location'" ).all(); if (indexes.length === 0) { throw new Error("Missing unique partial index uniq_repositories_user_mirrored_location"); } const lookupIdx = db.prepare( "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_repositories_mirrored_location'" ).all(); if (lookupIdx.length === 0) { throw new Error("Missing lookup index idx_repositories_mirrored_location"); } } function seedPre0011Database(db: any) { seedPre0009Database(db); runMigration(db, migrations.find((m) => m.entry.tag === "0009_nervous_tyger_tiger")!); runMigration(db, migrations.find((m) => m.entry.tag === "0010_mirrored_location_index")!); } function verify0011Migration(db: any) { const configColumns = db.query("PRAGMA table_info(configs)").all() as TableInfoRow[]; const notificationConfigColumn = configColumns.find((column: any) => column.name === "notification_config"); assert(notificationConfigColumn, "Expected configs.notification_config column to exist after migration"); assert(notificationConfigColumn.notnull === 1, "Expected configs.notification_config to be NOT NULL"); assert( notificationConfigColumn.dflt_value !== null, "Expected configs.notification_config to have a default value", ); const existingConfig = db.query("SELECT notification_config FROM configs WHERE id = 'c1'").get() as { notification_config: string } | null; assert(existingConfig, "Expected existing config row to still exist"); const parsed = JSON.parse(existingConfig.notification_config); assert(parsed.enabled === false, "Expected default notification_config.enabled to be false"); assert(parsed.provider === "ntfy", "Expected default notification_config.provider to be 'ntfy'"); } function seedPre0012Database(db: any) { // The harness has already run migrations 0000-0011, so the legacy // oidc-provider tables exist. Seed a registered client (with the legacy // comma-separated redirect_urls format) plus the related token/consent rows // to exercise the create/transform/drop paths in 0012. db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'user1', 'User One')"); db.run("INSERT INTO oauth_applications (id, client_id, client_secret, name, redirect_urls, type, disabled, user_id) VALUES ('app1', 'client-1', 'secret-1', 'Example App', 'https://example.com/callback,https://example.com/cb2', 'web', false, 'u1')"); db.run("INSERT INTO oauth_access_tokens (id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, client_id, user_id, scopes) VALUES ('oat1', 'tok', 'rtok', 7000, 8000, 'client-1', 'u1', '[\"repo\"]')"); db.run("INSERT INTO oauth_consent (id, user_id, client_id, scopes, consent_given) VALUES ('cons1', 'u1', 'client-1', '[\"repo\"]', true)"); } function verify0012Migration(db: any) { // Old provider tables are dropped. for (const table of ["oauth_applications", "oauth_consent"]) { const row = db .query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?") .get(table) as { name: string } | null; assert(!row, `Expected ${table} table to be dropped after migration`); } // New provider tables exist. for (const table of ["oauth_clients", "oauth_access_tokens", "oauth_refresh_tokens", "oauth_consents", "jwks"]) { const row = db .query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?") .get(table) as { name: string } | null; assert(row, `Expected ${table} table to exist after migration`); } // The registered client is preserved and its redirect URIs converted from // the legacy comma-separated string into a JSON string[]. const client = db .query("SELECT client_id, client_secret, name, redirect_uris, type, user_id FROM oauth_clients WHERE id = 'app1'") .get() as { client_id: string; client_secret: string; name: string; redirect_uris: string; type: string; user_id: string } | null; assert(client, "Expected migrated oauth_clients row for app1"); assert(client.client_id === "client-1", "Expected client_id to be preserved"); assert(client.name === "Example App", "Expected client name to be preserved"); assert(client.user_id === "u1", "Expected owner user_id to be preserved"); const uris = JSON.parse(client.redirect_uris); assert( Array.isArray(uris) && uris.length === 2 && uris[0] === "https://example.com/callback" && uris[1] === "https://example.com/cb2", `Expected redirect_uris to be a JSON array of the two callbacks, got ${client.redirect_uris}`, ); // The reshaped tables accept the new column layout. db.run("INSERT INTO oauth_clients (id, client_id, redirect_uris) VALUES ('app2', 'client-2', '[\"https://example.com/cb\"]')"); db.run("INSERT INTO oauth_refresh_tokens (id, token, client_id, user_id, scopes) VALUES ('rt1', 'refresh-1', 'client-2', 'u1', '[\"openid\"]')"); db.run("INSERT INTO oauth_access_tokens (id, token, client_id, user_id, scopes) VALUES ('at1', 'access-1', 'client-2', 'u1', '[\"openid\"]')"); db.run("INSERT INTO oauth_consents (id, client_id, user_id, scopes) VALUES ('co1', 'client-2', 'u1', '[\"openid\"]')"); db.run("INSERT INTO jwks (id, public_key, private_key) VALUES ('jwk1', 'public', 'private')"); } function seedPre0013Database(db: any) { // Migrations 0000-0012 have run, so sso_providers lacks samlConfig / // domainVerified and the organizations table still carries the inherited // DEFAULT '' on normalized_name from 0007. Seed both so the table-rebuild // and the column-adds can be verified end-to-end. db.run("INSERT INTO users (id, email, username, name) VALUES ('u-sso', 'sso@example.com', 'sso', 'SSO User')"); db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre13', 'u-sso', 'Default', 1, '{}', '{}', '{}', '{}')"); db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso-pre13', 'https://idp.example.com', 'example.com', '{\"clientId\":\"x\"}', 'u-sso', 'idp-pre13')"); db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, normalized_name) VALUES ('org-pre13', 'u-sso', 'cfg-pre13', 'Example', 'https://example.com/a.png', 'example')"); } function verify0013Migration(db: any) { // New columns on sso_providers. const ssoCols = db .query("PRAGMA table_info(sso_providers)") .all() as Array<{ name: string; notnull: number; dflt_value: string | null }>; const saml = ssoCols.find((c) => c.name === "saml_config"); const domainVerified = ssoCols.find((c) => c.name === "domain_verified"); assert(saml, "Expected sso_providers.saml_config column to exist"); assert(saml.notnull === 0, "Expected saml_config to be nullable"); assert(domainVerified, "Expected sso_providers.domain_verified column to exist"); assert(domainVerified.notnull === 1, "Expected domain_verified to be NOT NULL"); assert( domainVerified.dflt_value === "true", `Expected domain_verified DEFAULT true, got ${domainVerified.dflt_value}`, ); // Pre-existing SSO row picked up the default (1 = true) on domain_verified. const ssoRow = db .query("SELECT provider_id, saml_config, domain_verified FROM sso_providers WHERE id = 'sso-pre13'") .get() as { provider_id: string; saml_config: string | null; domain_verified: number } | null; assert(ssoRow, "Expected pre-existing OIDC provider row to survive migration"); assert(ssoRow.saml_config === null, `Expected saml_config NULL, got ${ssoRow.saml_config}`); assert(ssoRow.domain_verified === 1, `Expected domain_verified=1, got ${ssoRow.domain_verified}`); // Organizations rebuild preserved the seeded row and dropped the inherited // DEFAULT '' on normalized_name (drizzle reconciles to schema.ts). const orgRow = db .query("SELECT id, normalized_name FROM organizations WHERE id = 'org-pre13'") .get() as { id: string; normalized_name: string } | null; assert(orgRow, "Expected pre-existing organization row to survive table rebuild"); assert(orgRow.normalized_name === "example", `Expected organization normalized_name preserved, got ${orgRow.normalized_name}`); const orgCols = db .query("PRAGMA table_info(organizations)") .all() as Array<{ name: string; dflt_value: string | null }>; const normName = orgCols.find((c) => c.name === "normalized_name"); assert(normName, "Expected organizations.normalized_name column to exist"); assert(normName.dflt_value === null, `Expected normalized_name to have no default, got ${normName.dflt_value}`); } function seedPre0014Database(db: any) { // Migrations 0000-0013 have run, so repositories/organizations exist but // lack mirror_overrides. Seed one of each so the column-add can be verified // against pre-existing rows (they must come out NULL = "inherit"). db.run("INSERT INTO users (id, email, username, name) VALUES ('u-ovr', 'ovr@example.com', 'ovr', 'Override User')"); db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre14', 'u-ovr', 'Default', 1, '{}', '{}', '{}', '{}')"); db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, normalized_name) VALUES ('org-pre14', 'u-ovr', 'cfg-pre14', 'ExampleOrg', 'https://example.com/a.png', 'exampleorg')"); db.run( "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) " + "VALUES ('repo-pre14', 'u-ovr', 'cfg-pre14', 'browser-use', 'ExampleOrg/browser-use', 'exampleorg/browser-use', 'https://github.com/ExampleOrg/browser-use', 'https://github.com/ExampleOrg/browser-use.git', 'ExampleOrg', 'main')", ); } function verify0014Migration(db: any) { // Both tables gained a nullable mirror_overrides column with no default. for (const table of ["repositories", "organizations"]) { const cols = db .query(`PRAGMA table_info(${table})`) .all() as Array<{ name: string; type: string; notnull: number; dflt_value: string | null }>; const col = cols.find((c) => c.name === "mirror_overrides"); assert(col, `Expected ${table}.mirror_overrides column to exist`); assert(col.notnull === 0, `Expected ${table}.mirror_overrides to be nullable`); assert( col.dflt_value === null, `Expected ${table}.mirror_overrides to have no default, got ${col.dflt_value}`, ); } // Pre-existing rows inherit (NULL), rather than being backfilled with {}. const repoRow = db .query("SELECT id, mirror_overrides FROM repositories WHERE id = 'repo-pre14'") .get() as { id: string; mirror_overrides: string | null } | null; assert(repoRow, "Expected pre-existing repository row to survive migration"); assert( repoRow.mirror_overrides === null, `Expected existing repository to inherit (NULL mirror_overrides), got ${repoRow.mirror_overrides}`, ); const orgRow = db .query("SELECT id, mirror_overrides FROM organizations WHERE id = 'org-pre14'") .get() as { id: string; mirror_overrides: string | null } | null; assert(orgRow, "Expected pre-existing organization row to survive migration"); assert( orgRow.mirror_overrides === null, `Expected existing organization to inherit (NULL mirror_overrides), got ${orgRow.mirror_overrides}`, ); // The new column round-trips a JSON overrides payload (the #361 case: // mirror this repo but skip its LFS objects). db.run( "UPDATE repositories SET mirror_overrides = '{\"lfs\":false}' WHERE id = 'repo-pre14'", ); const updated = db .query("SELECT mirror_overrides FROM repositories WHERE id = 'repo-pre14'") .get() as { mirror_overrides: string } | null; assert(updated, "Expected repository row after override update"); const parsed = JSON.parse(updated.mirror_overrides); assert(parsed.lfs === false, `Expected persisted lfs override false, got ${updated.mirror_overrides}`); } function seedPre0015Database(db: any) { // Migrations 0000-0014 have run, so repositories exist but carry no source // columns. Seed one so the column-add can be verified against a // pre-existing row: everything before 0015 came from GitHub. db.run("INSERT INTO users (id, email, username, name) VALUES ('u-src', 'src@example.com', 'src', 'Source User')"); db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre15', 'u-src', 'Default', 1, '{}', '{}', '{}', '{}')"); db.run( "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) " + "VALUES ('repo-pre15', 'u-src', 'cfg-pre15', 'tool', 'src/tool', 'src/tool', 'https://github.com/src/tool', 'https://github.com/src/tool.git', 'src', 'main')", ); } function verify0015Migration(db: any) { const cols = db .query("PRAGMA table_info(repositories)") .all() as Array<{ name: string; type: string; notnull: number; dflt_value: string | null }>; for (const [name, expectedDefault] of [ ["source_provider", "'github'"], ["source_url", "'https://github.com'"], ] as const) { const col = cols.find((c) => c.name === name); assert(col, `Expected repositories.${name} column to exist`); assert(col.notnull === 1, `Expected repositories.${name} to be NOT NULL`); assert( col.dflt_value === expectedDefault, `Expected repositories.${name} default ${expectedDefault}, got ${col.dflt_value}`, ); } // Pre-existing rows are backfilled as GitHub, which is where they came from. const existing = db .query("SELECT source_provider, source_url FROM repositories WHERE id = 'repo-pre15'") .get() as { source_provider: string; source_url: string } | null; assert(existing, "Expected pre-existing repository row to survive migration"); assert( existing.source_provider === "github" && existing.source_url === "https://github.com", `Expected existing repository to default to GitHub, got ${JSON.stringify(existing)}`, ); // New rows can record another host. db.run( "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, source_provider, source_url) " + "VALUES ('repo-gitlab', 'u-src', 'cfg-pre15', 'tool', 'group/tool', 'group/tool', 'https://gitlab.com/group/tool', 'https://gitlab.com/group/tool.git', 'group', 'main', 'gitlab', 'https://gitlab.com')", ); const inserted = db .query("SELECT source_provider, source_url FROM repositories WHERE id = 'repo-gitlab'") .get() as { source_provider: string; source_url: string } | null; assert( inserted?.source_provider === "gitlab" && inserted.source_url === "https://gitlab.com", `Expected GitLab source to round-trip, got ${JSON.stringify(inserted)}`, ); } function seedPre0016Database(db: any) { // Migrations 0000-0015 have run: accounts have no issuer column and the // OAuth provider tables are in their better-auth 1.6 shape. Seed one // account of each kind so the issuer backfill can be checked, plus a row // in every OAuth table the 1.7 column adds touch. db.run("INSERT INTO users (id, email, username, name) VALUES ('u-16', 'u16@example.com', 'u16', 'User Sixteen')"); db.run("INSERT INTO accounts (id, account_id, user_id, provider_id, password) VALUES ('acct-cred', 'u-16', 'u-16', 'credential', 'hashed')"); db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso-16', 'https://idp.example.com', 'example.com', '{}', 'u-16', 'example-oidc')"); db.run("INSERT INTO accounts (id, account_id, user_id, provider_id) VALUES ('acct-sso', 'sub-123', 'u-16', 'example-oidc')"); db.run("INSERT INTO accounts (id, account_id, user_id, provider_id) VALUES ('acct-oauth', 'gh-123', 'u-16', 'github')"); db.run("INSERT INTO oauth_clients (id, client_id, redirect_uris) VALUES ('app-16', 'client-16', '[\"https://example.com/cb\"]')"); db.run("INSERT INTO oauth_access_tokens (id, token, client_id, user_id, scopes) VALUES ('at-16', 'access-16', 'client-16', 'u-16', '[\"openid\"]')"); db.run("INSERT INTO oauth_refresh_tokens (id, token, client_id, user_id, scopes) VALUES ('rt-16', 'refresh-16', 'client-16', 'u-16', '[\"openid\"]')"); db.run("INSERT INTO oauth_consents (id, client_id, user_id, scopes) VALUES ('co-16', 'client-16', 'u-16', '[\"openid\"]')"); db.run("INSERT INTO jwks (id, public_key, private_key) VALUES ('key-16', 'pub', 'priv')"); } function verify0016Migration(db: any) { // better-auth 1.7 keys accounts by (issuer, accountId); sign-in looks the // credential account up with issuer "local:credential", so the backfill // is what keeps existing users able to log in. const issuers = Object.fromEntries( (db.query("SELECT id, issuer FROM accounts ORDER BY id").all() as Array<{ id: string; issuer: string }>).map( (row) => [row.id, row.issuer], ), ); assert(issuers["acct-cred"] === "local:credential", `Expected credential account issuer local:credential, got ${issuers["acct-cred"]}`); assert(issuers["acct-sso"] === "https://idp.example.com", `Expected SSO account to take its provider issuer, got ${issuers["acct-sso"]}`); assert(issuers["acct-oauth"] === "local:oauth:github", `Expected OAuth account issuer local:oauth:github, got ${issuers["acct-oauth"]}`); const accountCols = db.query("PRAGMA table_info(accounts)").all() as Array<{ name: string; notnull: number; dflt_value: string | null }>; const issuerCol = accountCols.find((c) => c.name === "issuer"); assert(issuerCol && issuerCol.notnull === 1, "Expected accounts.issuer to be NOT NULL"); // jwks gained nullable alg and crv. const jwksCols = db.query("PRAGMA table_info(jwks)").all() as Array<{ name: string; notnull: number }>; for (const name of ["alg", "crv"]) { const col = jwksCols.find((c) => c.name === name); assert(col && col.notnull === 0, `Expected jwks.${name} to exist and be nullable`); } const key = db.query("SELECT id, alg FROM jwks WHERE id = 'key-16'").get() as { id: string; alg: string | null } | null; assert(key && key.alg === null, "Expected existing jwks row to survive with NULL alg"); // Columns added for @better-auth/oauth-provider 1.7, all nullable so // pre-existing rows are untouched. const expectedNewColumns: Record = { oauth_clients: [ "client_discovery_id", "client_credentials_scopes", "backchannel_logout_uri", "backchannel_logout_session_required", "application_type", "jwks", "jwks_uri", "dpop_bound_access_tokens", ], oauth_access_tokens: ["authorization_code_id", "resources", "requested_user_info_claims", "revoked", "confirmation"], oauth_refresh_tokens: [ "authorization_code_id", "resources", "requested_user_info_claims", "rotated_at", "rotation_replay_response", "rotation_replay_expires_at", "confirmation", ], oauth_consents: ["resources", "requested_user_info_claims"], }; for (const [table, columns] of Object.entries(expectedNewColumns)) { const cols = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string; notnull: number }>; for (const column of columns) { const col = cols.find((c) => c.name === column); assert(col, `Expected ${table}.${column} column to exist`); assert(col.notnull === 0, `Expected ${table}.${column} to be nullable`); } } // Seeded rows survive the column adds. for (const [table, id] of [ ["oauth_clients", "app-16"], ["oauth_access_tokens", "at-16"], ["oauth_refresh_tokens", "rt-16"], ["oauth_consents", "co-16"], ] as const) { const row = db.query(`SELECT id FROM ${table} WHERE id = '${id}'`).get(); assert(row, `Expected ${table} row '${id}' to survive migration`); } // The three new 1.7 tables exist and accept rows. db.run("INSERT INTO oauth_resources (id, identifier, name) VALUES ('res-16', 'https://api.example.com', 'Example API')"); db.run("INSERT INTO oauth_client_resources (id, client_id, resource_id) VALUES ('cr-16', 'client-16', 'https://api.example.com')"); db.run("INSERT INTO oauth_client_assertions (id, expires_at) VALUES ('jti-16', 1234567890)"); } function seedPre0017Database(db: any) { // Migrations 0000-0016 have run. Seed a user so the api_keys foreign key // has something to point at, and confirm the table does not exist yet. db.run("INSERT INTO users (id, email, username, name) VALUES ('u-17', 'u17@example.com', 'u17', 'User Seventeen')"); const before = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'api_keys'").get(); assert(!before, "Expected api_keys to be absent before migration 0017"); } function verify0017Migration(db: any) { // The table the @better-auth/api-key plugin writes to. Column names are // what the drizzle table in schema.ts declares; the plugin addresses them // through the drizzle field names, so a rename here breaks key creation. const cols = db.query("PRAGMA table_info(api_keys)").all() as Array<{ name: string; notnull: number; dflt_value: string | null; }>; const names = cols.map((c) => c.name); for (const expected of [ "id", "config_id", "name", "start", "prefix", "key", "reference_id", "refill_interval", "refill_amount", "last_refill_at", "enabled", "rate_limit_enabled", "rate_limit_time_window", "rate_limit_max", "request_count", "remaining", "last_request", "expires_at", "created_at", "updated_at", "permissions", "metadata", ]) { assert(names.includes(expected), `Expected api_keys.${expected} column to exist`); } const keyCol = cols.find((c) => c.name === "key"); assert(keyCol && keyCol.notnull === 1, "Expected api_keys.key to be NOT NULL"); const configCol = cols.find((c) => c.name === "config_id"); assert(configCol && configCol.dflt_value === "'default'", "Expected api_keys.config_id to default to 'default'"); const indexes = (db.query("PRAGMA index_list(api_keys)").all() as Array<{ name: string }>).map((i) => i.name); assert(indexes.includes("idx_api_keys_reference_id"), "Expected an index on api_keys.reference_id"); assert(indexes.includes("idx_api_keys_key"), "Expected an index on api_keys.key"); // A row shaped like what the plugin inserts, then the cascade when the // owning user goes away. db.run("PRAGMA foreign_keys = ON"); db.run( "INSERT INTO api_keys (id, name, start, prefix, key, reference_id, enabled, rate_limit_enabled, request_count, created_at, updated_at) " + "VALUES ('key-17', 'ci', 'gm_abcdefg', 'gm_', 'hashed-key', 'u-17', 1, 0, 0, unixepoch(), unixepoch())", ); const inserted = db.query("SELECT expires_at, config_id FROM api_keys WHERE id = 'key-17'").get() as { expires_at: number | null; config_id: string; } | null; assert(inserted && inserted.expires_at === null, "Expected a key without expiry to store NULL expires_at"); assert(inserted.config_id === "default", "Expected config_id to take the default"); let rejected = false; try { db.run("INSERT INTO api_keys (id, key, reference_id, created_at, updated_at) VALUES ('key-orphan', 'h', 'no-such-user', unixepoch(), unixepoch())"); } catch { rejected = true; } assert(rejected, "Expected api_keys.reference_id to require an existing user"); db.run("DELETE FROM users WHERE id = 'u-17'"); const remaining = db.query("SELECT COUNT(*) AS count FROM api_keys").get() as { count: number }; assert(remaining.count === 0, "Expected deleting a user to cascade to their API keys"); } function seedPre0018Database(db: any) { // Migrations 0000-0017 have run. Rows carry no destination columns yet; // seed one account whose gitea_config names a Forgejo server and one with // an empty config, so the backfill can be verified against both. db.run("INSERT INTO users (id, email, username, name) VALUES ('u-dest', 'dest@example.com', 'dest', 'Destination User')"); db.run( "INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " + "VALUES ('cfg-forgejo', 'u-dest', 'Default', 1, '{}', '{\"provider\":\"forgejo\",\"url\":\"https://forge.example.com\"}', '{}', '{}')", ); db.run( "INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " + "VALUES ('cfg-empty', 'u-dest', 'Empty', 0, '{}', '{}', '{}', '{}')", ); db.run( "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) " + "VALUES ('repo-forgejo', 'u-dest', 'cfg-forgejo', 'tool', 'dest/tool', 'dest/tool', 'https://github.com/dest/tool', 'https://github.com/dest/tool.git', 'dest', 'main')", ); db.run( "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) " + "VALUES ('repo-empty', 'u-dest', 'cfg-empty', 'lib', 'dest/lib', 'dest/lib', 'https://github.com/dest/lib', 'https://github.com/dest/lib.git', 'dest', 'main')", ); } function verify0018Migration(db: any) { const cols = db .query("PRAGMA table_info(repositories)") .all() as Array<{ name: string; notnull: number; dflt_value: string | null }>; for (const [name, expectedDefault] of [ ["destination_provider", "'gitea'"], ["destination_url", "''"], ] as const) { const col = cols.find((c) => c.name === name); assert(col, `Expected repositories.${name} column to exist`); assert(col.notnull === 1, `Expected repositories.${name} to be NOT NULL`); assert( col.dflt_value === expectedDefault, `Expected repositories.${name} default ${expectedDefault}, got ${col.dflt_value}`, ); } // Existing rows take the destination of the account that imported them. const forgejo = db .query("SELECT destination_provider, destination_url FROM repositories WHERE id = 'repo-forgejo'") .get() as { destination_provider: string; destination_url: string } | null; assert( forgejo?.destination_provider === "forgejo" && forgejo.destination_url === "https://forge.example.com", `Expected the Forgejo account's row to be backfilled from its config, got ${JSON.stringify(forgejo)}`, ); // An account without a destination yet leaves the URL empty, which the // guard treats as "not recorded", never as a different host. const empty = db .query("SELECT destination_provider, destination_url FROM repositories WHERE id = 'repo-empty'") .get() as { destination_provider: string; destination_url: string } | null; assert( empty?.destination_provider === "gitea" && empty.destination_url === "", `Expected an account without a destination to default to gitea with no URL, got ${JSON.stringify(empty)}`, ); // New rows can record a push target. db.run( "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, destination_provider, destination_url) " + "VALUES ('repo-github-target', 'u-dest', 'cfg-forgejo', 'app', 'dest/app', 'dest/app', 'https://gitlab.com/dest/app', 'https://gitlab.com/dest/app.git', 'dest', 'main', 'github', 'https://github.com')", ); const inserted = db .query("SELECT destination_provider, destination_url FROM repositories WHERE id = 'repo-github-target'") .get() as { destination_provider: string; destination_url: string } | null; assert( inserted?.destination_provider === "github" && inserted.destination_url === "https://github.com", `Expected a GitHub destination to round-trip, got ${JSON.stringify(inserted)}`, ); } function seedPre0019Database(db: any) { // Migrations 0000-0018 have run; the sources table does not exist yet. // Seed one user per connection flavor: a plain GitHub config, a GitLab // config without an instance URL (provider default must apply), a // "forgejo" alias with a trailing-slash URL (must normalize to the gitea // kind and the slashed URL), and an empty config (must create no source). for (const [id, email, name] of [ ["u-gh", "gh19@example.com", "GitHub Nineteen"], ["u-gl", "gl19@example.com", "GitLab Nineteen"], ["u-fj", "fj19@example.com", "Forgejo Nineteen"], ["u-none", "none19@example.com", "No Source Nineteen"], ] as const) { db.run("INSERT INTO users (id, email, username, name) VALUES (?, ?, ?, ?)", [id, email, id.slice(2), name]); } db.run( "INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " + "VALUES ('cfg-gh19', 'u-gh', 'Default', 1, '{\"provider\":\"github\",\"owner\":\"octocat\",\"token\":\"gh-encrypted-token\",\"type\":\"personal\"}', '{}', '{}', '{}')", ); db.run( "INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " + "VALUES ('cfg-gl19', 'u-gl', 'Default', 1, '{\"provider\":\"gitlab\",\"owner\":\"greta\",\"token\":\"gl-encrypted-token\",\"type\":\"personal\"}', '{}', '{}', '{}')", ); db.run( "INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " + "VALUES ('cfg-fj19', 'u-fj', 'Default', 1, '{\"provider\":\"forgejo\",\"owner\":\"cbuser\",\"url\":\"https://codeberg.org/\",\"token\":\"\",\"type\":\"personal\"}', '{}', '{}', '{}')", ); db.run( "INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " + "VALUES ('cfg-none19', 'u-none', 'Default', 1, '{\"owner\":\"\",\"token\":\"\"}', '{}', '{}', '{}')", ); const insertRepo = (id: string, userId: string, configId: string, fullName: string, provider: string, url: string) => db.run( "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, source_provider, source_url) " + `VALUES ('${id}', '${userId}', '${configId}', '${fullName.split("/")[1]}', '${fullName}', '${fullName}', 'https://example.com/${fullName}', 'https://example.com/${fullName}.git', '${fullName.split("/")[0]}', 'main', '${provider}', '${url}')`, ); // Matches the config's connection -> backfilled. insertRepo("repo-gh19", "u-gh", "cfg-gh19", "octocat/tool", "github", "https://github.com"); // From a host the user has no source for -> stays NULL ("switched source"). insertRepo("repo-other19", "u-gh", "cfg-gh19", "octocat/legacy", "gitlab", "https://gitlab.com"); // Trailing slash in the repo's source_url still matches the trimmed source url. insertRepo("repo-cb19", "u-fj", "cfg-fj19", "cbuser/tool", "gitea", "https://codeberg.org/"); } function verify0019Migration(db: any) { // The sources table and its indexes. const sourceCount = db.query("SELECT COUNT(*) AS count FROM sources").get() as { count: number }; assert(sourceCount.count === 3, `Expected 3 seeded source rows, got ${sourceCount.count}`); for (const indexName of ["idx_sources_user_id", "uniq_sources_user_provider_url_username"]) { const index = db .query("SELECT name FROM sqlite_master WHERE type='index' AND name = ?") .get(indexName) as { name: string } | null; assert(index, `Expected ${indexName} index to exist after migration`); } const byUser = Object.fromEntries( (db.query("SELECT user_id, name, provider, url, username, token, enabled FROM sources").all() as Array<{ user_id: string; name: string; provider: string; url: string; username: string; token: string | null; enabled: number; }>).map((row) => [row.user_id, row]), ); assert( byUser["u-gh"]?.provider === "github" && byUser["u-gh"].url === "https://github.com" && byUser["u-gh"].username === "octocat" && byUser["u-gh"].token === "gh-encrypted-token" && byUser["u-gh"].name === "GitHub (octocat)" && byUser["u-gh"].enabled === 1, `Expected the GitHub config to seed a matching source row, got ${JSON.stringify(byUser["u-gh"])}`, ); assert( byUser["u-gl"]?.provider === "gitlab" && byUser["u-gl"].url === "https://gitlab.com", `Expected a GitLab config without a URL to take the provider default, got ${JSON.stringify(byUser["u-gl"])}`, ); assert( byUser["u-fj"]?.provider === "gitea" && byUser["u-fj"].url === "https://codeberg.org" && byUser["u-fj"].token === null && byUser["u-fj"].name === "Gitea / Forgejo (cbuser)", `Expected the forgejo alias to normalize to gitea with a trimmed URL and NULL token, got ${JSON.stringify(byUser["u-fj"])}`, ); assert(!byUser["u-none"], "Expected an empty config to seed no source row"); // The repositories.source_id column exists, nullable, no default. const repoCols = db.query("PRAGMA table_info(repositories)").all() as TableInfoRow[]; const sourceIdCol = repoCols.find((column) => column.name === "source_id"); assert(sourceIdCol, "Expected repositories.source_id column to exist after migration"); assert(sourceIdCol.notnull === 0, "Expected repositories.source_id to be nullable"); assert(sourceIdCol.dflt_value === null, `Expected repositories.source_id to have no default, got ${sourceIdCol.dflt_value}`); // Backfill: matching repos linked, unmatched legacy repo left NULL. const linked = db .query( "SELECT repositories.source_id FROM repositories JOIN sources ON sources.id = repositories.source_id WHERE repositories.id = 'repo-gh19'", ) .get() as { source_id: string } | null; assert(linked, "Expected the GitHub repository to be linked to its source row"); const cbLinked = db .query( "SELECT repositories.source_id FROM repositories JOIN sources ON sources.id = repositories.source_id WHERE repositories.id = 'repo-cb19'", ) .get() as { source_id: string } | null; assert(cbLinked, "Expected the trailing-slash Codeberg repository to be linked to its source row"); const unlinked = db .query("SELECT source_id FROM repositories WHERE id = 'repo-other19'") .get() as { source_id: string | null } | null; assert(unlinked?.source_id === null, `Expected a repository from an unconnected host to keep NULL source_id, got ${JSON.stringify(unlinked)}`); // Full names are now unique per source: the same owner/repo can exist once // per connected source, and still only once per source. db.run( "INSERT INTO sources (id, user_id, name, provider, url, username) VALUES ('src-gl19', 'u-gh', 'GitLab (octocat)', 'gitlab', 'https://gitlab.com', 'octocat')", ); const ghSourceId = (db.query("SELECT id FROM sources WHERE user_id = 'u-gh' AND provider = 'github'").get() as { id: string }).id; db.run( "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, source_id) " + `VALUES ('repo-dup-gh', 'u-gh', 'cfg-gh19', 'dup-tool', 'octocat/dup-tool', 'octocat/dup-tool', 'https://github.com/octocat/dup-tool', 'https://github.com/octocat/dup-tool.git', 'octocat', 'main', '${ghSourceId}')`, ); db.run( "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, source_id) " + `VALUES ('repo-dup-gl', 'u-gh', 'cfg-gh19', 'dup-tool', 'octocat/dup-tool', 'octocat/dup-tool', 'https://gitlab.com/octocat/dup-tool', 'https://gitlab.com/octocat/dup-tool.git', 'octocat', 'main', 'src-gl19')`, ); let rejected = false; try { db.run( "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, source_id) " + `VALUES ('repo-dup-gh2', 'u-gh', 'cfg-gh19', 'dup-tool', 'octocat/dup-tool', 'octocat/dup-tool', 'https://github.com/octocat/dup-tool', 'https://github.com/octocat/dup-tool.git', 'octocat', 'main', '${ghSourceId}')`, ); } catch { rejected = true; } assert(rejected, "Expected the same full name under the same source to still be rejected"); } const MIGRATION_0012_TIMESTAMP = 1774062000000; const MIGRATION_0013_TIMESTAMP = 1780377747526; /** * Reproduce the issue #312 crash state — sso_providers already carries * saml_config / domain_verified before migration 0013 runs (stranded on an * intermediate build), with __drizzle_migrations recorded only through 0012 — * and verify repairDuplicateSsoColumns()/restoreSsoDataAfter0013() let the * canonical 0013 run while preserving real SAML provider data. */ function validateBroken0013Repair() { const migration0013 = migrations.find((m) => m.entry.tag === "0013_slim_galactus"); if (!migration0013) return; // 0013 not present (shouldn't happen) — nothing to test. const db = new Database(":memory:"); try { runMigrations(db, migrations.slice(0, 13)); // 0000-0012 // A real upgraded instance has a __drizzle_migrations table recorded // through 0012 but not 0013. db.run( "CREATE TABLE IF NOT EXISTS `__drizzle_migrations` (id INTEGER PRIMARY KEY AUTOINCREMENT, hash text NOT NULL, created_at numeric)", ); db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0012', ?)", [ MIGRATION_0012_TIMESTAMP, ]); // Stranded columns from the intermediate build. db.run("ALTER TABLE sso_providers ADD saml_config text"); db.run("ALTER TABLE sso_providers ADD domain_verified integer DEFAULT true NOT NULL"); db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'u1', 'User One')"); const samlJson = '{"entryPoint":"https://idp.example.com/sso","cert":"ABC123"}'; db.run( "INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('oidc1', 'https://idp', 'a.com', '{}', 'u1', 'p-oidc', NULL, 1)", ); db.run( "INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('saml1', 'https://idp', 'b.com', '{}', 'u1', 'p-saml', ?, 1)", [samlJson], ); db.run( "INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('unv1', 'https://idp', 'c.com', '{}', 'u1', 'p-unv', NULL, 0)", ); const preserved = repairDuplicateSsoColumns(db); const colsAfterRepair = (db.query("PRAGMA table_info(sso_providers)").all() as TableInfoRow[]).map( (c) => c.name, ); assert(!colsAfterRepair.includes("saml_config"), "Expected repair to drop stranded saml_config column"); assert( !colsAfterRepair.includes("domain_verified"), "Expected repair to drop stranded domain_verified column", ); const preservedIds = preserved.map((r) => r.id).sort(); assert( preservedIds.length === 2 && preservedIds[0] === "saml1" && preservedIds[1] === "unv1", `Expected SAML + unverified rows to be preserved, got ${JSON.stringify(preservedIds)}`, ); // The canonical 0013 must now run without a duplicate-column error. runMigration(db, migration0013); restoreSsoDataAfter0013(db, preserved); const rows = db .query("SELECT id, saml_config, domain_verified FROM sso_providers ORDER BY id") .all() as Array<{ id: string; saml_config: string | null; domain_verified: number }>; const byId = Object.fromEntries(rows.map((r) => [r.id, r])); assert(byId.oidc1.saml_config === null, "Expected OIDC provider saml_config to remain NULL"); assert(byId.oidc1.domain_verified === 1, "Expected OIDC provider domain_verified default 1"); assert(byId.saml1.saml_config === samlJson, "Expected SAML provider config to be preserved"); assert(byId.saml1.domain_verified === 1, "Expected SAML provider domain_verified preserved as 1"); assert(byId.unv1.saml_config === null, "Expected unverified provider saml_config NULL"); assert(byId.unv1.domain_verified === 0, "Expected explicit domain_verified=0 to be preserved"); // Idempotency: 0013 is now applied, so a re-run of the repair is a no-op. db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0013', ?)", [ MIGRATION_0013_TIMESTAMP, ]); const secondPass = repairDuplicateSsoColumns(db); assert(secondPass.length === 0, "Expected repair to no-op once migration 0013 is recorded"); const colsAfterSecondPass = ( db.query("PRAGMA table_info(sso_providers)").all() as TableInfoRow[] ).map((c) => c.name); assert( colsAfterSecondPass.includes("saml_config") && colsAfterSecondPass.includes("domain_verified"), "Expected columns to remain intact on the no-op second pass", ); } finally { db.close(); } } const MIGRATION_0015_TIMESTAMP = 1788333701612; /** * Reproduce a database that booted the reverted better-auth 1.7.0-rc.4 build: * migrations recorded through 0015, but the three rc tables and some of the * rc columns already present. Migration 0016 must fail on it as-is, and * repairStrandedBetterAuth17Schema() must reconcile it so 0016 then runs and * backfills account issuers. */ function validateStranded0016Repair() { const migration0016 = migrations.find((m) => m.entry.tag === "0016_better_auth_1_7"); if (!migration0016) return; const db = new Database(":memory:"); try { runMigrations(db, migrations.slice(0, 16)); // 0000-0015 db.run( "CREATE TABLE IF NOT EXISTS `__drizzle_migrations` (id INTEGER PRIMARY KEY AUTOINCREMENT, hash text NOT NULL, created_at numeric)", ); db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0015', ?)", [ MIGRATION_0015_TIMESTAMP, ]); // Leftovers from the rc build. db.run("CREATE TABLE oauth_client_assertions (id text PRIMARY KEY NOT NULL, expires_at integer NOT NULL)"); db.run("CREATE TABLE oauth_resources (id text PRIMARY KEY NOT NULL, identifier text NOT NULL, name text NOT NULL)"); db.run("ALTER TABLE oauth_clients ADD client_discovery_id text"); db.run("ALTER TABLE oauth_access_tokens ADD resources text"); db.run("INSERT INTO users (id, email, username, name) VALUES ('u-rc', 'rc@example.com', 'rc', 'RC User')"); db.run("INSERT INTO accounts (id, account_id, user_id, provider_id, password) VALUES ('acct-rc', 'u-rc', 'u-rc', 'credential', 'hashed')"); // As-is, 0016 cannot run: the CREATE TABLE collides. let failedAsIs = false; try { runMigration(db, migration0016); } catch { failedAsIs = true; } assert(failedAsIs, "Expected migration 0016 to fail on the rc leftovers without the repair"); const dropped = repairStrandedBetterAuth17Schema(db); assert(dropped === 4, `Expected the repair to drop 2 tables and 2 columns, dropped ${dropped}`); runMigration(db, migration0016); const account = db .query("SELECT issuer FROM accounts WHERE id = 'acct-rc'") .get() as { issuer: string } | null; assert(account?.issuer === "local:credential", `Expected the credential account to be backfilled after the repair, got ${account?.issuer}`); const clientCols = (db.query("PRAGMA table_info(oauth_clients)").all() as TableInfoRow[]).map((c) => c.name); assert(clientCols.includes("client_discovery_id") && clientCols.includes("jwks_uri"), "Expected 0016 to re-add the oauth_clients columns in full"); db.run("INSERT INTO oauth_client_assertions (id, expires_at) VALUES ('jti-rc', 1)"); // Idempotency: with 0016 recorded the repair is a no-op. db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0016', ?)", [ 1788336988526, ]); assert(repairStrandedBetterAuth17Schema(db) === 0, "Expected the repair to no-op once migration 0016 is recorded"); } finally { db.close(); } } const latestUpgradeFixtures: Record = { "0009_nervous_tyger_tiger": { seed: seedPre0009Database, verify: verify0009Migration, }, "0010_mirrored_location_index": { seed: seedPre0010Database, verify: verify0010Migration, }, "0011_notification_config": { seed: seedPre0011Database, verify: verify0011Migration, }, "0012_oauth_provider_migration": { seed: seedPre0012Database, verify: verify0012Migration, }, "0013_slim_galactus": { seed: seedPre0013Database, verify: verify0013Migration, }, "0014_needy_white_tiger": { seed: seedPre0014Database, verify: verify0014Migration, }, "0015_source_provider": { seed: seedPre0015Database, verify: verify0015Migration, }, "0016_better_auth_1_7": { seed: seedPre0016Database, verify: verify0016Migration, }, "0017_api_keys": { seed: seedPre0017Database, verify: verify0017Migration, }, "0018_destination_columns": { seed: seedPre0018Database, verify: verify0018Migration, }, "0019_multi_source": { seed: seedPre0019Database, verify: verify0019Migration, }, "0020_org_source": { seed: seedPre0020Database, verify: verify0020Migration, }, }; function seedPre0020Database(db: any) { // Migrations 0000-0019 have run: repositories carry source_id, // organizations have none yet. Seed two sources and three organizations: // one whose repos all come from a single source (must be pinned), one // whose repos span both sources (must stay unpinned), and one without // repos (must stay unpinned). db.run("INSERT INTO users (id, email, username, name) VALUES ('u-org20', 'org20@example.com', 'org20', 'Org Twenty')"); db.run( "INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " + "VALUES ('cfg-org20', 'u-org20', 'Default', 1, '{\"provider\":\"github\",\"owner\":\"octocat\",\"token\":\"gh-token\",\"type\":\"personal\"}', '{}', '{}', '{}')", ); db.run( "INSERT INTO sources (id, user_id, name, provider, url, username) VALUES ('src-a20', 'u-org20', 'GitHub (octocat)', 'github', 'https://github.com', 'octocat')", ); db.run( "INSERT INTO sources (id, user_id, name, provider, url, username) VALUES ('src-b20', 'u-org20', 'GitLab (octocat)', 'gitlab', 'https://gitlab.com', 'octocat')", ); const insertOrg = (id: string, name: string) => db.run( "INSERT INTO organizations (id, user_id, config_id, name, normalized_name, avatar_url) " + `VALUES ('${id}', 'u-org20', 'cfg-org20', '${name}', '${name.toLowerCase()}', 'https://example.com/${name}.png')`, ); insertOrg("org-acme20", "acme"); insertOrg("org-mixed20", "mixed"); insertOrg("org-empty20", "empty"); const insertRepo = (id: string, org: string, sourceId: string | null) => db.run( "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, organization, source_id) " + `VALUES ('${id}', 'u-org20', 'cfg-org20', '${id}', '${org}/${id}', '${org}/${id}', 'https://example.com/${org}/${id}', 'https://example.com/${org}/${id}.git', '${org}', 'main', '${org}', ${sourceId ? `'${sourceId}'` : "NULL"})`, ); insertRepo("repo-acme1-20", "acme", "src-a20"); insertRepo("repo-acme2-20", "acme", "src-a20"); insertRepo("repo-mixed1-20", "mixed", "src-a20"); insertRepo("repo-mixed2-20", "mixed", "src-b20"); } function verify0020Migration(db: any) { const orgCols = db.query("PRAGMA table_info(organizations)").all() as TableInfoRow[]; const sourceIdCol = orgCols.find((column) => column.name === "source_id"); assert(sourceIdCol, "Expected organizations.source_id column to exist after migration"); assert(sourceIdCol.notnull === 0, "Expected organizations.source_id to be nullable"); assert(sourceIdCol.dflt_value === null, `Expected organizations.source_id to have no default, got ${sourceIdCol.dflt_value}`); const byName = Object.fromEntries( (db.query("SELECT name, source_id FROM organizations").all() as Array<{ name: string; source_id: string | null }>).map( (row) => [row.name, row.source_id], ), ); assert(byName["acme"] === "src-a20", `Expected the single-source organization to be pinned, got ${JSON.stringify(byName["acme"])}`); assert(byName["mixed"] === null, `Expected the multi-source organization to stay unpinned, got ${JSON.stringify(byName["mixed"])}`); assert(byName["empty"] === null, `Expected the repo-less organization to stay unpinned, got ${JSON.stringify(byName["empty"])}`); db.run("UPDATE organizations SET source_id = 'src-b20' WHERE name = 'acme'"); const repinned = db.query("SELECT source_id FROM organizations WHERE name = 'acme'").get() as { source_id: string | null } | null; assert(repinned?.source_id === "src-b20", `Expected a manual pin to round-trip, got ${JSON.stringify(repinned)}`); } function lintMigrations(selectedMigrations: Migration[]) { const violations: string[] = []; for (const migration of selectedMigrations) { for (const statement of migration.statements) { for (const rule of SQLITE_LINT_RULES) { if (rule.pattern.test(statement)) { violations.push(`[${migration.entry.tag}] ${rule.message}\n Statement: ${statement.slice(0, 120)}...`); } } } } assert( violations.length === 0, `SQLite lint found ${violations.length} violation(s):\n\n${violations.join("\n\n")}`, ); } function validateMigrations() { assert(latestMigration, "No migrations found in drizzle/meta/_journal.json"); // Lint all migrations for known SQLite pitfalls before running anything. lintMigrations(migrations); const emptyDb = new Database(":memory:"); try { runMigrations(emptyDb, migrations); } finally { emptyDb.close(); } const upgradeFixture = latestUpgradeFixtures[latestMigration.entry.tag]; assert( upgradeFixture, `Missing upgrade fixture for latest migration ${latestMigration.entry.tag}. Add one in scripts/validate-migrations.ts.`, ); const upgradeDb = new Database(":memory:"); try { runMigrations(upgradeDb, migrations.slice(0, -1)); upgradeFixture.seed(upgradeDb); runMigration(upgradeDb, latestMigration); upgradeFixture.verify(upgradeDb); } finally { upgradeDb.close(); } // Exercise the runtime repair for the issue #312 duplicate-column crash. validateBroken0013Repair(); // Exercise the repair for databases that booted the reverted 1.7.0-rc.4 build. validateStranded0016Repair(); console.log( `Validated ${migrations.length} migrations from scratch and upgrade path for ${latestMigration.entry.tag}, plus the #312 SSO-column repair and the better-auth 1.7 rc repair.`, ); } try { validateMigrations(); } catch (error) { console.error("Migration validation failed:"); console.error(error instanceof Error ? error.stack ?? error.message : String(error)); process.exit(1); }