store sync bugfix

This commit is contained in:
2026-04-17 22:27:06 -04:00
parent 9ca7908c0e
commit 7abbd2e5c7
9 changed files with 104 additions and 94 deletions

View File

@@ -91,7 +91,7 @@ export const DEFAULT_STORES: StoreConfig[] = [
{ {
name: 'shapes', name: 'shapes',
persistent: true, persistent: true,
initialState: () => ({}), initialState: () => ({ shapes: {} }),
}, },
{ {
name: 'indicators', name: 'indicators',
@@ -285,7 +285,7 @@ export interface Shape {
/** /**
* Shapes store - persistent, stores TradingView drawings and annotations. * Shapes store - persistent, stores TradingView drawings and annotations.
*/ */
export type ShapesStore = Record<string, Shape>; export type ShapesStore = { shapes: Record<string, Shape> };
/** /**
* Parameter schema entry for a custom indicator. * Parameter schema entry for a custom indicator.

View File

@@ -90,7 +90,14 @@ export class WorkspaceManager {
const states = await this.containerSync.loadAllStores(persistentStores); const states = await this.containerSync.loadAllStores(persistentStores);
for (const [storeName, state] of states) { 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'); this.logger.debug({ store: storeName }, 'Loaded persistent store');
} }
} }

View File

@@ -4,9 +4,14 @@ import type { BackendMessage, FrontendMessage, HelloMessage, PatchMessage } from
import { wsManager } from './useWebSocket'; import { wsManager } from './useWebSocket';
function deepReplace(target: Record<string, any>, source: Record<string, any>) { function deepReplace(target: Record<string, any>, source: Record<string, any>) {
for (const key of Object.keys(target)) { // Only delete target keys when source has content. An empty source {} is an initial-state
if (!(key in source)) { // snapshot — skipping deletion preserves structural wrapper keys in composition API Pinia
delete target[key] // 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)) { for (const [key, value] of Object.entries(source)) {

View File

@@ -430,7 +430,7 @@ export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) {
function setupStoreWatchers() { function setupStoreWatchers() {
// Watch for shape store changes and apply to TradingView // Watch for shape store changes and apply to TradingView
watch( watch(
() => shapeStore.$state, () => shapeStore.shapes,
async (newShapes, oldShapes) => { async (newShapes, oldShapes) => {
if (isUpdatingStore || !isChartReady) return if (isUpdatingStore || !isChartReady) return

View File

@@ -1,4 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { CustomIndicatorMetadata } from './indicators' import type { CustomIndicatorMetadata } from './indicators'
export interface CustomIndicatorType { export interface CustomIndicatorType {
@@ -10,12 +11,7 @@ export interface CustomIndicatorType {
modified_at: number modified_at: number
} }
// Options API: flat $state = Record<string, CustomIndicatorType> matches backend initial state {}. export const useIndicatorTypesStore = defineStore('indicator_types', () => {
// Composition API would produce $state = { types: {} }, causing deepReplace to delete 'types' on const types = ref<Record<string, CustomIndicatorType>>({})
// the initial empty snapshot from the backend, which breaks $subscribe reactivity tracking. return { types }
export const useIndicatorTypesStore = defineStore('indicator_types', {
state: (): Record<string, CustomIndicatorType> => ({}),
getters: {
types: (state): Record<string, CustomIndicatorType> => state,
},
}) })

View File

@@ -1,4 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface CustomIndicatorParam { export interface CustomIndicatorParam {
type: 'int' | 'float' | 'bool' | 'string' type: 'int' | 'float' | 'bool' | 'string'
@@ -75,48 +76,48 @@ export interface IndicatorInstance {
custom_metadata?: CustomIndicatorMetadata custom_metadata?: CustomIndicatorMetadata
} }
// Options API: flat $state = Record<string, IndicatorInstance> matches backend initial state {}. export const useIndicatorStore = defineStore('indicators', () => {
// Composition API would produce $state = { indicators: {} }, causing deepReplace to delete const indicators = ref<Record<string, IndicatorInstance>>({})
// '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<string, IndicatorInstance> => ({}),
getters: { // Helper methods
indicators: (state): Record<string, IndicatorInstance> => state, const addIndicator = (indicator: IndicatorInstance) => {
}, indicators.value[indicator.id] = indicator
}
actions: { const updateIndicator = (id: string, updates: Partial<IndicatorInstance>) => {
addIndicator(indicator: IndicatorInstance) { if (indicators.value[id]) {
this.$patch({ [indicator.id]: indicator } as Partial<Record<string, IndicatorInstance>>) const updated = {
}, ...indicators.value[id],
...updates,
updateIndicator(id: string, updates: Partial<IndicatorInstance>) { modified_at: Math.floor(Date.now() / 1000)
const existing = (this.$state as Record<string, IndicatorInstance>)[id]
if (existing) {
this.$patch({
[id]: { ...existing, ...updates, modified_at: Math.floor(Date.now() / 1000) }
} as Partial<Record<string, IndicatorInstance>>)
} }
}, indicators.value[id] = updated
}
}
removeIndicator(id: string) { const removeIndicator = (id: string) => {
this.$patch((state) => { delete indicators.value[id]
delete (state as Record<string, IndicatorInstance>)[id] }
})
},
getIndicator(id: string): IndicatorInstance | undefined { const getIndicator = (id: string): IndicatorInstance | undefined => {
return (this.$state as Record<string, IndicatorInstance>)[id] return indicators.value[id]
}, }
getAllIndicators(): IndicatorInstance[] { const getAllIndicators = (): IndicatorInstance[] => {
return Object.values(this.$state as Record<string, IndicatorInstance>) return Object.values(indicators.value)
}, }
getIndicatorsBySymbol(symbol: string): IndicatorInstance[] { const getIndicatorsBySymbol = (symbol: string): IndicatorInstance[] => {
return Object.values(this.$state as Record<string, IndicatorInstance>) return Object.values(indicators.value).filter(ind => ind.symbol === symbol)
.filter(ind => ind.symbol === symbol) }
},
}, return {
indicators,
addIndicator,
updateIndicator,
removeIndicator,
getIndicator,
getAllIndicators,
getIndicatorsBySymbol
}
}) })

View File

@@ -1,4 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface ResearchType { export interface ResearchType {
display_name: string display_name: string
@@ -7,11 +8,7 @@ export interface ResearchType {
modified_at: number modified_at: number
} }
// Options API: flat $state matches backend initial state {}. export const useResearchTypesStore = defineStore('research_types', () => {
// See indicatorTypes.ts for explanation of why composition API breaks $subscribe. const types = ref<Record<string, ResearchType>>({})
export const useResearchTypesStore = defineStore('research_types', { return { types }
state: (): Record<string, ResearchType> => ({}),
getters: {
types: (state): Record<string, ResearchType> => state,
},
}) })

View File

@@ -1,4 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface ControlPoint { export interface ControlPoint {
time: number time: number
@@ -20,38 +21,44 @@ export interface Shape {
original_id?: string // Original ID from backend/agent before TradingView assigns its own ID original_id?: string // Original ID from backend/agent before TradingView assigns its own ID
} }
// Use Options API so $state is a flat Record<string, Shape> matching the backend's shape store structure. export const useShapeStore = defineStore('shapes', () => {
// Composition API would produce $state = { shapes: {} } (extra nesting), which causes deepReplace in const shapes = ref<Record<string, Shape>>({})
// useStateSync to delete the 'shapes' key when the backend sends an empty {} snapshot, breaking $subscribe.
export const useShapeStore = defineStore('shapes', {
state: () => ({} as Record<string, Shape>),
actions: { // Helper methods
addShape(shape: Shape) { const addShape = (shape: Shape) => {
this.$patch({ [shape.id]: shape } as Partial<Record<string, Shape>>) shapes.value[shape.id] = shape
}, }
updateShape(id: string, updates: Partial<Shape>) { const updateShape = (id: string, updates: Partial<Shape>) => {
const existing = (this.$state as Record<string, Shape>)[id] if (shapes.value[id]) {
if (existing) { const updated = {
this.$patch({ ...shapes.value[id],
[id]: { ...existing, ...updates, modified_at: Math.floor(Date.now() / 1000) } ...updates,
} as Partial<Record<string, Shape>>) 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) { const removeShape = (id: string) => {
this.$patch((state) => { delete shapes.value[id]
delete (state as Record<string, Shape>)[id] }
})
},
getShape(id: string): Shape | undefined { const getShape = (id: string): Shape | undefined => {
return (this.$state as Record<string, Shape>)[id] return shapes.value[id]
}, }
getAllShapes(): Shape[] { const getAllShapes = (): Shape[] => {
return Object.values(this.$state as Record<string, Shape>) return Object.values(shapes.value)
}, }
},
return {
shapes,
addShape,
updateShape,
removeShape,
getShape,
getAllShapes
}
}) })

View File

@@ -1,4 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface StrategyType { export interface StrategyType {
display_name: string display_name: string
@@ -7,11 +8,7 @@ export interface StrategyType {
modified_at: number modified_at: number
} }
// Options API: flat $state matches backend initial state {}. export const useStrategyTypesStore = defineStore('strategy_types', () => {
// See indicatorTypes.ts for explanation of why composition API breaks $subscribe. const types = ref<Record<string, StrategyType>>({})
export const useStrategyTypesStore = defineStore('strategy_types', { return { types }
state: (): Record<string, StrategyType> => ({}),
getters: {
types: (state): Record<string, StrategyType> => state,
},
}) })