refactor: remove deprecated apiKey from site management and update related functionality

- Removed apiKey from SiteRow type and related forms in Sites component.
- Updated site saving logic to handle site creation without apiKey.
- Adjusted tests to ensure apiKey is not included in site editor state.
- Refactored Tokens component to support embedded mode and redirect logic.
- Added migration tests for site apiKey to account migration service.
- Implemented siteApiKeyMigrationService to handle migration of apiKey to account tokens.
- Enhanced tests for segmented connections in Accounts and redirect logic for Tokens.
This commit is contained in:
cita
2026-03-07 15:03:30 +08:00
parent a98a6f2c18
commit c31bbcbb51
30 changed files with 1960 additions and 592 deletions
+70
View File
@@ -0,0 +1,70 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readFileSync } 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);
}
}
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();
});
});
+115 -2
View File
@@ -2,10 +2,49 @@ import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { config } from '../config.js';
import { mkdirSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
type MigrationJournalEntry = {
tag: string;
when: number;
};
type MigrationJournalFile = {
entries?: MigrationJournalEntry[];
};
type SchemaMarker = {
table: string;
column?: string;
};
type MigrationRecord = {
createdAt: number;
hash: string;
};
const VERIFIED_BOOTSTRAP_TAG = '0004_sorting_preferences';
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
{ table: 'sites' },
{ table: 'settings' },
{ table: 'accounts' },
{ table: 'checkin_logs' },
{ table: 'model_availability' },
{ table: 'proxy_logs' },
{ table: 'token_routes' },
{ table: 'route_channels', column: 'token_id' },
{ table: 'account_tokens' },
{ table: 'token_model_availability' },
{ table: 'events' },
{ table: 'sites', column: 'is_pinned' },
{ table: 'sites', column: 'sort_order' },
{ table: 'accounts', column: 'is_pinned' },
{ table: 'accounts', column: 'sort_order' },
];
function resolveSqliteDbPath(): string {
const raw = (config.dbUrl || '').trim();
if (!raw) return resolve(`${config.dataDir}/hub.db`);
@@ -24,15 +63,89 @@ function resolveMigrationsFolder(): string {
return resolve(dirname(fileURLToPath(import.meta.url)), '../../../drizzle');
}
function tableExists(sqlite: Database.Database, table: string): boolean {
const row = sqlite.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(table);
return !!row;
}
function columnExists(sqlite: Database.Database, table: string, column: string): boolean {
if (!tableExists(sqlite, table)) return false;
const rows = sqlite.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ name?: string }>;
return rows.some((row) => row.name === column);
}
function hasRecordedDrizzleMigrations(sqlite: Database.Database): boolean {
if (!tableExists(sqlite, '__drizzle_migrations')) return false;
const row = sqlite.prepare('SELECT 1 FROM __drizzle_migrations LIMIT 1').get();
return !!row;
}
function hasVerifiedLegacySchema(sqlite: Database.Database): boolean {
return VERIFIED_SCHEMA_MARKERS.every((marker) => (
marker.column
? columnExists(sqlite, marker.table, marker.column)
: tableExists(sqlite, marker.table)
));
}
function readVerifiedMigrationRecords(migrationsFolder: string): MigrationRecord[] {
const journalPath = resolve(migrationsFolder, 'meta', '_journal.json');
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile;
const records: MigrationRecord[] = [];
for (const entry of journal.entries ?? []) {
const migrationSql = readFileSync(resolve(migrationsFolder, `${entry.tag}.sql`), 'utf8');
records.push({
createdAt: Number(entry.when),
hash: createHash('sha256').update(migrationSql).digest('hex'),
});
if (entry.tag === VERIFIED_BOOTSTRAP_TAG) {
return records;
}
}
return [];
}
function bootstrapLegacyDrizzleMigrations(sqlite: Database.Database, migrationsFolder: string): boolean {
if (hasRecordedDrizzleMigrations(sqlite)) return false;
if (!hasVerifiedLegacySchema(sqlite)) return false;
const records = readVerifiedMigrationRecords(migrationsFolder);
if (records.length === 0) return false;
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 (?, ?)');
const applyBootstrap = sqlite.transaction((migrations: MigrationRecord[]) => {
for (const migrationRecord of migrations) {
insert.run(migrationRecord.hash, migrationRecord.createdAt);
}
});
applyBootstrap(records);
console.log('[db] Bootstrapped drizzle migration journal for existing SQLite schema.');
return true;
}
export function runSqliteMigrations(): void {
const dbPath = resolveSqliteDbPath();
const migrationsFolder = resolveMigrationsFolder();
if (dbPath !== ':memory:') {
mkdirSync(dirname(dbPath), { recursive: true });
}
const sqlite = new Database(dbPath);
bootstrapLegacyDrizzleMigrations(sqlite, migrationsFolder);
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: resolveMigrationsFolder() });
migrate(db, { migrationsFolder });
sqlite.close();
console.log('Migration complete.');
}