Merge pull request #217 from cita-777/codex/sqlite-site-announcements-migration-fixes
[codex] fix sqlite bootstrap and site announcement migration
This commit is contained in:
@@ -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 () => {});
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ 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();
|
sqliteMigrationsBootstrapped = true;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
sqliteMigrationsBootstrapped = true;
|
migrateModule.runSqliteMigrations();
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnsureRuntimeDatabaseReadyInput = {
|
type EnsureRuntimeDatabaseReadyInput = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user