gloomberb 0.17.2 → 0.17.3

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 (159) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/futures-curve.ts +16 -0
  3. package/src/api-client/index.ts +18 -8
  4. package/src/api-client/types.ts +3 -1
  5. package/src/cli/commands/market.ts +60 -9
  6. package/src/cli/commands/search.ts +43 -2
  7. package/src/cli/commands/ticker.ts +100 -21
  8. package/src/cli/desktop-pane-shot.ts +34 -9
  9. package/src/cli/fx-pair.ts +17 -0
  10. package/src/cli/glossary.ts +134 -6
  11. package/src/cli/history-rows.ts +16 -4
  12. package/src/cli/index.ts +44 -12
  13. package/src/cli/known-gaps.ts +21 -2
  14. package/src/cli/listing-arg.ts +19 -4
  15. package/src/cli/listing-suggestion.ts +33 -2
  16. package/src/cli/pane-functions/dom.ts +39 -6
  17. package/src/cli/pane-functions/freshness.ts +48 -5
  18. package/src/cli/pane-functions/function-help.ts +1 -1
  19. package/src/cli/pane-functions/headless.ts +85 -19
  20. package/src/cli/pane-functions/index.ts +60 -4
  21. package/src/cli/pane-functions/market-session.ts +69 -6
  22. package/src/cli/pane-functions/resolver.ts +4 -2
  23. package/src/cli/pane-functions/screenshot.ts +14 -1
  24. package/src/cli/scroll-truncation.ts +5 -1
  25. package/src/cli/symbol-args.ts +41 -3
  26. package/src/cli/symbol-list.ts +19 -7
  27. package/src/cli/unknown-command.ts +70 -0
  28. package/src/components/chart/native/surface/visibility.ts +37 -7
  29. package/src/components/layout/header.tsx +348 -102
  30. package/src/components/markdown-text.tsx +40 -66
  31. package/src/components/market-board/index.tsx +12 -6
  32. package/src/components/reader/detail-reader.tsx +28 -6
  33. package/src/components/ticker/badge/index.tsx +2 -0
  34. package/src/components/ui/display.tsx +6 -1
  35. package/src/components/ui/external-link.tsx +10 -2
  36. package/src/data/memory-resource-store.ts +1 -1
  37. package/src/data/resource-store.ts +13 -1
  38. package/src/docs/snapshot.json +13 -13
  39. package/src/i18n/es.ts +1 -0
  40. package/src/i18n/ja.ts +1 -0
  41. package/src/i18n/ko.ts +1 -0
  42. package/src/i18n/zh-cn.ts +1 -0
  43. package/src/i18n/zh-tw.ts +1 -0
  44. package/src/market-data/futures-contract.ts +10 -0
  45. package/src/market-data/market/format.ts +7 -2
  46. package/src/market-data/market/freshness.ts +25 -1
  47. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  48. package/src/market-data/quotes/resolution.ts +2 -0
  49. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  50. package/src/plugins/builtin/attention/headless.ts +19 -10
  51. package/src/plugins/builtin/attention/model.ts +22 -0
  52. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  53. package/src/plugins/builtin/changelog/index.tsx +11 -8
  54. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  55. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  56. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  57. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  58. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  59. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  60. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  61. package/src/plugins/builtin/chat/layout.ts +91 -31
  62. package/src/plugins/builtin/chat/markdown.ts +94 -0
  63. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  64. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  65. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  66. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  67. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  68. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  69. package/src/plugins/builtin/company-attention/model.ts +53 -2
  70. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  71. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  72. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  73. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  74. package/src/plugins/builtin/cot/client.ts +12 -4
  75. package/src/plugins/builtin/cot/pane.tsx +2 -2
  76. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  77. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  78. package/src/plugins/builtin/debug/index.tsx +15 -15
  79. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  80. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  81. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  82. package/src/plugins/builtin/docs/index.tsx +5 -3
  83. package/src/plugins/builtin/docs/pane.tsx +18 -1
  84. package/src/plugins/builtin/docs/targets.ts +5 -3
  85. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  86. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  87. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  88. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  89. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  90. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  91. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  92. package/src/plugins/builtin/futures/index.tsx +32 -4
  93. package/src/plugins/builtin/futures/model.ts +65 -3
  94. package/src/plugins/builtin/futures/table.tsx +31 -11
  95. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  96. package/src/plugins/builtin/holders/pane.tsx +6 -1
  97. package/src/plugins/builtin/mna/client.ts +12 -2
  98. package/src/plugins/builtin/mna/detail.tsx +2 -2
  99. package/src/plugins/builtin/mna/pane.tsx +2 -2
  100. package/src/plugins/builtin/notes/owner.tsx +5 -4
  101. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  102. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  103. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  104. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  105. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  106. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  107. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -9
  108. package/src/plugins/builtin/research/event-model.ts +14 -0
  109. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  110. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  111. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  112. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  113. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  114. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  115. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  116. package/src/plugins/builtin/research-search/data.ts +11 -0
  117. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  118. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  119. package/src/plugins/builtin/seasonality/client.ts +23 -0
  120. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  121. package/src/plugins/builtin/sectors/index.tsx +7 -3
  122. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  123. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  124. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  125. package/src/plugins/builtin/short-interest/model.ts +62 -3
  126. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  127. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  128. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  129. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  130. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  131. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  132. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  133. package/src/plugins/builtin/themes/headless.ts +1 -1
  134. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  135. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  136. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  137. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  138. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  139. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  140. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  141. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  142. package/src/renderers/dom/styles.css +6 -0
  143. package/src/sources/gloomberb-cloud/normalizers.ts +1 -0
  144. package/src/sources/provider-router/batches.ts +2 -1
  145. package/src/sources/provider-router/cache.ts +3 -0
  146. package/src/sync/baseline.ts +19 -14
  147. package/src/sync/controller.ts +140 -40
  148. package/src/sync/core-contributors.ts +256 -32
  149. package/src/sync/types.ts +44 -0
  150. package/src/time-series/resolve.ts +19 -2
  151. package/src/types/data-provider.ts +8 -0
  152. package/src/types/financials.ts +40 -4
  153. package/src/types/headless.ts +43 -0
  154. package/src/types/plugin.ts +7 -0
  155. package/src/utils/declared-dividends.ts +39 -0
  156. package/src/utils/forward-dividend.ts +34 -11
  157. package/src/utils/fundamentals.ts +13 -2
  158. package/src/utils/markdown.ts +180 -0
  159. package/src/utils/market-capitalization.ts +22 -0
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import { useMemo } from "react";
2
+ import { useMemo, useRef } from "react";
3
3
  import { Box, Text, TextAttributes, useUiCapabilities } from "../../../../ui";
