fix: refine config backup import semantics

This commit is contained in:
ciat-777
2026-03-21 16:55:16 +08:00
parent 89d6527aa1
commit 89d0aba497
4 changed files with 1033 additions and 37 deletions
+615 -3
View File
@@ -34,9 +34,15 @@ describe('backupService', () => {
await db.delete(schema.modelAvailability).run();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.siteAnnouncements).run();
await db.delete(schema.siteDisabledModels).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
await db.delete(schema.downstreamApiKeys).run();
await db.delete(schema.proxyFiles).run();
await db.delete(schema.proxyVideoTasks).run();
await db.delete(schema.events).run();
await db.delete(schema.settings).run();
});
@@ -44,7 +50,7 @@ describe('backupService', () => {
delete process.env.DATA_DIR;
});
it('preserves extended fields in full backup import/export roundtrip', async () => {
it('exports backup-owned config in v2.1 backups and still roundtrips core connection fields', async () => {
const now = new Date().toISOString();
const site = await db.insert(schema.sites).values({
name: 'roundtrip-site',
@@ -143,8 +149,85 @@ describe('backupService', () => {
cooldownUntil: now,
}).run();
const exported = await backupService.exportBackup('all');
const result = await backupService.importBackup(exported as unknown as Record<string, unknown>);
await db.insert(schema.siteDisabledModels).values({
siteId: site.id,
modelName: 'gpt-hidden',
createdAt: now,
}).run();
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'gpt-manual',
available: true,
isManual: true,
latencyMs: null,
checkedAt: now,
},
{
accountId: account.id,
modelName: 'gpt-discovered',
available: true,
isManual: false,
latencyMs: 42,
checkedAt: now,
},
]).run();
await db.insert(schema.downstreamApiKeys).values({
name: 'Shared Downstream',
key: 'downstream-roundtrip-key',
description: 'shared quota',
groupName: 'team-a',
tags: '["prod"]',
enabled: false,
expiresAt: now,
maxCost: 100,
usedCost: 11.5,
maxRequests: 500,
usedRequests: 33,
supportedModels: '["gpt-4o-mini"]',
allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.5}`,
lastUsedAt: now,
createdAt: now,
updatedAt: now,
}).run();
const exported = await backupService.exportBackup('all') as any;
expect(exported.version).toBe('2.1');
expect(exported.accounts.siteDisabledModels).toEqual([
{ siteId: site.id, modelName: 'gpt-hidden' },
]);
expect(exported.accounts.manualModels).toEqual([
{ accountId: account.id, modelName: 'gpt-manual' },
]);
expect(exported.accounts.downstreamApiKeys).toEqual([
expect.objectContaining({
name: 'Shared Downstream',
key: 'downstream-roundtrip-key',
description: 'shared quota',
groupName: 'team-a',
tags: '["prod"]',
enabled: false,
expiresAt: now,
maxCost: 100,
maxRequests: 500,
supportedModels: '["gpt-4o-mini"]',
allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.5}`,
}),
]);
expect(exported.accounts.accounts[0]).not.toHaveProperty('balanceUsed');
expect(exported.accounts.accounts[0]).not.toHaveProperty('lastCheckinAt');
expect(exported.accounts.accounts[0]).not.toHaveProperty('lastBalanceRefresh');
expect(exported.accounts.routeChannels[0]).not.toHaveProperty('successCount');
expect(exported.accounts.routeChannels[0]).not.toHaveProperty('lastUsedAt');
expect(exported.accounts.downstreamApiKeys[0]).not.toHaveProperty('usedCost');
expect(exported.accounts.downstreamApiKeys[0]).not.toHaveProperty('usedRequests');
expect(exported.accounts.downstreamApiKeys[0]).not.toHaveProperty('lastUsedAt');
const result = await backupService.importBackup(exported as Record<string, unknown>);
expect(result.allImported).toBe(true);
expect(result.sections.accounts).toBe(true);
@@ -155,6 +238,9 @@ describe('backupService', () => {
const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const restoredRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get();
const restoredChannel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).get();
const restoredDisabledModels = await db.select().from(schema.siteDisabledModels).all();
const restoredModelAvailability = await db.select().from(schema.modelAvailability).all();
const restoredDownstreamKeys = await db.select().from(schema.downstreamApiKeys).all();
expect(restoredSite?.proxyUrl).toBe('http://127.0.0.1:8080');
expect(restoredSite?.externalCheckinUrl).toBe('https://checkin.roundtrip.example.com');
@@ -179,6 +265,29 @@ describe('backupService', () => {
expect(restoredGroupSource?.sourceRouteId).toBe(sourceRoute.id);
expect(restoredChannel?.sourceModel).toBe('gpt-4o');
expect(restoredDisabledModels).toEqual([
expect.objectContaining({ siteId: site.id, modelName: 'gpt-hidden' }),
]);
expect(restoredModelAvailability.some((row) => row.modelName === 'gpt-manual' && row.isManual)).toBe(true);
expect(restoredModelAvailability.some((row) => row.modelName === 'gpt-discovered' && !row.isManual)).toBe(true);
expect(restoredDownstreamKeys).toEqual([
expect.objectContaining({
name: 'Shared Downstream',
key: 'downstream-roundtrip-key',
description: 'shared quota',
groupName: 'team-a',
tags: '["prod"]',
enabled: false,
maxCost: 100,
usedCost: 11.5,
maxRequests: 500,
usedRequests: 33,
supportedModels: '["gpt-4o-mini"]',
allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.5}`,
lastUsedAt: now,
}),
]);
});
it('preserves local logs and runtime stats when importing account backups', async () => {
@@ -342,6 +451,509 @@ describe('backupService', () => {
expect(restoredCheckinLogs[0]?.message).toBe('local-checkin');
});
it('preserves local-only state while replacing backup-owned config during account imports', async () => {
const exportedAt = '2026-03-20T09:00:00.000Z';
const localRuntimeAt = '2026-03-21T10:30:00.000Z';
const site = await db.insert(schema.sites).values({
name: 'backup-site',
url: 'https://preserve-local-state.example.com',
platform: 'new-api',
status: 'active',
createdAt: exportedAt,
updatedAt: exportedAt,
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'preserve-user',
accessToken: 'session-token',
apiToken: 'api-token',
balance: 20,
balanceUsed: 3,
quota: 100,
status: 'active',
checkinEnabled: true,
createdAt: exportedAt,
updatedAt: exportedAt,
}).returning().get();
const accountToken = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-preserve-token',
source: 'manual',
enabled: true,
isDefault: true,
createdAt: exportedAt,
updatedAt: exportedAt,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-preserve-*',
displayName: 'backup-route',
modelMapping: JSON.stringify({ to: 'gpt-4o-mini' }),
routeMode: 'pattern',
routingStrategy: 'weighted',
enabled: true,
createdAt: exportedAt,
updatedAt: exportedAt,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: accountToken.id,
sourceModel: 'gpt-4o',
priority: 1,
weight: 10,
enabled: true,
manualOverride: false,
successCount: 1,
failCount: 0,
totalLatencyMs: 200,
totalCost: 0.5,
lastUsedAt: exportedAt,
lastSelectedAt: exportedAt,
lastFailAt: null,
consecutiveFailCount: 0,
cooldownLevel: 0,
cooldownUntil: null,
}).run();
const insertedChannel = await db.select()
.from(schema.routeChannels)
.where(eq(schema.routeChannels.routeId, route.id))
.get();
expect(insertedChannel).toBeTruthy();
await db.insert(schema.siteDisabledModels).values({
siteId: site.id,
modelName: 'gpt-backup-disabled',
createdAt: exportedAt,
}).run();
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'gpt-backup-manual',
available: true,
isManual: true,
latencyMs: null,
checkedAt: exportedAt,
},
{
accountId: account.id,
modelName: 'gpt-cached',
available: false,
isManual: false,
latencyMs: 50,
checkedAt: exportedAt,
},
]).run();
await db.insert(schema.tokenModelAvailability).values({
tokenId: accountToken.id,
modelName: 'gpt-token-cache',
available: false,
latencyMs: 33,
checkedAt: exportedAt,
}).run();
await db.insert(schema.siteAnnouncements).values({
siteId: site.id,
platform: 'new-api',
sourceKey: 'notice-1',
title: 'Backup banner',
content: 'Backup content',
level: 'info',
firstSeenAt: exportedAt,
lastSeenAt: exportedAt,
readAt: null,
dismissedAt: null,
rawPayload: '{"revision":"backup"}',
}).run();
const downstreamKey = await db.insert(schema.downstreamApiKeys).values({
name: 'Backup Downstream',
key: 'downstream-shared',
description: 'backup config',
groupName: 'team-a',
tags: '["backup"]',
enabled: false,
expiresAt: '2026-12-31T00:00:00.000Z',
maxCost: 25,
usedCost: 1.5,
maxRequests: 250,
usedRequests: 2,
supportedModels: '["gpt-4o"]',
allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.25}`,
lastUsedAt: exportedAt,
createdAt: exportedAt,
updatedAt: exportedAt,
}).returning().get();
const exported = await backupService.exportBackup('accounts') as any;
expect(exported.version).toBe('2.1');
await db.insert(schema.events).values({
type: 'status',
title: 'keep-event',
message: 'should stay after import',
level: 'info',
createdAt: localRuntimeAt,
}).run();
await db.insert(schema.proxyVideoTasks).values({
publicId: 'video-task-1',
upstreamVideoId: 'upstream-video-1',
siteUrl: site.url,
tokenValue: account.accessToken,
createdAt: localRuntimeAt,
updatedAt: localRuntimeAt,
}).run();
await db.insert(schema.proxyFiles).values({
publicId: 'proxy-file-1',
ownerType: 'message',
ownerId: 'msg-1',
filename: 'snapshot.json',
mimeType: 'application/json',
byteSize: 4,
sha256: 'abcd',
contentBase64: 'e30=',
createdAt: localRuntimeAt,
updatedAt: localRuntimeAt,
}).run();
await db.update(schema.sites).set({
name: 'local-site-name',
updatedAt: localRuntimeAt,
}).where(eq(schema.sites.id, site.id)).run();
await db.update(schema.tokenRoutes).set({
displayName: 'local-route-name',
updatedAt: localRuntimeAt,
}).where(eq(schema.tokenRoutes.id, route.id)).run();
await db.update(schema.accounts).set({
balanceUsed: 99,
lastCheckinAt: localRuntimeAt,
lastBalanceRefresh: localRuntimeAt,
updatedAt: localRuntimeAt,
}).where(eq(schema.accounts.id, account.id)).run();
await db.update(schema.routeChannels).set({
successCount: 77,
failCount: 9,
totalLatencyMs: 4321,
totalCost: 7.89,
lastUsedAt: localRuntimeAt,
lastSelectedAt: localRuntimeAt,
lastFailAt: localRuntimeAt,
consecutiveFailCount: 4,
cooldownLevel: 2,
cooldownUntil: localRuntimeAt,
}).where(eq(schema.routeChannels.id, insertedChannel!.id)).run();
await db.delete(schema.siteDisabledModels)
.where(eq(schema.siteDisabledModels.siteId, site.id))
.run();
await db.insert(schema.siteDisabledModels).values({
siteId: site.id,
modelName: 'gpt-local-disabled',
createdAt: localRuntimeAt,
}).run();
await db.delete(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.run();
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'gpt-local-manual',
available: true,
isManual: true,
latencyMs: null,
checkedAt: localRuntimeAt,
},
{
accountId: account.id,
modelName: 'gpt-cached',
available: true,
isManual: false,
latencyMs: 777,
checkedAt: localRuntimeAt,
},
]).run();
await db.update(schema.tokenModelAvailability).set({
available: true,
latencyMs: 888,
checkedAt: localRuntimeAt,
}).where(eq(schema.tokenModelAvailability.tokenId, accountToken.id)).run();
await db.update(schema.siteAnnouncements).set({
title: 'Local banner',
content: 'Local content',
lastSeenAt: localRuntimeAt,
readAt: localRuntimeAt,
dismissedAt: localRuntimeAt,
rawPayload: '{"revision":"local"}',
}).where(eq(schema.siteAnnouncements.siteId, site.id)).run();
await db.update(schema.downstreamApiKeys).set({
name: 'Local Mutated Downstream',
description: 'local config',
groupName: 'team-local',
tags: '["local"]',
enabled: true,
expiresAt: '2027-01-01T00:00:00.000Z',
maxCost: 999,
usedCost: 44,
maxRequests: 999,
usedRequests: 55,
supportedModels: '["gpt-local"]',
allowedRouteIds: '[999]',
siteWeightMultipliers: '{"999":9}',
lastUsedAt: localRuntimeAt,
updatedAt: localRuntimeAt,
}).where(eq(schema.downstreamApiKeys.id, downstreamKey.id)).run();
const localOnlyDownstreamKey = await db.insert(schema.downstreamApiKeys).values({
name: 'Local Only Downstream',
key: 'downstream-local-only',
usedCost: 7,
usedRequests: 8,
lastUsedAt: localRuntimeAt,
createdAt: localRuntimeAt,
updatedAt: localRuntimeAt,
}).returning().get();
await db.insert(schema.checkinLogs).values({
accountId: account.id,
status: 'success',
message: 'local-checkin',
reward: '1.5',
createdAt: localRuntimeAt,
}).run();
await db.insert(schema.proxyLogs).values([
{
routeId: route.id,
channelId: insertedChannel!.id,
accountId: account.id,
downstreamApiKeyId: downstreamKey.id,
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
totalTokens: 321,
estimatedCost: 0.123,
createdAt: localRuntimeAt,
},
{
routeId: route.id,
channelId: insertedChannel!.id,
accountId: account.id,
downstreamApiKeyId: localOnlyDownstreamKey.id,
modelRequested: 'gpt-4o-mini',
modelActual: 'gpt-4o-mini',
status: 'failed',
totalTokens: 654,
estimatedCost: 0.456,
createdAt: localRuntimeAt,
},
]).run();
const result = await backupService.importBackup(exported as Record<string, unknown>);
expect(result.allImported).toBe(true);
expect(result.sections.accounts).toBe(true);
const restoredSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get();
const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const restoredRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get();
const restoredChannel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, insertedChannel!.id)).get();
const restoredDisabledModels = await db.select().from(schema.siteDisabledModels).all();
const restoredAvailability = await db.select().from(schema.modelAvailability).all();
const restoredTokenAvailability = await db.select().from(schema.tokenModelAvailability).all();
const restoredAnnouncements = await db.select().from(schema.siteAnnouncements).all();
const restoredDownstreamKeys = await db.select().from(schema.downstreamApiKeys).all();
const restoredProxyLogs = await db.select().from(schema.proxyLogs).all();
const restoredCheckinLogs = await db.select().from(schema.checkinLogs).all();
const restoredEvents = await db.select().from(schema.events).all();
const restoredProxyVideoTasks = await db.select().from(schema.proxyVideoTasks).all();
const restoredProxyFiles = await db.select().from(schema.proxyFiles).all();
expect(restoredSite?.name).toBe('backup-site');
expect(restoredRoute?.displayName).toBe('backup-route');
expect(restoredAccount?.balanceUsed).toBe(99);
expect(restoredAccount?.lastCheckinAt).toBe(localRuntimeAt);
expect(restoredAccount?.lastBalanceRefresh).toBe(localRuntimeAt);
expect(restoredChannel?.successCount).toBe(77);
expect(restoredChannel?.failCount).toBe(9);
expect(restoredChannel?.totalLatencyMs).toBe(4321);
expect(restoredChannel?.totalCost).toBe(7.89);
expect(restoredChannel?.lastUsedAt).toBe(localRuntimeAt);
expect(restoredChannel?.lastSelectedAt).toBe(localRuntimeAt);
expect(restoredChannel?.lastFailAt).toBe(localRuntimeAt);
expect(restoredChannel?.consecutiveFailCount).toBe(4);
expect(restoredChannel?.cooldownLevel).toBe(2);
expect(restoredChannel?.cooldownUntil).toBe(localRuntimeAt);
expect(restoredDisabledModels).toEqual([
expect.objectContaining({ siteId: site.id, modelName: 'gpt-backup-disabled' }),
]);
const restoredManualModels = restoredAvailability
.filter((row) => row.isManual)
.map((row) => row.modelName)
.sort();
expect(restoredManualModels).toEqual(['gpt-backup-manual']);
const restoredCachedModel = restoredAvailability.find((row) => row.modelName === 'gpt-cached' && !row.isManual);
expect(restoredCachedModel).toEqual(expect.objectContaining({
available: true,
latencyMs: 777,
checkedAt: localRuntimeAt,
}));
expect(restoredTokenAvailability).toEqual([
expect.objectContaining({
tokenId: accountToken.id,
modelName: 'gpt-token-cache',
available: true,
latencyMs: 888,
checkedAt: localRuntimeAt,
}),
]);
expect(restoredAnnouncements).toEqual([
expect.objectContaining({
siteId: site.id,
title: 'Local banner',
content: 'Local content',
lastSeenAt: localRuntimeAt,
readAt: localRuntimeAt,
dismissedAt: localRuntimeAt,
rawPayload: '{"revision":"local"}',
}),
]);
expect(restoredDownstreamKeys).toEqual([
expect.objectContaining({
name: 'Backup Downstream',
key: 'downstream-shared',
description: 'backup config',
groupName: 'team-a',
tags: '["backup"]',
enabled: false,
expiresAt: '2026-12-31T00:00:00.000Z',
maxCost: 25,
usedCost: 44,
maxRequests: 250,
usedRequests: 55,
supportedModels: '["gpt-4o"]',
allowedRouteIds: `[${route.id}]`,
siteWeightMultipliers: `{"${site.id}":1.25}`,
lastUsedAt: localRuntimeAt,
}),
]);
expect(restoredProxyLogs).toHaveLength(2);
const matchedDownstreamLog = restoredProxyLogs.find((row) => row.totalTokens === 321);
const orphanedDownstreamLog = restoredProxyLogs.find((row) => row.totalTokens === 654);
expect(matchedDownstreamLog?.downstreamApiKeyId).toBe(restoredDownstreamKeys[0]?.id);
expect(orphanedDownstreamLog?.downstreamApiKeyId).toBeNull();
expect(restoredCheckinLogs).toEqual([
expect.objectContaining({
accountId: account.id,
message: 'local-checkin',
}),
]);
expect(restoredEvents).toHaveLength(1);
expect(restoredProxyVideoTasks).toHaveLength(1);
expect(restoredProxyFiles).toHaveLength(1);
});
it('keeps importing native v2.0 backups without the new v2.1 config arrays', async () => {
const localDownstreamKey = await db.insert(schema.downstreamApiKeys).values({
name: 'Local downstream',
key: 'local-downstream-key',
usedCost: 12,
usedRequests: 3,
createdAt: '2026-03-21T08:00:00.000Z',
updatedAt: '2026-03-21T08:00:00.000Z',
}).returning().get();
const payload = {
version: '2.0',
timestamp: Date.now(),
type: 'accounts',
accounts: {
sites: [
{
id: 1,
name: 'Legacy native site',
url: 'https://legacy-native.example.com',
externalCheckinUrl: null,
platform: 'new-api',
proxyUrl: null,
useSystemProxy: false,
customHeaders: null,
status: 'active',
isPinned: false,
sortOrder: 0,
globalWeight: 1,
apiKey: null,
createdAt: '2026-03-20T00:00:00.000Z',
updatedAt: '2026-03-20T00:00:00.000Z',
},
],
accounts: [
{
id: 1,
siteId: 1,
username: 'legacy-user',
accessToken: 'legacy-session-token',
apiToken: 'legacy-api-token',
balance: 10,
quota: 20,
unitCost: null,
valueScore: 0,
status: 'active',
isPinned: false,
sortOrder: 0,
checkinEnabled: true,
extraConfig: null,
createdAt: '2026-03-20T00:00:00.000Z',
updatedAt: '2026-03-20T00:00:00.000Z',
},
],
accountTokens: [],
tokenRoutes: [],
routeChannels: [],
routeGroupSources: [],
},
} as Record<string, unknown>;
const result = await backupService.importBackup(payload);
expect(result.allImported).toBe(true);
expect(result.sections.accounts).toBe(true);
const restoredSites = await db.select().from(schema.sites).all();
const restoredAccounts = await db.select().from(schema.accounts).all();
const restoredDownstreamKeys = await db.select().from(schema.downstreamApiKeys).all();
expect(restoredSites).toHaveLength(1);
expect(restoredAccounts).toHaveLength(1);
expect(restoredAccounts[0]?.username).toBe('legacy-user');
expect(restoredDownstreamKeys).toHaveLength(1);
expect(restoredDownstreamKeys[0]?.id).toBe(localDownstreamKey.id);
expect(restoredDownstreamKeys[0]?.key).toBe('local-downstream-key');
});
it('imports ALL-API-Hub style payload with accounts and preferences', async () => {
const payload = {
timestamp: Date.now(),
+333 -16
View File
@@ -5,7 +5,7 @@ import { upsertSetting } from '../db/upsertSetting.js';
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
const BACKUP_VERSION = '2.0';
const BACKUP_VERSION = '2.1';
export type BackupExportType = 'all' | 'accounts' | 'preferences';
@@ -41,16 +41,71 @@ type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect;
type RouteChannelRow = typeof schema.routeChannels.$inferSelect;
type RouteGroupSourceRow = typeof schema.routeGroupSources.$inferSelect;
type SiteDisabledModelRow = typeof schema.siteDisabledModels.$inferSelect;
type ModelAvailabilityRow = typeof schema.modelAvailability.$inferSelect;
type TokenModelAvailabilityRow = typeof schema.tokenModelAvailability.$inferSelect;
type ProxyLogRow = typeof schema.proxyLogs.$inferSelect;
type CheckinLogRow = typeof schema.checkinLogs.$inferSelect;
type DownstreamApiKeyRow = typeof schema.downstreamApiKeys.$inferSelect;
type SiteAnnouncementRow = typeof schema.siteAnnouncements.$inferSelect;
type BackupAccountRow = Omit<AccountRow, 'balanceUsed' | 'lastCheckinAt' | 'lastBalanceRefresh'>
& Partial<Pick<AccountRow, 'balanceUsed' | 'lastCheckinAt' | 'lastBalanceRefresh'>>;
type BackupRouteChannelRow = Omit<RouteChannelRow,
'successCount'
| 'failCount'
| 'totalLatencyMs'
| 'totalCost'
| 'lastUsedAt'
| 'lastSelectedAt'
| 'lastFailAt'
| 'consecutiveFailCount'
| 'cooldownLevel'
| 'cooldownUntil'
> & Partial<Pick<RouteChannelRow,
'successCount'
| 'failCount'
| 'totalLatencyMs'
| 'totalCost'
| 'lastUsedAt'
| 'lastSelectedAt'
| 'lastFailAt'
| 'consecutiveFailCount'
| 'cooldownLevel'
| 'cooldownUntil'
>>;
type BackupSiteDisabledModelRow = Pick<SiteDisabledModelRow, 'siteId' | 'modelName'>;
type BackupManualModelRow = {
accountId: number;
modelName: string;
};
type BackupDownstreamApiKeyRow = Pick<DownstreamApiKeyRow,
'name'
| 'key'
| 'description'
| 'groupName'
| 'tags'
| 'enabled'
| 'expiresAt'
| 'maxCost'
| 'maxRequests'
| 'supportedModels'
| 'allowedRouteIds'
| 'siteWeightMultipliers'
> & Partial<Pick<DownstreamApiKeyRow, 'usedCost' | 'usedRequests' | 'lastUsedAt'>>;
interface AccountsBackupSection {
sites: SiteRow[];
accounts: AccountRow[];
accounts: BackupAccountRow[];
accountTokens: AccountTokenRow[];
tokenRoutes: TokenRouteRow[];
routeChannels: RouteChannelRow[];
routeChannels: BackupRouteChannelRow[];
routeGroupSources: RouteGroupSourceRow[];
siteDisabledModels?: BackupSiteDisabledModelRow[];
manualModels?: BackupManualModelRow[];
downstreamApiKeys?: BackupDownstreamApiKeyRow[];
}
interface PreferencesBackupSection {
@@ -105,13 +160,30 @@ type ProxyLogSnapshot = ProxyLogRow & {
accountKey: string | null;
routeKey: string | null;
channelKey: string | null;
downstreamApiKeyKey: string | null;
};
type CheckinLogSnapshot = CheckinLogRow & {
accountKey: string | null;
};
type SiteAnnouncementSnapshot = SiteAnnouncementRow & {
siteKey: string | null;
};
type ModelAvailabilitySnapshot = ModelAvailabilityRow & {
accountKey: string | null;
};
type TokenModelAvailabilitySnapshot = TokenModelAvailabilityRow & {
tokenKey: string | null;
};
type DownstreamApiKeyRuntimeSnapshot = Pick<DownstreamApiKeyRow, 'usedCost' | 'usedRequests' | 'lastUsedAt'>;
interface RuntimeIdentityIndexes {
siteKeyById: Map<number, string>;
siteIdByKey: Map<string, number>;
accountKeyById: Map<number, string>;
accountIdByKey: Map<string, number>;
tokenKeyById: Map<number, string>;
@@ -125,6 +197,11 @@ interface RuntimeIdentityIndexes {
interface RuntimeStateSnapshot {
accountRuntimeByKey: Map<string, AccountRuntimeSnapshot>;
routeChannelRuntimeByKey: Map<string, RouteChannelRuntimeSnapshot>;
siteAnnouncements: SiteAnnouncementSnapshot[];
nonManualAvailability: ModelAvailabilitySnapshot[];
tokenAvailability: TokenModelAvailabilitySnapshot[];
downstreamApiKeyRuntimeByKey: Map<string, DownstreamApiKeyRuntimeSnapshot>;
downstreamApiKeyIdByKey: Map<string, number>;
proxyLogs: ProxyLogSnapshot[];
checkinLogs: CheckinLogSnapshot[];
}
@@ -307,6 +384,10 @@ function buildRouteIdentityKey(row: Pick<TokenRouteRow, 'modelPattern' | 'routeM
].join('::');
}
function buildModelAvailabilityIdentityKey(accountKey: string, modelName: string): string {
return [accountKey, asString(modelName)].join('::');
}
function buildRouteChannelIdentityKey(
row: Pick<RouteChannelRow, 'routeId' | 'accountId' | 'tokenId' | 'sourceModel'>,
indexes: Pick<RuntimeIdentityIndexes, 'accountKeyById' | 'tokenKeyById' | 'routeKeyById'>,
@@ -321,6 +402,7 @@ function buildRouteChannelIdentityKey(
function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection): RuntimeIdentityIndexes {
const siteKeyById = new Map<number, string>();
const siteIdByKey = new Map<string, number>();
const accountKeyById = new Map<number, string>();
const accountIdByKey = new Map<string, number>();
const tokenKeyById = new Map<number, string>();
@@ -331,7 +413,9 @@ function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection):
const channelIdByKey = new Map<string, number>();
for (const row of section.sites) {
siteKeyById.set(row.id, buildSiteIdentityKey(row));
const siteKey = buildSiteIdentityKey(row);
siteKeyById.set(row.id, siteKey);
siteIdByKey.set(siteKey, row.id);
}
for (const row of section.accounts) {
@@ -377,6 +461,8 @@ function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection):
}
return {
siteKeyById,
siteIdByKey,
accountKeyById,
accountIdByKey,
tokenKeyById,
@@ -389,7 +475,19 @@ function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection):
}
async function collectCurrentRuntimeStateSnapshot(): Promise<RuntimeStateSnapshot> {
const [sites, accounts, accountTokens, tokenRoutes, routeChannels, proxyLogs, checkinLogs] = await Promise.all([
const [
sites,
accounts,
accountTokens,
tokenRoutes,
routeChannels,
proxyLogs,
checkinLogs,
siteAnnouncements,
modelAvailability,
tokenModelAvailability,
downstreamApiKeys,
] = await Promise.all([
db.select().from(schema.sites).all(),
db.select().from(schema.accounts).all(),
db.select().from(schema.accountTokens).all(),
@@ -397,6 +495,10 @@ async function collectCurrentRuntimeStateSnapshot(): Promise<RuntimeStateSnapsho
db.select().from(schema.routeChannels).all(),
db.select().from(schema.proxyLogs).all(),
db.select().from(schema.checkinLogs).all(),
db.select().from(schema.siteAnnouncements).all(),
db.select().from(schema.modelAvailability).all(),
db.select().from(schema.tokenModelAvailability).all(),
db.select().from(schema.downstreamApiKeys).all(),
]);
const siteKeyById = new Map<number, string>();
@@ -463,14 +565,46 @@ async function collectCurrentRuntimeStateSnapshot(): Promise<RuntimeStateSnapsho
});
}
const downstreamApiKeyKeyById = new Map<number, string>();
const downstreamApiKeyIdByKey = new Map<string, number>();
const downstreamApiKeyRuntimeByKey = new Map<string, DownstreamApiKeyRuntimeSnapshot>();
for (const row of downstreamApiKeys) {
const key = asString(row.key);
if (!key) continue;
downstreamApiKeyKeyById.set(row.id, key);
downstreamApiKeyIdByKey.set(key, row.id);
downstreamApiKeyRuntimeByKey.set(key, {
usedCost: row.usedCost ?? 0,
usedRequests: row.usedRequests ?? 0,
lastUsedAt: row.lastUsedAt ?? null,
});
}
return {
accountRuntimeByKey,
routeChannelRuntimeByKey,
siteAnnouncements: siteAnnouncements.map((row) => ({
...row,
siteKey: siteKeyById.get(row.siteId) || null,
})),
nonManualAvailability: modelAvailability
.filter((row) => !row.isManual)
.map((row) => ({
...row,
accountKey: accountKeyById.get(row.accountId) || null,
})),
tokenAvailability: tokenModelAvailability.map((row) => ({
...row,
tokenKey: tokenKeyById.get(row.tokenId) || null,
})),
downstreamApiKeyRuntimeByKey,
downstreamApiKeyIdByKey,
proxyLogs: proxyLogs.map((row) => ({
...row,
accountKey: row.accountId ? (accountKeyById.get(row.accountId) || null) : null,
routeKey: row.routeId ? (routeKeyById.get(row.routeId) || null) : null,
channelKey: row.channelId ? (channelKeyById.get(row.channelId) || null) : null,
downstreamApiKeyKey: row.downstreamApiKeyId ? (downstreamApiKeyKeyById.get(row.downstreamApiKeyId) || null) : null,
})),
checkinLogs: checkinLogs.map((row) => ({
...row,
@@ -1192,20 +1326,69 @@ function isSettingValueAcceptable(key: string, value: unknown): boolean {
}
async function exportAccountsSection(): Promise<AccountsBackupSection> {
const sites = await db.select().from(schema.sites).orderBy(asc(schema.sites.id)).all();
const accounts = await db.select().from(schema.accounts).orderBy(asc(schema.accounts.id)).all();
const accountTokens = await db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all();
const tokenRoutes = await db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all();
const routeChannels = await db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all();
const routeGroupSources = await db.select().from(schema.routeGroupSources).orderBy(asc(schema.routeGroupSources.id)).all();
return {
const [
sites,
accounts,
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources,
siteDisabledModels,
manualModels,
downstreamApiKeys,
] = await Promise.all([
db.select().from(schema.sites).orderBy(asc(schema.sites.id)).all(),
db.select().from(schema.accounts).orderBy(asc(schema.accounts.id)).all(),
db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all(),
db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all(),
db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all(),
db.select().from(schema.routeGroupSources).orderBy(asc(schema.routeGroupSources.id)).all(),
db.select().from(schema.siteDisabledModels)
.orderBy(asc(schema.siteDisabledModels.siteId), asc(schema.siteDisabledModels.modelName))
.all(),
db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.isManual, true))
.orderBy(asc(schema.modelAvailability.accountId), asc(schema.modelAvailability.modelName))
.all(),
db.select().from(schema.downstreamApiKeys).orderBy(asc(schema.downstreamApiKeys.id)).all(),
]);
return {
sites,
accounts: accounts.map(({ balanceUsed: _balanceUsed, lastCheckinAt: _lastCheckinAt, lastBalanceRefresh: _lastBalanceRefresh, ...row }) => row),
accountTokens,
tokenRoutes,
routeChannels: routeChannels.map(({
successCount: _successCount,
failCount: _failCount,
totalLatencyMs: _totalLatencyMs,
totalCost: _totalCost,
lastUsedAt: _lastUsedAt,
lastSelectedAt: _lastSelectedAt,
lastFailAt: _lastFailAt,
consecutiveFailCount: _consecutiveFailCount,
cooldownLevel: _cooldownLevel,
cooldownUntil: _cooldownUntil,
...row
}) => row),
routeGroupSources,
siteDisabledModels: siteDisabledModels.map((row) => ({
siteId: row.siteId,
modelName: row.modelName,
})),
manualModels: manualModels.map((row) => ({
accountId: row.accountId,
modelName: row.modelName,
})),
downstreamApiKeys: downstreamApiKeys.map(({
id: _id,
usedCost: _usedCost,
usedRequests: _usedRequests,
lastUsedAt: _lastUsedAt,
createdAt: _createdAt,
updatedAt: _updatedAt,
...row
}) => row),
};
}
@@ -1252,13 +1435,22 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
if (!isRecord(input)) return null;
const sites = Array.isArray(input.sites) ? input.sites as SiteRow[] : null;
const accounts = Array.isArray(input.accounts) ? input.accounts as AccountRow[] : null;
const accounts = Array.isArray(input.accounts) ? input.accounts as BackupAccountRow[] : null;
const accountTokens = Array.isArray(input.accountTokens) ? input.accountTokens as AccountTokenRow[] : null;
const tokenRoutes = Array.isArray(input.tokenRoutes) ? input.tokenRoutes as TokenRouteRow[] : null;
const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as RouteChannelRow[] : null;
const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as BackupRouteChannelRow[] : null;
const routeGroupSources = Array.isArray(input.routeGroupSources)
? input.routeGroupSources as RouteGroupSourceRow[]
: [];
const siteDisabledModels = Array.isArray(input.siteDisabledModels)
? input.siteDisabledModels as BackupSiteDisabledModelRow[]
: undefined;
const manualModels = Array.isArray(input.manualModels)
? input.manualModels as BackupManualModelRow[]
: undefined;
const downstreamApiKeys = Array.isArray(input.downstreamApiKeys)
? input.downstreamApiKeys as BackupDownstreamApiKeyRow[]
: undefined;
if (!sites || !accounts || !accountTokens || !tokenRoutes || !routeChannels) return null;
@@ -1269,6 +1461,9 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
tokenRoutes,
routeChannels,
routeGroupSources,
siteDisabledModels,
manualModels,
downstreamApiKeys,
};
}
@@ -1347,8 +1542,14 @@ function detectImportMetadata(data: RawBackupData): {
async function importAccountsSection(section: AccountsBackupSection): Promise<void> {
const runtimeState = await collectCurrentRuntimeStateSnapshot();
const importedIndexes = buildRuntimeIdentityIndexesFromSection(section);
const shouldReplaceSiteDisabledModels = Array.isArray(section.siteDisabledModels);
const shouldReplaceManualModels = Array.isArray(section.manualModels);
const shouldReplaceDownstreamApiKeys = Array.isArray(section.downstreamApiKeys);
await db.transaction(async (tx) => {
if (shouldReplaceDownstreamApiKeys) {
await tx.delete(schema.downstreamApiKeys).run();
}
await tx.delete(schema.proxyLogs).run();
await tx.delete(schema.routeChannels).run();
await tx.delete(schema.routeGroupSources).run();
@@ -1476,17 +1677,133 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
}).run();
}
if (shouldReplaceSiteDisabledModels) {
for (const row of section.siteDisabledModels || []) {
await tx.insert(schema.siteDisabledModels).values({
siteId: row.siteId,
modelName: row.modelName,
}).run();
}
}
const importedManualModelKeys = new Set<string>();
if (shouldReplaceManualModels) {
const checkedAt = new Date().toISOString();
for (const row of section.manualModels || []) {
const accountKey = importedIndexes.accountKeyById.get(row.accountId);
if (accountKey) {
importedManualModelKeys.add(buildModelAvailabilityIdentityKey(accountKey, row.modelName));
}
await tx.insert(schema.modelAvailability).values({
accountId: row.accountId,
modelName: row.modelName,
available: true,
isManual: true,
latencyMs: null,
checkedAt,
}).run();
}
}
for (const row of runtimeState.nonManualAvailability) {
if (!row.accountKey) continue;
const accountId = importedIndexes.accountIdByKey.get(row.accountKey);
if (!accountId) continue;
const modelKey = buildModelAvailabilityIdentityKey(row.accountKey, row.modelName);
if (importedManualModelKeys.has(modelKey)) continue;
await tx.insert(schema.modelAvailability).values({
accountId,
modelName: row.modelName,
available: row.available,
isManual: false,
latencyMs: row.latencyMs ?? null,
checkedAt: row.checkedAt,
}).run();
}
for (const row of runtimeState.tokenAvailability) {
if (!row.tokenKey) continue;
const tokenId = importedIndexes.tokenIdByKey.get(row.tokenKey);
if (!tokenId) continue;
await tx.insert(schema.tokenModelAvailability).values({
tokenId,
modelName: row.modelName,
available: row.available,
latencyMs: row.latencyMs ?? null,
checkedAt: row.checkedAt,
}).run();
}
for (const row of runtimeState.siteAnnouncements) {
if (!row.siteKey) continue;
const siteId = importedIndexes.siteIdByKey.get(row.siteKey);
if (!siteId) continue;
await tx.insert(schema.siteAnnouncements).values({
siteId,
platform: row.platform,
sourceKey: row.sourceKey,
title: row.title,
content: row.content,
level: row.level,
sourceUrl: row.sourceUrl ?? null,
startsAt: row.startsAt ?? null,
endsAt: row.endsAt ?? null,
upstreamCreatedAt: row.upstreamCreatedAt ?? null,
upstreamUpdatedAt: row.upstreamUpdatedAt ?? null,
firstSeenAt: row.firstSeenAt ?? null,
lastSeenAt: row.lastSeenAt ?? null,
readAt: row.readAt ?? null,
dismissedAt: row.dismissedAt ?? null,
rawPayload: row.rawPayload ?? null,
}).run();
}
const downstreamApiKeyIdByKey = shouldReplaceDownstreamApiKeys
? new Map<string, number>()
: new Map(runtimeState.downstreamApiKeyIdByKey);
if (shouldReplaceDownstreamApiKeys) {
for (const row of section.downstreamApiKeys || []) {
const normalizedKey = asString(row.key);
if (!normalizedKey) continue;
const runtimeDownstream = runtimeState.downstreamApiKeyRuntimeByKey.get(normalizedKey);
const insertedKey = await tx.insert(schema.downstreamApiKeys).values({
name: row.name,
key: normalizedKey,
description: row.description ?? null,
groupName: row.groupName ?? null,
tags: row.tags ?? null,
enabled: row.enabled ?? true,
expiresAt: row.expiresAt ?? null,
maxCost: row.maxCost ?? null,
usedCost: runtimeDownstream?.usedCost ?? row.usedCost ?? 0,
maxRequests: row.maxRequests ?? null,
usedRequests: runtimeDownstream?.usedRequests ?? row.usedRequests ?? 0,
supportedModels: row.supportedModels ?? null,
allowedRouteIds: row.allowedRouteIds ?? null,
siteWeightMultipliers: row.siteWeightMultipliers ?? null,
lastUsedAt: runtimeDownstream?.lastUsedAt ?? row.lastUsedAt ?? null,
}).returning({ id: schema.downstreamApiKeys.id }).get();
downstreamApiKeyIdByKey.set(normalizedKey, insertedKey.id);
}
}
for (const row of runtimeState.proxyLogs) {
const accountId = row.accountKey ? (importedIndexes.accountIdByKey.get(row.accountKey) ?? null) : null;
const routeId = row.routeKey ? (importedIndexes.routeIdByKey.get(row.routeKey) ?? null) : null;
const channelId = row.channelKey ? (importedIndexes.channelIdByKey.get(row.channelKey) ?? null) : null;
const downstreamApiKeyId = row.downstreamApiKeyKey
? (downstreamApiKeyIdByKey.get(row.downstreamApiKeyKey) ?? null)
: null;
await tx.insert(schema.proxyLogs).values({
id: row.id,
routeId,
channelId,
accountId,
downstreamApiKeyId: row.downstreamApiKeyId ?? null,
downstreamApiKeyId,
modelRequested: row.modelRequested ?? null,
modelActual: row.modelActual ?? null,
status: row.status ?? null,