gloomberb 0.9.7 → 0.9.9

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.
Files changed (276) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.test.ts +3 -3
  4. package/src/api-client/index.ts +48 -6
  5. package/src/api-client/request.test.ts +63 -0
  6. package/src/api-client/request.ts +53 -5
  7. package/src/api-client/types.ts +45 -2
  8. package/src/app/pane-runtime/layout-placement.ts +0 -1
  9. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  10. package/src/app/runtime/desktop-deeplink.ts +496 -0
  11. package/src/app.tsx +34 -0
  12. package/src/brokers/account-cache.ts +2 -10
  13. package/src/brokers/broker-portfolio-sync.ts +0 -1
  14. package/src/brokers/broker-ticker-sync.ts +20 -16
  15. package/src/brokers/remote-broker-adapter.ts +3 -0
  16. package/src/capabilities/registry.test.ts +25 -0
  17. package/src/capabilities/registry.ts +22 -5
  18. package/src/cli/commands/automation.ts +1 -3
  19. package/src/cli/commands/command-utils.ts +0 -7
  20. package/src/cli/commands/overview.ts +2 -2
  21. package/src/cli/commands/plugins.ts +34 -17
  22. package/src/cli/index.ts +2 -2
  23. package/src/cli.test.ts +0 -157
  24. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  25. package/src/components/chart/bar-chart-renderer.ts +111 -55
  26. package/src/components/chart/comparison/renderer.ts +0 -1
  27. package/src/components/chart/comparison-stock-chart/render-output.ts +3 -8
  28. package/src/components/chart/comparison-stock-chart/view.tsx +0 -2
  29. package/src/components/chart/core/index.test.ts +0 -24
  30. package/src/components/chart/core/renderer.ts +0 -4
  31. package/src/components/chart/core/resolution.test.ts +0 -17
  32. package/src/components/chart/core/resolution.ts +0 -26
  33. package/src/components/chart/core/scroll.test.ts +0 -6
  34. package/src/components/chart/core/scroll.ts +0 -22
  35. package/src/components/chart/core/viewport.test.ts +2 -23
  36. package/src/components/chart/core/viewport.ts +0 -27
  37. package/src/components/chart/market-session.ts +1 -1
  38. package/src/components/chart/native/surface/hooks.ts +10 -3
  39. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  40. package/src/components/chart/stock-chart/data-runtime.ts +0 -1
  41. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  42. package/src/components/chart/stock-chart/footer.ts +68 -13
  43. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  44. package/src/components/chart/stock-chart/viewport/effects.ts +0 -3
  45. package/src/components/command-bar/helpers.test.ts +1 -80
  46. package/src/components/command-bar/helpers.ts +1 -36
  47. package/src/components/command-bar/pane-settings.ts +1 -1
  48. package/src/components/command-bar/pane-templates/items.ts +11 -3
  49. package/src/components/command-bar/panel/runtime.ts +0 -2
  50. package/src/components/command-bar/routes/root/results.ts +1 -1
  51. package/src/components/command-bar/routes/ticker-search/results.ts +0 -11
  52. package/src/components/command-bar/surface/index.test.tsx +52 -16
  53. package/src/components/command-bar/surface/index.tsx +0 -1
  54. package/src/components/command-bar/workflow/ops.ts +81 -20
  55. package/src/components/feed-data-table/stack-view.test.tsx +36 -130
  56. package/src/components/feed-data-table/stack-view.tsx +0 -1
  57. package/src/components/index.ts +1 -0
  58. package/src/components/layout/shell/index.test.tsx +2 -113
  59. package/src/components/layout/shell/index.tsx +0 -1
  60. package/src/components/layout/shell/menu.ts +0 -1
  61. package/src/components/layout/shell/terminal-pointer-runtime.ts +0 -1
  62. package/src/components/onboarding/onboarding-wizard.tsx +3 -3
  63. package/src/components/onboarding/wizard-keyboard.ts +2 -23
  64. package/src/components/price-selector-dialog.tsx +3 -3
  65. package/src/components/ticker/badge/text.tsx +0 -1
  66. package/src/components/toggle-list.tsx +29 -73
  67. package/src/components/ui/button.tsx +4 -1
  68. package/src/components/ui/checkbox.tsx +76 -0
  69. package/src/components/ui/choice-dialog.tsx +15 -3
  70. package/src/components/ui/frame.tsx +1 -1
  71. package/src/components/ui/index.ts +2 -0
  72. package/src/components/ui/message-composer.tsx +4 -0
  73. package/src/components/ui/native-select.tsx +4 -2
  74. package/src/components/ui/page-stack-view.tsx +1 -1
  75. package/src/components/ui/ui.test.tsx +0 -78
  76. package/src/core/state/app/layout-reducer.ts +0 -1
  77. package/src/data/config/layout.ts +3 -3
  78. package/src/data/resource-store.ts +6 -0
  79. package/src/data/sqlite/cache.test.ts +5 -0
  80. package/src/data/ticker-store.ts +0 -7
  81. package/src/market-data/coordinator/auxiliary.ts +1 -44
  82. package/src/market-data/coordinator/entries.ts +0 -1
  83. package/src/market-data/coordinator/events.test.ts +49 -0
  84. package/src/market-data/coordinator/events.ts +25 -4
  85. package/src/market-data/coordinator/index.test.ts +223 -10
  86. package/src/market-data/coordinator/index.ts +41 -21
  87. package/src/market-data/coordinator/quotes.ts +13 -11
  88. package/src/market-data/coordinator/subscriptions.test.ts +1 -0
  89. package/src/market-data/hooks.tsx +17 -13
  90. package/src/market-data/market/freshness.ts +58 -1
  91. package/src/market-data/market/status.ts +19 -1
  92. package/src/market-data/query-store.ts +0 -4
  93. package/src/market-data/quotes/freshness.test.ts +49 -1
  94. package/src/market-data/quotes/freshness.ts +8 -0
  95. package/src/market-data/quotes/resolution.test.ts +185 -0
  96. package/src/market-data/quotes/resolution.ts +96 -10
  97. package/src/market-data/request-types.test.ts +61 -1
  98. package/src/market-data/request-types.ts +42 -8
  99. package/src/market-data/result-types.ts +0 -11
  100. package/src/market-data/selectors.ts +1 -5
  101. package/src/news/aggregator.ts +5 -1
  102. package/src/pane-settings.test.ts +0 -18
  103. package/src/plugins/builtin/account-management/footer.ts +2 -5
  104. package/src/plugins/builtin/account-management/form-components.tsx +175 -59
  105. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  106. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  107. package/src/plugins/builtin/account-management/model.ts +259 -23
  108. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  109. package/src/plugins/builtin/broker-manager/index.test.tsx +0 -32
  110. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  111. package/src/plugins/builtin/buildout/detail/satellite-images.tsx +1 -1
  112. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -1
  113. package/src/plugins/builtin/buildout/format.ts +2 -11
  114. package/src/plugins/builtin/chat/chat.test.tsx +127 -1
  115. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  116. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  117. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  118. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  119. package/src/plugins/builtin/chat/content/index.tsx +153 -16
  120. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  121. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  122. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  123. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  124. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  125. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  126. package/src/plugins/builtin/chat/content/transcript.tsx +0 -5
  127. package/src/plugins/builtin/chat/controller/index.ts +0 -10
  128. package/src/plugins/builtin/chat/controller.test.ts +5 -5
  129. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  130. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  131. package/src/plugins/builtin/chat/sidebar.test.tsx +83 -6
  132. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  133. package/src/plugins/builtin/chat/test-harness.tsx +22 -12
  134. package/src/plugins/builtin/cloud/plugin.tsx +8 -1
  135. package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -1
  136. package/src/plugins/builtin/congress-trades/model.ts +2 -7
  137. package/src/plugins/builtin/congress-trades/pane.tsx +0 -1
  138. package/src/plugins/builtin/correlation/index.tsx +4 -1
  139. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  140. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  141. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  142. package/src/plugins/builtin/earnings/index.tsx +0 -9
  143. package/src/plugins/builtin/econ/index.tsx +1 -5
  144. package/src/plugins/builtin/help/index.tsx +2 -2
  145. package/src/plugins/builtin/holders/pane.tsx +0 -5
  146. package/src/plugins/builtin/insider/index.tsx +1 -8
  147. package/src/plugins/builtin/kelly-sizer/index.test.tsx +0 -26
  148. package/src/plugins/builtin/kelly-sizer/index.tsx +0 -2
  149. package/src/plugins/builtin/market-movers/index.tsx +0 -2
  150. package/src/plugins/builtin/news/index.tsx +0 -1
  151. package/src/plugins/builtin/news/wire/news/table.tsx +0 -11
  152. package/src/plugins/builtin/options/view.test.tsx +1 -38
  153. package/src/plugins/builtin/plugin-groups.ts +17 -3
  154. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -8
  155. package/src/plugins/builtin/portfolio-list/index.test.ts +52 -25
  156. package/src/plugins/builtin/portfolio-list/metrics.test.ts +44 -7
  157. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  158. package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
  159. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
  160. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
  161. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
  162. package/src/plugins/builtin/sec/filing-documents.ts +0 -11
  163. package/src/plugins/builtin/sec/index.tsx +0 -2
  164. package/src/plugins/builtin/substack/article-detail.tsx +1 -1
  165. package/src/plugins/builtin/thirteenf/api.ts +0 -15
  166. package/src/plugins/builtin/thirteenf/pane.tsx +1 -3
  167. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +42 -6
  168. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  169. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  170. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  171. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  172. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  173. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +0 -7
  174. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  175. package/src/plugins/builtin/ticker-detail/quote-monitor/index.test.tsx +5 -34
  176. package/src/plugins/builtin/world-indices/index.tsx +0 -4
  177. package/src/plugins/builtin/world-indices/model.ts +0 -7
  178. package/src/plugins/ibkr/account-cache.ts +1 -1
  179. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  180. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  181. package/src/plugins/ibkr/portfolio-performance.ts +1 -1
  182. package/src/plugins/ibkr/trade/utils.ts +1 -7
  183. package/src/plugins/loader.ts +2 -1
  184. package/src/plugins/prediction-markets/detail.test.tsx +0 -34
  185. package/src/plugins/registry/context.ts +15 -0
  186. package/src/plugins/registry/contributions.ts +22 -11
  187. package/src/plugins/registry/index.test.ts +44 -0
  188. package/src/plugins/registry/index.ts +75 -26
  189. package/src/remote/controller.ts +1 -7
  190. package/src/remote/json-patch.ts +15 -1
  191. package/src/remote/server.ts +16 -6
  192. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
  193. package/src/renderers/electrobun/bun/core-capabilities.ts +2 -0
  194. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +14 -0
  195. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +8 -0
  196. package/src/renderers/electrobun/bun/desktop/initialization.ts +2 -1
  197. package/src/renderers/electrobun/bun/index.ts +42 -0
  198. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  199. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  200. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  201. package/src/renderers/electrobun/view/data-table/dom.ts +0 -1
  202. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  203. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  204. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  205. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  206. package/src/renderers/electrobun/view/host/mouse.ts +0 -11
  207. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  208. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  209. package/src/renderers/electrobun/view/http-fetch.ts +15 -2
  210. package/src/renderers/electrobun/view/main.tsx +3 -0
  211. package/src/renderers/electrobun/view/remote/asset-data-client.ts +9 -18
  212. package/src/renderers/electrobun/view/remote/capability-invoker.test.ts +50 -0
  213. package/src/renderers/electrobun/view/remote/capability-invoker.ts +36 -0
  214. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  215. package/src/renderers/opentui/host.tsx +11 -3
  216. package/src/renderers/opentui/input-host.tsx +5 -1
  217. package/src/sources/gloomberb-cloud/index.test.ts +0 -72
  218. package/src/sources/gloomberb-cloud/index.ts +1 -30
  219. package/src/sources/gloomberb-cloud/news.ts +0 -12
  220. package/src/sources/provider-router/batches.ts +11 -3
  221. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  222. package/src/sources/provider-router/financial-routes.ts +77 -5
  223. package/src/sources/provider-router/financials.test.ts +49 -0
  224. package/src/sources/provider-router/financials.ts +40 -3
  225. package/src/sources/provider-router/index.test.ts +314 -0
  226. package/src/sources/provider-router/primary.ts +17 -3
  227. package/src/sources/provider-router/search.ts +5 -0
  228. package/src/sources/provider-router/streaming.ts +6 -1
  229. package/src/sources/provider-router/supplemental.ts +4 -3
  230. package/src/sources/sec-edgar.test.ts +42 -0
  231. package/src/sources/sec-edgar.ts +1 -2
  232. package/src/sources/yahoo-finance/history.ts +5 -1
  233. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  234. package/src/sources/yahoo-finance/mappers.ts +2 -1
  235. package/src/sources/yahoo-finance/options.ts +59 -14
  236. package/src/sources/yahoo-finance/quote-summary.ts +4 -1
  237. package/src/sources/yahoo-finance/requests.ts +2 -1
  238. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  239. package/src/sources/yahoo-finance.test.ts +40 -21
  240. package/src/sources/yahoo-finance.ts +19 -10
  241. package/src/state/app/context/index.test.tsx +0 -81
  242. package/src/state/app/context/selector.test.tsx +6 -2
  243. package/src/state/hooks/quote-streaming.test.ts +14 -1
  244. package/src/state/hooks/quote-streaming.ts +5 -4
  245. package/src/sync/controller.test.ts +31 -0
  246. package/src/sync/controller.ts +300 -0
  247. package/src/sync/core-contributors.test.ts +251 -0
  248. package/src/sync/core-contributors.ts +400 -0
  249. package/src/sync/profile-analytics.ts +39 -0
  250. package/src/sync/react.ts +53 -0
  251. package/src/sync/types.ts +77 -0
  252. package/src/test-support/data-provider.ts +0 -1
  253. package/src/types/desktop-deeplink.ts +7 -0
  254. package/src/types/plugin.ts +3 -0
  255. package/src/ui/host.tsx +15 -1
  256. package/src/ui/index.tsx +2 -0
  257. package/src/updater.test.ts +0 -21
  258. package/src/updater.ts +20 -16
  259. package/src/utils/async-deadline.ts +26 -0
  260. package/src/utils/exchanges.test.ts +11 -1
  261. package/src/utils/exchanges.ts +5 -1
  262. package/src/utils/selection-clipboard.ts +2 -2
  263. package/src/version.ts +1 -1
  264. package/src/components/layout/pane/header.test.tsx +0 -39
  265. package/src/plugins/builtin/chat/channels.test.ts +0 -28
  266. package/src/plugins/builtin/chat/index.tsx +0 -4
  267. package/src/plugins/builtin/cloud-tweets/index.ts +0 -1
  268. package/src/plugins/builtin/earnings/index.test.ts +0 -12
  269. package/src/plugins/builtin/help/index.test.tsx +0 -242
  270. package/src/plugins/builtin/substack/article-detail.test.tsx +0 -10
  271. package/src/renderers/electrobun/bun/application-menu/index.test.ts +0 -21
  272. package/src/renderers/electrobun/bun/window/focus.test.ts +0 -35
  273. package/src/renderers/electrobun/view/host/mouse.test.ts +0 -37
  274. package/src/renderers/electrobun/view/host/text.test.ts +0 -22
  275. package/src/renderers/opentui/dialog-host.test.tsx +0 -44
  276. /package/src/{plugins/ibkr → utils}/hash.ts +0 -0