4
4
  import type { PricePoint, Quote, TickerFinancials } from "../../../../types/financials";
5
5
  import type { TickerRecord } from "../../../../types/ticker";
@@ -22,7 +22,7 @@ import { t } from "../../../../i18n";
22
22
  import { isQuoteStaleForCurrentSession } from "../../../../market-data/quotes/freshness";
23
23
  import { formatQuoteNavAsOf } from "../../../../market-data/quotes/time";
24
24
  import { useQuoteFlashDirection } from "../../../../components/quote-flash";
25
- import { appendLiveQuotePoint } from "../../../../time-series/chart-data";
25
+ import { followLiveSparklinePrice } from "../../../../components/price-sparkline/model";
26
26
  import {
27
27
  PriceAreaSparklineBackground,
28
28
  PriceSparkline,
@@ -145,8 +145,13 @@ export function QuoteMonitorCard({
145
145
  : cachedFinancials?.priceHistory;
146
146
  const assetCategory = quote?.instrumentType ?? ticker?.metadata.assetCategory;
147
147
  // Completed bars end at the prior close; the live price closes the line and the stated range.
148
- const priceHistory = useMemo(() => barHistory && appendLiveQuotePoint(barHistory, quote, { assetCategory }),
149
- [assetCategory, barHistory, quote]);
148
+ const sparklineRef = useRef<PricePoint[] | undefined>(undefined);
149
+ const priceHistory = useMemo(() => {
150
+ if (!barHistory) return undefined;
151
+ const next = followLiveSparklinePrice(barHistory, quote, { assetCategory, period: chartPeriod, previous: sparklineRef.current });
152
+ sparklineRef.current = next;
153
+ return next;
154
+ }, [assetCategory, barHistory, chartPeriod, quote]);
150
155
  const flashFinancials = useMemo<TickerFinancials | null>(
151
156
  () => quote ? { quote, annualStatements: [], quarterlyStatements: [], priceHistory: [] } : null,
152
157
  [quote],
@@ -21,6 +21,8 @@ import { useAutoRefresh } from "../../../react/auto-refresh";
21
21
  import { useLiveStreamingSetting } from "../../../state/hooks/live-streaming";
22
22
  import { CLOUD_QUOTE_DELAY_MINUTES } from "../../../api-client/plan-access";
23
23
  import { useLiveSessionRefresh } from "../shared/volatility/live-session";
24
+ import { getSharedMarketDataCoordinator } from "../../../market-data/coordinator";
25
+ import { hasFreshReadyEntry } from "../../../market-data/coordinator/entries";
24
26
  import { createSurfaceDependencies, loadVolatilitySurface, withReusedYieldCurve, type SurfaceLoadRequest } from "./client";
25
27
  import { stableSurfaceSheet, SURFACE_LIVE_RELOAD_MS, surfaceFreshnessLabel, type SurfaceSheetAxes } from "./live";
26
28
  import { loadStoredSurface, loadSurfaceDates } from "../iv-history/client";
@@ -120,8 +122,20 @@ export function VolSurfacePane({ focused, width, height }: PaneProps) {
120
122
  // never collapses to the first few expiries while the rest reload.
121
123
  const [live, setLive] = useState<{ key: string; snapshot: SurfaceSnapshot; at: number } | null>(null);
122
124
  const liveController = useRef<AbortController | null>(null);
125
+ const settledRef = useRef<SurfaceSnapshot | null | undefined>(null);
123
126
  const liveDependencies = useMemo(() => withReusedYieldCurve(createSurfaceDependencies()), []);
124
127
  const reloadLive = useCallback(async () => {
128
+ const coordinator = getSharedMarketDataCoordinator();
129
+ const instrument = target?.instrument;
130
+ const chainInstrument = { symbol: target?.effectiveTicker ?? symbol!, exchange: target?.effectiveExchange ?? "",
131
+ brokerId: instrument?.brokerId, brokerInstanceId: instrument?.brokerInstanceId, instrument };
132
+ const shown = settledRef.current;
133
+ const expiries = shown?.expiries.map((entry) => entry.expiration) ?? [];
134
+ const freshChain = coordinator != null && expiries.length > 0 && expiries.every((expiration) => {
135
+ const entry = coordinator.getOptionsEntry({ instrument: chainInstrument, expirationDate: expiration });
136
+ return hasFreshReadyEntry(entry, SURFACE_LIVE_RELOAD_MS);
137
+ });
138
+ if (freshChain) return;
125
139
  liveController.current?.abort();
126
140
  const abort = new AbortController();
127
141
  liveController.current = abort;
@@ -129,10 +143,11 @@ export function VolSurfacePane({ focused, width, height }: PaneProps) {
129
143
  // Each expiry is fitted as its chain arrives, never all together at the end.
130
144
  onSnapshot: () => {} }, liveDependencies);
131
145
  if (!abort.signal.aborted) setLive({ key: requestKey, snapshot, at: Date.now() });
132
- }, [surfaceRequest, liveDependencies]);
146
+ }, [surfaceRequest, liveDependencies, symbol, target]);
133
147
  useEffect(() => () => liveController.current?.abort(), [reloadLive]);
134
148
  const liveReload = live?.key === requestKey && (resource.updatedAt == null || live.at > resource.updatedAt) ? live.snapshot : null;
135
149
  const settledSnapshot = liveReload ?? resource.data;
150
+ settledRef.current = settledSnapshot;
136
151
  // Stored close surfaces and IV rank come from the Cloud IV history of the underlying.
137
152
  const underlying = (target?.effectiveTicker ?? symbol ?? "").toUpperCase();
138
153
  const datesLoader = useCallback(async () => (await loadSurfaceDates(underlying)).dates, [underlying]);
@@ -141,7 +141,7 @@ export function volatilityHistorySeries(fred: FredChartInput, colors: Volatility
141
141
  const value = values.get(date);
142
142
  return { date: new Date(date), observedAt: new Date(date), value: value != null && Number.isFinite(value) ? value : null };
143
143
  }), { id, label, color, calendarSpaced: true }),
