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',
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<string, Shape>;
export type ShapesStore = { shapes: Record<string, Shape> };
/**
* Parameter schema entry for a custom indicator.

View File

@@ -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');
}
}

View File

@@ -4,11 +4,16 @@ import type { BackendMessage, FrontendMessage, HelloMessage, PatchMessage } from
import { wsManager } from './useWebSocket';
function deepReplace(target: Record<string, any>, source: Record<string, any>) {
// 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)) {
if (
value !== null && typeof value === 'object' && !Array.isArray(value) &&

View File

@@ -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

View File

@@ -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<string, CustomIndicatorType> 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<string, CustomIndicatorType> => ({}),
getters: {
types: (state): Record<string, CustomIndicatorType> => state,
},
export const useIndicatorTypesStore = defineStore('indicator_types', () => {
const types = ref<Record<string, CustomIndicatorType>>({})
return { types }
})

View File

@@ -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<string, IndicatorInstance> 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<string, IndicatorInstance> => ({}),
export const useIndicatorStore = defineStore('indicators', () => {
const indicators = ref<Record<string, IndicatorInstance>>({})
getters: {
indicators: (state): Record<string, IndicatorInstance> => state,
},
actions: {
addIndicator(indicator: IndicatorInstance) {
this.$patch({ [indicator.id]: indicator } as Partial<Record<string, IndicatorInstance>>)
},
updateIndicator(id: string, updates: Partial<IndicatorInstance>) {
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>>)
// Helper methods
const addIndicator = (indicator: IndicatorInstance) => {
indicators.value[indicator.id] = indicator
}
},
removeIndicator(id: string) {
this.$patch((state) => {
delete (state as Record<string, IndicatorInstance>)[id]
})
},
getIndicator(id: string): IndicatorInstance | undefined {
return (this.$state as Record<string, IndicatorInstance>)[id]
},
getAllIndicators(): IndicatorInstance[] {
return Object.values(this.$state as Record<string, IndicatorInstance>)
},
getIndicatorsBySymbol(symbol: string): IndicatorInstance[] {
return Object.values(this.$state as Record<string, IndicatorInstance>)
.filter(ind => ind.symbol === symbol)
},
},
const updateIndicator = (id: string, updates: Partial<IndicatorInstance>) => {
if (indicators.value[id]) {
const updated = {
...indicators.value[id],
...updates,
modified_at: Math.floor(Date.now() / 1000)
}
indicators.value[id] = updated
}
}
const removeIndicator = (id: string) => {
delete indicators.value[id]
}
const getIndicator = (id: string): IndicatorInstance | undefined => {
return indicators.value[id]
}
const getAllIndicators = (): IndicatorInstance[] => {
return Object.values(indicators.value)
}
const getIndicatorsBySymbol = (symbol: string): IndicatorInstance[] => {
return Object.values(indicators.value).filter(ind => ind.symbol === symbol)
}
return {
indicators,
addIndicator,
updateIndicator,
removeIndicator,
getIndicator,
getAllIndicators,
getIndicatorsBySymbol
}
})

View File

@@ -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<string, ResearchType> => ({}),
getters: {
types: (state): Record<string, ResearchType> => state,
},
export const useResearchTypesStore = defineStore('research_types', () => {
const types = ref<Record<string, ResearchType>>({})
return { types }
})

View File

@@ -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<string, Shape> 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<string, Shape>),
export const useShapeStore = defineStore('shapes', () => {
const shapes = ref<Record<string, Shape>>({})
actions: {
addShape(shape: Shape) {
this.$patch({ [shape.id]: shape } as Partial<Record<string, Shape>>)
},
updateShape(id: string, updates: Partial<Shape>) {
const existing = (this.$state as Record<string, Shape>)[id]
if (existing) {
this.$patch({
[id]: { ...existing, ...updates, modified_at: Math.floor(Date.now() / 1000) }
} as Partial<Record<string, Shape>>)
// Helper methods
const addShape = (shape: Shape) => {
shapes.value[shape.id] = shape
}
},
removeShape(id: string) {
this.$patch((state) => {
delete (state as Record<string, Shape>)[id]
})
},
getShape(id: string): Shape | undefined {
return (this.$state as Record<string, Shape>)[id]
},
getAllShapes(): Shape[] {
return Object.values(this.$state as Record<string, Shape>)
},
},
const updateShape = (id: string, updates: Partial<Shape>) => {
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
}
}
const removeShape = (id: string) => {
delete shapes.value[id]
}
const getShape = (id: string): Shape | undefined => {
return shapes.value[id]
}
const getAllShapes = (): Shape[] => {
return Object.values(shapes.value)
}
return {
shapes,
addShape,
updateShape,
removeShape,
getShape,
getAllShapes
}
})

View File

@@ -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<string, StrategyType> => ({}),
getters: {
types: (state): Record<string, StrategyType> => state,
},
export const useStrategyTypesStore = defineStore('strategy_types', () => {
const types = ref<Record<string, StrategyType>>({})
return { types }
})