From 9bc804c121f61f00641bdd5ce10dc89cced5cd3d Mon Sep 17 00:00:00 2001 From: cita Date: Fri, 20 Mar 2026 22:51:57 +0800 Subject: [PATCH] fix sqlite bootstrap and site announcement migration --- src/server/runtimeDatabaseBootstrap.test.ts | 24 ++- src/server/runtimeDatabaseBootstrap.ts | 7 +- .../services/databaseMigrationService.test.ts | 172 +++++++++++++++++- .../services/databaseMigrationService.ts | 50 +++++ 4 files changed, 246 insertions(+), 7 deletions(-) diff --git a/src/server/runtimeDatabaseBootstrap.test.ts b/src/server/runtimeDatabaseBootstrap.test.ts index 66ba073..b71f8fb 100644 --- a/src/server/runtimeDatabaseBootstrap.test.ts +++ b/src/server/runtimeDatabaseBootstrap.test.ts @@ -1,7 +1,27 @@ -import { describe, expect, it, vi } from 'vitest'; -import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { runSqliteMigrations } from './db/migrate.js'; +import { + __runtimeDatabaseBootstrapTestUtils, + ensureRuntimeDatabaseReady, + runSqliteRuntimeMigrations, +} from './runtimeDatabaseBootstrap.js'; + +vi.mock('./db/migrate.js', () => ({ + runSqliteMigrations: vi.fn(), +})); describe('runtimeDatabaseBootstrap', () => { + beforeEach(() => { + __runtimeDatabaseBootstrapTestUtils.resetSqliteMigrationsBootstrapped(); + vi.clearAllMocks(); + }); + + it('runs sqlite migrations on the first runtime bootstrap call', async () => { + await runSqliteRuntimeMigrations(); + + expect(runSqliteMigrations).toHaveBeenCalledTimes(1); + }); + it('runs sqlite runtime migrations when dialect is sqlite', async () => { const runSqliteRuntimeMigrations = vi.fn(async () => {}); const ensureExternalRuntimeSchema = vi.fn(async () => {}); diff --git a/src/server/runtimeDatabaseBootstrap.ts b/src/server/runtimeDatabaseBootstrap.ts index e326a28..2fbd980 100644 --- a/src/server/runtimeDatabaseBootstrap.ts +++ b/src/server/runtimeDatabaseBootstrap.ts @@ -7,11 +7,10 @@ let sqliteMigrationsBootstrapped = false; export async function runSqliteRuntimeMigrations(): Promise { const migrateModule = await import('./db/migrate.js'); - if (sqliteMigrationsBootstrapped) { - migrateModule.runSqliteMigrations(); - return; + if (!sqliteMigrationsBootstrapped) { + sqliteMigrationsBootstrapped = true; } - sqliteMigrationsBootstrapped = true; + migrateModule.runSqliteMigrations(); } type EnsureRuntimeDatabaseReadyInput = { diff --git a/src/server/services/databaseMigrationService.test.ts b/src/server/services/databaseMigrationService.test.ts index 0ffcb68..223ad59 100644 --- a/src/server/services/databaseMigrationService.test.ts +++ b/src/server/services/databaseMigrationService.test.ts @@ -1,5 +1,5 @@ import currentContract from '../db/generated/schemaContract.json' with { type: 'json' }; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { __databaseMigrationServiceTestUtils, maskConnectionString, @@ -10,6 +10,42 @@ function cloneContract(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +function createDbSchemaMock() { + return { + settings: { __table: 'settings' }, + sites: { __table: 'sites' }, + siteAnnouncements: { __table: 'siteAnnouncements' }, + siteDisabledModels: { __table: 'siteDisabledModels' }, + accounts: { __table: 'accounts' }, + accountTokens: { __table: 'accountTokens' }, + checkinLogs: { __table: 'checkinLogs' }, + modelAvailability: { __table: 'modelAvailability' }, + tokenModelAvailability: { __table: 'tokenModelAvailability' }, + tokenRoutes: { __table: 'tokenRoutes' }, + routeChannels: { __table: 'routeChannels' }, + routeGroupSources: { __table: 'routeGroupSources' }, + proxyLogs: { __table: 'proxyLogs' }, + proxyVideoTasks: { __table: 'proxyVideoTasks' }, + proxyFiles: { __table: 'proxyFiles' }, + downstreamApiKeys: { __table: 'downstreamApiKeys' }, + events: { __table: 'events' }, + }; +} + +function createDbMock(rowsByTable: Record) { + return { + select() { + return { + from(table: { __table: string }) { + return { + all: async () => rowsByTable[table.__table] ?? [], + }; + }, + }; + }, + }; +} + describe('databaseMigrationService', () => { it('accepts postgres migration input with normalized url', () => { const normalized = normalizeMigrationInput({ @@ -179,6 +215,7 @@ describe('databaseMigrationService', () => { customHeaders: '{"x-site-scope":"internal"}', status: 'active', }], + siteAnnouncements: [], siteDisabledModels: [], accounts: [], accountTokens: [], @@ -214,6 +251,7 @@ describe('databaseMigrationService', () => { timestamp: Date.now(), accounts: { sites: [], + siteAnnouncements: [], siteDisabledModels: [{ id: 3, siteId: 12, @@ -295,4 +333,136 @@ describe('databaseMigrationService', () => { expect(routeModeIndex).toBeGreaterThanOrEqual(0); expect(tokenRouteStatement?.values[routeModeIndex]).toBe('explicit_group'); }); + + it('includes site announcements in migration statements', () => { + const statements = __databaseMigrationServiceTestUtils.buildStatements({ + version: 'test', + timestamp: Date.now(), + accounts: { + sites: [], + siteDisabledModels: [], + accounts: [], + accountTokens: [], + checkinLogs: [], + modelAvailability: [], + tokenModelAvailability: [], + tokenRoutes: [], + routeChannels: [], + routeGroupSources: [], + proxyLogs: [], + proxyVideoTasks: [], + proxyFiles: [], + downstreamApiKeys: [], + events: [], + siteAnnouncements: [{ + id: 11, + siteId: 3, + platform: 'openai', + sourceKey: 'notice-1', + title: '????', + content: '????', + level: 'warning', + sourceUrl: 'https://example.com/notice', + startsAt: '2026-03-20T00:00:00.000Z', + endsAt: '2026-03-21T00:00:00.000Z', + upstreamCreatedAt: '2026-03-19T00:00:00.000Z', + upstreamUpdatedAt: '2026-03-20T00:00:00.000Z', + firstSeenAt: '2026-03-20T00:00:00.000Z', + lastSeenAt: '2026-03-20T01:00:00.000Z', + readAt: null, + dismissedAt: null, + rawPayload: '{"id":"notice-1"}', + }], + }, + preferences: { + settings: [], + }, + } as any); + + const statement = statements.find((item) => item.table === 'site_announcements'); + expect(statement).toBeDefined(); + expect(statement?.columns).toContain('source_key'); + expect(statement?.values[statement?.columns.indexOf('title') ?? -1]).toBe('????'); + }); + + it('includes site announcements in migration summary', async () => { + vi.resetModules(); + + const rowsByTable = { + settings: [], + sites: [], + siteAnnouncements: [{ + id: 11, + siteId: 3, + platform: 'openai', + sourceKey: 'notice-1', + title: '????', + content: '????', + level: 'warning', + sourceUrl: 'https://example.com/notice', + startsAt: '2026-03-20T00:00:00.000Z', + endsAt: '2026-03-21T00:00:00.000Z', + upstreamCreatedAt: '2026-03-19T00:00:00.000Z', + upstreamUpdatedAt: '2026-03-20T00:00:00.000Z', + firstSeenAt: '2026-03-20T00:00:00.000Z', + lastSeenAt: '2026-03-20T01:00:00.000Z', + readAt: null, + dismissedAt: null, + rawPayload: '{"id":"notice-1"}', + }], + siteDisabledModels: [], + accounts: [], + accountTokens: [], + checkinLogs: [], + modelAvailability: [], + tokenModelAvailability: [], + tokenRoutes: [], + routeChannels: [], + routeGroupSources: [], + proxyLogs: [], + proxyVideoTasks: [], + proxyFiles: [], + downstreamApiKeys: [], + events: [], + }; + + const client = { + dialect: 'sqlite', + connectionString: ':memory:', + ssl: false, + begin: vi.fn(async () => {}), + commit: vi.fn(async () => {}), + rollback: vi.fn(async () => {}), + execute: vi.fn(async () => []), + queryScalar: vi.fn(async () => 0), + close: vi.fn(async () => {}), + }; + + vi.doMock('../db/index.js', () => ({ + db: createDbMock(rowsByTable), + schema: createDbSchemaMock(), + })); + vi.doMock('../db/runtimeSchemaBootstrap.js', () => ({ + createRuntimeSchemaClient: async () => client, + ensureRuntimeDatabaseSchema: async () => {}, + })); + + try { + const { migrateCurrentDatabase } = await import('./databaseMigrationService.js'); + const summary = await migrateCurrentDatabase({ + dialect: 'sqlite', + connectionString: ':memory:', + overwrite: true, + }); + + expect(summary.rows.siteAnnouncements).toBe(1); + expect(client.begin).toHaveBeenCalledTimes(1); + expect(client.commit).toHaveBeenCalledTimes(1); + expect(client.close).toHaveBeenCalledTimes(1); + } finally { + vi.doUnmock('../db/index.js'); + vi.doUnmock('../db/runtimeSchemaBootstrap.js'); + vi.resetModules(); + } + }); }); diff --git a/src/server/services/databaseMigrationService.ts b/src/server/services/databaseMigrationService.ts index 0559cc5..d03f0b9 100644 --- a/src/server/services/databaseMigrationService.ts +++ b/src/server/services/databaseMigrationService.ts @@ -28,6 +28,7 @@ type BackupSnapshot = { timestamp: number; accounts: { sites: Array>; + siteAnnouncements: Array>; siteDisabledModels: Array>; accounts: Array>; accountTokens: Array>; @@ -56,6 +57,7 @@ export interface DatabaseMigrationSummary { timestamp: number; rows: { sites: number; + siteAnnouncements: number; siteDisabledModels: number; accounts: number; accountTokens: number; @@ -212,6 +214,7 @@ async function toBackupSnapshot(): Promise { timestamp: Date.now(), accounts: { sites: await db.select().from(schema.sites).all() as Array>, + siteAnnouncements: await db.select().from(schema.siteAnnouncements).all() as Array>, siteDisabledModels: await db.select().from(schema.siteDisabledModels).all() as Array>, accounts: await db.select().from(schema.accounts).all() as Array>, accountTokens: await db.select().from(schema.accountTokens).all() as Array>, @@ -257,6 +260,7 @@ async function clearTargetData(client: SqlClient): Promise { 'proxy_files', 'account_tokens', 'accounts', + 'site_announcements', 'site_disabled_models', 'token_routes', 'sites', @@ -309,6 +313,50 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { }); } + for (const row of snapshot.accounts.siteAnnouncements || []) { + statements.push({ + table: 'site_announcements', + columns: [ + 'id', + 'site_id', + 'platform', + 'source_key', + 'title', + 'content', + 'level', + 'source_url', + 'starts_at', + 'ends_at', + 'upstream_created_at', + 'upstream_updated_at', + 'first_seen_at', + 'last_seen_at', + 'read_at', + 'dismissed_at', + 'raw_payload', + ], + values: [ + asNumber(row.id, 0), + asNumber(row.siteId, 0), + asNullableString(row.platform), + asNullableString(row.sourceKey), + asNullableString(row.title), + asNullableString(row.content), + asNullableString(row.level) ?? 'info', + asNullableString(row.sourceUrl), + asNullableString(row.startsAt), + asNullableString(row.endsAt), + asNullableString(row.upstreamCreatedAt), + asNullableString(row.upstreamUpdatedAt), + asNullableString(row.firstSeenAt), + asNullableString(row.lastSeenAt), + asNullableString(row.readAt), + asNullableString(row.dismissedAt), + asNullableString(row.rawPayload), + ], + }); + } + for (const row of snapshot.accounts.accounts) { statements.push({ table: 'accounts', @@ -617,6 +665,7 @@ async function syncPostgresSequences(client: SqlClient): Promise { if (client.dialect !== 'postgres') return; const tables = [ 'sites', + 'site_announcements', 'site_disabled_models', 'accounts', 'account_tokens', @@ -684,6 +733,7 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro timestamp: snapshot.timestamp, rows: { sites: snapshot.accounts.sites.length, + siteAnnouncements: snapshot.accounts.siteAnnouncements.length, siteDisabledModels: snapshot.accounts.siteDisabledModels.length, accounts: snapshot.accounts.accounts.length, accountTokens: snapshot.accounts.accountTokens.length,