144
- unit: unitGroup === "volatility" ? "%" : "", unitGroup, panelId,
144
+ unit: unitGroup === "volatility" ? "index" : "", unitGroup, panelId,
145
145
  });
146
146
  const first = dates[0], last = dates.at(-1);
147
147
  return [
@@ -26,7 +26,7 @@ export function VolatilityHistoryChart({ fred, width, height, focused = false }:
26
26
  return <CompositeChart series={series} legendSeries={legendSeries} panels={HISTORY_PANELS} width={width} height={height}
27
27
  focused={focused} navigable={false} showLegend showTimeAxis remoteKind="vix-history"
28
28
  formatValue={(value, entry) => entry.unitGroup === "ratio" ? value.toFixed(2) : formatCompositeSeriesValue(value, entry)}
29
- formatAxisValue={(value, domain) => domain.unitGroup === "ratio" ? value.toFixed(2) : `${value.toFixed(0)}%`}
29
+ formatAxisValue={(value, domain) => domain.unitGroup === "ratio" ? value.toFixed(2) : value.toFixed(0)}
30
30
  colors={{ background: palette.bgColor, grid: palette.gridColor, crosshair: palette.crosshairColor, text: colors.text,
31
31
  textDim: palette.axisColor, negative: colors.negative }} />;
32
32
  }
@@ -81,6 +81,12 @@ button, [data-gloom-interactive="true"] { cursor: pointer; }
81
81
  [data-titlebar-overlay="true"] * {
82
82
  cursor: inherit;
83
83
  }
84
+ /* A row of text grows with the text size and the title bar does not: at the
85
+ largest sizes the top bar's buttons and prompt would stand out of it. */
86
+ [data-gloom-role="app-header"][data-titlebar-overlay="true"] button,
87
+ [data-gloom-role="app-header"][data-titlebar-overlay="true"] [role="button"] {
88
+ max-height: __TITLEBAR_OVERLAY_HEIGHT_PX__px;
89
+ }
84
90
  [data-titlebar-overlay="true"] button,
85
91
  [data-titlebar-overlay="true"] input,
86
92
  [data-titlebar-overlay="true"] textarea,
@@ -222,6 +222,7 @@ export function mapPricePoint(
222
222
  volume: point.volume,
223
223
  ...(point.partial === true ? { partial: true } : {}),
224
224
  ...(point.filled === true ? { filled: true } : {}),
225
+ ...(point.unsettled === true ? { unsettled: true } : {}),
225
226
  };
226
227
  }
