diff --git a/gateway/src/workspace/types.ts b/gateway/src/workspace/types.ts index f8ba10ac..cbe150e4 100644 --- a/gateway/src/workspace/types.ts +++ b/gateway/src/workspace/types.ts @@ -91,7 +91,7 @@ export const DEFAULT_STORES: StoreConfig[] = [ { name: 'shapes', persistent: true, - initialState: () => ({}), + initialState: () => ({ shapes: {} }), }, { name: 'indicators', @@ -285,7 +285,7 @@ export interface Shape { /** * Shapes store - persistent, stores TradingView drawings and annotations. */ -export type ShapesStore = Record; +export type ShapesStore = { shapes: Record }; /** * Parameter schema entry for a custom indicator. diff --git a/gateway/src/workspace/workspace-manager.ts b/gateway/src/workspace/workspace-manager.ts index 70e41ec6..64a30e1f 100644 --- a/gateway/src/workspace/workspace-manager.ts +++ b/gateway/src/workspace/workspace-manager.ts @@ -90,7 +90,14 @@ export class WorkspaceManager { const states = await this.containerSync.loadAllStores(persistentStores); for (const [storeName, state] of states) { - this.registry.setState(storeName, state); + let migratedState = state; + // Migrate shapes from old flat format { shapeId: {...} } to wrapped { shapes: { shapeId: {...} } } + if (storeName === 'shapes' && state && typeof state === 'object' && !('shapes' in (state as object))) { + migratedState = { shapes: state }; + this.dirtyStores.add(storeName); // persist migrated format immediately + this.logger.info({ store: storeName }, 'Migrated shapes store to wrapped format'); + } + this.registry.setState(storeName, migratedState); this.logger.debug({ store: storeName }, 'Loaded persistent store'); } } diff --git a/web/src/composables/useStateSync.ts b/web/src/composables/useStateSync.ts index ea34cc6f..ec914680 100644 --- a/web/src/composables/useStateSync.ts +++ b/web/src/composables/useStateSync.ts @@ -4,9 +4,14 @@ import type { BackendMessage, FrontendMessage, HelloMessage, PatchMessage } from import { wsManager } from './useWebSocket'; function deepReplace(target: Record, source: Record) { - for (const key of Object.keys(target)) { - if (!(key in source)) { - delete target[key] + // Only delete target keys when source has content. An empty source {} is an initial-state + // snapshot — skipping deletion preserves structural wrapper keys in composition API Pinia + // stores (e.g. shapes, indicators, types) so $subscribe reactivity keeps working. + if (Object.keys(source).length > 0) { + for (const key of Object.keys(target)) { + if (!(key in source)) { + delete target[key] + } } } for (const [key, value] of Object.entries(source)) { diff --git a/web/src/composables/useTradingViewShapes.ts b/web/src/composables/useTradingViewShapes.ts index 144db787..b88e791b 100644 --- a/web/src/composables/useTradingViewShapes.ts +++ b/web/src/composables/useTradingViewShapes.ts @@ -430,7 +430,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) { function setupStoreWatchers() { // Watch for shape store changes and apply to TradingView watch( - () => shapeStore.$state, + () => shapeStore.shapes, async (newShapes, oldShapes) => { if (isUpdatingStore || !isChartReady) return diff --git a/web/src/stores/indicatorTypes.ts b/web/src/stores/indicatorTypes.ts index 48700240..97fe32fa 100644 --- a/web/src/stores/indicatorTypes.ts +++ b/web/src/stores/indicatorTypes.ts @@ -1,4 +1,5 @@ import { defineStore } from 'pinia' +import { ref } from 'vue' import type { CustomIndicatorMetadata } from './indicators' export interface CustomIndicatorType { @@ -10,12 +11,7 @@ export interface CustomIndicatorType { modified_at: number } -// Options API: flat $state = Record matches backend initial state {}. -// Composition API would produce $state = { types: {} }, causing deepReplace to delete 'types' on -// the initial empty snapshot from the backend, which breaks $subscribe reactivity tracking. -export const useIndicatorTypesStore = defineStore('indicator_types', { - state: (): Record => ({}), - getters: { - types: (state): Record => state, - }, +export const useIndicatorTypesStore = defineStore('indicator_types', () => { + const types = ref>({}) + return { types } }) diff --git a/web/src/stores/indicators.ts b/web/src/stores/indicators.ts index aba914a8..2484fc90 100644 --- a/web/src/stores/indicators.ts +++ b/web/src/stores/indicators.ts @@ -1,4 +1,5 @@ import { defineStore } from 'pinia' +import { ref } from 'vue' export interface CustomIndicatorParam { type: 'int' | 'float' | 'bool' | 'string' @@ -75,48 +76,48 @@ export interface IndicatorInstance { custom_metadata?: CustomIndicatorMetadata } -// Options API: flat $state = Record matches backend initial state {}. -// Composition API would produce $state = { indicators: {} }, causing deepReplace to delete -// 'indicators' on the initial empty snapshot, which breaks $subscribe reactivity tracking. -// The 'indicators' getter maintains backwards compatibility with existing callers. -export const useIndicatorStore = defineStore('indicators', { - state: (): Record => ({}), +export const useIndicatorStore = defineStore('indicators', () => { + const indicators = ref>({}) - getters: { - indicators: (state): Record => state, - }, + // Helper methods + const addIndicator = (indicator: IndicatorInstance) => { + indicators.value[indicator.id] = indicator + } - actions: { - addIndicator(indicator: IndicatorInstance) { - this.$patch({ [indicator.id]: indicator } as Partial>) - }, - - updateIndicator(id: string, updates: Partial) { - const existing = (this.$state as Record)[id] - if (existing) { - this.$patch({ - [id]: { ...existing, ...updates, modified_at: Math.floor(Date.now() / 1000) } - } as Partial>) + const updateIndicator = (id: string, updates: Partial) => { + if (indicators.value[id]) { + const updated = { + ...indicators.value[id], + ...updates, + modified_at: Math.floor(Date.now() / 1000) } - }, + indicators.value[id] = updated + } + } - removeIndicator(id: string) { - this.$patch((state) => { - delete (state as Record)[id] - }) - }, + const removeIndicator = (id: string) => { + delete indicators.value[id] + } - getIndicator(id: string): IndicatorInstance | undefined { - return (this.$state as Record)[id] - }, + const getIndicator = (id: string): IndicatorInstance | undefined => { + return indicators.value[id] + } - getAllIndicators(): IndicatorInstance[] { - return Object.values(this.$state as Record) - }, + const getAllIndicators = (): IndicatorInstance[] => { + return Object.values(indicators.value) + } - getIndicatorsBySymbol(symbol: string): IndicatorInstance[] { - return Object.values(this.$state as Record) - .filter(ind => ind.symbol === symbol) - }, - }, + const getIndicatorsBySymbol = (symbol: string): IndicatorInstance[] => { + return Object.values(indicators.value).filter(ind => ind.symbol === symbol) + } + + return { + indicators, + addIndicator, + updateIndicator, + removeIndicator, + getIndicator, + getAllIndicators, + getIndicatorsBySymbol + } }) diff --git a/web/src/stores/researchTypes.ts b/web/src/stores/researchTypes.ts index d10dd639..d9a95f07 100644 --- a/web/src/stores/researchTypes.ts +++ b/web/src/stores/researchTypes.ts @@ -1,4 +1,5 @@ import { defineStore } from 'pinia' +import { ref } from 'vue' export interface ResearchType { display_name: string @@ -7,11 +8,7 @@ export interface ResearchType { modified_at: number } -// Options API: flat $state matches backend initial state {}. -// See indicatorTypes.ts for explanation of why composition API breaks $subscribe. -export const useResearchTypesStore = defineStore('research_types', { - state: (): Record => ({}), - getters: { - types: (state): Record => state, - }, +export const useResearchTypesStore = defineStore('research_types', () => { + const types = ref>({}) + return { types } }) diff --git a/web/src/stores/shapes.ts b/web/src/stores/shapes.ts index 7008edcd..dee30007 100644 --- a/web/src/stores/shapes.ts +++ b/web/src/stores/shapes.ts @@ -1,4 +1,5 @@ import { defineStore } from 'pinia' +import { ref } from 'vue' export interface ControlPoint { time: number @@ -20,38 +21,44 @@ export interface Shape { original_id?: string // Original ID from backend/agent before TradingView assigns its own ID } -// Use Options API so $state is a flat Record matching the backend's shape store structure. -// Composition API would produce $state = { shapes: {} } (extra nesting), which causes deepReplace in -// useStateSync to delete the 'shapes' key when the backend sends an empty {} snapshot, breaking $subscribe. -export const useShapeStore = defineStore('shapes', { - state: () => ({} as Record), +export const useShapeStore = defineStore('shapes', () => { + const shapes = ref>({}) - actions: { - addShape(shape: Shape) { - this.$patch({ [shape.id]: shape } as Partial>) - }, + // Helper methods + const addShape = (shape: Shape) => { + shapes.value[shape.id] = shape + } - updateShape(id: string, updates: Partial) { - const existing = (this.$state as Record)[id] - if (existing) { - this.$patch({ - [id]: { ...existing, ...updates, modified_at: Math.floor(Date.now() / 1000) } - } as Partial>) + const updateShape = (id: string, updates: Partial) => { + if (shapes.value[id]) { + const updated = { + ...shapes.value[id], + ...updates, + modified_at: Math.floor(Date.now() / 1000) } - }, + // Replace the entire shape object to ensure arrays are replaced atomically + shapes.value[id] = updated + } + } - removeShape(id: string) { - this.$patch((state) => { - delete (state as Record)[id] - }) - }, + const removeShape = (id: string) => { + delete shapes.value[id] + } - getShape(id: string): Shape | undefined { - return (this.$state as Record)[id] - }, + const getShape = (id: string): Shape | undefined => { + return shapes.value[id] + } - getAllShapes(): Shape[] { - return Object.values(this.$state as Record) - }, - }, + const getAllShapes = (): Shape[] => { + return Object.values(shapes.value) + } + + return { + shapes, + addShape, + updateShape, + removeShape, + getShape, + getAllShapes + } }) diff --git a/web/src/stores/strategyTypes.ts b/web/src/stores/strategyTypes.ts index 5c29406a..fc2f65e8 100644 --- a/web/src/stores/strategyTypes.ts +++ b/web/src/stores/strategyTypes.ts @@ -1,4 +1,5 @@ import { defineStore } from 'pinia' +import { ref } from 'vue' export interface StrategyType { display_name: string @@ -7,11 +8,7 @@ export interface StrategyType { modified_at: number } -// Options API: flat $state matches backend initial state {}. -// See indicatorTypes.ts for explanation of why composition API breaks $subscribe. -export const useStrategyTypesStore = defineStore('strategy_types', { - state: (): Record => ({}), - getters: { - types: (state): Record => state, - }, +export const useStrategyTypesStore = defineStore('strategy_types', () => { + const types = ref>({}) + return { types } })