gloomberb 0.9.7 → 0.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api-client/auth.ts +20 -0
- package/src/api-client/index.ts +48 -0
- package/src/api-client/types.ts +45 -0
- package/src/app/runtime/desktop-deeplink.test.ts +87 -0
- package/src/app/runtime/desktop-deeplink.ts +496 -0
- package/src/app.tsx +34 -0
- package/src/components/chart/bar-chart-renderer.test.ts +87 -12
- package/src/components/chart/bar-chart-renderer.ts +111 -55
- package/src/components/chart/static/bar-chart-surface.tsx +104 -20
- package/src/components/chart/stock-chart/footer.test.ts +65 -0
- package/src/components/chart/stock-chart/footer.ts +68 -13
- package/src/components/chart/stock-chart/resolved.tsx +18 -4
- package/src/components/command-bar/pane-templates/items.ts +11 -3
- package/src/components/command-bar/routes/root/results.ts +1 -1
- package/src/components/command-bar/surface/index.test.tsx +52 -0
- package/src/components/command-bar/workflow/ops.ts +81 -20
- package/src/components/index.ts +1 -0
- package/src/components/toggle-list.tsx +29 -73
- package/src/components/ui/button.tsx +4 -1
- package/src/components/ui/checkbox.tsx +76 -0
- package/src/components/ui/choice-dialog.tsx +15 -3
- package/src/components/ui/frame.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/message-composer.tsx +4 -0
- package/src/components/ui/native-select.tsx +4 -2
- package/src/market-data/coordinator/events.test.ts +49 -0
- package/src/market-data/coordinator/events.ts +25 -4
- package/src/market-data/coordinator/index.test.ts +223 -0
- package/src/market-data/coordinator/index.ts +40 -3
- package/src/market-data/coordinator/quotes.ts +13 -5
- package/src/market-data/hooks.tsx +17 -13
- package/src/market-data/market/freshness.ts +58 -1
- package/src/market-data/quotes/freshness.test.ts +49 -1
- package/src/market-data/quotes/freshness.ts +8 -0
- package/src/market-data/quotes/resolution.test.ts +185 -0
- package/src/market-data/quotes/resolution.ts +94 -8
- package/src/market-data/request-types.test.ts +61 -1
- package/src/market-data/request-types.ts +42 -3
- package/src/plugins/builtin/account-management/footer.ts +2 -5
- package/src/plugins/builtin/account-management/form-components.tsx +260 -24
- package/src/plugins/builtin/account-management/keyboard.ts +32 -2
- package/src/plugins/builtin/account-management/model.test.ts +99 -4
- package/src/plugins/builtin/account-management/model.ts +263 -3
- package/src/plugins/builtin/account-management/pane.tsx +784 -155
- package/src/plugins/builtin/chat/chat.test.tsx +125 -0
- package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
- package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
- package/src/plugins/builtin/chat/content/composer.tsx +84 -1
- package/src/plugins/builtin/chat/content/index.tsx +154 -16
- package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
- package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
- package/src/plugins/builtin/chat/content/mentions.ts +88 -0
- package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
- package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
- package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
- package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
- package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
- package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
- package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
- package/src/plugins/builtin/chat/sidebar.tsx +26 -2
- package/src/plugins/builtin/chat/test-harness.tsx +21 -11
- package/src/plugins/builtin/cloud/plugin.tsx +7 -0
- package/src/plugins/builtin/correlation/index.tsx +4 -1
- package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
- package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
- package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
- package/src/plugins/builtin/portfolio-list/index.test.ts +51 -1
- package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
- package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
- package/src/plugins/ibkr/gateway/market-data.ts +12 -5
- package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
- package/src/plugins/registry/context.ts +15 -0
- package/src/plugins/registry/index.ts +31 -0
- package/src/renderers/electrobun/bun/index.ts +42 -0
- package/src/renderers/electrobun/shared/protocol.ts +5 -0
- package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
- package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
- package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
- package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
- package/src/renderers/electrobun/view/host/box.tsx +0 -2
- package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
- package/src/renderers/electrobun/view/host/text.tsx +1 -2
- package/src/renderers/electrobun/view/main.tsx +3 -0
- package/src/renderers/electrobun/view/ui-host.tsx +2 -0
- package/src/sources/provider-router/batches.ts +11 -3
- package/src/sources/provider-router/cached-financials.test.ts +123 -0
- package/src/sources/provider-router/financial-routes.ts +55 -5
- package/src/sources/provider-router/financials.test.ts +49 -0
- package/src/sources/provider-router/financials.ts +40 -3
- package/src/sources/provider-router/index.test.ts +147 -0
- package/src/sources/provider-router/primary.ts +17 -3
- package/src/sources/provider-router/streaming.ts +6 -1
- package/src/sources/sec-edgar.test.ts +42 -0
- package/src/sources/sec-edgar.ts +1 -2
- package/src/sources/yahoo-finance/mappers.test.ts +29 -0
- package/src/sources/yahoo-finance/mappers.ts +2 -1
- package/src/sources/yahoo-finance/requests.ts +2 -1
- package/src/sources/yahoo-finance/snapshots.ts +13 -2
- package/src/sources/yahoo-finance.ts +6 -2
- package/src/state/hooks/quote-streaming.test.ts +14 -1
- package/src/state/hooks/quote-streaming.ts +5 -4
- package/src/sync/controller.ts +296 -0
- package/src/sync/core-contributors.test.ts +251 -0
- package/src/sync/core-contributors.ts +400 -0
- package/src/sync/profile-analytics.ts +39 -0
- package/src/sync/react.ts +53 -0
- package/src/sync/types.ts +77 -0
- package/src/types/desktop-deeplink.ts +7 -0
- package/src/types/plugin.ts +3 -0
- package/src/ui/host.tsx +14 -1
- package/src/ui/index.tsx +1 -0
- package/src/utils/exchanges.test.ts +11 -1
- package/src/utils/exchanges.ts +5 -1
- package/src/version.ts +1 -1
|
@@ -2,18 +2,18 @@ import { useEffect } from "react";
|
|
|
2
2
|
import { useAppActive } from "../app/activity";
|
|
3
3
|
import type { QuoteSubscriptionTarget } from "../../types/data-provider";
|
|
4
4
|
import { debugLog } from "../../utils/debug-log";
|
|
5
|
-
import {
|
|
5
|
+
import { normalizeSymbol } from "../../utils/exchanges";
|
|
6
6
|
import { getSharedMarketDataCoordinator } from "../../market-data/coordinator";
|
|
7
7
|
|
|
8
8
|
const quoteStreamLog = debugLog.createLogger("quote-stream");
|
|
9
9
|
|
|
10
|
-
function
|
|
10
|
+
export function normalizeQuoteStreamSubscriptionTarget(target: QuoteSubscriptionTarget): QuoteSubscriptionTarget | null {
|
|
11
11
|
const symbol = normalizeSymbol(target.symbol);
|
|
12
12
|
if (!symbol) return null;
|
|
13
13
|
return {
|
|
14
14
|
...target,
|
|
15
15
|
symbol,
|
|
16
|
-
exchange:
|
|
16
|
+
exchange: target.exchange?.trim().toUpperCase() ?? "",
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -43,7 +43,7 @@ export function useQuoteStreaming(targets: QuoteSubscriptionTarget[]): void {
|
|
|
43
43
|
|
|
44
44
|
const normalizedEntries = new Map<string, QuoteSubscriptionTarget>();
|
|
45
45
|
for (const target of targets) {
|
|
46
|
-
const normalized =
|
|
46
|
+
const normalized = normalizeQuoteStreamSubscriptionTarget(target);
|
|
47
47
|
if (!normalized) continue;
|
|
48
48
|
const key = buildQuoteStreamSubscriptionKey(normalized);
|
|
49
49
|
normalizedEntries.set(key, normalized);
|
|
@@ -74,6 +74,7 @@ export function useQuoteStreaming(targets: QuoteSubscriptionTarget[]): void {
|
|
|
74
74
|
instrument: target.context?.instrument ?? null,
|
|
75
75
|
},
|
|
76
76
|
priority: {
|
|
77
|
+
route: target.route,
|
|
77
78
|
surface: target.surface,
|
|
78
79
|
visible: target.visible,
|
|
79
80
|
selected: target.selected,
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import type { Dispatch } from "react";
|
|
2
|
+
import type { AppAction, AppState } from "../core/state/app/state";
|
|
3
|
+
import type { TickerRepository } from "../data/ticker-repository";
|
|
4
|
+
import {
|
|
5
|
+
SYNC_SNAPSHOT_SCHEMA_VERSION,
|
|
6
|
+
type RegisteredSyncContributor,
|
|
7
|
+
type RegisteredSyncTransport,
|
|
8
|
+
type SyncContributor,
|
|
9
|
+
type SyncSnapshot,
|
|
10
|
+
type SyncTransport,
|
|
11
|
+
} from "./types";
|
|
12
|
+
|
|
13
|
+
export type CloudSyncPhase = "idle" | "disabled" | "syncing" | "synced" | "error";
|
|
14
|
+
|
|
15
|
+
export interface CloudSyncStatus {
|
|
16
|
+
phase: CloudSyncPhase;
|
|
17
|
+
transportId: string | null;
|
|
18
|
+
lastSyncAt: string | null;
|
|
19
|
+
lastPullAt: string | null;
|
|
20
|
+
revision: number | null;
|
|
21
|
+
error: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SyncRuntime {
|
|
25
|
+
state: AppState;
|
|
26
|
+
dispatch: Dispatch<AppAction>;
|
|
27
|
+
tickerRepository: TickerRepository;
|
|
28
|
+
getContributors: () => RegisteredSyncContributor[];
|
|
29
|
+
getTransport: () => RegisteredSyncTransport | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CLIENT_ID_STORAGE_KEY = "gloomberb.sync.clientId";
|
|
33
|
+
const PUSH_DEBOUNCE_MS = 2500;
|
|
34
|
+
const PULL_MIN_INTERVAL_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
function nowIso(): string {
|
|
37
|
+
return new Date().toISOString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stableStringify(value: unknown): string {
|
|
41
|
+
if (value == null || typeof value !== "object") return JSON.stringify(value);
|
|
42
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
43
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
44
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
45
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`);
|
|
46
|
+
return `{${entries.join(",")}}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveClientId(): string {
|
|
50
|
+
const storage = globalThis.localStorage;
|
|
51
|
+
const existing = storage?.getItem(CLIENT_ID_STORAGE_KEY);
|
|
52
|
+
if (existing) return existing;
|
|
53
|
+
const id = `client_${crypto.randomUUID()}`;
|
|
54
|
+
storage?.setItem(CLIENT_ID_STORAGE_KEY, id);
|
|
55
|
+
return id;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class CloudSyncController {
|
|
59
|
+
private runtime: SyncRuntime | null = null;
|
|
60
|
+
private contributors = new Map<string, RegisteredSyncContributor>();
|
|
61
|
+
private transports = new Map<string, RegisteredSyncTransport>();
|
|
62
|
+
private listeners = new Set<() => void>();
|
|
63
|
+
private pushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
64
|
+
private inFlight: Promise<void> | null = null;
|
|
65
|
+
private clientId: string | null = null;
|
|
66
|
+
private lastSignature: string | null = null;
|
|
67
|
+
private lastPulledAtMs = 0;
|
|
68
|
+
private hasPulledForTransport = new Set<string>();
|
|
69
|
+
private status: CloudSyncStatus = {
|
|
70
|
+
phase: "idle",
|
|
71
|
+
transportId: null,
|
|
72
|
+
lastSyncAt: null,
|
|
73
|
+
lastPullAt: null,
|
|
74
|
+
revision: null,
|
|
75
|
+
error: null,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
registerContributor(pluginId: string, contributor: SyncContributor): () => void {
|
|
79
|
+
this.contributors.set(contributor.id, { pluginId, contributor });
|
|
80
|
+
this.emit();
|
|
81
|
+
return () => {
|
|
82
|
+
const current = this.contributors.get(contributor.id);
|
|
83
|
+
if (current?.contributor === contributor) {
|
|
84
|
+
this.contributors.delete(contributor.id);
|
|
85
|
+
this.emit();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
registerTransport(pluginId: string, transport: SyncTransport): () => void {
|
|
91
|
+
this.transports.set(transport.id, { pluginId, transport });
|
|
92
|
+
this.emit();
|
|
93
|
+
return () => {
|
|
94
|
+
const current = this.transports.get(transport.id);
|
|
95
|
+
if (current?.transport === transport) {
|
|
96
|
+
this.transports.delete(transport.id);
|
|
97
|
+
this.emit();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
getRegisteredContributors(): RegisteredSyncContributor[] {
|
|
103
|
+
return [...this.contributors.values()];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
getRegisteredTransports(): RegisteredSyncTransport[] {
|
|
107
|
+
return [...this.transports.values()];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
setRuntime(runtime: SyncRuntime): void {
|
|
111
|
+
this.runtime = runtime;
|
|
112
|
+
this.updateAvailability();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
clearRuntime(): void {
|
|
116
|
+
this.runtime = null;
|
|
117
|
+
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
118
|
+
this.pushTimer = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
subscribe(listener: () => void): () => void {
|
|
122
|
+
this.listeners.add(listener);
|
|
123
|
+
return () => this.listeners.delete(listener);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getStatus(): CloudSyncStatus {
|
|
127
|
+
return this.status;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
schedulePush(reason: string): void {
|
|
131
|
+
const runtime = this.runtime;
|
|
132
|
+
if (!runtime) return;
|
|
133
|
+
const transport = runtime.getTransport();
|
|
134
|
+
if (!transport?.transport.isAvailable()) {
|
|
135
|
+
this.setStatus({
|
|
136
|
+
phase: "disabled",
|
|
137
|
+
transportId: transport?.transport.id ?? null,
|
|
138
|
+
error: null,
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
143
|
+
this.pushTimer = setTimeout(() => {
|
|
144
|
+
void this.requestSync({ reason });
|
|
145
|
+
}, PUSH_DEBOUNCE_MS);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async requestSync(options: { reason?: string; force?: boolean } = {}): Promise<void> {
|
|
149
|
+
if (this.inFlight) return this.inFlight;
|
|
150
|
+
const run = this.syncOnce(options).finally(() => {
|
|
151
|
+
this.inFlight = null;
|
|
152
|
+
});
|
|
153
|
+
this.inFlight = run;
|
|
154
|
+
return run;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async pullLatest(options: { force?: boolean } = {}): Promise<void> {
|
|
158
|
+
const runtime = this.runtime;
|
|
159
|
+
if (!runtime) return;
|
|
160
|
+
const transport = runtime.getTransport();
|
|
161
|
+
if (!transport?.transport.isAvailable()) {
|
|
162
|
+
this.setStatus({ phase: "disabled", transportId: transport?.transport.id ?? null, error: null });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const currentMs = Date.now();
|
|
166
|
+
if (!options.force && currentMs - this.lastPulledAtMs < PULL_MIN_INTERVAL_MS) return;
|
|
167
|
+
this.lastPulledAtMs = currentMs;
|
|
168
|
+
await this.runPull(runtime, transport.transport);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async syncOnce(options: { reason?: string; force?: boolean }): Promise<void> {
|
|
172
|
+
const runtime = this.runtime;
|
|
173
|
+
if (!runtime) return;
|
|
174
|
+
const transportRegistration = runtime.getTransport();
|
|
175
|
+
if (!transportRegistration?.transport.isAvailable()) {
|
|
176
|
+
this.setStatus({ phase: "disabled", transportId: transportRegistration?.transport.id ?? null, error: null });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const transport = transportRegistration.transport;
|
|
180
|
+
if (!this.hasPulledForTransport.has(transport.id)) {
|
|
181
|
+
await this.runPull(runtime, transport);
|
|
182
|
+
this.hasPulledForTransport.add(transport.id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const snapshot = await this.assembleSnapshot(runtime);
|
|
186
|
+
const signature = stableStringify(snapshot.contributors);
|
|
187
|
+
if (!options.force && signature === this.lastSignature) return;
|
|
188
|
+
|
|
189
|
+
this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
|
|
190
|
+
try {
|
|
191
|
+
const result = await transport.pushSnapshot(snapshot, { baseRevision: this.status.revision });
|
|
192
|
+
this.lastSignature = signature;
|
|
193
|
+
this.setStatus({
|
|
194
|
+
phase: "synced",
|
|
195
|
+
transportId: transport.id,
|
|
196
|
+
revision: result.revision,
|
|
197
|
+
lastSyncAt: result.updatedAt,
|
|
198
|
+
error: null,
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
this.setStatus({
|
|
202
|
+
phase: "error",
|
|
203
|
+
transportId: transport.id,
|
|
204
|
+
error: error instanceof Error ? error.message : String(error),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async runPull(runtime: SyncRuntime, transport: SyncTransport): Promise<void> {
|
|
210
|
+
this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
|
|
211
|
+
try {
|
|
212
|
+
const response = await transport.pullSnapshot();
|
|
213
|
+
this.setStatus({
|
|
214
|
+
phase: response.snapshot ? "synced" : "idle",
|
|
215
|
+
transportId: transport.id,
|
|
216
|
+
revision: response.revision,
|
|
217
|
+
lastPullAt: response.updatedAt ?? nowIso(),
|
|
218
|
+
lastSyncAt: response.updatedAt,
|
|
219
|
+
error: null,
|
|
220
|
+
});
|
|
221
|
+
if (!response.snapshot) return;
|
|
222
|
+
for (const entry of runtime.getContributors()) {
|
|
223
|
+
const payload = response.snapshot.contributors[entry.contributor.id]?.payload;
|
|
224
|
+
if (payload === undefined || !entry.contributor.apply) continue;
|
|
225
|
+
await entry.contributor.apply(payload, {
|
|
226
|
+
snapshot: response.snapshot,
|
|
227
|
+
state: runtime.state,
|
|
228
|
+
dispatch: runtime.dispatch,
|
|
229
|
+
tickerRepository: runtime.tickerRepository,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
this.setStatus({
|
|
234
|
+
phase: "error",
|
|
235
|
+
transportId: transport.id,
|
|
236
|
+
error: error instanceof Error ? error.message : String(error),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async assembleSnapshot(runtime: SyncRuntime): Promise<SyncSnapshot> {
|
|
242
|
+
const contributors = runtime.getContributors();
|
|
243
|
+
const createdAt = nowIso();
|
|
244
|
+
const payloads: SyncSnapshot["contributors"] = {};
|
|
245
|
+
for (const entry of contributors) {
|
|
246
|
+
const payload = await entry.contributor.collect({ state: runtime.state });
|
|
247
|
+
payloads[entry.contributor.id] = {
|
|
248
|
+
schemaVersion: entry.contributor.schemaVersion,
|
|
249
|
+
updatedAt: createdAt,
|
|
250
|
+
payload,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
schemaVersion: SYNC_SNAPSHOT_SCHEMA_VERSION,
|
|
255
|
+
appId: "gloomberb",
|
|
256
|
+
clientId: this.clientId ??= resolveClientId(),
|
|
257
|
+
createdAt,
|
|
258
|
+
contributors: payloads,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private updateAvailability(): void {
|
|
263
|
+
const transport = this.runtime?.getTransport() ?? null;
|
|
264
|
+
if (!transport) {
|
|
265
|
+
this.setStatus({ phase: "disabled", transportId: null, error: null });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (!transport.transport.isAvailable()) {
|
|
269
|
+
this.setStatus({ phase: "disabled", transportId: transport.transport.id, error: null });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
this.status.transportId === transport.transport.id &&
|
|
274
|
+
this.status.phase !== "disabled" &&
|
|
275
|
+
this.status.phase !== "idle"
|
|
276
|
+
) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
this.setStatus({
|
|
280
|
+
phase: "idle",
|
|
281
|
+
transportId: transport.transport.id,
|
|
282
|
+
error: null,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private setStatus(patch: Partial<CloudSyncStatus>): void {
|
|
287
|
+
this.status = { ...this.status, ...patch };
|
|
288
|
+
this.emit();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private emit(): void {
|
|
292
|
+
for (const listener of this.listeners) listener();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export const cloudSyncController = new CloudSyncController();
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createInitialState } from "../core/state/app/state";
|
|
3
|
+
import { createDefaultConfig } from "../types/config";
|
|
4
|
+
import type { PricePoint } from "../types/financials";
|
|
5
|
+
import type { TickerRecord } from "../types/ticker";
|
|
6
|
+
import {
|
|
7
|
+
__syncContributorInternalsForTests,
|
|
8
|
+
coreCollectionsSyncContributor,
|
|
9
|
+
coreConfigSyncContributor,
|
|
10
|
+
} from "./core-contributors";
|
|
11
|
+
import { setSyncedProfileAnalytics } from "./profile-analytics";
|
|
12
|
+
|
|
13
|
+
describe("core sync contributors", () => {
|
|
14
|
+
function priceHistoryFromReturns(returns: number[]): PricePoint[] {
|
|
15
|
+
let close = 100;
|
|
16
|
+
return [
|
|
17
|
+
{ date: new Date("2026-06-01T20:00:00.000Z"), close },
|
|
18
|
+
...returns.map((value, index) => {
|
|
19
|
+
close *= 1 + value;
|
|
20
|
+
return {
|
|
21
|
+
date: new Date(Date.UTC(2026, 5, index + 2, 20)),
|
|
22
|
+
close,
|
|
23
|
+
};
|
|
24
|
+
}),
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("redacts local paths and credential-like config keys", async () => {
|
|
29
|
+
const config = createDefaultConfig("/Users/vince/private-data");
|
|
30
|
+
config.brokerInstances = [{
|
|
31
|
+
id: "broker-1",
|
|
32
|
+
brokerType: "demo",
|
|
33
|
+
label: "Demo Broker",
|
|
34
|
+
config: {
|
|
35
|
+
apiKey: "secret-api-key",
|
|
36
|
+
password: "secret-password",
|
|
37
|
+
},
|
|
38
|
+
}];
|
|
39
|
+
config.pluginConfig = {
|
|
40
|
+
"demo-plugin": {
|
|
41
|
+
theme: "dark",
|
|
42
|
+
token: "secret-token",
|
|
43
|
+
downloadPath: "/Users/vince/private-downloads",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const state = createInitialState(config);
|
|
48
|
+
const payload = await coreConfigSyncContributor.collect({ state });
|
|
49
|
+
const serialized = JSON.stringify(payload);
|
|
50
|
+
|
|
51
|
+
expect(serialized).not.toContain("/Users/vince/private-data");
|
|
52
|
+
expect(serialized).not.toContain("/Users/vince/private-downloads");
|
|
53
|
+
expect(serialized).not.toContain("secret-api-key");
|
|
54
|
+
expect(serialized).not.toContain("secret-password");
|
|
55
|
+
expect(serialized).not.toContain("secret-token");
|
|
56
|
+
expect(serialized).toContain("Demo Broker");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("syncs collection memberships and sanitized positions", async () => {
|
|
60
|
+
const config = createDefaultConfig("/tmp/gloomberb-sync-test");
|
|
61
|
+
config.portfolios = [{
|
|
62
|
+
id: "main",
|
|
63
|
+
name: "Main",
|
|
64
|
+
currency: "USD",
|
|
65
|
+
brokerAccountId: "account-id",
|
|
66
|
+
brokerInstanceId: "broker-id",
|
|
67
|
+
}];
|
|
68
|
+
config.watchlists = [{ id: "ai", name: "AI" }];
|
|
69
|
+
const ticker: TickerRecord = {
|
|
70
|
+
metadata: {
|
|
71
|
+
ticker: "NVDA",
|
|
72
|
+
exchange: "NASDAQ",
|
|
73
|
+
currency: "USD",
|
|
74
|
+
name: "NVIDIA",
|
|
75
|
+
portfolios: ["main"],
|
|
76
|
+
watchlists: ["ai"],
|
|
77
|
+
positions: [{
|
|
78
|
+
portfolio: "main",
|
|
79
|
+
shares: 10,
|
|
80
|
+
avgCost: 100,
|
|
81
|
+
broker: "manual",
|
|
82
|
+
marketValue: 1500,
|
|
83
|
+
brokerAccountId: "account-id",
|
|
84
|
+
brokerInstanceId: "broker-id",
|
|
85
|
+
brokerContractId: 42,
|
|
86
|
+
}],
|
|
87
|
+
custom: { secretToken: "hidden", note: "keep" },
|
|
88
|
+
tags: ["semis"],
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
const state = createInitialState(config);
|
|
92
|
+
state.tickers = new Map([["NVDA", ticker]]);
|
|
93
|
+
state.financials = new Map([[
|
|
94
|
+
"NVDA",
|
|
95
|
+
{
|
|
96
|
+
quote: {
|
|
97
|
+
symbol: "NVDA",
|
|
98
|
+
price: 150,
|
|
99
|
+
currency: "USD",
|
|
100
|
+
change: 1,
|
|
101
|
+
changePercent: 2,
|
|
102
|
+
lastUpdated: 1,
|
|
103
|
+
},
|
|
104
|
+
fundamentals: { return1Y: 0.42 },
|
|
105
|
+
annualStatements: [],
|
|
106
|
+
quarterlyStatements: [],
|
|
107
|
+
priceHistory: [
|
|
108
|
+
{ date: new Date("2026-06-23T20:00:00.000Z"), close: 125 },
|
|
109
|
+
{ date: new Date("2026-06-30T20:00:00.000Z"), close: 149 },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
]]);
|
|
113
|
+
|
|
114
|
+
const payload = await coreCollectionsSyncContributor.collect({ state });
|
|
115
|
+
const serialized = JSON.stringify(payload);
|
|
116
|
+
|
|
117
|
+
expect(serialized).toContain("NVDA");
|
|
118
|
+
expect(serialized).toContain("AI");
|
|
119
|
+
expect(serialized).toContain("Main");
|
|
120
|
+
expect(serialized).toContain("keep");
|
|
121
|
+
expect(serialized).not.toContain("account-id");
|
|
122
|
+
expect(serialized).not.toContain("broker-id");
|
|
123
|
+
expect(serialized).not.toContain("hidden");
|
|
124
|
+
expect(serialized).not.toContain("brokerContractId");
|
|
125
|
+
expect((payload as any).baseCurrency).toBe("USD");
|
|
126
|
+
expect((payload as any).exchangeRates).toEqual({ USD: 1 });
|
|
127
|
+
expect((payload as any).tickers[0].quote.price).toBe(150);
|
|
128
|
+
expect((payload as any).tickers[0].quote.weekReferencePrice).toBe(125);
|
|
129
|
+
expect((payload as any).tickers[0].quote.weekChangePercent).toBe(20);
|
|
130
|
+
expect((payload as any).analyticsByPortfolio.main.oneYearReturn).toBe(0.42);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("syncs only public return and beta analytics", async () => {
|
|
134
|
+
const config = createDefaultConfig("/tmp/gloomberb-sync-test");
|
|
135
|
+
config.baseCurrency = "USD";
|
|
136
|
+
config.portfolios = [
|
|
137
|
+
{ id: "main", name: "Main", currency: "USD" },
|
|
138
|
+
{
|
|
139
|
+
id: "broker:ibkr:U123",
|
|
140
|
+
name: "U123",
|
|
141
|
+
currency: "USD",
|
|
142
|
+
brokerId: "ibkr",
|
|
143
|
+
brokerInstanceId: "ibkr",
|
|
144
|
+
brokerAccountId: "U123",
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
config.brokerInstances = [{
|
|
148
|
+
id: "ibkr",
|
|
149
|
+
brokerType: "ibkr",
|
|
150
|
+
label: "IBKR",
|
|
151
|
+
config: {},
|
|
152
|
+
enabled: true,
|
|
153
|
+
}];
|
|
154
|
+
const ticker = (symbol: string, portfolio: string): TickerRecord => ({
|
|
155
|
+
metadata: {
|
|
156
|
+
ticker: symbol,
|
|
157
|
+
exchange: "TSE",
|
|
158
|
+
currency: "JPY",
|
|
159
|
+
name: symbol,
|
|
160
|
+
portfolios: [portfolio],
|
|
161
|
+
watchlists: [],
|
|
162
|
+
positions: [{
|
|
163
|
+
portfolio,
|
|
164
|
+
shares: 10,
|
|
165
|
+
avgCost: 900,
|
|
166
|
+
broker: "manual",
|
|
167
|
+
currency: "JPY",
|
|
168
|
+
}],
|
|
169
|
+
custom: {},
|
|
170
|
+
tags: [],
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
const state = createInitialState(config);
|
|
174
|
+
state.exchangeRates = new Map([["USD", 1], ["JPY", 0.0067]]);
|
|
175
|
+
state.tickers = new Map([
|
|
176
|
+
["7203.T", ticker("7203.T", "main")],
|
|
177
|
+
["6758.T", ticker("6758.T", "broker:ibkr:U123")],
|
|
178
|
+
]);
|
|
179
|
+
state.financials = new Map([
|
|
180
|
+
["7203.T", {
|
|
181
|
+
quote: { symbol: "7203.T", price: 1000, currency: "JPY", change: 0, changePercent: 0, lastUpdated: 1 },
|
|
182
|
+
fundamentals: { return1Y: 0.1 },
|
|
183
|
+
priceHistory: priceHistoryFromReturns([0.015, -0.0045, 0.018, 0.009, -0.006, 0.012, 0.0045, -0.003, 0.0105, 0.006, -0.0015]),
|
|
184
|
+
annualStatements: [],
|
|
185
|
+
quarterlyStatements: [],
|
|
186
|
+
}],
|
|
187
|
+
["6758.T", {
|
|
188
|
+
quote: { symbol: "6758.T", price: 1000, currency: "JPY", change: 0, changePercent: 0, lastUpdated: 1 },
|
|
189
|
+
fundamentals: { return1Y: 0.2 },
|
|
190
|
+
priceHistory: priceHistoryFromReturns([0.03, -0.009, 0.036, 0.018, -0.012, 0.024, 0.009, -0.006, 0.021, 0.012, -0.003]),
|
|
191
|
+
annualStatements: [],
|
|
192
|
+
quarterlyStatements: [],
|
|
193
|
+
}],
|
|
194
|
+
["SPY", {
|
|
195
|
+
quote: { symbol: "SPY", price: 100, currency: "USD", change: 0, changePercent: 0, lastUpdated: 1 },
|
|
196
|
+
priceHistory: priceHistoryFromReturns([0.01, -0.003, 0.012, 0.006, -0.004, 0.008, 0.003, -0.002, 0.007, 0.004, -0.001]),
|
|
197
|
+
annualStatements: [],
|
|
198
|
+
quarterlyStatements: [],
|
|
199
|
+
}],
|
|
200
|
+
]);
|
|
201
|
+
state.brokerAccounts = {
|
|
202
|
+
ibkr: [{
|
|
203
|
+
accountId: "U123",
|
|
204
|
+
name: "U123",
|
|
205
|
+
currency: "USD",
|
|
206
|
+
source: "flex",
|
|
207
|
+
grossPositionValue: 1234,
|
|
208
|
+
}],
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const payload = await coreCollectionsSyncContributor.collect({ state }) as any;
|
|
212
|
+
|
|
213
|
+
expect(payload.baseCurrency).toBe("USD");
|
|
214
|
+
expect(payload.exchangeRates).toEqual({ USD: 1, JPY: 0.0067 });
|
|
215
|
+
expect(payload.analyticsByPortfolio.main.oneYearReturn).toBe(0.1);
|
|
216
|
+
expect(payload.analyticsByPortfolio.main.spyBeta).toBeCloseTo(1.5, 5);
|
|
217
|
+
expect(payload.analyticsByPortfolio["broker:ibkr:U123"].oneYearReturn).toBe(0.2);
|
|
218
|
+
expect(payload.analyticsByPortfolio["broker:ibkr:U123"].spyBeta).toBeCloseTo(3, 5);
|
|
219
|
+
expect(payload.analyticsByPortfolio.main).not.toHaveProperty("marketValue");
|
|
220
|
+
expect(payload.analyticsByPortfolio.main).not.toHaveProperty("holdingsCount");
|
|
221
|
+
expect(payload.analyticsByPortfolio.main).not.toHaveProperty("currency");
|
|
222
|
+
expect(payload.analyticsByPortfolio.main).not.toHaveProperty("sourceLabel");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("redaction removes nested credential-shaped fields", () => {
|
|
226
|
+
const sanitized = __syncContributorInternalsForTests.sanitizeUnknown({
|
|
227
|
+
nested: {
|
|
228
|
+
refreshToken: "nope",
|
|
229
|
+
publicValue: "ok",
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(sanitized).toEqual({ nested: { publicValue: "ok" } });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("uses preview-computed profile analytics without exposing values", async () => {
|
|
237
|
+
const config = createDefaultConfig("/tmp/gloomberb-sync-test");
|
|
238
|
+
config.portfolios = [{ id: "preview", name: "Preview", currency: "USD" }];
|
|
239
|
+
const state = createInitialState(config);
|
|
240
|
+
|
|
241
|
+
setSyncedProfileAnalytics("preview", { oneYearReturn: 0.25, spyBeta: 1.4 });
|
|
242
|
+
const payload = await coreCollectionsSyncContributor.collect({ state }) as any;
|
|
243
|
+
setSyncedProfileAnalytics("preview", null);
|
|
244
|
+
|
|
245
|
+
expect(payload.analyticsByPortfolio.preview).toEqual({
|
|
246
|
+
oneYearReturn: 0.25,
|
|
247
|
+
spyBeta: 1.4,
|
|
248
|
+
});
|
|
249
|
+
expect(payload.analyticsByPortfolio.preview).not.toHaveProperty("marketValue");
|
|
250
|
+
});
|
|
251
|
+
});
|