refactor: move user metadata and presets onto cosmos paths

This commit is contained in:
Saravana Achu Mac 2026-04-04 17:24:55 -07:00
parent 0baf32bfcf
commit e043f3c79d
5 changed files with 206 additions and 4 deletions

View File

@ -49,6 +49,15 @@ interface TradeProfileDocument extends TradeProfileRecord {
}
const PROFILE_CONTAINER = 'trade_profiles';
const USER_PROFILE_CONTAINER = 'trading_users';
interface TradingUserProfileDocument extends TradingUserProfile {
id: string;
productId: string;
type: 'trading_user';
createdAt: string;
updatedAt: string;
}
function isCosmosConfigured(): boolean {
return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY);
@ -105,6 +114,68 @@ function buildDefaultTradeProfile(userId: string): TradeProfileRecord {
};
}
function normalizeTradingUserProfile(
row: Partial<TradingUserProfile> | null | undefined,
fallbackUserId?: string
): TradingUserProfile | null {
const userId = String(row?.user_id || fallbackUserId || '').trim();
if (!userId) {
return null;
}
return {
user_id: userId,
first_name: String(row?.first_name || ''),
last_name: String(row?.last_name || ''),
email: String(row?.email || ''),
role: String(row?.role || 'member'),
trade_enable: Boolean(row?.trade_enable ?? true),
ALPACA_API_KEY: row?.ALPACA_API_KEY,
ALPACA_SECRET_KEY: row?.ALPACA_SECRET_KEY,
REAL_ALPACA_API_KEY: row?.REAL_ALPACA_API_KEY,
REAL_ALPACA_SECRET_KEY: row?.REAL_ALPACA_SECRET_KEY,
drop_threshold_for_buy: Number(row?.drop_threshold_for_buy ?? 0),
gain_threshold_for_sell: Number(row?.gain_threshold_for_sell ?? 0),
market_poll_interval_in_seconds: Number(row?.market_poll_interval_in_seconds ?? 0),
};
}
function toTradingUserProfileDocument(profile: TradingUserProfile): TradingUserProfileDocument {
const now = new Date().toISOString();
return {
...profile,
id: profile.user_id,
productId: config.PRODUCT_ID,
type: 'trading_user',
createdAt: now,
updatedAt: now,
};
}
async function getTradingUserProfileFromCosmos(userId: string): Promise<TradingUserProfile | null> {
if (!isCosmosConfigured() || !userId) {
return null;
}
const container = getContainer(USER_PROFILE_CONTAINER);
const { resources } = await container.items.query<TradingUserProfileDocument>({
query: 'SELECT TOP 1 * FROM c WHERE c.productId = @productId AND c.type = @type AND c.user_id = @userId',
parameters: [
{ name: '@productId', value: config.PRODUCT_ID },
{ name: '@type', value: 'trading_user' },
{ name: '@userId', value: userId },
],
}).fetchAll();
return normalizeTradingUserProfile(resources[0], userId);
}
async function upsertTradingUserProfileToCosmos(profile: TradingUserProfile): Promise<void> {
if (!isCosmosConfigured()) return;
const container = getContainer(USER_PROFILE_CONTAINER);
await container.items.upsert<TradingUserProfileDocument>(toTradingUserProfileDocument(profile));
}
async function listProfilesFromCosmos(userId: string): Promise<TradeProfileRecord[]> {
if (!isCosmosConfigured()) {
return [];
@ -479,6 +550,17 @@ export async function getCurrentUserProfile(
fallback: Partial<TradingUserProfile> = {},
legacyService?: LegacySupabaseService
): Promise<TradingUserProfile> {
if (isCosmosConfigured()) {
try {
const cosmosProfile = await getTradingUserProfileFromCosmos(userId);
if (cosmosProfile) {
return cosmosProfile;
}
} catch (error) {
logger.warn(`[Profiles] Cosmos user profile read failed for ${userId}: ${error instanceof Error ? error.message : 'unknown error'}`);
}
}
const client = legacyService?.getClient?.();
if (client) {
try {
@ -489,7 +571,7 @@ export async function getCurrentUserProfile(
.maybeSingle();
if (!error && data) {
return {
const normalized = {
user_id: String((data as any).user_id || userId),
first_name: String((data as any).first_name || fallback.first_name || ''),
last_name: String((data as any).last_name || fallback.last_name || ''),
@ -504,6 +586,8 @@ export async function getCurrentUserProfile(
gain_threshold_for_sell: Number((data as any).gain_threshold_for_sell ?? fallback.gain_threshold_for_sell ?? 0),
market_poll_interval_in_seconds: Number((data as any).market_poll_interval_in_seconds ?? fallback.market_poll_interval_in_seconds ?? 0),
};
await upsertTradingUserProfileToCosmos(normalized);
return normalized;
}
} catch (error) {
logger.warn(`[Profiles] Legacy user profile read failed: ${error instanceof Error ? error.message : 'unknown error'}`);
@ -548,6 +632,12 @@ export async function saveCurrentUserProfile(
market_poll_interval_in_seconds: Number(input.market_poll_interval_in_seconds ?? existing.market_poll_interval_in_seconds ?? fallback.market_poll_interval_in_seconds ?? 0),
};
try {
await upsertTradingUserProfileToCosmos(merged);
} catch (error) {
logger.warn(`[Profiles] Cosmos user profile save failed: ${error instanceof Error ? error.message : 'unknown error'}`);
}
const client = legacyService?.getClient?.();
if (client) {
try {

View File

@ -1,13 +1,48 @@
import { getContainer } from '@bytelyst/cosmos';
import { randomUUID } from 'node:crypto';
import { config } from '../config/index.js';
import logger from '../utils/logger.js';
import type { supabaseService } from './SupabaseService.js';
type LegacySupabaseService = typeof supabaseService;
const PRESET_CONTAINER = 'strategy_presets';
interface StrategyPresetDocument {
id: string;
productId: string;
type: 'strategy_preset';
created_at?: string;
[key: string]: unknown;
}
function isCosmosConfigured(): boolean {
return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY);
}
function getClient(legacyService?: LegacySupabaseService) {
return legacyService?.getClient?.() ?? null;
}
export async function listStrategyPresets(legacyService?: LegacySupabaseService): Promise<any[]> {
if (isCosmosConfigured()) {
try {
const container = getContainer(PRESET_CONTAINER);
const { resources } = await container.items.query<StrategyPresetDocument>({
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type ORDER BY c.created_at DESC',
parameters: [
{ name: '@productId', value: config.PRODUCT_ID },
{ name: '@type', value: 'strategy_preset' },
],
}).fetchAll();
if (resources.length > 0) {
return resources;
}
} catch (error: any) {
logger.error(`[StrategyPresetRepo] Cosmos preset lookup failed: ${error.message}`);
return [];
}
}
const client = getClient(legacyService);
if (!client) return [];
@ -22,7 +57,21 @@ export async function listStrategyPresets(legacyService?: LegacySupabaseService)
return [];
}
return data || [];
const rows = data || [];
if (isCosmosConfigured() && rows.length > 0) {
try {
const container = getContainer(PRESET_CONTAINER);
await Promise.all(rows.map((row: any) => container.items.upsert<StrategyPresetDocument>({
id: String(row.id || row.original_profile_id || randomUUID()),
productId: config.PRODUCT_ID,
type: 'strategy_preset',
...row,
})));
} catch (seedError: any) {
logger.warn(`[StrategyPresetRepo] Cosmos preset seed failed: ${seedError.message}`);
}
}
return rows;
} catch (error: any) {
logger.error(`[StrategyPresetRepo] Preset lookup unexpected error: ${error.message}`);
return [];
@ -30,9 +79,20 @@ export async function listStrategyPresets(legacyService?: LegacySupabaseService)
}
export async function createStrategyPreset(payload: Record<string, unknown>, legacyService?: LegacySupabaseService): Promise<void> {
if (isCosmosConfigured()) {
const container = getContainer(PRESET_CONTAINER);
await container.items.upsert<StrategyPresetDocument>({
id: String(payload.id || payload['original_profile_id'] || randomUUID()),
productId: config.PRODUCT_ID,
type: 'strategy_preset',
created_at: String(payload['created_at'] || new Date().toISOString()),
...payload,
});
}
const client = getClient(legacyService);
if (!client) {
throw new Error('Strategy preset store is unavailable');
return;
}
const { error } = await client.from('strategy_presets').insert([payload]);

View File

@ -1,7 +1,20 @@
import { getContainer } from '@bytelyst/cosmos';
import { config } from '../config/index.js';
import logger from '../utils/logger.js';
import type { UserConfig, supabaseService } from './SupabaseService.js';
type LegacySupabaseService = typeof supabaseService;
const USER_PROFILE_CONTAINER = 'trading_users';
interface TradingUserDocument extends UserConfig {
id: string;
productId: string;
type: 'trading_user';
}
function isCosmosConfigured(): boolean {
return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY);
}
function normalizeUser(row: Partial<UserConfig> | null | undefined): UserConfig | null {
const userId = String(row?.user_id || '').trim();
@ -27,6 +40,29 @@ function normalizeUser(row: Partial<UserConfig> | null | undefined): UserConfig
}
export async function listActiveTradingUsers(legacyService?: LegacySupabaseService): Promise<UserConfig[]> {
if (isCosmosConfigured()) {
try {
const container = getContainer(USER_PROFILE_CONTAINER);
const { resources } = await container.items.query<TradingUserDocument>({
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type AND c.trade_enable = true',
parameters: [
{ name: '@productId', value: config.PRODUCT_ID },
{ name: '@type', value: 'trading_user' },
],
}).fetchAll();
const normalized = resources
.map((row) => normalizeUser(row as UserConfig))
.filter((user): user is UserConfig => Boolean(user));
if (normalized.length > 0) {
return normalized;
}
} catch (error) {
logger.warn(`[Users] Cosmos active trading user lookup failed: ${error instanceof Error ? error.message : 'unknown error'}`);
return [];
}
}
const client = legacyService?.getClient?.();
if (!client) {
return [];
@ -42,9 +78,23 @@ export async function listActiveTradingUsers(legacyService?: LegacySupabaseServi
return [];
}
return data
const normalized = data
.map((row) => normalizeUser(row as UserConfig))
.filter((user): user is UserConfig => Boolean(user));
if (isCosmosConfigured() && normalized.length > 0) {
try {
const container = getContainer(USER_PROFILE_CONTAINER);
await Promise.all(normalized.map((user) => container.items.upsert({
...user,
id: user.user_id,
productId: config.PRODUCT_ID,
type: 'trading_user',
} as TradingUserDocument)));
} catch (error) {
logger.warn(`[Users] Cosmos user seed failed: ${error instanceof Error ? error.message : 'unknown error'}`);
}
}
return normalized;
} catch (error) {
logger.warn(`[Users] Active trading user lookup failed: ${error instanceof Error ? error.message : 'unknown error'}`);
return [];

View File

@ -81,6 +81,7 @@ Rule:
- platform-service and Cosmos are the target system
- Supabase remains transitional only where trading persistence has not yet been migrated
- trading user profiles, dynamic config, trading controls, snapshots, capital ledgers, and strategy presets now have Cosmos-backed authority paths
## Verification Standard

View File

@ -41,6 +41,7 @@ It assumes:
- [x] Release smoke coverage now exists for web auth and product accessibility flows, with a tracked mobile release smoke checklist in operations
- [x] Request ID propagation is now standardized across the main web/mobile API paths and echoed by backend HTTP responses
- [x] Backtest feature access now reads from an explicit backend feature-flags contract instead of scraping generic runtime config
- [x] Trading user profiles and marketplace presets now have Cosmos-backed authority paths with legacy seeding/mirroring during migration
- [x] Root verification and lint flows now run successfully without sandbox-hostile script harness behavior
- [-] DRY cleanup completed for runtime/config/bootstrap concerns, shared websocket auth helpers, platform-session handling, and request tracing, but not yet for all persistence and feature-flag concerns
- [!] Full common-platform data-plane replacement remains a follow-up where selected trading-record repositories still depend on legacy Supabase storage because Cosmos-native equivalents are not finished yet