diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index cc67a35..bdcaec1 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -14,6 +14,11 @@ import { observabilityService } from './observabilityService.js'; import { isTradingAdmin, verifyTradingAccessToken } from './platformAuthService.js'; import { loadGlobalTradingControl, saveGlobalTradingControl } from './tradingControlRepository.js'; import { listDynamicConfigEntries, upsertDynamicConfigEntries } from './dynamicConfigRepository.js'; +import { + loadLatestBotStateSnapshot as loadLatestBotStateSnapshotFromRepository, + resolveSnapshotOwnerId as resolveSnapshotOwnerIdFromRepository, + saveBotStateSnapshot as saveBotStateSnapshotFromRepository +} from './snapshotRepository.js'; import { deleteTradeProfileForUser, ensureDefaultTradeProfileForUser, @@ -1102,7 +1107,7 @@ export class ApiServer { private async resolveSnapshotOwnerId(): Promise { if (this.snapshotOwnerId) return this.snapshotOwnerId; - const owner = await supabaseService.getSnapshotOwnerId(); + const owner = await resolveSnapshotOwnerIdFromRepository(supabaseService); this.snapshotOwnerId = owner; return owner; } @@ -1111,9 +1116,9 @@ export class ApiServer { try { const ownerId = await this.resolveSnapshotOwnerId(); if (!ownerId) { - logger.warn('[API] Snapshot owner not resolved; skipping Supabase restore.'); + logger.warn('[API] Snapshot owner not resolved; skipping snapshot restore.'); } else { - const snapshot = await supabaseService.loadLatestBotStateSnapshot(ownerId); + const snapshot = await loadLatestBotStateSnapshotFromRepository(ownerId, supabaseService); if (snapshot && snapshot.state) { const restoredState = snapshot.state as Partial; this.state = { @@ -1128,7 +1133,7 @@ export class ApiServer { if (this.state.health.tradingControl) { healthTracker.recordTradingControl(this.state.health.tradingControl); } - logger.info(`[API] Restored runtime state from Supabase snapshot (user=${ownerId}).`); + logger.info(`[API] Restored runtime state from snapshot repository (user=${ownerId}).`); } } @@ -1178,7 +1183,7 @@ export class ApiServer { try { const ownerId = await this.resolveSnapshotOwnerId(); if (!ownerId) return; - await supabaseService.saveBotStateSnapshot(ownerId, this.getPersistableState()); + await saveBotStateSnapshotFromRepository(ownerId, this.getPersistableState(), supabaseService); this.lastSnapshotWriteAt = Date.now(); logger.info(`[API] Persisted snapshot for ${ownerId}. Interval: ${Math.round(elapsed / 1000)}s`); } catch (error: any) { diff --git a/backend/src/services/snapshotRepository.ts b/backend/src/services/snapshotRepository.ts new file mode 100644 index 0000000..040541b --- /dev/null +++ b/backend/src/services/snapshotRepository.ts @@ -0,0 +1,97 @@ +import { getContainer } from '@bytelyst/cosmos'; +import { config } from '../config/index.js'; +import logger from '../utils/logger.js'; +import type { supabaseService } from './SupabaseService.js'; +import { listActiveTradingUsers } from './userRepository.js'; + +type LegacySupabaseService = typeof supabaseService; + +const CONTAINER_NAME = 'bot_state_snapshots'; + +interface BotStateSnapshotDocument { + id: string; + productId: string; + ownerId: string; + state: unknown; + updatedAt: string; +} + +function isCosmosConfigured(): boolean { + return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY); +} + +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function buildSnapshotId(ownerId: string): string { + return `${config.PRODUCT_ID}:${ownerId}`; +} + +export async function resolveSnapshotOwnerId(legacyService?: LegacySupabaseService): Promise { + const configured = String(config.SNAPSHOT_USER_ID || '').trim().toLowerCase(); + if (isUuid(configured)) { + return configured; + } + + const users = await listActiveTradingUsers(legacyService); + const firstUserId = String(users[0]?.user_id || '').trim().toLowerCase(); + if (isUuid(firstUserId)) { + return firstUserId; + } + + return null; +} + +export async function loadLatestBotStateSnapshot( + ownerId: string, + legacyService?: LegacySupabaseService +): Promise<{ state: unknown } | null> { + if (!ownerId) return null; + + if (isCosmosConfigured()) { + try { + const container = getContainer(CONTAINER_NAME); + const { resource } = await container + .item(buildSnapshotId(ownerId), config.PRODUCT_ID) + .read(); + if (resource?.state !== undefined) { + return { state: resource.state }; + } + } catch (error) { + const code = (error as { code?: number })?.code; + if (code !== 404) { + logger.warn(`[Snapshots] Cosmos load failed, falling back to legacy store: ${error instanceof Error ? error.message : 'unknown error'}`); + } + } + } + + return legacyService?.loadLatestBotStateSnapshot(ownerId) ?? null; +} + +export async function saveBotStateSnapshot( + ownerId: string, + state: unknown, + legacyService?: LegacySupabaseService +): Promise { + if (!ownerId) return; + + if (isCosmosConfigured()) { + try { + const container = getContainer(CONTAINER_NAME); + const doc: BotStateSnapshotDocument = { + id: buildSnapshotId(ownerId), + productId: config.PRODUCT_ID, + ownerId, + state, + updatedAt: new Date().toISOString() + }; + await container.items.upsert(doc); + return; + } catch (error) { + logger.warn(`[Snapshots] Cosmos upsert failed, falling back to legacy store: ${error instanceof Error ? error.message : 'unknown error'}`); + } + } + + await legacyService?.saveBotStateSnapshot(ownerId, state); +}