227
228
 
@@ -73,7 +73,7 @@ export class ProviderRouterBatchRoutes {
73
73
  : isProviderQuoteUsableForCurrentSession(rawCached.value, target.exchange, target.symbol, { recentAnswer: !rawCached.stale }))
74
74
  ? rawCached
75
75
  : null;
76
- if (cached && !forceRefresh && !cached.stale) {
76
+ if (cached && !forceRefresh && !cached.stale && !options.revalidate) {
77
77
  if (brokerSourceKeys.includes(cached.sourceKey)) {
78
78
  misses.push({ index, target });
79
79
  return;
@@ -81,6 +81,7 @@ export class ProviderRouterBatchRoutes {
81
81
  results[index] = { target, quote: cached.value };
82
82
  return;
83
83
  }
84
+ // Revalidated, a fresh copy is still what the quote route answers when no source does.
84
85
  misses.push({ index, target });
85
86
  });
86
87
 
@@ -9,6 +9,8 @@ import { brokerContractIdentityKey } from "../../utils/instrument-identity";
9
9
  import { providerFinancialsMatchTarget, providerQuoteMatchesTarget } from "./financials";
10
10
 
11
11
  const MARKET_NAMESPACE = "market";
12
+ /** Quotes and price histories are read on every tick; their eviction order is refreshed at most this often. */
13
+ const HOT_READ_TOUCH_MS = 10 * 60_000;
12
14
  // A record written under another version is a miss: bump these instead of
13
15
  // repairing older records on read.
14
16
  export const FINANCIALS_SCHEMA_VERSION = 13;
@@ -174,6 +176,7 @@ export function listCachedResources<T>(
174
176
  sourceKeys,
175
177
  allowExpired,
176
178
  ...(SCHEMA_VERSIONS[kind] ? { schemaVersion: SCHEMA_VERSIONS[kind] } : {}),
179
+ ...(kind === "quote" || kind === "price-history" || kind === "price-history-recheck" ? { touchIntervalMs: HOT_READ_TOUCH_MS } : {}),
177
180
  }).filter((record) => {
178
181
  if (!record.sourceKey.startsWith("provider:") || entityKey.startsWith("contract:")
179
182
  || (kind !== "financials" && kind !== "quote")) return true;
@@ -3,6 +3,7 @@ import type { SyncBaselineStore } from "./types";
3
3
 
4
4
  const BASELINE_PLUGIN_ID = "core.sync";
5
5
  const BASELINE_KEY = "last-synced-payloads";
6
+ const MEMOS_KEY = "contributor-memos";
6
7
 
7
8
  /**
8
9
  * Persists what this device last pushed so the next pull can tell "the cloud is
@@ -13,20 +14,24 @@ const BASELINE_KEY = "last-synced-payloads";
13
14
  * local state, never to discarding it.
14
15
  */
15
16
  export function createSyncBaselineStore(pluginState: AppPluginStateStorePort): SyncBaselineStore {
17
+ const load = (key: string) => {
18
+ try {
19
+ return pluginState.get<Record<string, unknown>>(BASELINE_PLUGIN_ID, key)?.value ?? null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+ const save = (key: string, value: Record<string, unknown>) => {
25
+ try {
26
+ pluginState.set(BASELINE_PLUGIN_ID, key, value);
27
+ } catch {
28
+ // Ignored: a stale baseline or memo only makes the next pull more conservative.
29
+ }
30
+ };
16
31
  return {
17
- load: () => {
18
- try {
19
- return pluginState.get<Record<string, unknown>>(BASELINE_PLUGIN_ID, BASELINE_KEY)?.value ?? null;
20
- } catch {
21
- return null;
22
- }
23
- },
24
- save: (payloads) => {
25
- try {
26
- pluginState.set(BASELINE_PLUGIN_ID, BASELINE_KEY, payloads);
27
- } catch {
28
- // Ignored: a stale baseline only makes the next pull more conservative.
29
- }
30
- },
32
+ load: () => load(BASELINE_KEY),
33
+ save: (payloads) => save(BASELINE_KEY, payloads),
34
+ loadMemos: () => load(MEMOS_KEY),
35
+ saveMemos: (memos) => save(MEMOS_KEY, memos),
31
36
  };
32
37
  }
@@ -5,6 +5,7 @@ import { stableStringify } from "../utils/hash";
5
5
  import { VERSION } from "../version";
6
6
  import {
7
7
  SYNC_SNAPSHOT_SCHEMA_VERSION,
8
+ SyncRevisionConflictError,
8
9
  type RegisteredSyncContributor,
9
10
  type RegisteredSyncTransport,
10
11
  type SyncBaselineStore,
@@ -35,6 +36,16 @@ interface SyncRuntime {
35
36
 
36
37
  const CLIENT_ID_STORAGE_KEY = "gloomberb.sync.clientId";
37
38
  const PUSH_DEBOUNCE_MS = 2500;
39
+ /** How soon after a change contributors date it, so an edit made just before quitting still is. */
40
+ const OBSERVE_DEBOUNCE_MS = 250;
41
+ /** Pushes refused because another device wrote first, each followed by a fresh pull, before giving up until the next sync. */
42
+ const MAX_CONFLICT_RETRIES = 3;
43
+
44
+ /** What one pull left behind: the stored revision it merged with (0 for none) and that copy's content. */
45
+ interface PullResult {
46
+ revision: number | null;
47
+ signature: string | null;
48
+ }
38
49
 
39
50
  function nowIso(): string {
40
51
  return new Date().toISOString();
@@ -49,13 +60,17 @@ function resolveClientId(): string {
49
60
  return id;
50
61
  }
51
62
 
63
+ /**
64
+ * The content of a snapshot as it reads once stored, so one assembled here
65
+ * (with fields left undefined) compares equal to the same one pulled back.
66
+ */
52
67
  function snapshotContentSignature(snapshot: SyncSnapshot): string {
53
- return stableStringify(Object.fromEntries(
68
+ return stableStringify(JSON.parse(JSON.stringify(Object.fromEntries(
54
69
  Object.entries(snapshot.contributors).map(([id, contributor]) => [
55
70
  id,
56
71
  { schemaVersion: contributor.schemaVersion, payload: contributor.payload },
57
72
  ]),
58
- ));
73
+ ))));
59
74
  }
60
75
 
61
76
  export class CloudSyncController {
@@ -64,6 +79,10 @@ export class CloudSyncController {
64
79
  private transports = new Map<string, RegisteredSyncTransport>();
65
80
  private listeners = new Set<() => void>();
66
81
  private pushTimer: ReturnType<typeof setTimeout> | null = null;
82
+ private observeTimer: ReturnType<typeof setTimeout> | null = null;
83
+ private observedSinceStart = false;
84
+ private memos: Record<string, unknown> | null = null;
85
+ private memosLoaded = false;
67
86
  private inFlight: Promise<void> | null = null;
68
87
  private syncQueued = false;
69
88
  private clientId: string | null = null;
@@ -141,6 +160,7 @@ export class CloudSyncController {
141
160
  schedulePush(reason: string): void {
142
161
  const runtime = this.runtime;
143
162
  if (!runtime) return;
163
+ this.scheduleObserve(runtime);
144
164
  const registration = runtime.getTransport();
145
165
  if (!registration?.transport.isAvailable()) {
146
166
  this.setStatus({
@@ -176,6 +196,7 @@ export class CloudSyncController {
176
196
  private async syncOnce(options: { reason?: string; force?: boolean }): Promise<void> {
177
197
  const runtime = this.runtime;
178
198
  if (!runtime) return;
199
+ this.observe(runtime);
179
200
  const registration = runtime.getTransport();
180
201
  if (!registration?.transport.isAvailable()) {
181
202
  this.setStatus({
@@ -187,53 +208,72 @@ export class CloudSyncController {
187
208
  }
188
209
  const transport = registration.transport;
189
210
 
190
- // Always pull before push. Another device (TUI, browser, phone) may have
191
- // written since this process last synced, and contributors already merge
192
- // so local-only edits survive the apply.
193
- if (!await this.runPull(runtime, transport)) return;
194
- if (!this.isCurrent(runtime, transport)) return;
211
+ for (let attempt = 0; ; attempt += 1) {
212
+ // Always pull before push. Another device (TUI, browser, phone) may have
213
+ // written since this process last synced, and contributors already merge
214
+ // so local-only edits survive the apply.
215
+ const pulled = await this.runPull(runtime, transport);
216
+ if (!pulled || !this.isCurrent(runtime, transport)) return;
195
217
 
196
- const snapshot = await this.assembleSnapshot(runtime);
197
- if (!this.isCurrent(runtime, transport)) return;
198
- const signature = snapshotContentSignature(snapshot);
199
- if (!options.force && signature === this.lastSignature) return;
200
-
201
- this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
202
- try {
203
- const result = await transport.pushSnapshot(snapshot, { baseRevision: this.status.revision });
204
- if (!this.isCurrent(runtime, transport)) return;
205
- this.lastSignature = signature;
206
- this.appliedRevision = result.revision;
207
- this.saveBaseline(runtime, snapshot);
208
- this.setStatus({
209
- phase: "synced",
210
- transportId: transport.id,
211
- revision: result.revision,
212
- lastSyncAt: result.updatedAt,
213
- error: null,
214
- });
215
- } catch (error) {
218
+ const snapshot = await this.assembleSnapshot(runtime);
216
219
  if (!this.isCurrent(runtime, transport)) return;
217
- this.setStatus({
218
- phase: "error",
219
- transportId: transport.id,
220
- error: error instanceof Error ? error.message : String(error),
221
- });
220
+ const signature = snapshotContentSignature(snapshot);
221
+ if (!options.force && signature === this.lastSignature) return;
222
+ if (!options.force && signature === pulled.signature) {
223
+ // The cloud already holds exactly this: pushing it back would only
224
+ // bump the revision under another device's next push.
225
+ this.lastSignature = signature;
226
+ this.saveBaseline(runtime, snapshot);
227
+ return;
228
+ }
229
+
230
+ this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
231
+ try {
232
+ // The revision this snapshot was merged with. The cloud refuses the
233
+ // write if another device wrote since, instead of losing that write.
234
+ const result = await transport.pushSnapshot(snapshot, { baseRevision: pulled.revision });
235
+ if (!this.isCurrent(runtime, transport)) return;
236
+ this.lastSignature = signature;
237
+ this.appliedRevision = result.revision;
238
+ this.saveBaseline(runtime, snapshot);
239
+ this.setStatus({
240
+ phase: "synced",
241
+ transportId: transport.id,
242
+ revision: result.revision,
243
+ lastSyncAt: result.updatedAt,
244
+ error: null,
245
+ });
246
+ return;
247
+ } catch (error) {
248
+ if (!this.isCurrent(runtime, transport)) return;
249
+ if (error instanceof SyncRevisionConflictError && attempt < MAX_CONFLICT_RETRIES) {
250
+ // Pull whatever the other device wrote, even an older revision than
251
+ // this process last saw (an account switch, a restored snapshot).
252
+ this.appliedRevision = null;
253
+ continue;
254
+ }
255
+ this.setStatus({
256
+ phase: "error",
257
+ transportId: transport.id,
258
+ error: error instanceof Error ? error.message : String(error),
259
+ });
260
+ return;
261
+ }
222
262
  }
223
263
  }
224
264
 
225
- private async runPull(runtime: SyncRuntime, transport: SyncTransport): Promise<boolean> {
265
+ private async runPull(runtime: SyncRuntime, transport: SyncTransport): Promise<PullResult | null> {
226
266
  const baselineState = runtime.getState();
227
267
  this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
228
268
  try {
229
269
  const response = await transport.pullSnapshot();
230
- if (!this.isCurrent(runtime, transport)) return false;
270
+ if (!this.isCurrent(runtime, transport)) return null;
231
271
 
232
272
  if (response.snapshot && this.shouldApplyPulledRevision(response.revision)) {
233
273
  this.assertSnapshotCompatible(response.snapshot, runtime.getContributors());
234
274
  this.pulledContributors = response.snapshot.contributors;
235
275
  for (const entry of runtime.getContributors()) {
236
- if (!this.isCurrent(runtime, transport)) return false;
276
+ if (!this.isCurrent(runtime, transport)) return null;
237
277
  const contributorPayload = response.snapshot.contributors[entry.contributor.id];
238
278
  if (!contributorPayload || !entry.contributor.apply) continue;
239
279
  await entry.contributor.apply(contributorPayload.payload, {
@@ -245,12 +285,13 @@ export class CloudSyncController {
245
285
  isCurrent: () => this.isCurrent(runtime, transport),
246
286
  dispatch: runtime.dispatch,
247
287
  tickerRepository: runtime.tickerRepository,
288
+ memo: this.memoFor(runtime, entry.contributor.id),
248
289
  });
249
290
  }
250
291
  if (response.revision != null) this.appliedRevision = response.revision;
251
292
  }
252
293
 
253
- if (!this.isCurrent(runtime, transport)) return false;
294
+ if (!this.isCurrent(runtime, transport)) return null;
254
295
  this.setStatus({
255
296
  phase: response.snapshot ? "synced" : "idle",
256
297
  transportId: transport.id,
@@ -259,15 +300,18 @@ export class CloudSyncController {
259
300
  lastSyncAt: response.updatedAt ?? this.status.lastSyncAt,
260
301
  error: null,
261
302
  });
262
- return true;
303
+ return {
304
+ revision: response.snapshot ? response.revision : 0,
305
+ signature: response.snapshot ? snapshotContentSignature(response.snapshot) : null,
306
+ };
263
307
  } catch (error) {
264
- if (!this.isCurrent(runtime, transport)) return false;
308
+ if (!this.isCurrent(runtime, transport)) return null;
265
309
  this.setStatus({
266
310
  phase: "error",
267
311
  transportId: transport.id,
268
312
  error: error instanceof Error ? error.message : String(error),
269
313
  });
270
- return false;
314
+ return null;
271
315
  }
272
316
  }
273
317
 
@@ -302,7 +346,11 @@ export class CloudSyncController {
302
346
  // carried over instead of being deleted for every other device.
303
347
  const payloads: SyncSnapshot["contributors"] = { ...this.pulledContributors };
304
348
  for (const entry of contributors) {
305
- const payload = await entry.contributor.collect({ state: runtime.getState() });
349
+ const payload = await entry.contributor.collect({
350
+ state: runtime.getState(),
351
+ baselinePayload: this.loadBaseline(runtime)?.[entry.contributor.id] ?? null,
352
+ memo: this.memoFor(runtime, entry.contributor.id),
353
+ });
306
354
  payloads[entry.contributor.id] = {
307
355
  schemaVersion: entry.contributor.schemaVersion,
308
356
  updatedAt: createdAt,
@@ -327,6 +375,53 @@ export class CloudSyncController {
327
375
  return this.baseline;
328
376
  }
329
377
 
378
+ private scheduleObserve(runtime: SyncRuntime): void {
379
+ if (this.observeTimer) clearTimeout(this.observeTimer);
380
+ this.observeTimer = setTimeout(() => {
381
+ this.observeTimer = null;
382
+ if (this.runtime === runtime) this.observe(runtime);
383
+ }, OBSERVE_DEBOUNCE_MS);
384
+ }
385
+
386
+ /** Lets contributors date local edits as they happen, whether or not the cloud is reachable. */
387
+ private observe(runtime: SyncRuntime): void {
388
+ const launch = !this.observedSinceStart;
389
+ this.observedSinceStart = true;
390
+ for (const entry of runtime.getContributors()) {
391
+ if (!entry.contributor.observe) continue;
392
+ try {
393
+ entry.contributor.observe({
394
+ state: runtime.getState(),
395
+ baselinePayload: this.loadBaseline(runtime)?.[entry.contributor.id] ?? null,
396
+ memo: this.memoFor(runtime, entry.contributor.id),
397
+ launch,
398
+ });
399
+ } catch {
400
+ // A contributor that cannot date an edit leaves it undated, which only
401
+ // makes it lose a conflict it might have won.
402
+ }
403
+ }
404
+ }
405
+
406
+ private memoFor(runtime: SyncRuntime, contributorId: string) {
407
+ return {
408
+ get: () => this.loadMemos(runtime)[contributorId] ?? null,
409
+ set: (value: unknown) => {
410
+ const memos = { ...this.loadMemos(runtime), [contributorId]: value };
411
+ this.memos = memos;
412
+ runtime.baselineStore?.saveMemos?.(memos);
413
+ },
414
+ };
415
+ }
416
+
417
+ private loadMemos(runtime: SyncRuntime): Record<string, unknown> {
418
+ if (!this.memosLoaded) {
419
+ this.memosLoaded = true;
420
+ this.memos = runtime.baselineStore?.loadMemos?.() ?? null;
421
+ }
422
+ return this.memos ?? {};
423
+ }
424
+
330
425
  private saveBaseline(runtime: SyncRuntime, snapshot: SyncSnapshot): void {
331
426
  const payloads = Object.fromEntries(
332
427
  Object.entries(snapshot.contributors).map(([id, contributor]) => [id, contributor.payload]),
@@ -353,6 +448,11 @@ export class CloudSyncController {
353
448
  private resetOperations(): void {
354
449
  if (this.pushTimer) clearTimeout(this.pushTimer);
355
450
  this.pushTimer = null;
451
+ if (this.observeTimer) clearTimeout(this.observeTimer);
452
+ this.observeTimer = null;
453
+ this.observedSinceStart = false;
454
+ this.memos = null;
455
+ this.memosLoaded = false;
356
456
  this.inFlight = null;
357
457
  this.syncQueued = false;
358
458
  this.lastSignature = null;