@@ -0,0 +1,300 @@
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 { stableStringify } from "../remote/revision";
5
+ import {
6
+ SYNC_SNAPSHOT_SCHEMA_VERSION,
7
+ type RegisteredSyncContributor,
8
+ type RegisteredSyncTransport,
9
+ type SyncContributor,
10
+ type SyncSnapshot,
11
+ type SyncTransport,
12
+ } from "./types";
13
+
14
+ export type CloudSyncPhase = "idle" | "disabled" | "syncing" | "synced" | "error";
15
+
16
+ export interface CloudSyncStatus {
17
+ phase: CloudSyncPhase;
18
+ transportId: string | null;
19
+ lastSyncAt: string | null;
20
+ lastPullAt: string | null;
21
+ revision: number | null;
22
+ error: string | null;
23
+ }
24
+
25
+ interface SyncRuntime {
26
+ state: AppState;
27
+ dispatch: Dispatch<AppAction>;
28
+ tickerRepository: TickerRepository;
29
+ getContributors: () => RegisteredSyncContributor[];
30
+ getTransport: () => RegisteredSyncTransport | null;
31
+ }
32
+
33
+ const CLIENT_ID_STORAGE_KEY = "gloomberb.sync.clientId";
34
+ const PUSH_DEBOUNCE_MS = 2500;
35
+ const PULL_MIN_INTERVAL_MS = 60_000;
36
+
37
+ function nowIso(): string {
38
+ return new Date().toISOString();
39
+ }
40
+
41
+ function resolveClientId(): string {
42
+ const storage = globalThis.localStorage;
43
+ const existing = storage?.getItem(CLIENT_ID_STORAGE_KEY);
44
+ if (existing) return existing;
45
+ const id = `client_${crypto.randomUUID()}`;
46
+ storage?.setItem(CLIENT_ID_STORAGE_KEY, id);
47
+ return id;
48
+ }
49
+
50
+ export class CloudSyncController {
51
+ private runtime: SyncRuntime | null = null;
52
+ private contributors = new Map<string, RegisteredSyncContributor>();
53
+ private transports = new Map<string, RegisteredSyncTransport>();
54
+ private listeners = new Set<() => void>();
55
+ private pushTimer: ReturnType<typeof setTimeout> | null = null;
56
+ private inFlight: Promise<void> | null = null;
57
+ private syncQueued = false;
58
+ private clientId: string | null = null;
59
+ private lastSignature: string | null = null;
60
+ private lastPulledAtMs = 0;
61
+ private hasPulledForTransport = new Set<string>();
62
+ private status: CloudSyncStatus = {
63
+ phase: "idle",
64
+ transportId: null,
65
+ lastSyncAt: null,
66
+ lastPullAt: null,
67
+ revision: null,
68
+ error: null,
69
+ };
70
+
71
+ registerContributor(pluginId: string, contributor: SyncContributor): () => void {
72
+ this.contributors.set(contributor.id, { pluginId, contributor });
73
+ this.emit();
74
+ return () => {
75
+ const current = this.contributors.get(contributor.id);
76
+ if (current?.contributor === contributor) {
77
+ this.contributors.delete(contributor.id);
78
+ this.emit();
79
+ }
80
+ };
81
+ }
82
+
83
+ registerTransport(pluginId: string, transport: SyncTransport): () => void {
84
+ this.transports.set(transport.id, { pluginId, transport });
85
+ this.emit();
86
+ return () => {
87
+ const current = this.transports.get(transport.id);
88
+ if (current?.transport === transport) {
89
+ this.transports.delete(transport.id);
90
+ this.emit();
91
+ }
92
+ };
93
+ }
94
+
95
+ getRegisteredContributors(): RegisteredSyncContributor[] {
96
+ return [...this.contributors.values()];
97
+ }
98
+
99
+ getRegisteredTransports(): RegisteredSyncTransport[] {
100
+ return [...this.transports.values()];
101
+ }
102
+
103
+ setRuntime(runtime: SyncRuntime): void {
104
+ this.runtime = runtime;
105
+ this.updateAvailability();
106
+ }
107
+
108
+ clearRuntime(): void {
109
+ this.runtime = null;
110
+ if (this.pushTimer) clearTimeout(this.pushTimer);
111
+ this.pushTimer = null;
112
+ this.syncQueued = false;
113
+ }
114
+
115
+ subscribe(listener: () => void): () => void {
116
+ this.listeners.add(listener);
117
+ return () => this.listeners.delete(listener);
118
+ }
119
+
120
+ getStatus(): CloudSyncStatus {
121
+ return this.status;
122
+ }
123
+
124
+ schedulePush(reason: string): void {
125
+ const runtime = this.runtime;
126
+ if (!runtime) return;
127
+ const transport = runtime.getTransport();
128
+ if (!transport?.transport.isAvailable()) {
129
+ this.setStatus({
130
+ phase: "disabled",
131
+ transportId: transport?.transport.id ?? null,
132
+ error: null,
133
+ });
134
+ return;
135
+ }
136
+ if (this.pushTimer) clearTimeout(this.pushTimer);
137
+ this.pushTimer = setTimeout(() => {
138
+ this.pushTimer = null;
139
+ void this.requestSync({ reason });
140
+ }, PUSH_DEBOUNCE_MS);
141
+ }
142
+
143
+ async requestSync(options: { reason?: string; force?: boolean } = {}): Promise<void> {
144
+ if (this.inFlight) {
145
+ this.syncQueued = true;
146
+ return this.inFlight;
147
+ }
148
+ const run = this.syncOnce(options).finally(async () => {
149
+ this.inFlight = null;
150
+ if (!this.syncQueued) return;
151
+ this.syncQueued = false;
152
+ await this.requestSync({ reason: "queued-state-change" });
153
+ });
154
+ this.inFlight = run;
155
+ return run;
156
+ }
157
+
158
+ async pullLatest(options: { force?: boolean } = {}): Promise<void> {
159
+ const runtime = this.runtime;
160
+ if (!runtime) return;
161
+ const transport = runtime.getTransport();
162
+ if (!transport?.transport.isAvailable()) {
163
+ this.setStatus({ phase: "disabled", transportId: transport?.transport.id ?? null, error: null });
164
+ return;
165
+ }
166
+ const currentMs = Date.now();
167
+ if (!options.force && currentMs - this.lastPulledAtMs < PULL_MIN_INTERVAL_MS) return;
168
+ if (await this.runPull(runtime, transport.transport)) {
169
+ this.lastPulledAtMs = currentMs;
170
+ }
171
+ }
172
+
173
+ private async syncOnce(options: { reason?: string; force?: boolean }): Promise<void> {
174
+ const runtime = this.runtime;
175
+ if (!runtime) return;
176
+ const transportRegistration = runtime.getTransport();
177
+ if (!transportRegistration?.transport.isAvailable()) {
178
+ this.setStatus({ phase: "disabled", transportId: transportRegistration?.transport.id ?? null, error: null });
179
+ return;
180
+ }
181
+ const transport = transportRegistration.transport;
182
+ if (!this.hasPulledForTransport.has(transport.id)) {
183
+ if (!await this.runPull(runtime, transport)) return;
184
+ this.hasPulledForTransport.add(transport.id);
185
+ }
186
+
187
+ const snapshot = await this.assembleSnapshot(runtime);
188
+ const signature = stableStringify(snapshot.contributors);
189
+ if (!options.force && signature === this.lastSignature) return;
190
+
191
+ this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
192
+ try {
193
+ const result = await transport.pushSnapshot(snapshot, { baseRevision: this.status.revision });
194
+ this.lastSignature = signature;
195
+ this.setStatus({
196
+ phase: "synced",
197
+ transportId: transport.id,
198
+ revision: result.revision,
199
+ lastSyncAt: result.updatedAt,
200
+ error: null,
201
+ });
202
+ } catch (error) {
203
+ this.setStatus({
204
+ phase: "error",
205
+ transportId: transport.id,
206
+ error: error instanceof Error ? error.message : String(error),
207
+ });
208
+ }
209
+ }
210
+
211
+ private async runPull(runtime: SyncRuntime, transport: SyncTransport): Promise<boolean> {
212
+ this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
213
+ try {
214
+ const response = await transport.pullSnapshot();
215
+ this.setStatus({
216
+ phase: response.snapshot ? "synced" : "idle",
217
+ transportId: transport.id,
218
+ revision: response.revision,
219
+ lastPullAt: response.updatedAt ?? nowIso(),
220
+ lastSyncAt: response.updatedAt,
221
+ error: null,
222
+ });
223
+ if (!response.snapshot) return true;
224
+ for (const entry of runtime.getContributors()) {
225
+ const payload = response.snapshot.contributors[entry.contributor.id]?.payload;
226
+ if (payload === undefined || !entry.contributor.apply) continue;
227
+ await entry.contributor.apply(payload, {
228
+ snapshot: response.snapshot,
229
+ state: runtime.state,
230
+ dispatch: runtime.dispatch,
231
+ tickerRepository: runtime.tickerRepository,
232
+ });
233
+ }
234
+ return true;
235
+ } catch (error) {
236
+ this.setStatus({
237
+ phase: "error",
238
+ transportId: transport.id,
239
+ error: error instanceof Error ? error.message : String(error),
240
+ });
241
+ return false;
242
+ }
243
+ }
244
+
245
+ private async assembleSnapshot(runtime: SyncRuntime): Promise<SyncSnapshot> {
246
+ const contributors = runtime.getContributors();
247
+ const createdAt = nowIso();
248
+ const payloads: SyncSnapshot["contributors"] = {};
249
+ for (const entry of contributors) {
250
+ const payload = await entry.contributor.collect({ state: runtime.state });
251
+ payloads[entry.contributor.id] = {
252
+ schemaVersion: entry.contributor.schemaVersion,
253
+ updatedAt: createdAt,
254
+ payload,
255
+ };
256
+ }
257
+ return {
258
+ schemaVersion: SYNC_SNAPSHOT_SCHEMA_VERSION,
259
+ appId: "gloomberb",
260
+ clientId: this.clientId ??= resolveClientId(),
261
+ createdAt,
262
+ contributors: payloads,
263
+ };
264
+ }
265
+
266
+ private updateAvailability(): void {
267
+ const transport = this.runtime?.getTransport() ?? null;
268
+ if (!transport) {
269
+ this.setStatus({ phase: "disabled", transportId: null, error: null });
270
+ return;
271
+ }
272
+ if (!transport.transport.isAvailable()) {
273
+ this.setStatus({ phase: "disabled", transportId: transport.transport.id, error: null });
274
+ return;
275
+ }
276
+ if (
277
+ this.status.transportId === transport.transport.id &&
278
+ this.status.phase !== "disabled" &&
279
+ this.status.phase !== "idle"
280
+ ) {
281
+ return;
282
+ }
283
+ this.setStatus({
284
+ phase: "idle",
285
+ transportId: transport.transport.id,
286
+ error: null,
287
+ });
288
+ }
289
+
290
+ private setStatus(patch: Partial<CloudSyncStatus>): void {
291
+ this.status = { ...this.status, ...patch };
292
+ this.emit();
293
+ }
294
+
295
+ private emit(): void {
296
+ for (const listener of this.listeners) listener();
297
+ }
298
+ }
299
+
300
+ 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
+ });