gloomberb 0.19.1 → 0.19.2
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 +2 -1
- package/src/api-client/errors.ts +38 -0
- package/src/app/runtime/startup.ts +3 -1
- package/src/capabilities/chart-series.ts +2 -1
- package/src/cli/commands/market.ts +9 -4
- package/src/cli/commands/overview.ts +5 -6
- package/src/cli/commands/search.ts +13 -13
- package/src/cli/context.ts +1 -0
- package/src/cli/glossary.ts +2 -1
- package/src/cli/history-rows.ts +35 -7
- package/src/cli/known-gaps.ts +11 -0
- package/src/cli/listing-arg.ts +17 -1
- package/src/cli/quote-answer.ts +29 -6
- package/src/components/command-bar/routes/root/runtime.ts +27 -5
- package/src/components/command-bar/selection-runtime.ts +12 -29
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/header.tsx +2 -2
- package/src/components/layout/pane/footer/index.tsx +1 -0
- package/src/components/layout/pane/footer/model.ts +33 -1
- package/src/components/layout/pane/header.tsx +2 -2
- package/src/components/layout/pane/status-footer.ts +2 -1
- package/src/components/onboarding/onboarding-frame.tsx +2 -1
- package/src/components/onboarding/onboarding-wizard.tsx +6 -3
- package/src/components/ui/display.tsx +16 -5
- package/src/components/ui/icon.tsx +8 -2
- package/src/core/app-services.ts +11 -2
- package/src/data/app-persistence.ts +9 -7
- package/src/data/plugin-cache.ts +15 -0
- package/src/data/plugin-state-store.ts +81 -24
- package/src/data/resource-store.ts +130 -58
- package/src/data/session-store.ts +31 -8
- package/src/data/sqlite/access.ts +313 -0
- package/src/data/sqlite/checkpointer.ts +198 -0
- package/src/data/sqlite/database.ts +47 -4
- package/src/data/sqlite/retry.ts +50 -9
- package/src/data/ticker-store.ts +57 -23
- package/src/market-data/realized-volatility.ts +4 -2
- package/src/plugins/auto-update-node.ts +10 -6
- package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
- package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
- package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
- package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
- package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
- package/src/plugins/builtin/commodity-balances/client.ts +4 -1
- package/src/plugins/builtin/cot/client.ts +28 -2
- package/src/plugins/builtin/cot/headless.ts +4 -1
- package/src/plugins/builtin/cot/index.tsx +4 -2
- package/src/plugins/builtin/cot/model.ts +58 -7
- package/src/plugins/builtin/cot/pane.tsx +6 -2
- package/src/plugins/builtin/cpi/client.ts +6 -5
- package/src/plugins/builtin/credit-boards/series.ts +3 -4
- package/src/plugins/builtin/credit-conditions/client.ts +22 -14
- package/src/plugins/builtin/crypto-board/client.ts +5 -5
- package/src/plugins/builtin/crypto-board/series.ts +4 -4
- package/src/plugins/builtin/debt-maturities/series.ts +3 -3
- package/src/plugins/builtin/doe/client.ts +6 -5
- package/src/plugins/builtin/doe/prices.ts +2 -3
- package/src/plugins/builtin/doe/series.ts +4 -4
- package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
- package/src/plugins/builtin/earnings-calls/data.ts +19 -11
- package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
- package/src/plugins/builtin/econ/detail-view.tsx +16 -2
- package/src/plugins/builtin/executives/data.ts +6 -1
- package/src/plugins/builtin/executives/pane.tsx +9 -21
- package/src/plugins/builtin/fear-greed/cache.ts +4 -0
- package/src/plugins/builtin/fear-greed/series.ts +6 -4
- package/src/plugins/builtin/gpu/client.ts +4 -1
- package/src/plugins/builtin/gpu/series.ts +2 -3
- package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
- package/src/plugins/builtin/jodi/client.ts +4 -1
- package/src/plugins/builtin/market-movers/client.ts +1 -0
- package/src/plugins/builtin/market-movers/index.tsx +10 -5
- package/src/plugins/builtin/market-movers/screener.ts +23 -6
- package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
- package/src/plugins/builtin/money-markets/series.ts +3 -5
- package/src/plugins/builtin/power/gas-client.ts +2 -1
- package/src/plugins/builtin/power/prices-client.ts +3 -2
- package/src/plugins/builtin/risk-factors/data.ts +8 -4
- package/src/plugins/builtin/risk-factors/index.tsx +3 -1
- package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
- package/src/plugins/builtin/scanner/lock-note.ts +26 -3
- package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
- package/src/plugins/builtin/shared/fred-points.ts +6 -1
- package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
- package/src/plugins/builtin/shared/series-warning.ts +13 -4
- package/src/plugins/builtin/short-volume/series.ts +2 -3
- package/src/plugins/builtin/social-mentions/client.ts +15 -10
- package/src/plugins/builtin/social-mentions/series.ts +2 -3
- package/src/plugins/builtin/thirteenf/api.ts +11 -16
- package/src/plugins/builtin/thirteenf/data.ts +34 -6
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
- package/src/plugins/builtin/thirteenf/types.ts +2 -0
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
- package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
- package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
- package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
- package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
- package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
- package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
- package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
- package/src/plugins/builtin/volatility/client.ts +43 -16
- package/src/plugins/builtin/volatility/headless.ts +5 -2
- package/src/plugins/builtin/volatility/model.ts +17 -6
- package/src/plugins/builtin/volatility/pane.tsx +6 -2
- package/src/plugins/builtin/volatility/series.ts +2 -1
- package/src/plugins/builtin/yield-curve/client.ts +4 -6
- package/src/plugins/installer.ts +50 -39
- package/src/renderers/dom/desktop/icons.tsx +3 -2
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
- package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
- package/src/renderers/electrobun/bun/window/attention.ts +54 -0
- package/src/renderers/electrobun/bun/window/create.ts +2 -0
- package/src/renderers/electrobun/launcher/preload.ts +13 -0
- package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
- package/src/renderers/electrobun/view/ui-host.tsx +9 -2
- package/src/series/resolve.ts +2 -1
- package/src/series/types.ts +5 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
- package/src/telemetry/stall-reports-node.ts +13 -2
- package/src/telemetry/stall-reports.ts +189 -13
- package/src/test-support/outage.ts +8 -0
- package/src/tickers/company-name-listing.ts +59 -29
- package/src/tickers/company-name.ts +65 -1
- package/src/tickers/search/home-listing.ts +93 -8
- package/src/tickers/search/index.ts +1 -1
- package/src/time-series/market.ts +5 -3
- package/src/time-series/price-comparison.ts +20 -2
- package/src/time-series/resolve.ts +20 -1
- package/src/time-series/types.ts +7 -0
- package/src/types/financials.ts +18 -3
- package/src/ui/host.tsx +6 -1
- package/src/utils/financial-statements.ts +73 -16
- package/src/utils/fundamentals.ts +17 -5
- package/src/utils/lease-liabilities.ts +54 -0
- package/src/utils/main-thread-stall.ts +47 -2
- package/src/utils/perf-marks.ts +23 -11
- package/src/utils/price-history-integrity.ts +47 -1
|
@@ -8,12 +8,12 @@ import {
|
|
|
8
8
|
DataTableView,
|
|
9
9
|
EmptyState, FigureList, PaneStatusBody, Prose, QueryBar, READING_WIDTH, SectionHeading,
|
|
10
10
|
usePaneFooter,
|
|
11
|
-
usePaneNoticeFooter,
|
|
12
11
|
type DataTableCell,
|
|
13
12
|
type DataTableColumn,
|
|
14
13
|
type PaneFooterSegment,
|
|
15
14
|
type PaneHint,
|
|
16
15
|
} from "../../../components";
|
|
16
|
+
import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
|
|
17
17
|
import { useAsyncResource } from "../../../react/async-resource";
|
|
18
18
|
import { usePaneStateValue } from "../../../state/app/context";
|
|
19
19
|
import { colors, getChartIndicatorColor } from "../../../theme/colors";
|
|
@@ -45,13 +45,6 @@ import {
|
|
|
45
45
|
|
|
46
46
|
export const EXECUTIVES_PANE_ID = "executives";
|
|
47
47
|
|
|
48
|
-
function retainedDataNotice(subject: string, error: string, fetchedAt: number | null): string {
|
|
49
|
-
const retrieved = new Date(fetchedAt ?? Number.NaN);
|
|
50
|
-
const age = Number.isFinite(retrieved.getTime())
|
|
51
|
-
? `retrieved ${retrieved.toISOString()}` : "with unavailable retrieval time";
|
|
52
|
-
return `${subject} refresh failed: ${error}. Retained data ${age}.`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
48
|
const PAY_PARTS: Array<{ key: keyof CloudExecutiveRowPayload; label: string }> =
|
|
56
49
|
[
|
|
57
50
|
{ key: "salary", label: "Salary" },
|
|
@@ -301,18 +294,10 @@ function ExecutiveResearch({ ticker, listing, tier, focused, width, nested, guar
|
|
|
301
294
|
// The free preview: each executive's total pay; the breakdown and earlier years are Pro.
|
|
302
295
|
const preview = statement?.access === "preview" || list.data?.data?.access === "preview";
|
|
303
296
|
const loading = list.loading || detail.loading;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
notices: [
|
|
309
|
-
list.data?.data && listError
|
|
310
|
-
? retainedDataNotice("Proxy list", listError, list.data.fetchedAt) : null,
|
|
311
|
-
detail.data?.data && statementError
|
|
312
|
-
? retainedDataNotice(`${year} proxy`, statementError, detail.data.fetchedAt) : null,
|
|
313
|
-
].filter((notice): notice is string => notice !== null),
|
|
314
|
-
focused,
|
|
315
|
-
});
|
|
297
|
+
// A copy saved earlier answering a failed refresh says when it was saved, in the footer every pane uses;
|
|
298
|
+
// a failed refresh with only what is on screen says why. Without anything shown, the body says it.
|
|
299
|
+
const listError = list.data?.data ? list.error ?? (list.data.savedAt == null ? list.data.refreshError : null) ?? null : null;
|
|
300
|
+
const statementError = detail.data?.data ? detail.error ?? (detail.data.savedAt == null ? detail.data.refreshError : null) ?? null : null;
|
|
316
301
|
const refresh = useCallback(() => {
|
|
317
302
|
void list.reload();
|
|
318
303
|
if (year !== null) void detail.reload();
|
|
@@ -338,6 +323,9 @@ function ExecutiveResearch({ ticker, listing, tier, focused, width, nested, guar
|
|
|
338
323
|
const info: PaneFooterSegment[] = [];
|
|
339
324
|
if (loading)
|
|
340
325
|
info.push({ id: "loading", parts: [{ text: "loading", tone: "muted" }] });
|
|
326
|
+
if (listError) info.push({ id: "list-error", parts: [{ text: `Proxy list: ${listError}`, tone: "warning" }] });
|
|
327
|
+
if (statementError) info.push({ id: "statement-error", parts: [{ text: `${year} proxy: ${statementError}`, tone: "warning" }] });
|
|
328
|
+
info.push(...savedCopyFooterInfo([list.data?.data ? list.data.savedAt : null, statement ? detail.data?.savedAt : null]));
|
|
341
329
|
if (statement) {
|
|
342
330
|
info.push({
|
|
343
331
|
id: "filed",
|
|
@@ -351,7 +339,7 @@ function ExecutiveResearch({ ticker, listing, tier, focused, width, nested, guar
|
|
|
351
339
|
if (yearHint) hints.push(yearHint);
|
|
352
340
|
if (preview) hints.push({ id: "upgrade", key: CLOUD_PLAN_KEY, label: "upgrade", title: "Upgrade to Pro", onPress: openUpgrade });
|
|
353
341
|
return { info, hints };
|
|
354
|
-
}, [loading, statement, openFiling, yearHint, preview, openUpgrade]);
|
|
342
|
+
}, [loading, listError, statementError, year, list.data, detail.data, statement, openFiling, yearHint, preview, openUpgrade]);
|
|
355
343
|
|
|
356
344
|
const figures = useMemo(
|
|
357
345
|
() => (statement ? figuresOf(statement) : []),
|
|
@@ -35,6 +35,10 @@ export type FearGreedCacheEntry = {
|
|
|
35
35
|
export interface FearGreedLoadResult extends FearGreedCacheEntry {
|
|
36
36
|
/** Set when the network failed and cached data was served instead. */
|
|
37
37
|
refreshError?: string;
|
|
38
|
+
/** `stale-fallback` when a saved copy answered a failed refresh. */
|
|
39
|
+
source?: "cache" | "network" | "stale-fallback";
|
|
40
|
+
/** The failure a `stale-fallback` copy answered. */
|
|
41
|
+
error?: unknown;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
const cache = createPluginCache<FearGreedData, PersistedFearGreedData>({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SeriesDefinition, SeriesLoadResult, SeriesPoint } from "../../../series/types";
|
|
2
|
-
import {
|
|
2
|
+
import { savedCopyForTransientFailure } from "../../../data/plugin-cache";
|
|
3
|
+
import { cachedSeriesNote } from "../shared/series-warning";
|
|
3
4
|
import { loadFearGreed } from "./cache";
|
|
4
5
|
import type { FearGreedData } from "./data";
|
|
5
6
|
|
|
@@ -30,9 +31,10 @@ function series(id: string, name: string, unit: string, read: Reading, tags: rea
|
|
|
30
31
|
tags: ["fear and greed", ...tags],
|
|
31
32
|
refreshMs: REFRESH_MS,
|
|
32
33
|
load: async (): Promise<SeriesLoadResult> => {
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
return { points: read(result.data),
|
|
34
|
+
// A saved copy answers only a failure asking again may get past; a refusal is the chart's error.
|
|
35
|
+
const result = savedCopyForTransientFailure(await loadFearGreed());
|
|
36
|
+
return { points: read(result.data),
|
|
37
|
+
...cachedSeriesNote({ ...result, ...(result.source === "stale-fallback" ? { savedAt: result.fetchedAt } : {}) }) };
|
|
36
38
|
},
|
|
37
39
|
};
|
|
38
40
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolvePlanAccess } from "../../../api-client/plan-rules";
|
|
2
|
+
import { sourceDidNotAnswer } from "../../../api-client/errors";
|
|
2
3
|
import { apiClient } from "../../../api-client";
|
|
3
4
|
import type { GpuBoardPayload, GpuEventsPayload, GpuHistoryPayload, GpuHistoryQuery, GpuObservation } from "../../../api-client/gpu";
|
|
4
5
|
import { createPluginCache } from "../../../data/plugin-cache";
|
|
@@ -94,7 +95,9 @@ export async function fetchGpuBoard(client: Pick<GpuApi, "getCloudGpuBoard"> = a
|
|
|
94
95
|
try { payload = await client.getCloudGpuBoard(); }
|
|
95
96
|
catch (error) { throw unavailableOnServer(error, GPU_NOT_AVAILABLE, [404, 503]); }
|
|
96
97
|
const board = validateBoard(payload);
|
|
97
|
-
|
|
98
|
+
// The server's source did not answer this time: a saved copy answers, as it does a 5xx.
|
|
99
|
+
if (board.status === "unavailable") throw sourceDidNotAnswer(GPU_NOT_AVAILABLE);
|
|
100
|
+
if (!board.rows.length) throw new Error(GPU_NOT_AVAILABLE);
|
|
98
101
|
return board;
|
|
99
102
|
}
|
|
100
103
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SeriesDefinition } from "../../../series/types";
|
|
2
|
-
import {
|
|
2
|
+
import { cachedSeriesNote } from "../shared/series-warning";
|
|
3
3
|
import { loadGpuBoard, loadGpuHistory } from "./client";
|
|
4
4
|
import { GPU_MODELS, gpuListIndex } from "./model";
|
|
5
5
|
|
|
@@ -27,12 +27,11 @@ export const GPU_SERIES: readonly SeriesDefinition[] = GPU_MODELS.map((model): S
|
|
|
27
27
|
if (!index) throw new Error(`No ${model} list index is listed yet.`);
|
|
28
28
|
const history = await loadGpuHistory(index.id);
|
|
29
29
|
if (history.payload.access?.locked) throw new Error(GPU_PRO_ONLY);
|
|
30
|
-
const warning = cachedSeriesWarning(history);
|
|
31
30
|
return {
|
|
32
31
|
points: history.payload.points
|
|
33
32
|
.filter((point) => `${point.source}:${point.skuKey}` === index.id)
|
|
34
33
|
.map((point) => ({ date: point.observedAt, value: point.pricePerGpuHr })),
|
|
35
|
-
...(
|
|
34
|
+
...cachedSeriesNote(history),
|
|
36
35
|
};
|
|
37
36
|
},
|
|
38
37
|
}));
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../../../components";
|
|
12
12
|
import { useCompanyDataFooter } from "../../../components/layout/pane/status-footer";
|
|
13
13
|
import { handleRefreshKey, loadingErrorFooterInfo } from "../../../components/data-table/table-pane";
|
|
14
|
+
import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
|
|
14
15
|
import type { IssuerListingParams } from "../../../api-client/paths";
|
|
15
16
|
import { useAsyncResource } from "../../../react/async-resource";
|
|
16
17
|
import { useAutoRefresh } from "../../../react/auto-refresh";
|
|
@@ -43,8 +44,6 @@ import {
|
|
|
43
44
|
import type { Holder13FMatch } from "./thirteenf-match";
|
|
44
45
|
import type { HolderRow } from "./types";
|
|
45
46
|
|
|
46
|
-
const STALE_SEGMENT = { id: "stale", parts: [{ text: "stale", tone: "warning" as const }] };
|
|
47
|
-
|
|
48
47
|
/**
|
|
49
48
|
* A table line: a filer's row, or the line beneath it giving the same shares
|
|
50
49
|
* against today's count when the report's base is no longer today's.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { apiClient } from "../../../api-client";
|
|
2
|
+
import { sourceDidNotAnswer } from "../../../api-client/errors";
|
|
2
3
|
import type { JodiBoardPayload } from "../../../api-client/commodity-balances";
|
|
3
4
|
import { unavailableOnServer } from "../shared/cloud-resource";
|
|
4
5
|
import type { BalanceCommodity, Board } from "./model";
|
|
@@ -32,7 +33,9 @@ function readBoard(payload: JodiBoardPayload): Balances {
|
|
|
32
33
|
|| (payload.gas != null && !validBoard(payload.gas, "gas"))) {
|
|
33
34
|
throw new Error("The server returned an invalid oil and gas balance board");
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
+
// The server's source did not answer this time, which may pass; a board with no oil has none yet.
|
|
37
|
+
if (payload.status === "unavailable") throw sourceDidNotAnswer(JODI_NOT_AVAILABLE);
|
|
38
|
+
if (payload.oil == null) throw new Error(JODI_NOT_AVAILABLE);
|
|
36
39
|
if (!validBoard(payload.oil, "oil")) throw new Error("The server returned an invalid oil and gas balance board");
|
|
37
40
|
return { oil: payload.oil, gas: payload.gas ?? null, stale: payload.stale === true, gaps: payload.gaps };
|
|
38
41
|
}
|
|
@@ -119,6 +119,7 @@ export async function loadMarketMoverTab(
|
|
|
119
119
|
...hydrated,
|
|
120
120
|
source: "gloom",
|
|
121
121
|
stale: trending.stale || !!hydrated.unavailableSymbols?.length,
|
|
122
|
+
...(trending.savedAt != null ? { savedAt: trending.savedAt } : {}),
|
|
122
123
|
};
|
|
123
124
|
}
|
|
124
125
|
const category = CATEGORY_MAP[tab];
|
|
@@ -2,6 +2,8 @@ import { Box, useUiCapabilities } from "../../../ui";
|
|
|
2
2
|
import { nextHeaderSort } from "../../../utils/sort-values";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { DataTableView, EmptyState, QueryBar, usePaneFooter, usePaneTabs, type DataTableKeyEvent } from "../../../components";
|
|
5
|
+
import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
|
|
6
|
+
import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
|
|
5
7
|
import { handleRefreshKey } from "../../../components/data-table/table-pane";
|
|
6
8
|
import type { PaneProps } from "../../../types/plugin";
|
|
7
9
|
import type { PluginModule } from "../plugin-module";
|
|
@@ -167,6 +169,8 @@ function ScreenerMoversBody({ activeTab, focused, width, summaryQuotes, liveStre
|
|
|
167
169
|
const [selectedSymbol, setSelectedSymbol] = useState<string | null>(null);
|
|
168
170
|
const [sortPreference, setSortPreference] = useState<MarketMoverSortPreference>(DEFAULT_SORT_PREFERENCE);
|
|
169
171
|
const [moversStale, setMoversStale] = useState(false);
|
|
172
|
+
// The list's refresh failed and a copy saved at this time (ms) answered instead.
|
|
173
|
+
const [moversSavedAt, setMoversSavedAt] = useState<number | null>(null);
|
|
170
174
|
const [lastSuccess, setLastSuccess] = useState<{ key: string; at: number } | null>(null);
|
|
171
175
|
|
|
172
176
|
const fetchGenRef = useRef(0);
|
|
@@ -241,11 +245,13 @@ function ScreenerMoversBody({ activeTab, focused, width, summaryQuotes, liveStre
|
|
|
241
245
|
if (!options?.background) setSelectedSymbol(null);
|
|
242
246
|
}
|
|
243
247
|
setMoversStale(result.stale);
|
|
248
|
+
setMoversSavedAt(result.savedAt ?? null);
|
|
244
249
|
setLoadError(quoteError);
|
|
245
250
|
if (!quoteError && !result.stale) setLastSuccess({ key: `${tab}:${minFloat ?? ""}`, at: Date.now() });
|
|
246
251
|
} catch {
|
|
247
252
|
if (fetchGenRef.current !== gen) return;
|
|
248
253
|
setMoversStale(true);
|
|
254
|
+
setMoversSavedAt(null);
|
|
249
255
|
setLoadError("Market movers temporarily unavailable");
|
|
250
256
|
}
|
|
251
257
|
finally {
|
|
@@ -288,14 +294,13 @@ function ScreenerMoversBody({ activeTab, focused, width, summaryQuotes, liveStre
|
|
|
288
294
|
id: "feed",
|
|
289
295
|
parts: [{ text: feedStatus, tone: feedStatus === "live" ? "value" as const : "muted" as const }],
|
|
290
296
|
}] : []),
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}] : []),
|
|
297
|
+
// A saved copy answering a failed refresh says when it was saved; other stale rows say only that.
|
|
298
|
+
...(loadedKey === listKey ? savedCopyFooterInfo([moversSavedAt]) : []),
|
|
299
|
+
...(moversStale && moversSavedAt == null && loadedKey === listKey ? [STALE_SEGMENT] : []),
|
|
295
300
|
// The FLOAT column shows while a minimum is set; a float the source does not date says so once.
|
|
296
301
|
...partialFloatFooter(columns.some((column) => column.id === "floatShares"), rows),
|
|
297
302
|
],
|
|
298
|
-
}), [columns, feedStatus, listKey, loadedKey, loadError, loading, moversStale, rows, summaryQuotes]);
|
|
303
|
+
}), [columns, feedStatus, listKey, loadedKey, loadError, loading, moversStale, moversSavedAt, rows, summaryQuotes]);
|
|
299
304
|
|
|
300
305
|
return (
|
|
301
306
|
<DataTableView<MarketMoverRow, MarketMoverColumn>
|
|
@@ -5,6 +5,7 @@ import { logFailure } from "../../../utils/log-failure";
|
|
|
5
5
|
import { savedCopyMark, servedFromSavedCopy } from "../../../data/saved-copies";
|
|
6
6
|
import type { PluginPersistence } from "../../../types/plugin";
|
|
7
7
|
import { apiClient } from "../../../api-client";
|
|
8
|
+
import { isTransientFailure, sourceDidNotAnswer } from "../../../api-client/errors";
|
|
8
9
|
import type {
|
|
9
10
|
CloudMarketResponse,
|
|
10
11
|
CloudMarketScreenerCategory,
|
|
@@ -96,6 +97,8 @@ function writeCache<T>(key: string, data: T): void {
|
|
|
96
97
|
export interface CachedResult<T> {
|
|
97
98
|
data: T;
|
|
98
99
|
stale: boolean;
|
|
100
|
+
/** Its refresh failed and the copy saved at this time (ms) answered instead. */
|
|
101
|
+
savedAt?: number;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
async function loadCached<T>(
|
|
@@ -119,11 +122,13 @@ async function loadCached<T>(
|
|
|
119
122
|
return result;
|
|
120
123
|
})
|
|
121
124
|
.catch((error) => {
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
// The next read asks again rather than read the saved copy as current.
|
|
126
|
+
if (fallback) failedFetches.add(key);
|
|
127
|
+
// Serving the expired copy is right for a failure asking again may get past; hiding that it is
|
|
128
|
+
// expired is not. A command that shows it ends with when the copy was saved. A refusal or a
|
|
129
|
+
// reply that fails validation is the error, saved copy or not.
|
|
130
|
+
if (fallback && isTransientFailure(error)) {
|
|
131
|
+
return servedFromSavedCopy({ data: fallback.data, stale: true, savedAt: fallback.fetchedAt }, fallback.fetchedAt);
|
|
127
132
|
}
|
|
128
133
|
throw error;
|
|
129
134
|
})
|
|
@@ -146,6 +151,8 @@ export interface MarketMoversResult {
|
|
|
146
151
|
quotes: ScreenerQuote[];
|
|
147
152
|
source: MarketMoversDataSource;
|
|
148
153
|
stale: boolean;
|
|
154
|
+
/** Its refresh failed and the list saved at this time (ms) answered instead. */
|
|
155
|
+
savedAt?: number;
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
export interface PreferredMarketMoverSources {
|
|
@@ -268,6 +275,8 @@ export async function fetchScreenerResult(
|
|
|
268
275
|
const session = category === "most_actives" ? undefined : options?.session;
|
|
269
276
|
const load = async () => {
|
|
270
277
|
const response = await api.getMarketMovers(category, count, options?.forceRefresh, session);
|
|
278
|
+
// The service's own source did not answer this time: a saved list answers, as it does a 5xx.
|
|
279
|
+
if (!response.data && response.status === "retryable_error") throw sourceDidNotAnswer("Market movers temporarily unavailable");
|
|
271
280
|
if (!response.data) throw new Error("Invalid market movers response");
|
|
272
281
|
return { data: parseScreenerResponse(response.data), stale: response.stale === true || response.data.stale === true };
|
|
273
282
|
};
|
|
@@ -392,7 +401,13 @@ export async function fetchPreferredMarketMovers(
|
|
|
392
401
|
const eligibility = sources.isCloudEligible();
|
|
393
402
|
if (!(typeof eligibility === "boolean" ? eligibility : await eligibility)) {
|
|
394
403
|
const result = await sources.fetchMarket(category, count, options);
|
|
395
|
-
return {
|
|
404
|
+
return {
|
|
405
|
+
...savedCopyMark(result),
|
|
406
|
+
quotes: rankScreenerQuotes(category, result.data),
|
|
407
|
+
source: "gloom",
|
|
408
|
+
stale: result.stale,
|
|
409
|
+
...(result.savedAt != null ? { savedAt: result.savedAt } : {}),
|
|
410
|
+
};
|
|
396
411
|
}
|
|
397
412
|
|
|
398
413
|
const marketMetadata = sources.fetchMarket(
|
|
@@ -432,6 +447,7 @@ export async function fetchPreferredMarketMovers(
|
|
|
432
447
|
quotes: rankScreenerQuotes(category, fallback.data),
|
|
433
448
|
source: "gloom",
|
|
434
449
|
stale: fallback.stale,
|
|
450
|
+
...(fallback.savedAt != null ? { savedAt: fallback.savedAt } : {}),
|
|
435
451
|
};
|
|
436
452
|
}
|
|
437
453
|
|
|
@@ -448,6 +464,7 @@ export async function fetchTrendingResult(
|
|
|
448
464
|
const load = async () => {
|
|
449
465
|
const response = await (api.getMarketTrending ?? apiClient.getMarketTrending)(count);
|
|
450
466
|
if (response.status === "empty") return { data: [], stale: response.stale === true };
|
|
467
|
+
if (!response.data && response.status === "retryable_error") throw sourceDidNotAnswer("Market trends temporarily unavailable");
|
|
451
468
|
if (!response.data) throw new Error("Market trends unavailable");
|
|
452
469
|
return { data: parseTrendingResponse(response.data), stale: response.stale === true };
|
|
453
470
|
};
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "../../../api-client/market-movers";
|
|
7
7
|
import { usePlanAccess } from "../../../api-client/plan-access";
|
|
8
8
|
import { DataTableView, EmptyState, QueryBar, usePaneFooter, type DataTableKeyEvent } from "../../../components";
|
|
9
|
+
import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
|
|
9
10
|
import { handleRefreshKey } from "../../../components/data-table/table-pane";
|
|
10
11
|
import {
|
|
11
12
|
buildScreenerQuoteTargets,
|
|
@@ -201,7 +202,7 @@ function SessionMoversTable({ view, session, focused, width, summaryQuotes, live
|
|
|
201
202
|
id: "feed",
|
|
202
203
|
parts: [{ text: feedStatus, tone: feedStatus === "live" ? "value" as const : "muted" as const }],
|
|
203
204
|
}] : []),
|
|
204
|
-
...(payload?.stale ? [
|
|
205
|
+
...(payload?.stale ? [STALE_SEGMENT] : []),
|
|
205
206
|
// A float the source does not date says so once, while the FLOAT column shows.
|
|
206
207
|
...partialFloatFooter(columns.some((column) => column.id === "floatShares"), rows),
|
|
207
208
|
],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { MoneyMarketObservation } from "../../../api-client/money-markets";
|
|
2
2
|
import type { SeriesDefinition } from "../../../series/types";
|
|
3
|
-
import {
|
|
3
|
+
import { cachedSeriesNote } from "../shared/series-warning";
|
|
4
4
|
import { loadMoneyMarkets } from "./client";
|
|
5
5
|
|
|
6
6
|
function observations(history: readonly MoneyMarketObservation[]) {
|
|
@@ -21,8 +21,7 @@ export const MONEY_MARKET_SERIES: readonly SeriesDefinition[] = [
|
|
|
21
21
|
tags: ["liquidity", "Fed balance sheet", "TGA", "RRP", "money markets"],
|
|
22
22
|
load: async () => {
|
|
23
23
|
const resource = await loadMoneyMarkets();
|
|
24
|
-
|
|
25
|
-
return { points: observations(resource.payload.netLiquidity.history), ...(warning ? { warning } : {}) };
|
|
24
|
+
return { points: observations(resource.payload.netLiquidity.history), ...cachedSeriesNote(resource) };
|
|
26
25
|
},
|
|
27
26
|
},
|
|
28
27
|
{
|
|
@@ -34,8 +33,7 @@ export const MONEY_MARKET_SERIES: readonly SeriesDefinition[] = [
|
|
|
34
33
|
tags: ["bills", "curve", "money markets"],
|
|
35
34
|
load: async () => {
|
|
36
35
|
const resource = await loadMoneyMarkets();
|
|
37
|
-
|
|
38
|
-
return { points: observations(resource.payload.billsCurve.slope.history), ...(warning ? { warning } : {}) };
|
|
36
|
+
return { points: observations(resource.payload.billsCurve.slope.history), ...cachedSeriesNote(resource) };
|
|
39
37
|
},
|
|
40
38
|
},
|
|
41
39
|
];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { apiClient } from "../../../api-client";
|
|
2
|
+
import { sourceDidNotAnswer } from "../../../api-client/errors";
|
|
2
3
|
import type { GasStorageBoard, GasStorageHistory, GasStorageRow, GasStorageSeasonal } from "../../../api-client/europe-energy";
|
|
3
4
|
import { createPluginCache } from "../../../data/plugin-cache";
|
|
4
5
|
import { cachedCloudResource, loadCloudResource, unavailableOnServer } from "../shared/cloud-resource";
|
|
@@ -77,7 +78,7 @@ export async function fetchGasStorage(client: Pick<GasApi, "getCloudEuropeGas">
|
|
|
77
78
|
} catch (error) {
|
|
78
79
|
throw unavailableOnServer(error, GAS_STORAGE_NOT_AVAILABLE, [404, 503]);
|
|
79
80
|
}
|
|
80
|
-
if (board.status === "unavailable") throw
|
|
81
|
+
if (board.status === "unavailable") throw sourceDidNotAnswer(GAS_STORAGE_NOT_AVAILABLE);
|
|
81
82
|
return board;
|
|
82
83
|
}
|
|
83
84
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { apiClient } from "../../../api-client";
|
|
2
|
+
import { sourceDidNotAnswer } from "../../../api-client/errors";
|
|
2
3
|
import type { CarbonBoard, CarbonHistory, EuropePowerBoard, EuropePowerHistory, PowerPriceRow } from "../../../api-client/europe-energy";
|
|
3
4
|
import { createPluginCache } from "../../../data/plugin-cache";
|
|
4
5
|
import { cachedCloudResource, loadCloudResource, unavailableOnServer } from "../shared/cloud-resource";
|
|
@@ -87,7 +88,7 @@ export async function fetchPowerPrices(client: Pick<PricesApi, "getCloudEuropePo
|
|
|
87
88
|
} catch (error) {
|
|
88
89
|
throw unavailableOnServer(error, POWER_PRICES_NOT_AVAILABLE, [404, 503]);
|
|
89
90
|
}
|
|
90
|
-
if (board.status === "unavailable") throw
|
|
91
|
+
if (board.status === "unavailable") throw sourceDidNotAnswer(POWER_PRICES_NOT_AVAILABLE);
|
|
91
92
|
return board;
|
|
92
93
|
}
|
|
93
94
|
|
|
@@ -110,7 +111,7 @@ export async function fetchCarbonPrice(client: Pick<PricesApi, "getCloudEuropeCa
|
|
|
110
111
|
} catch (error) {
|
|
111
112
|
throw unavailableOnServer(error, CARBON_PRICE_NOT_AVAILABLE, [404, 503]);
|
|
112
113
|
}
|
|
113
|
-
if (board.status === "unavailable") throw
|
|
114
|
+
if (board.status === "unavailable") throw sourceDidNotAnswer(CARBON_PRICE_NOT_AVAILABLE);
|
|
114
115
|
return board;
|
|
115
116
|
}
|
|
116
117
|
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
type CloudRiskReportListPayload,
|
|
4
4
|
type CloudRiskReportPayload,
|
|
5
5
|
} from "../../../api-client";
|
|
6
|
-
import { ApiRequestError, isPermanentClientError } from "../../../api-client/errors";
|
|
6
|
+
import { ApiRequestError, isPermanentClientError, isTransientFailure } from "../../../api-client/errors";
|
|
7
7
|
import { listingAbroad, type IssuerListingParams } from "../../../api-client/paths";
|
|
8
8
|
import { servedFromSavedCopy } from "../../../data/saved-copies";
|
|
9
9
|
import type { HeadlessPaneApiClient, PluginPersistence } from "../../../types/plugin";
|
|
@@ -21,6 +21,8 @@ type RiskApiClient = Pick<HeadlessPaneApiClient, "getRiskReports" | "getRiskRepo
|
|
|
21
21
|
interface RiskFreshness {
|
|
22
22
|
fetchedAt: number;
|
|
23
23
|
stale: boolean;
|
|
24
|
+
/** Its refresh failed and the copy saved at this time (ms) answered instead. */
|
|
25
|
+
savedAt?: number;
|
|
24
26
|
refreshError?: string;
|
|
25
27
|
errorStatus?: number;
|
|
26
28
|
}
|
|
@@ -72,9 +74,10 @@ function loadCached<T extends object>(
|
|
|
72
74
|
// A failed forced refresh must be retried when this pane is reopened, even
|
|
73
75
|
// when the previously cached value has not reached its ordinary TTL yet.
|
|
74
76
|
if (current()) failedRefreshes.set(failedKey, store);
|
|
75
|
-
// A
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
// A saved copy answers only for a failure asking again may get past: a refusal or a reply that
|
|
78
|
+
// fails validation is the error, and a missing or denied resource also drops its copy.
|
|
79
|
+
if (!isTransientFailure(error)) {
|
|
80
|
+
if (isPermanentClientError(error) && current()) store?.deleteResource(kind, key, { sourceKey: CACHE_SOURCE });
|
|
78
81
|
throw error;
|
|
79
82
|
}
|
|
80
83
|
const fallback = store?.getResource<T>(kind, key, { ...options, allowExpired: true });
|
|
@@ -84,6 +87,7 @@ function loadCached<T extends object>(
|
|
|
84
87
|
...fallback.value,
|
|
85
88
|
fetchedAt: fallback.fetchedAt,
|
|
86
89
|
stale: true,
|
|
90
|
+
savedAt: fallback.fetchedAt,
|
|
87
91
|
refreshError: (error instanceof Error ? error.message : String(error)).trim() || "Request failed",
|
|
88
92
|
errorStatus: error instanceof ApiRequestError ? error.status : undefined,
|
|
89
93
|
}, fallback.fetchedAt);
|
|
@@ -21,7 +21,8 @@ const description =
|
|
|
21
21
|
* filing list, which needs no account: `filings` lists them with a link to
|
|
22
22
|
* each, where Item 1A is the risk factors as written, without RISK's grouping
|
|
23
23
|
* or its changes since the year before. A foreign issuer files a 20-F instead
|
|
24
|
-
* (ASML), whose Item 3.D holds them.
|
|
24
|
+
* (ASML), whose Item 3.D holds them. A company that files neither (IAG on
|
|
25
|
+
* LSE) has nothing in RISK either, and the note says so.
|
|
25
26
|
*/
|
|
26
27
|
function riskFactorsLockNote(lock: PaneLock): Promise<string> {
|
|
27
28
|
return proLockNote(lock, {
|
|
@@ -35,6 +36,7 @@ function riskFactorsLockNote(lock: PaneLock): Promise<string> {
|
|
|
35
36
|
text: (ticker, company) => `gloom filings ${ticker} --form 20-F lists ${company}'s annual reports, each with its filing date and a link to the filing, whose Item 3.D holds the risk factors as written.`,
|
|
36
37
|
answers: (symbol, context) => filesForm(symbol, "20-F", context),
|
|
37
38
|
}],
|
|
39
|
+
noneFor: (ticker) => `${ticker}'s company files no 10-K or 20-F with the SEC, so RISK has no risk factors for it.`,
|
|
38
40
|
});
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type PaneFooterSegment,
|
|
9
9
|
type PaneHint,
|
|
10
10
|
} from "../../../components";
|
|
11
|
+
import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
|
|
11
12
|
import { useAsyncResource } from "../../../react/async-resource";
|
|
12
13
|
import { colors } from "../../../theme/colors";
|
|
13
14
|
import {
|
|
@@ -119,8 +120,9 @@ function RiskFactorsReader({
|
|
|
119
120
|
const reportLoader = useCallback((force: boolean) => guard(loadRiskReport(ticker!, year!, { force, listing })), [guard, listing, ticker, year]);
|
|
120
121
|
const detail = useAsyncResource(ticker && year !== null ? reportLoader : null, { clearOnError: isPermanentClientError });
|
|
121
122
|
const report = detail.data;
|
|
122
|
-
|
|
123
|
-
const
|
|
123
|
+
// A copy saved earlier answering a failed refresh says when it was saved, in the footer every pane uses, not as an error.
|
|
124
|
+
const listError = list.error ?? (list.data?.savedAt == null ? list.data?.refreshError : null) ?? null;
|
|
125
|
+
const reportError = detail.error ?? (report?.savedAt == null ? report?.refreshError : null) ?? null;
|
|
124
126
|
const refresh = useCallback(() => {
|
|
125
127
|
void list.reload();
|
|
126
128
|
if (year !== null) void detail.reload();
|
|
@@ -151,12 +153,7 @@ function RiskFactorsReader({
|
|
|
151
153
|
info.push({ id: "loading", parts: [{ text: "loading", tone: "muted" }] });
|
|
152
154
|
if (listError) info.push({ id: "list-error", parts: [{ text: `Report list: ${listError}`, tone: "warning" }] });
|
|
153
155
|
if (reportError) info.push({ id: "report-error", parts: [{ text: `${year} report: ${reportError}`, tone: "warning" }] });
|
|
154
|
-
|
|
155
|
-
info.push({ id: "list-stale", parts: [{ text: `List cached ${new Date(list.data.fetchedAt).toISOString()}`, tone: "muted" }] });
|
|
156
|
-
}
|
|
157
|
-
if ((reportError || report?.stale) && report) {
|
|
158
|
-
info.push({ id: "report-stale", parts: [{ text: `Report cached ${new Date(report.fetchedAt).toISOString()}`, tone: "muted" }] });
|
|
159
|
-
}
|
|
156
|
+
info.push(...savedCopyFooterInfo([list.data?.savedAt, report?.savedAt]));
|
|
160
157
|
const hints: PaneHint[] = report?.docUrl
|
|
161
158
|
? [{ id: "open", key: "o", label: "pen filing", onPress: openFiling }]
|
|
162
159
|
: [];
|
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
import type { PaneLock } from "../../../types/plugin";
|
|
2
|
-
import {
|
|
2
|
+
import { isOptionsUnavailableError } from "../../../market-data/options-alternatives";
|
|
3
|
+
import { resolveHeadlessInstrument } from "../shared/headless-market-data";
|
|
4
|
+
import { proLockNote, type FreeRoute } from "../shared/pro-lock-note";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The listing's option chain, which needs no account, where options are
|
|
8
|
+
* served for it: LHA:XETRA has none, and `options LHA:XETRA` says so.
|
|
9
|
+
*/
|
|
10
|
+
const OPTIONS_ROUTE: FreeRoute = {
|
|
11
|
+
text: (ticker, company) => `gloom options ${ticker} lists ${company}'s option chain, each contract with its volume and open interest.`,
|
|
12
|
+
answers: async (symbol, context) => {
|
|
13
|
+
const { symbol: ticker, exchange } = await resolveHeadlessInstrument(context, symbol);
|
|
14
|
+
if (!context.marketData.getOptionsChain) return false;
|
|
15
|
+
try {
|
|
16
|
+
const chain = await context.marketData.getOptionsChain(ticker, exchange ?? "");
|
|
17
|
+
return chain.expirationDates.length + chain.calls.length + chain.puts.length > 0;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (isOptionsUnavailableError(error)) return false;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
};
|
|
3
24
|
|
|
4
25
|
/**
|
|
5
26
|
* FLOW is Pro whole: the one scanner with no delayed tier to fall back to.
|
|
6
27
|
* The options chain needs no account and lists each contract's day volume and
|
|
7
28
|
* open interest, the raw counts behind a sweep or a block, without the prints.
|
|
8
29
|
* FLOW takes a ticker only to narrow the market to one name, so the note
|
|
9
|
-
* repeats it only when the reader gave one
|
|
30
|
+
* repeats it only when the reader gave one, and only when the listing has
|
|
31
|
+
* options served: FLOW's prints are of the same US options.
|
|
10
32
|
*/
|
|
11
33
|
export function flowLockNote(lock: PaneLock): Promise<string> {
|
|
12
34
|
return proLockNote(lock, {
|
|
@@ -14,6 +36,7 @@ export function flowLockNote(lock: PaneLock): Promise<string> {
|
|
|
14
36
|
argument: "optional",
|
|
15
37
|
does: "it streams real-time sweeps, blocks and large premium prints across US options exchanges",
|
|
16
38
|
shows: "it",
|
|
17
|
-
free: [
|
|
39
|
+
free: [OPTIONS_ROUTE],
|
|
40
|
+
noneFor: (ticker) => `Options are not available for ${ticker}, so FLOW has no prints of it.`,
|
|
18
41
|
});
|
|
19
42
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { logFailure } from "../../../utils/log-failure";
|
|
2
|
-
import { ApiRequestError
|
|
3
|
-
import type
|
|
2
|
+
import { ApiRequestError } from "../../../api-client/errors";
|
|
3
|
+
import { savedCopyForTransientFailure, type PluginCacheResult } from "../../../data/plugin-cache";
|
|
4
4
|
|
|
5
5
|
interface CloudCache<T> {
|
|
6
6
|
get(key: string, options?: { allowExpired?: boolean }): PluginCacheResult<T> | null;
|
|
@@ -38,9 +38,11 @@ const isAbort = (error: unknown) => !!error && typeof error === "object" && (err
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Loads through the cache, falling back to the cached copy when a refresh
|
|
41
|
-
* fails
|
|
42
|
-
* session, so the pane shows its sign-in wall
|
|
43
|
-
* no longer see,
|
|
41
|
+
* fails for a reason asking again may get past (`isTransientFailure`). A
|
|
42
|
+
* refusal still throws: a refused session, so the pane shows its sign-in wall
|
|
43
|
+
* instead of data the account can no longer see, any other permanent client
|
|
44
|
+
* error (an unknown series, a route not served), and a reply that fails
|
|
45
|
+
* validation.
|
|
44
46
|
*
|
|
45
47
|
* Callers of one key share the request in flight, which runs on the first
|
|
46
48
|
* caller's `signal`. When that caller goes away (a tab left while it loads,
|
|
@@ -60,8 +62,7 @@ export async function loadCloudResource<T>(
|
|
|
60
62
|
throw error;
|
|
61
63
|
});
|
|
62
64
|
if (!result || cancelledByOther(result.error)) result = await cache.load(key, fetch, { force });
|
|
63
|
-
const saved = result.source === "stale-fallback";
|
|
64
|
-
if (saved && isPermanentClientError(result.error)) throw result.error;
|
|
65
|
+
const saved = savedCopyForTransientFailure(result).source === "stale-fallback";
|
|
65
66
|
// A copy served because its refresh failed is one by where it came from, whatever the failure said (or did not).
|
|
66
67
|
return {
|
|
67
68
|
payload: validate(result.data), stale: result.stale, refreshError: result.refreshError ?? null,
|
|
@@ -72,7 +73,11 @@ export async function loadCloudResource<T>(
|
|
|
72
73
|
/**
|
|
73
74
|
* Turns a route the server does not serve yet (404, or the given statuses)
|
|
74
75
|
* into a message that says what is unavailable; other errors pass through.
|
|
76
|
+
* The status stays, so a 404 is still a refusal no saved copy answers for and
|
|
77
|
+
* a 503 still a failure one may.
|
|
75
78
|
*/
|
|
76
79
|
export function unavailableOnServer(error: unknown, message: string, statuses: readonly number[] = [404]): unknown {
|
|
77
|
-
return error instanceof ApiRequestError && statuses.includes(error.status ?? 0)
|
|
80
|
+
return error instanceof ApiRequestError && statuses.includes(error.status ?? 0)
|
|
81
|
+
? new ApiRequestError(message, error.status, error.retryAfterMs, error.code, error.details)
|
|
82
|
+
: error;
|
|
78
83
|
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { apiClient } from "../../../api-client";
|
|
2
2
|
import { loadCachedFredSeries } from "../../../sources/gloomberb-cloud/fred-series";
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* A FRED series from `start` on, oldest first, through the shared FRED cache;
|
|
6
|
+
* `savedAt` when a failed refresh left the copy saved then answering.
|
|
7
|
+
*/
|
|
5
8
|
export async function loadFredPoints(seriesId: string, start?: Date): Promise<{
|
|
6
9
|
points: Array<{ date: string; value: number | null }>;
|
|
7
10
|
stale: boolean;
|
|
8
11
|
refreshError?: string;
|
|
12
|
+
savedAt?: number;
|
|
9
13
|
}> {
|
|
10
14
|
const startDate = start ? start.toISOString().slice(0, 10) : "1900-01-01";
|
|
11
15
|
const result = await loadCachedFredSeries(
|
|
@@ -16,5 +20,6 @@ export async function loadFredPoints(seriesId: string, start?: Date): Promise<{
|
|
|
16
20
|
points: (result.data.observations ?? []).map((row) => ({ date: row.date, value: row.value })),
|
|
17
21
|
stale: result.stale,
|
|
18
22
|
...(result.refreshError ? { refreshError: result.refreshError } : {}),
|
|
23
|
+
...(result.source === "stale-fallback" ? { savedAt: result.fetchedAt } : {}),
|
|
19
24
|
};
|
|
20
25
|
}
|