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
|
@@ -51,6 +51,13 @@ export interface PaneFooterSegment {
|
|
|
51
51
|
* warning.
|
|
52
52
|
*/
|
|
53
53
|
capture?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* When the copy the pane shows was saved, on the saved-copy segment
|
|
56
|
+
* ({@link savedCopyFooterSegment}). The footer draws that segment first,
|
|
57
|
+
* wherever a pane registers it, and once: the oldest when several parts of
|
|
58
|
+
* the pane show a saved copy.
|
|
59
|
+
*/
|
|
60
|
+
savedCopyAt?: number;
|
|
54
61
|
/**
|
|
55
62
|
* Shorter forms of `parts`, longest first. When the row is too narrow for
|
|
56
63
|
* `parts` beside the hints, it shows the first that fits, or the last, and
|
|
@@ -113,12 +120,23 @@ export function savedCopyFooterSegment(id: string, savedAt: number, now = Date.n
|
|
|
113
120
|
return {
|
|
114
121
|
id,
|
|
115
122
|
quoteStatus: true,
|
|
123
|
+
savedCopyAt: savedAt,
|
|
116
124
|
title: `${t("Couldn't refresh")}: ${text}`,
|
|
117
125
|
parts: [{ text, tone: "warning" }],
|
|
118
126
|
variants: [[{ text: t("saved copy"), tone: "warning" }]],
|
|
119
127
|
};
|
|
120
128
|
}
|
|
121
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The saved-copy segment of a pane showing several answers (a list and the
|
|
132
|
+
* report it opens): the oldest of the copies among `savedAts` (ms), or no
|
|
133
|
+
* segment when none of them is one.
|
|
134
|
+
*/
|
|
135
|
+
export function savedCopyFooterInfo(savedAts: readonly (number | null | undefined)[], id = "saved-copy"): PaneFooterSegment[] {
|
|
136
|
+
const times = savedAts.filter((at): at is number => typeof at === "number" && Number.isFinite(at));
|
|
137
|
+
return times.length > 0 ? [savedCopyFooterSegment(id, Math.min(...times))] : [];
|
|
138
|
+
}
|
|
139
|
+
|
|
122
140
|
/**
|
|
123
141
|
* The footer's one form for company data a pane shows from another listing
|
|
124
142
|
* of the company: a receipt or a foreign line (SKHY, HY9H in Frankfurt) reads
|
|
@@ -450,7 +468,20 @@ export function combinePaneFooterRegistrations(registrations: Map<string, PaneFo
|
|
|
450
468
|
const shown = yielded.length > 0 ? hints.filter((hint) => !yielded.includes(hint)) : hints;
|
|
451
469
|
|
|
452
470
|
if (info.length === 0 && shown.length === 0 && menu.length === 0 && keys.length === 0) return EMPTY_FOOTER;
|
|
453
|
-
return { info, hints: shown, menu, keys };
|
|
471
|
+
return { info: savedCopyFirst(info), hints: shown, menu, keys };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The saved-copy segment in its one place: first, so it reads the same in
|
|
476
|
+
* every pane and is the last a narrow row cuts, and once, the oldest copy,
|
|
477
|
+
* when a list and the report it opens each register one.
|
|
478
|
+
*/
|
|
479
|
+
function savedCopyFirst(info: PaneFooterSegment[]): PaneFooterSegment[] {
|
|
480
|
+
let oldest: PaneFooterSegment | undefined;
|
|
481
|
+
for (const segment of info) {
|
|
482
|
+
if (segment.savedCopyAt != null && (oldest?.savedCopyAt == null || segment.savedCopyAt < oldest.savedCopyAt)) oldest = segment;
|
|
483
|
+
}
|
|
484
|
+
return oldest ? [oldest, ...info.filter((segment) => segment.savedCopyAt == null)] : info;
|
|
454
485
|
}
|
|
455
486
|
|
|
456
487
|
function sameFooterParts(left: PaneFooterPart[], right: PaneFooterPart[]): boolean {
|
|
@@ -507,6 +538,7 @@ export function samePaneFooterRegistration(
|
|
|
507
538
|
&& segment.shortcut === other.shortcut
|
|
508
539
|
&& !!segment.onPress === !!other.onPress
|
|
509
540
|
&& segment.disabled === other.disabled
|
|
541
|
+
&& segment.savedCopyAt === other.savedCopyAt
|
|
510
542
|
&& sameFooterParts(segment.parts, other.parts)
|
|
511
543
|
&& (segment.variants?.length ?? 0) === (other.variants?.length ?? 0)
|
|
512
544
|
&& (segment.variants ?? []).every((variant, variantIndex) => sameFooterParts(variant, other.variants![variantIndex]!));
|
|
@@ -178,13 +178,13 @@ export function PaneHeader({
|
|
|
178
178
|
event.stopPropagation?.();
|
|
179
179
|
onHeaderMouseDown?.(event);
|
|
180
180
|
};
|
|
181
|
-
const beginWindowDrag = (event?: { stopPropagation?: () => void }) => {
|
|
181
|
+
const beginWindowDrag = (event?: { stopPropagation?: () => void; detail?: number }) => {
|
|
182
182
|
event?.stopPropagation?.();
|
|
183
183
|
if (titleBar) {
|
|
184
184
|
onHeaderMouseDown?.(event);
|
|
185
185
|
return;
|
|
186
186
|
}
|
|
187
|
-
void rendererHost.startWindowDrag?.();
|
|
187
|
+
void rendererHost.startWindowDrag?.(event);
|
|
188
188
|
};
|
|
189
189
|
const barHandlers = windowDrag
|
|
190
190
|
? { onMouseDown: beginWindowDrag }
|
|
@@ -11,7 +11,8 @@ const EMPTY_STATUS_INFO: PaneFooterSegment[] = [];
|
|
|
11
11
|
// has no per-pane footer hint. Do not reintroduce one. See PR #589. Panes bind
|
|
12
12
|
// it with `usePaneRefreshKey`.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** The shared warning for data on screen older than it should be, for a pane composing its own footer. */
|
|
15
|
+
export const STALE_SEGMENT: PaneFooterSegment = { id: "stale", parts: [{ text: "stale", tone: "warning" }] };
|
|
15
16
|
|
|
16
17
|
interface PaneStatusInfoOptions {
|
|
17
18
|
loading?: boolean;
|
|
@@ -219,7 +219,8 @@ export function OnboardingCoach({
|
|
|
219
219
|
</Box>
|
|
220
220
|
<Box height={1} />
|
|
221
221
|
<Box flexGrow={1} minHeight={0}>{children}</Box>
|
|
222
|
-
|
|
222
|
+
{/* As OnboardingActions: a button that does not fit beside the one before it wraps under it. */}
|
|
223
|
+
<Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} flexShrink={0}>{actions}</Box>
|
|
223
224
|
</Box>
|
|
224
225
|
);
|
|
225
226
|
}
|
|
@@ -322,12 +322,15 @@ export function OnboardingWizard(props: OnboardingWizardProps) {
|
|
|
322
322
|
return <OnboardingCoach step={t("YOUR WORKSPACE")}
|
|
323
323
|
title={tf("Built around {ticker}", { ticker })}
|
|
324
324
|
actions={<>
|
|
325
|
-
{/*
|
|
326
|
-
|
|
325
|
+
{/* Each button shows its key, as Connect shows Alt+Enter; in the
|
|
326
|
+
terminal the second wraps under the first. The desktop card has no
|
|
327
|
+
room for both: like a toast, its dismiss key is in the tooltip.
|
|
328
|
+
F10 also dismisses it. */}
|
|
327
329
|
<OnboardingButton
|
|
328
330
|
label="Keep exploring"
|
|
329
331
|
variant="ghost"
|
|
330
|
-
|
|
332
|
+
shortcut={!desktop && notificationDismissShortcut ? notificationDismissShortcut : undefined}
|
|
333
|
+
title={desktop && notificationDismissShortcut ? `${t("Keep exploring")} (${notificationDismissShortcut})` : undefined}
|
|
331
334
|
onPress={() => { void finish(); }}
|
|
332
335
|
/>
|
|
333
336
|
<OnboardingButton
|
|
@@ -214,8 +214,18 @@ export interface BadgeProps {
|
|
|
214
214
|
export const NATIVE_BADGE_STYLE = { height: "calc(100% - 2px)", borderRadius: "var(--gloom-radius-sm)" };
|
|
215
215
|
/** How far a subtle chip's fill is tinted toward its tone. */
|
|
216
216
|
export const BADGE_TINT = 0.28;
|
|
217
|
-
const NATIVE_BADGE_TEXT_STYLE = { fontWeight: "var(--gloom-weight-regular)" };
|
|
218
217
|
const NATIVE_ICON_BADGE_STYLE = { ...NATIVE_BADGE_STYLE, gap: "var(--gloom-space-xs)" };
|
|
218
|
+
const NATIVE_BADGE_TEXT_STYLE = { fontWeight: "var(--gloom-weight-regular)" };
|
|
219
|
+
/**
|
|
220
|
+
* A label with lowercase letters has them in the middle of the chip,
|
|
221
|
+
* wherever the font puts them in its line: the line is cut to x-height and
|
|
222
|
+
* baseline, and that is what the chip centres. Centred whole, SF Mono's
|
|
223
|
+
* lowercase sit a pixel and a half low. An icon centres in the chip, so it
|
|
224
|
+
* reads on the letters' line. A label in capitals and digits keeps the
|
|
225
|
+
* whole line, as ticker chips do; a browser without `text-box` keeps it too.
|
|
226
|
+
*/
|
|
227
|
+
const NATIVE_LOWERCASE_BADGE_TEXT_STYLE = { ...NATIVE_BADGE_TEXT_STYLE, textBox: "trim-both ex alphabetic" };
|
|
228
|
+
const LOWERCASE = /\p{Ll}/u;
|
|
219
229
|
|
|
220
230
|
export function Badge({ label, tone = "neutral", color, variant = "subtle", icon, translate = true }: BadgeProps) {
|
|
221
231
|
const colors = useThemeColors();
|
|
@@ -233,11 +243,12 @@ export function Badge({ label, tone = "neutral", color, variant = "subtle", icon
|
|
|
233
243
|
const chip = (
|
|
234
244
|
<Box height={nativePaneChrome ? undefined : 1} paddingX={1} backgroundColor={background}
|
|
235
245
|
data-gloom-ui="badge" {...(nativeIcon ? { flexDirection: "row", alignItems: "center" } as const : {})}
|
|
236
|
-
justifyContent={nativePaneChrome && !
|
|
237
|
-
style={nativePaneChrome ?
|
|
238
|
-
{nativeIcon ? <Icon name={nativeIcon} color={foreground} /> : null}
|
|
246
|
+
justifyContent={nativePaneChrome && !nativeIcon ? "center" : undefined}
|
|
247
|
+
style={nativePaneChrome ? nativeIcon ? NATIVE_ICON_BADGE_STYLE : NATIVE_BADGE_STYLE : undefined}>
|
|
248
|
+
{nativeIcon ? <Icon name={nativeIcon} color={foreground} fullHeight /> : null}
|
|
239
249
|
{shown || !nativeIcon ? (
|
|
240
|
-
<Text fg={foreground} attributes={TextAttributes.BOLD}
|
|
250
|
+
<Text fg={foreground} attributes={TextAttributes.BOLD}
|
|
251
|
+
style={nativePaneChrome ? LOWERCASE.test(text) ? NATIVE_LOWERCASE_BADGE_TEXT_STYLE : NATIVE_BADGE_TEXT_STYLE : undefined}>{shown}</Text>
|
|
241
252
|
) : null}
|
|
242
253
|
</Box>
|
|
243
254
|
);
|
|
@@ -94,13 +94,19 @@ export interface IconProps {
|
|
|
94
94
|
/** Pixel size on the desktop; the terminal draws one glyph. */
|
|
95
95
|
size?: number;
|
|
96
96
|
color?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Desktop: takes its row's full height and draws in the middle of it. A
|
|
99
|
+
* box centred in a row an odd number of pixels taller lands on a whole
|
|
100
|
+
* pixel, half a pixel off; the drawing inside the box does not.
|
|
101
|
+
*/
|
|
102
|
+
fullHeight?: boolean;
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
/** One glyph from the app's icon set. */
|
|
100
|
-
export function Icon({ name, size = 12, color }: IconProps) {
|
|
106
|
+
export function Icon({ name, size = 12, color, fullHeight }: IconProps) {
|
|
101
107
|
const colors = useThemeColors();
|
|
102
108
|
const HostIcon = useUiHost().Icon as ComponentType<IconProps> | undefined;
|
|
103
|
-
if (HostIcon) return <HostIcon name={name} size={size} color={color} />;
|
|
109
|
+
if (HostIcon) return <HostIcon name={name} size={size} color={color} fullHeight={fullHeight} />;
|
|
104
110
|
return <Text fg={color ?? colors.textDim} selectable={false}>{ICON_GLYPHS[name]}</Text>;
|
|
105
111
|
}
|
|
106
112
|
|
package/src/core/app-services.ts
CHANGED
|
@@ -10,8 +10,17 @@ import { createAppRuntime } from "./app-runtime";
|
|
|
10
10
|
|
|
11
11
|
export type AppServices = ReturnType<typeof createAppServices>;
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
interface AppServicesOptions extends AppServicesFactoryOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Runs where the user waits on it (the terminal, the desktop app), so no
|
|
16
|
+
* database statement waits on another program's lock (data/sqlite/access.ts).
|
|
17
|
+
* A command sets it to false and waits instead.
|
|
18
|
+
*/
|
|
19
|
+
interactive?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createAppServices({ config, plugins, externalPlugins, interactive = true }: AppServicesOptions) {
|
|
23
|
+
const persistence = new AppPersistence(join(config.dataDir, ".gloomberb-cache.db"), { interactive });
|
|
15
24
|
const tickerRepository = new TickerRepository(persistence.tickers);
|
|
16
25
|
const providerRouter = new AssetDataRouter(null, [], persistence.resources, connectionHealth);
|
|
17
26
|
const runtime = createAppRuntime({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { PluginStateStore } from "./plugin-state-store";
|
|
2
2
|
import { ResourceStore } from "./resource-store";
|
|
3
3
|
import { SessionStore } from "./session-store";
|
|
4
|
-
import { SqliteDatabase } from "./sqlite/database";
|
|
4
|
+
import { SqliteDatabase, type SqliteDatabaseOptions } from "./sqlite/database";
|
|
5
5
|
import { TickerStore } from "./ticker-store";
|
|
6
6
|
|
|
7
7
|
export class AppPersistence {
|
|
@@ -11,15 +11,17 @@ export class AppPersistence {
|
|
|
11
11
|
readonly pluginState: PluginStateStore;
|
|
12
12
|
readonly sessions: SessionStore;
|
|
13
13
|
|
|
14
|
-
constructor(dbPath: string) {
|
|
15
|
-
this.database = new SqliteDatabase(dbPath);
|
|
16
|
-
|
|
17
|
-
this.
|
|
18
|
-
this.
|
|
19
|
-
this.
|
|
14
|
+
constructor(dbPath: string, options: SqliteDatabaseOptions = {}) {
|
|
15
|
+
this.database = new SqliteDatabase(dbPath, options);
|
|
16
|
+
const { connection, access } = this.database;
|
|
17
|
+
this.tickers = new TickerStore(connection, access);
|
|
18
|
+
this.resources = new ResourceStore(connection, access);
|
|
19
|
+
this.pluginState = new PluginStateStore(connection, access);
|
|
20
|
+
this.sessions = new SessionStore(connection, access);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
close(): void {
|
|
24
|
+
this.resources.close();
|
|
23
25
|
this.database.close();
|
|
24
26
|
}
|
|
25
27
|
}
|
package/src/data/plugin-cache.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { logFailure } from "../utils/log-failure";
|
|
|
2
2
|
import type { CachePolicy } from "../types/persistence";
|
|
3
3
|
import type { PluginPersistence } from "../types/plugin";
|
|
4
4
|
import { CachedQuery, type CachedValue } from "./cached-query";
|
|
5
|
+
import { isTransientFailure } from "../api-client/errors";
|
|
5
6
|
|
|
6
7
|
export interface PluginCacheResult<T> {
|
|
7
8
|
data: T;
|
|
@@ -12,6 +13,20 @@ export interface PluginCacheResult<T> {
|
|
|
12
13
|
source: "cache" | "network" | "stale-fallback";
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* `result`, or the failure of the refresh it stands in for when a saved copy
|
|
18
|
+
* may not answer for it. A saved copy covers only a failure asking again may
|
|
19
|
+
* get past (`isTransientFailure`: no connection, a timeout, a rate-limited or
|
|
20
|
+
* overloaded server, a reply whose source did not answer this time); a
|
|
21
|
+
* refusal (another 4xx, such as a series no longer served or a signed-out
|
|
22
|
+
* session) and an answer that failed validation are thrown, so they show as
|
|
23
|
+
* the errors they are instead of as old data.
|
|
24
|
+
*/
|
|
25
|
+
export function savedCopyForTransientFailure<R extends { source?: PluginCacheResult<unknown>["source"]; error?: unknown }>(result: R): R {
|
|
26
|
+
if (result.source === "stale-fallback" && !isTransientFailure(result.error)) throw result.error;
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
/** Shared persistence/request lifecycle; keys, codecs, validation and policies stay with the plugin. */
|
|
16
31
|
export function createPluginCache<T, Stored = T>(options: {
|
|
17
32
|
kind: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { createSqliteAccess, type SqliteAccess } from "./sqlite/access";
|
|
2
3
|
import { safeParseJson, serializeJson } from "./sqlite/json";
|
|
3
|
-
import { withSqliteBusyRetry } from "./sqlite/retry";
|
|
4
4
|
|
|
5
5
|
const DEFAULT_PLUGIN_STATE_SCHEMA_VERSION = 1;
|
|
6
6
|
|
|
@@ -24,14 +24,39 @@ interface CachedPluginStateRecord {
|
|
|
24
24
|
rawValue: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** A queued write's record (null for a delete), one object per write. */
|
|
28
|
+
interface PendingPluginState {
|
|
29
|
+
pluginId: string;
|
|
30
|
+
key: string;
|
|
31
|
+
record: CachedPluginStateRecord | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stateKey(pluginId: string, key: string): string {
|
|
35
|
+
return `${pluginId}:${key}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A queued write's row, unambiguous where ids and keys hold colons. */
|
|
39
|
+
function rowKey(pluginId: string, key: string): string {
|
|
40
|
+
return `${pluginId}\u0000${key}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
export class PluginStateStore {
|
|
28
44
|
private readonly cache = new Map<string, CachedPluginStateRecord>();
|
|
45
|
+
/** Writes queued behind another program's lock, read back until they land. */
|
|
46
|
+
private readonly pending = new Map<string, PendingPluginState>();
|
|
29
47
|
|
|
30
|
-
constructor(
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly db: Database,
|
|
50
|
+
private readonly access: SqliteAccess = createSqliteAccess(db),
|
|
51
|
+
) {}
|
|
31
52
|
|
|
32
53
|
get<T>(pluginId: string, key: string, schemaVersion = DEFAULT_PLUGIN_STATE_SCHEMA_VERSION): PluginStateRecord<T> | null {
|
|
33
|
-
const cacheKey =
|
|
34
|
-
const
|
|
54
|
+
const cacheKey = stateKey(pluginId, key);
|
|
55
|
+
const pending = this.pending.get(rowKey(pluginId, key));
|
|
56
|
+
if (pending) {
|
|
57
|
+
return pending.record?.schemaVersion === schemaVersion ? pending.record as PluginStateRecord<T> : null;
|
|
58
|
+
}
|
|
59
|
+
const row = this.access.run("load plugin state", () => (
|
|
35
60
|
this.db
|
|
36
61
|
.query<{ schema_version: number; value: string; updated_at: number }, [string, string]>(
|
|
37
62
|
"SELECT schema_version, value, updated_at FROM plugin_state WHERE plugin_id = ? AND key = ?",
|
|
@@ -77,46 +102,42 @@ export class PluginStateStore {
|
|
|
77
102
|
setMany(entries: PluginStateSetEntry[]): void {
|
|
78
103
|
if (entries.length === 0) return;
|
|
79
104
|
const updatedAt = Date.now();
|
|
80
|
-
const
|
|
105
|
+
const writes: PendingPluginState[] = entries.map((entry) => ({
|
|
81
106
|
pluginId: entry.pluginId,
|
|
82
107
|
key: entry.key,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
108
|
+
record: {
|
|
109
|
+
value: entry.value,
|
|
110
|
+
schemaVersion: entry.schemaVersion ?? DEFAULT_PLUGIN_STATE_SCHEMA_VERSION,
|
|
111
|
+
updatedAt,
|
|
112
|
+
rawValue: serializeJson(entry.value),
|
|
113
|
+
},
|
|
86
114
|
}));
|
|
87
115
|
|
|
88
|
-
|
|
116
|
+
this.write("save plugin state", writes, writes.length === 1, () => {
|
|
89
117
|
const insert = this.db.query(
|
|
90
118
|
`INSERT OR REPLACE INTO plugin_state (plugin_id, key, schema_version, value, updated_at)
|
|
91
119
|
VALUES (?, ?, ?, ?, ?)`,
|
|
92
120
|
);
|
|
93
121
|
const tx = this.db.transaction(() => {
|
|
94
|
-
for (const record of
|
|
95
|
-
insert.run(
|
|
122
|
+
for (const { pluginId, key, record } of writes) {
|
|
123
|
+
insert.run(pluginId, key, record!.schemaVersion, record!.rawValue, updatedAt);
|
|
96
124
|
}
|
|
97
125
|
});
|
|
98
126
|
tx();
|
|
99
127
|
});
|
|
100
128
|
|
|
101
|
-
for (const record of
|
|
102
|
-
this.cache.set(`${record.pluginId}:${record.key}`, {
|
|
103
|
-
value: record.value,
|
|
104
|
-
schemaVersion: record.schemaVersion,
|
|
105
|
-
updatedAt,
|
|
106
|
-
rawValue: record.rawValue,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
129
|
+
for (const { pluginId, key, record } of writes) this.cache.set(stateKey(pluginId, key), record!);
|
|
109
130
|
}
|
|
110
131
|
|
|
111
132
|
delete(pluginId: string, key: string): void {
|
|
112
|
-
|
|
133
|
+
this.write("delete plugin state", [{ pluginId, key, record: null }], true, () => {
|
|
113
134
|
this.db.query("DELETE FROM plugin_state WHERE plugin_id = ? AND key = ?").run(pluginId, key);
|
|
114
135
|
});
|
|
115
|
-
this.cache.delete(
|
|
136
|
+
this.cache.delete(stateKey(pluginId, key));
|
|
116
137
|
}
|
|
117
138
|
|
|
118
139
|
keys(pluginId: string): string[] {
|
|
119
|
-
|
|
140
|
+
const stored = this.access.run("list plugin state keys", () => (
|
|
120
141
|
this.db
|
|
121
142
|
.query<{ key: string }, [string]>(
|
|
122
143
|
"SELECT key FROM plugin_state WHERE plugin_id = ? ORDER BY key",
|
|
@@ -124,10 +145,18 @@ export class PluginStateStore {
|
|
|
124
145
|
.all(pluginId)
|
|
125
146
|
.map((row) => row.key)
|
|
126
147
|
));
|
|
148
|
+
if (this.pending.size === 0) return stored;
|
|
149
|
+
const keys = new Set(stored);
|
|
150
|
+
for (const entry of this.pending.values()) {
|
|
151
|
+
if (entry.pluginId !== pluginId) continue;
|
|
152
|
+
if (entry.record) keys.add(entry.key);
|
|
153
|
+
else keys.delete(entry.key);
|
|
154
|
+
}
|
|
155
|
+
return [...keys].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
127
156
|
}
|
|
128
157
|
|
|
129
158
|
pluginIds(): string[] {
|
|
130
|
-
|
|
159
|
+
const stored = this.access.run("list plugin state ids", () => (
|
|
131
160
|
this.db
|
|
132
161
|
.query<{ plugin_id: string }, []>(
|
|
133
162
|
"SELECT DISTINCT plugin_id FROM plugin_state ORDER BY plugin_id",
|
|
@@ -135,10 +164,19 @@ export class PluginStateStore {
|
|
|
135
164
|
.all()
|
|
136
165
|
.map((row) => row.plugin_id)
|
|
137
166
|
));
|
|
167
|
+
if (this.pending.size === 0) return stored;
|
|
168
|
+
const ids = new Set(stored);
|
|
169
|
+
for (const { pluginId } of this.pending.values()) {
|
|
170
|
+
if (this.keys(pluginId).length > 0) ids.add(pluginId);
|
|
171
|
+
else ids.delete(pluginId);
|
|
172
|
+
}
|
|
173
|
+
return [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
138
174
|
}
|
|
139
175
|
|
|
140
176
|
clear(pluginId: string): void {
|
|
141
|
-
|
|
177
|
+
const cleared = this.keys(pluginId).map((key): PendingPluginState => ({ pluginId, key, record: null }));
|
|
178
|
+
// Deletes rows this store may not know of: never one row's write.
|
|
179
|
+
this.write("clear plugin state", cleared, false, () => {
|
|
142
180
|
this.db.query("DELETE FROM plugin_state WHERE plugin_id = ?").run(pluginId);
|
|
143
181
|
});
|
|
144
182
|
for (const cacheKey of this.cache.keys()) {
|
|
@@ -147,4 +185,23 @@ export class PluginStateStore {
|
|
|
147
185
|
}
|
|
148
186
|
}
|
|
149
187
|
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Runs `statement` through the write queue, reading `entries` back until it
|
|
191
|
+
* lands. A write of exactly one row (`oneRow`) replaces a queued write of
|
|
192
|
+
* the same row.
|
|
193
|
+
*/
|
|
194
|
+
private write(operationName: string, entries: PendingPluginState[], oneRow: boolean, statement: () => void): void {
|
|
195
|
+
for (const entry of entries) this.pending.set(rowKey(entry.pluginId, entry.key), entry);
|
|
196
|
+
const only = oneRow && entries.length === 1 ? entries[0] : undefined;
|
|
197
|
+
this.access.write(operationName, statement, {
|
|
198
|
+
...(only ? { key: `plugin_state\u0000${rowKey(only.pluginId, only.key)}` } : {}),
|
|
199
|
+
settled: () => {
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
const pendingKey = rowKey(entry.pluginId, entry.key);
|
|
202
|
+
if (this.pending.get(pendingKey) === entry) this.pending.delete(pendingKey);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
150
207
|
}
|