Files
metapi/src/server/db/migrate.test.ts
T
Cita 38721cb890 [codex] support downstream key site and credential exclusions (#431)
* support downstream key site and credential exclusions

* skip electron binary download in non-desktop ci

* fix migrate partial-journal test for 0020
2026-04-05 23:43:07 +08:00

634 lines
22 KiB
TypeScript

import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
type MigrationJournalEntry = {
tag: string;
when: number;
};
const migrationsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../drizzle');
function readMigrationJournalEntries(): MigrationJournalEntry[] {
const journalPath = join(migrationsDir, 'meta', '_journal.json');
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries?: MigrationJournalEntry[] };
return journal.entries ?? [];
}
function applyMigrationSql(sqlite: Database.Database, sqlText: string) {
const statements = sqlText
.split('--> statement-breakpoint')
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
for (const statement of statements) {
sqlite.exec(statement);
}
}
function recordAppliedMigrations(
sqlite: Database.Database,
journalEntries: MigrationJournalEntry[],
) {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at numeric
)
`);
const insert = sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)');
for (const entry of journalEntries) {
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
const hash = createHash('sha256').update(sqlText).digest('hex');
insert.run(hash, entry.when);
}
}
const STALE_JOURNAL_TIMESTAMP_DRIFT_MS = 4_196_930;
describe('sqlite migrate bootstrap', () => {
afterEach(() => {
delete process.env.DATA_DIR;
delete process.env.DB_URL;
vi.resetModules();
});
it('accepts an already-synced sqlite schema with an empty drizzle journal', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-'));
const dbPath = join(dataDir, 'hub.db');
const sqlite = new Database(dbPath);
const journalEntries = readMigrationJournalEntries();
for (const entry of journalEntries) {
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
applyMigrationSql(sqlite, sqlText);
}
sqlite.close();
process.env.DATA_DIR = dataDir;
vi.resetModules();
await expect(import('./migrate.js')).resolves.toMatchObject({
runSqliteMigrations: expect.any(Function),
});
const verified = new Database(dbPath, { readonly: true });
const appliedRows = verified
.prepare('select created_at from __drizzle_migrations order by created_at asc')
.all() as Array<{ created_at: number }>;
expect(appliedRows.map((row) => Number(row.created_at))).toEqual(
journalEntries.map((entry) => entry.when),
);
verified.close();
});
it('recovers from duplicate-column errors for single-statement migrations', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-'));
process.env.DATA_DIR = dataDir;
vi.resetModules();
const migrateModule = await import('./migrate.js');
const { __migrateTestUtils } = migrateModule;
const sqlite = new Database(':memory:');
sqlite.exec(`
CREATE TABLE account_tokens (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
token_group text
);
`);
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-'));
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
writeFileSync(
join(tempMigrationsDir, 'meta', '_journal.json'),
JSON.stringify({
entries: [
{
tag: '0007_account_token_group',
when: 1772500000000,
},
],
}),
);
writeFileSync(
join(tempMigrationsDir, '0007_account_token_group.sql'),
'ALTER TABLE `account_tokens` ADD `token_group` text;\n',
);
const duplicateColumnError = new Error(
"DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n' duplicate column name: token_group",
);
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
sqlite,
tempMigrationsDir,
duplicateColumnError,
);
expect(recovered).toBe(true);
const applied = sqlite
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
.all() as Array<{ hash: string; created_at: number }>;
expect(applied).toHaveLength(1);
expect(Number(applied[0].created_at)).toBe(1772500000000);
sqlite.close();
});
it('recovers when duplicate-column message appears only in error cause', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-cause-'));
process.env.DATA_DIR = dataDir;
vi.resetModules();
const migrateModule = await import('./migrate.js');
const { __migrateTestUtils } = migrateModule;
const sqlite = new Database(':memory:');
sqlite.exec(`
CREATE TABLE account_tokens (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
token_group text
);
`);
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-cause-'));
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
writeFileSync(
join(tempMigrationsDir, 'meta', '_journal.json'),
JSON.stringify({
entries: [
{
tag: '0007_account_token_group',
when: 1772500000001,
},
],
}),
);
writeFileSync(
join(tempMigrationsDir, '0007_account_token_group.sql'),
'ALTER TABLE `account_tokens` ADD `token_group` text;\n',
);
const drizzleLikeError = {
message: "DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n'",
cause: {
message: 'SqliteError: duplicate column name: token_group',
},
};
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
sqlite,
tempMigrationsDir,
drizzleLikeError,
);
expect(recovered).toBe(true);
const applied = sqlite
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
.all() as Array<{ hash: string; created_at: number }>;
expect(applied).toHaveLength(1);
expect(Number(applied[0].created_at)).toBe(1772500000001);
sqlite.close();
});
it('updates only the latest matching migration record when reconciling stale timestamps', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-rowid-reconcile-'));
process.env.DATA_DIR = dataDir;
vi.resetModules();
const migrateModule = await import('./migrate.js');
const { __migrateTestUtils } = migrateModule;
const sqlite = new Database(':memory:');
sqlite.exec(`
CREATE TABLE "__drizzle_migrations" (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at numeric
);
`);
sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)').run('same-hash', 10);
sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)').run('same-hash', 20);
const changed = __migrateTestUtils.markMigrationRecordIfMissing(sqlite, {
hash: 'same-hash',
createdAt: 30,
});
const records = sqlite
.prepare('SELECT rowid, hash, created_at FROM "__drizzle_migrations" ORDER BY rowid ASC')
.all() as Array<{ rowid: number; hash: string; created_at: number }>;
expect(changed).toBe(true);
expect(records).toEqual([
{ rowid: 1, hash: 'same-hash', created_at: 10 },
{ rowid: 2, hash: 'same-hash', created_at: 30 },
]);
sqlite.close();
});
it('recovers duplicate-column errors when the failed SQL contains quoted literals', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-quoted-'));
process.env.DATA_DIR = dataDir;
vi.resetModules();
const migrateModule = await import('./migrate.js');
const { __migrateTestUtils } = migrateModule;
const sqlite = new Database(':memory:');
sqlite.exec(`
CREATE TABLE account_tokens (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
value_status text DEFAULT 'ready' NOT NULL
);
`);
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-quoted-'));
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
writeFileSync(
join(tempMigrationsDir, 'meta', '_journal.json'),
JSON.stringify({
entries: [
{
tag: '0012_account_token_value_status',
when: 1773665311013,
},
],
}),
);
writeFileSync(
join(tempMigrationsDir, '0012_account_token_value_status.sql'),
"ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n",
);
const duplicateColumnError = new Error(
"DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n' duplicate column name: value_status",
);
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
sqlite,
tempMigrationsDir,
duplicateColumnError,
);
expect(recovered).toBe(true);
const applied = sqlite
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
.all() as Array<{ hash: string; created_at: number }>;
expect(applied).toHaveLength(1);
expect(Number(applied[0].created_at)).toBe(1773665311013);
sqlite.close();
});
it('recovers duplicate-column errors inside multi-statement migrations by replaying the full migration', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-multi-'));
process.env.DATA_DIR = dataDir;
vi.resetModules();
const migrateModule = await import('./migrate.js');
const { __migrateTestUtils } = migrateModule;
const sqlite = new Database(':memory:');
sqlite.exec(`
CREATE TABLE account_tokens (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
token_group text
);
`);
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-multi-'));
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
writeFileSync(
join(tempMigrationsDir, 'meta', '_journal.json'),
JSON.stringify({
entries: [
{
tag: '0009_model_availability_is_manual',
when: 1772600000000,
},
],
}),
);
writeFileSync(
join(tempMigrationsDir, '0009_model_availability_is_manual.sql'),
[
'CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL);',
'ALTER TABLE `account_tokens` ADD `token_group` text;',
].join('\n--> statement-breakpoint\n'),
);
const duplicateColumnError = new Error(
"DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n' duplicate column name: token_group",
);
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
sqlite,
tempMigrationsDir,
duplicateColumnError,
);
expect(recovered).toBe(true);
const createdTable = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'downstream_api_keys'")
.get() as { name?: string } | undefined;
const applied = sqlite
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
.all() as Array<{ hash: string; created_at: number }>;
expect(createdTable?.name).toBe('downstream_api_keys');
expect(applied).toHaveLength(1);
expect(Number(applied[0].created_at)).toBe(1772600000000);
sqlite.close();
});
it('replays missing migrations before marking a duplicate-column migration as applied', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-partial-journal-'));
const dbPath = join(dataDir, 'hub.db');
const sqlite = new Database(dbPath);
const journalEntries = readMigrationJournalEntries();
const missingTags = new Set([
'0006_site_disabled_models',
'0007_account_token_group',
'0008_sqlite_schema_backfill',
'0009_model_availability_is_manual',
'0010_proxy_logs_downstream_api_key',
'0011_downstream_api_key_metadata',
'0012_account_token_value_status',
'0013_oauth_multi_provider',
// 0008 creates downstream_api_keys, so later table-dependent migrations
// must stay missing in this partial-journal fixture too.
'0020_downstream_api_key_exclusions',
]);
const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag));
for (const entry of appliedEntries) {
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
applyMigrationSql(sqlite, sqlText);
}
recordAppliedMigrations(sqlite, appliedEntries);
// Simulate legacy compatibility code adding token_group before the formal 0007 migration ran.
sqlite.exec('ALTER TABLE account_tokens ADD COLUMN token_group text;');
sqlite.close();
process.env.DATA_DIR = dataDir;
vi.resetModules();
await expect(import('./migrate.js')).resolves.toMatchObject({
runSqliteMigrations: expect.any(Function),
});
const verified = new Database(dbPath, { readonly: true });
const appliedRows = verified
.prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at ASC')
.all() as Array<{ created_at: number }>;
const disabledModelsTable = verified
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'site_disabled_models'")
.get() as { name?: string } | undefined;
const downstreamApiKeysTable = verified
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'downstream_api_keys'")
.get() as { name?: string } | undefined;
expect(disabledModelsTable?.name).toBe('site_disabled_models');
expect(downstreamApiKeysTable?.name).toBe('downstream_api_keys');
expect(appliedRows.map((row) => Number(row.created_at))).toEqual(
journalEntries.map((entry) => entry.when),
);
verified.close();
});
it('recovers sequential duplicate-column migrations when a legacy sqlite schema predates the drizzle journal', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-legacy-schema-'));
const dbPath = join(dataDir, 'hub.db');
const sqlite = new Database(dbPath);
const journalEntries = readMigrationJournalEntries();
const appliedEntries = journalEntries.filter((entry) => entry.tag !== '0019_proxy_logs_stream_timing');
for (const entry of appliedEntries) {
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
applyMigrationSql(sqlite, sqlText);
}
// Simulate SQLite legacy compatibility code partially adding the latest proxy log columns
// before drizzle creates its own migration journal.
sqlite.exec('ALTER TABLE proxy_logs ADD COLUMN is_stream integer;');
sqlite.close();
process.env.DATA_DIR = dataDir;
vi.resetModules();
await expect(import('./migrate.js')).resolves.toMatchObject({
runSqliteMigrations: expect.any(Function),
});
const verified = new Database(dbPath, { readonly: true });
const appliedRows = verified
.prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at ASC')
.all() as Array<{ created_at: number }>;
const proxyLogColumns = verified
.prepare('PRAGMA table_info("proxy_logs")')
.all() as Array<{ name: string }>;
expect(appliedRows.map((row) => Number(row.created_at))).toEqual(
journalEntries.map((entry) => entry.when),
);
expect(proxyLogColumns.some((column) => column.name === 'is_stream')).toBe(true);
expect(proxyLogColumns.some((column) => column.name === 'first_byte_latency_ms')).toBe(true);
verified.close();
});
it('reconciles stale migration timestamps when the latest migration hash already exists', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-stale-timestamp-'));
const dbPath = join(dataDir, 'hub.db');
const sqlite = new Database(dbPath);
const journalEntries = readMigrationJournalEntries();
for (const entry of journalEntries) {
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
applyMigrationSql(sqlite, sqlText);
}
recordAppliedMigrations(sqlite, journalEntries);
const latestEntry = journalEntries.at(-1);
const latestSqlText = latestEntry
? readFileSync(join(migrationsDir, `${latestEntry.tag}.sql`), 'utf8')
: '';
const latestHash = createHash('sha256').update(latestSqlText).digest('hex');
// Introduce about 70 minutes of timestamp drift so journal reconciliation has work to do.
sqlite
.prepare('UPDATE __drizzle_migrations SET created_at = ? WHERE hash = ?')
.run((latestEntry?.when ?? 0) - STALE_JOURNAL_TIMESTAMP_DRIFT_MS, latestHash);
sqlite.close();
process.env.DATA_DIR = dataDir;
vi.resetModules();
await expect(import('./migrate.js')).resolves.toMatchObject({
runSqliteMigrations: expect.any(Function),
});
const verified = new Database(dbPath, { readonly: true });
const latestApplied = verified
.prepare('SELECT created_at FROM __drizzle_migrations WHERE hash = ? LIMIT 1')
.get(latestHash) as { created_at: number } | undefined;
expect(Number(latestApplied?.created_at)).toBe(latestEntry?.when);
verified.close();
});
it('deduplicates legacy duplicate sites before applying the oauth site unique index', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-duplicate-sites-'));
const dbPath = join(dataDir, 'hub.db');
const sqlite = new Database(dbPath);
const journalEntries = readMigrationJournalEntries();
const appliedEntries = journalEntries.filter((entry) => entry.tag !== '0013_oauth_multi_provider');
for (const entry of appliedEntries) {
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
applyMigrationSql(sqlite, sqlText);
}
recordAppliedMigrations(sqlite, appliedEntries);
sqlite.exec(`
INSERT INTO sites (id, name, url, platform, status, is_pinned, sort_order, global_weight)
VALUES
(101, 'Primary Codex', 'https://chatgpt.com/backend-api/codex', 'codex', 'active', 0, 0, 1),
(202, 'Duplicate Codex', 'https://chatgpt.com/backend-api/codex', 'codex', 'disabled', 1, 9, 3);
INSERT INTO accounts (site_id, username, access_token, status, checkin_enabled)
VALUES
(101, 'first@example.com', 'token-a', 'active', 0),
(202, 'second@example.com', 'token-b', 'disabled', 0);
INSERT INTO site_disabled_models (site_id, model_name)
VALUES
(101, 'gpt-5'),
(202, 'gpt-5'),
(202, 'gpt-5-mini');
`);
sqlite.close();
process.env.DATA_DIR = dataDir;
vi.resetModules();
await expect(import('./migrate.js')).resolves.toMatchObject({
runSqliteMigrations: expect.any(Function),
});
const verified = new Database(dbPath, { readonly: true });
const sites = verified
.prepare('SELECT id, name, url, platform, status, is_pinned, sort_order, global_weight FROM sites ORDER BY id ASC')
.all() as Array<{
id: number;
name: string;
url: string;
platform: string;
status: string;
is_pinned: number;
sort_order: number;
global_weight: number;
}>;
const accounts = verified
.prepare('SELECT username, site_id FROM accounts ORDER BY username ASC')
.all() as Array<{ username: string; site_id: number }>;
const disabledModels = verified
.prepare('SELECT site_id, model_name FROM site_disabled_models ORDER BY site_id ASC, model_name ASC')
.all() as Array<{ site_id: number; model_name: string }>;
expect(sites).toEqual([
expect.objectContaining({
id: 101,
name: 'Primary Codex',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
}),
]);
expect(accounts).toEqual([
{ username: 'first@example.com', site_id: 101 },
{ username: 'second@example.com', site_id: 101 },
]);
expect(disabledModels).toEqual([
{ site_id: 101, model_name: 'gpt-5' },
{ site_id: 101, model_name: 'gpt-5-mini' },
]);
verified.close();
});
it('fails fast when duplicate-column recovery exceeds the retry budget', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-retry-budget-'));
process.env.DATA_DIR = dataDir;
vi.resetModules();
const migrateModule = await import('./migrate.js');
const { __migrateTestUtils } = migrateModule as {
__migrateTestUtils: Record<string, unknown>;
};
const runSqliteMigrationRecoveryLoop = __migrateTestUtils
.runSqliteMigrationRecoveryLoop as ((input: {
runMigrate: () => void;
recoverDuplicateColumnMigrationError: (error: unknown) => { tag: string; recoveredCount: number } | null;
isSitesPlatformUrlUniqueConflictError: (error: unknown) => boolean;
deduplicateLegacySitesForUniqueIndex: () => boolean;
closeSqlite: () => void;
}) => void) | undefined;
const retryBudget = __migrateTestUtils.sqliteMigrationRecoveryRetryBudget as number | undefined;
const runMigrate = vi.fn(() => {
throw new Error('duplicate column name: token_group');
});
const closeSqlite = vi.fn();
expect(runSqliteMigrationRecoveryLoop).toBeTypeOf('function');
expect(typeof retryBudget).toBe('number');
expect(() => runSqliteMigrationRecoveryLoop!({
runMigrate,
recoverDuplicateColumnMigrationError: () => ({
tag: '0007_account_token_group',
recoveredCount: 1,
}),
isSitesPlatformUrlUniqueConflictError: () => false,
deduplicateLegacySitesForUniqueIndex: () => false,
closeSqlite,
})).toThrow(/retry budget/i);
expect(runMigrate).toHaveBeenCalledTimes((retryBudget ?? 0) + 1);
expect(closeSqlite).toHaveBeenCalledTimes(1);
});
});