1
0
forked from boos/BoosAPI

fix sqlite bootstrap and site announcement migration

This commit is contained in:
cita
2026-03-20 22:51:57 +08:00
parent 79146af16a
commit 9bc804c121
4 changed files with 246 additions and 7 deletions
+22 -2
View File
@@ -1,7 +1,27 @@
import { describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js'; import { runSqliteMigrations } from './db/migrate.js';
import {
__runtimeDatabaseBootstrapTestUtils,
ensureRuntimeDatabaseReady,
runSqliteRuntimeMigrations,
} from './runtimeDatabaseBootstrap.js';
vi.mock('./db/migrate.js', () => ({
runSqliteMigrations: vi.fn(),
}));
describe('runtimeDatabaseBootstrap', () => { 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 () => { it('runs sqlite runtime migrations when dialect is sqlite', async () => {
const runSqliteRuntimeMigrations = vi.fn(async () => {}); const runSqliteRuntimeMigrations = vi.fn(async () => {});
const ensureExternalRuntimeSchema = vi.fn(async () => {}); const ensureExternalRuntimeSchema = vi.fn(async () => {});
+3 -4
View File
@@ -7,12 +7,11 @@ let sqliteMigrationsBootstrapped = false;
export async function runSqliteRuntimeMigrations(): Promise<void> { export async function runSqliteRuntimeMigrations(): Promise<void> {
const migrateModule = await import('./db/migrate.js'); const migrateModule = await import('./db/migrate.js');
if (sqliteMigrationsBootstrapped) { if (!sqliteMigrationsBootstrapped) {
migrateModule.runSqliteMigrations();
return;
}
sqliteMigrationsBootstrapped = true; sqliteMigrationsBootstrapped = true;
} }
migrateModule.runSqliteMigrations();
}
type EnsureRuntimeDatabaseReadyInput = { type EnsureRuntimeDatabaseReadyInput = {
dialect: RuntimeSchemaDialect; dialect: RuntimeSchemaDialect;
@@ -1,5 +1,5 @@
import currentContract from '../db/generated/schemaContract.json' with { type: 'json' }; import currentContract from '../db/generated/schemaContract.json' with { type: 'json' };
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { import {
__databaseMigrationServiceTestUtils, __databaseMigrationServiceTestUtils,
maskConnectionString, maskConnectionString,
@@ -10,6 +10,42 @@ function cloneContract<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as 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<string, unknown[]>) {
return {
select() {
return {
from(table: { __table: string }) {
return {
all: async () => rowsByTable[table.__table] ?? [],
};
},
};
},
};
}
describe('databaseMigrationService', () => { describe('databaseMigrationService', () => {
it('accepts postgres migration input with normalized url', () => { it('accepts postgres migration input with normalized url', () => {
const normalized = normalizeMigrationInput({ const normalized = normalizeMigrationInput({
@@ -179,6 +215,7 @@ describe('databaseMigrationService', () => {
customHeaders: '{"x-site-scope":"internal"}', customHeaders: '{"x-site-scope":"internal"}',
status: 'active', status: 'active',
}], }],
siteAnnouncements: [],
siteDisabledModels: [], siteDisabledModels: [],
accounts: [], accounts: [],
accountTokens: [], accountTokens: [],
@@ -214,6 +251,7 @@ describe('databaseMigrationService', () => {
timestamp: Date.now(), timestamp: Date.now(),
accounts: { accounts: {
sites: [], sites: [],
siteAnnouncements: [],
siteDisabledModels: [{ siteDisabledModels: [{
id: 3, id: 3,
siteId: 12, siteId: 12,
@@ -295,4 +333,136 @@ describe('databaseMigrationService', () => {
expect(routeModeIndex).toBeGreaterThanOrEqual(0); expect(routeModeIndex).toBeGreaterThanOrEqual(0);
expect(tokenRouteStatement?.values[routeModeIndex]).toBe('explicit_group'); 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();
}
});
}); });
@@ -28,6 +28,7 @@ type BackupSnapshot = {
timestamp: number; timestamp: number;
accounts: { accounts: {
sites: Array<Record<string, unknown>>; sites: Array<Record<string, unknown>>;
siteAnnouncements: Array<Record<string, unknown>>;
siteDisabledModels: Array<Record<string, unknown>>; siteDisabledModels: Array<Record<string, unknown>>;
accounts: Array<Record<string, unknown>>; accounts: Array<Record<string, unknown>>;
accountTokens: Array<Record<string, unknown>>; accountTokens: Array<Record<string, unknown>>;
@@ -56,6 +57,7 @@ export interface DatabaseMigrationSummary {
timestamp: number; timestamp: number;
rows: { rows: {
sites: number; sites: number;
siteAnnouncements: number;
siteDisabledModels: number; siteDisabledModels: number;
accounts: number; accounts: number;
accountTokens: number; accountTokens: number;
@@ -212,6 +214,7 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
timestamp: Date.now(), timestamp: Date.now(),
accounts: { accounts: {
sites: await db.select().from(schema.sites).all() as Array<Record<string, unknown>>, sites: await db.select().from(schema.sites).all() as Array<Record<string, unknown>>,
siteAnnouncements: await db.select().from(schema.siteAnnouncements).all() as Array<Record<string, unknown>>,
siteDisabledModels: await db.select().from(schema.siteDisabledModels).all() as Array<Record<string, unknown>>, siteDisabledModels: await db.select().from(schema.siteDisabledModels).all() as Array<Record<string, unknown>>,
accounts: await db.select().from(schema.accounts).all() as Array<Record<string, unknown>>, accounts: await db.select().from(schema.accounts).all() as Array<Record<string, unknown>>,
accountTokens: await db.select().from(schema.accountTokens).all() as Array<Record<string, unknown>>, accountTokens: await db.select().from(schema.accountTokens).all() as Array<Record<string, unknown>>,
@@ -257,6 +260,7 @@ async function clearTargetData(client: SqlClient): Promise<void> {
'proxy_files', 'proxy_files',
'account_tokens', 'account_tokens',
'accounts', 'accounts',
'site_announcements',
'site_disabled_models', 'site_disabled_models',
'token_routes', 'token_routes',
'sites', '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) { for (const row of snapshot.accounts.accounts) {
statements.push({ statements.push({
table: 'accounts', table: 'accounts',
@@ -617,6 +665,7 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
if (client.dialect !== 'postgres') return; if (client.dialect !== 'postgres') return;
const tables = [ const tables = [
'sites', 'sites',
'site_announcements',
'site_disabled_models', 'site_disabled_models',
'accounts', 'accounts',
'account_tokens', 'account_tokens',
@@ -684,6 +733,7 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro
timestamp: snapshot.timestamp, timestamp: snapshot.timestamp,
rows: { rows: {
sites: snapshot.accounts.sites.length, sites: snapshot.accounts.sites.length,
siteAnnouncements: snapshot.accounts.siteAnnouncements.length,
siteDisabledModels: snapshot.accounts.siteDisabledModels.length, siteDisabledModels: snapshot.accounts.siteDisabledModels.length,
accounts: snapshot.accounts.accounts.length, accounts: snapshot.accounts.accounts.length,
accountTokens: snapshot.accounts.accountTokens.length, accountTokens: snapshot.accounts.accountTokens.length,