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.
- package/package.json +1 -1
- package/src/api-client/futures-curve.ts +16 -0
- package/src/api-client/index.ts +18 -8
- package/src/api-client/types.ts +3 -1
- package/src/cli/commands/market.ts +60 -9
- package/src/cli/commands/search.ts +43 -2
- package/src/cli/commands/ticker.ts +100 -21
- package/src/cli/desktop-pane-shot.ts +34 -9
- package/src/cli/fx-pair.ts +17 -0
- package/src/cli/glossary.ts +134 -6
- package/src/cli/history-rows.ts +16 -4
- package/src/cli/index.ts +44 -12
- package/src/cli/known-gaps.ts +21 -2
- package/src/cli/listing-arg.ts +19 -4
- package/src/cli/listing-suggestion.ts +33 -2
- package/src/cli/pane-functions/dom.ts +39 -6
- package/src/cli/pane-functions/freshness.ts +48 -5
- package/src/cli/pane-functions/function-help.ts +1 -1
- package/src/cli/pane-functions/headless.ts +85 -19
- package/src/cli/pane-functions/index.ts +60 -4
- package/src/cli/pane-functions/market-session.ts +69 -6
- package/src/cli/pane-functions/resolver.ts +4 -2
- package/src/cli/pane-functions/screenshot.ts +14 -1
- package/src/cli/scroll-truncation.ts +5 -1
- package/src/cli/symbol-args.ts +41 -3
- package/src/cli/symbol-list.ts +19 -7
- package/src/cli/unknown-command.ts +70 -0
- package/src/components/chart/native/surface/visibility.ts +37 -7
- package/src/components/layout/header.tsx +348 -102
- package/src/components/markdown-text.tsx +40 -66
- package/src/components/market-board/index.tsx +12 -6
- package/src/components/reader/detail-reader.tsx +28 -6
- package/src/components/ticker/badge/index.tsx +2 -0
- package/src/components/ui/display.tsx +6 -1
- package/src/components/ui/external-link.tsx +10 -2
- package/src/data/memory-resource-store.ts +1 -1
- package/src/data/resource-store.ts +13 -1
- package/src/docs/snapshot.json +13 -13
- package/src/i18n/es.ts +1 -0
- package/src/i18n/ja.ts +1 -0
- package/src/i18n/ko.ts +1 -0
- package/src/i18n/zh-cn.ts +1 -0
- package/src/i18n/zh-tw.ts +1 -0
- package/src/market-data/futures-contract.ts +10 -0
- package/src/market-data/market/format.ts +7 -2
- package/src/market-data/market/freshness.ts +25 -1
- package/src/market-data/quotes/regular-session-valuation.ts +32 -1
- package/src/market-data/quotes/resolution.ts +2 -0
- package/src/plugins/builtin/account-management/pane.tsx +17 -13
- package/src/plugins/builtin/attention/headless.ts +19 -10
- package/src/plugins/builtin/attention/model.ts +22 -0
- package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
- package/src/plugins/builtin/changelog/index.tsx +11 -8
- package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
- package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
- package/src/plugins/builtin/chat/attachments/model.ts +5 -3
- package/src/plugins/builtin/chat/content/footer.tsx +6 -0
- package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
- package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
- package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
- package/src/plugins/builtin/chat/layout.ts +91 -31
- package/src/plugins/builtin/chat/markdown.ts +94 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
- package/src/plugins/builtin/chat/message/render-state.ts +3 -0
- package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
- package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
- package/src/plugins/builtin/company-attention/headless.ts +8 -5
- package/src/plugins/builtin/company-attention/model.ts +53 -2
- package/src/plugins/builtin/company-attention/pane.tsx +1 -1
- package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
- package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
- package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
- package/src/plugins/builtin/cot/client.ts +12 -4
- package/src/plugins/builtin/cot/pane.tsx +2 -2
- package/src/plugins/builtin/crypto-board/model.ts +7 -3
- package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
- package/src/plugins/builtin/debug/index.tsx +15 -15
- package/src/plugins/builtin/dividend-yield/client.ts +8 -1
- package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
- package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
- package/src/plugins/builtin/docs/index.tsx +5 -3
- package/src/plugins/builtin/docs/pane.tsx +18 -1
- package/src/plugins/builtin/docs/targets.ts +5 -3
- package/src/plugins/builtin/earnings-calls/data.ts +22 -0
- package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
- package/src/plugins/builtin/equity-screener/headless.ts +4 -3
- package/src/plugins/builtin/equity-screener/live.ts +27 -1
- package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
- package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
- package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
- package/src/plugins/builtin/futures/index.tsx +32 -4
- package/src/plugins/builtin/futures/model.ts +65 -3
- package/src/plugins/builtin/futures/table.tsx +31 -11
- package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
- package/src/plugins/builtin/holders/pane.tsx +6 -1
- package/src/plugins/builtin/mna/client.ts +12 -2
- package/src/plugins/builtin/mna/detail.tsx +2 -2
- package/src/plugins/builtin/mna/pane.tsx +2 -2
- package/src/plugins/builtin/notes/owner.tsx +5 -4
- package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
- package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
- package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
- package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
- package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -9
- package/src/plugins/builtin/research/event-model.ts +14 -0
- package/src/plugins/builtin/research/headless-schema.ts +31 -1
- package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
- package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
- package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
- package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
- package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
- package/src/plugins/builtin/research-search/data.ts +11 -0
- package/src/plugins/builtin/research-search/pane.tsx +13 -2
- package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
- package/src/plugins/builtin/seasonality/client.ts +23 -0
- package/src/plugins/builtin/seasonality/pane.tsx +4 -2
- package/src/plugins/builtin/sectors/index.tsx +7 -3
- package/src/plugins/builtin/shared/report-freshness.ts +6 -2
- package/src/plugins/builtin/short-interest/headless.ts +13 -3
- package/src/plugins/builtin/short-interest/index.tsx +1 -1
- package/src/plugins/builtin/short-interest/model.ts +62 -3
- package/src/plugins/builtin/short-interest/pane.tsx +2 -2
- package/src/plugins/builtin/short-interest/schedule.ts +107 -0
- package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
- package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
- package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
- package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
- package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
- package/src/plugins/builtin/themes/headless.ts +1 -1
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
- package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
- package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
- package/src/plugins/builtin/volatility/chart-model.ts +1 -1
- package/src/plugins/builtin/volatility/charts.tsx +1 -1
- package/src/renderers/dom/styles.css +6 -0
- package/src/sources/gloomberb-cloud/normalizers.ts +1 -0
- package/src/sources/provider-router/batches.ts +2 -1
- package/src/sources/provider-router/cache.ts +3 -0
- package/src/sync/baseline.ts +19 -14
- package/src/sync/controller.ts +140 -40
- package/src/sync/core-contributors.ts +256 -32
- package/src/sync/types.ts +44 -0
- package/src/time-series/resolve.ts +19 -2
- package/src/types/data-provider.ts +8 -0
- package/src/types/financials.ts +40 -4
- package/src/types/headless.ts +43 -0
- package/src/types/plugin.ts +7 -0
- package/src/utils/declared-dividends.ts +39 -0
- package/src/utils/forward-dividend.ts +34 -11
- package/src/utils/fundamentals.ts +13 -2
- package/src/utils/markdown.ts +180 -0
- 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 {
|
|
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
|
|
149
|
-
|
|
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" ? "
|
|
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) :
|
|
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,
|
|
@@ -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;
|
package/src/sync/baseline.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
}
|
package/src/sync/controller.ts
CHANGED
|
@@ -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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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<
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
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;
|