@gloom-sh/terminal 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.
Files changed (138) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/errors.ts +38 -0
  3. package/src/app/runtime/startup.ts +3 -1
  4. package/src/capabilities/chart-series.ts +2 -1
  5. package/src/cli/commands/market.ts +9 -4
  6. package/src/cli/commands/overview.ts +5 -6
  7. package/src/cli/commands/search.ts +13 -13
  8. package/src/cli/context.ts +1 -0
  9. package/src/cli/glossary.ts +2 -1
  10. package/src/cli/history-rows.ts +35 -7
  11. package/src/cli/known-gaps.ts +11 -0
  12. package/src/cli/listing-arg.ts +17 -1
  13. package/src/cli/quote-answer.ts +29 -6
  14. package/src/components/command-bar/routes/root/runtime.ts +27 -5
  15. package/src/components/command-bar/selection-runtime.ts +12 -29
  16. package/src/components/layout/detached-pane-shell.tsx +2 -2
  17. package/src/components/layout/header.tsx +2 -2
  18. package/src/components/layout/pane/footer/index.tsx +1 -0
  19. package/src/components/layout/pane/footer/model.ts +33 -1
  20. package/src/components/layout/pane/header.tsx +2 -2
  21. package/src/components/layout/pane/status-footer.ts +2 -1
  22. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  23. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  24. package/src/components/ui/display.tsx +16 -5
  25. package/src/components/ui/icon.tsx +8 -2
  26. package/src/core/app-services.ts +11 -2
  27. package/src/data/app-persistence.ts +9 -7
  28. package/src/data/plugin-cache.ts +15 -0
  29. package/src/data/plugin-state-store.ts +81 -24
  30. package/src/data/resource-store.ts +130 -58
  31. package/src/data/session-store.ts +31 -8
  32. package/src/data/sqlite/access.ts +313 -0
  33. package/src/data/sqlite/checkpointer.ts +198 -0
  34. package/src/data/sqlite/database.ts +47 -4
  35. package/src/data/sqlite/retry.ts +50 -9
  36. package/src/data/ticker-store.ts +57 -23
  37. package/src/market-data/realized-volatility.ts +4 -2
  38. package/src/plugins/auto-update-node.ts +10 -6
  39. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  40. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  41. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  42. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  43. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  44. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  45. package/src/plugins/builtin/cot/client.ts +28 -2
  46. package/src/plugins/builtin/cot/headless.ts +4 -1
  47. package/src/plugins/builtin/cot/index.tsx +4 -2
  48. package/src/plugins/builtin/cot/model.ts +58 -7
  49. package/src/plugins/builtin/cot/pane.tsx +6 -2
  50. package/src/plugins/builtin/cpi/client.ts +6 -5
  51. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  52. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  53. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  54. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  55. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  56. package/src/plugins/builtin/doe/client.ts +6 -5
  57. package/src/plugins/builtin/doe/prices.ts +2 -3
  58. package/src/plugins/builtin/doe/series.ts +4 -4
  59. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  60. package/src/plugins/builtin/earnings-calls/data.ts +19 -11
  61. package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
  62. package/src/plugins/builtin/econ/detail-view.tsx +16 -2
  63. package/src/plugins/builtin/executives/data.ts +6 -1
  64. package/src/plugins/builtin/executives/pane.tsx +9 -21
  65. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  66. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  67. package/src/plugins/builtin/gpu/client.ts +4 -1
  68. package/src/plugins/builtin/gpu/series.ts +2 -3
  69. package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
  70. package/src/plugins/builtin/jodi/client.ts +4 -1
  71. package/src/plugins/builtin/market-movers/client.ts +1 -0
  72. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  73. package/src/plugins/builtin/market-movers/screener.ts +23 -6
  74. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  75. package/src/plugins/builtin/money-markets/series.ts +3 -5
  76. package/src/plugins/builtin/power/gas-client.ts +2 -1
  77. package/src/plugins/builtin/power/prices-client.ts +3 -2
  78. package/src/plugins/builtin/risk-factors/data.ts +8 -4
  79. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  80. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  81. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  82. package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
  83. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  84. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  85. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  86. package/src/plugins/builtin/short-volume/series.ts +2 -3
  87. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  88. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  89. package/src/plugins/builtin/thirteenf/api.ts +11 -16
  90. package/src/plugins/builtin/thirteenf/data.ts +34 -6
  91. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  92. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  93. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  94. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  95. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  96. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  97. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  98. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  99. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  100. package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
  101. package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
  102. package/src/plugins/builtin/volatility/client.ts +43 -16
  103. package/src/plugins/builtin/volatility/headless.ts +5 -2
  104. package/src/plugins/builtin/volatility/model.ts +17 -6
  105. package/src/plugins/builtin/volatility/pane.tsx +6 -2
  106. package/src/plugins/builtin/volatility/series.ts +2 -1
  107. package/src/plugins/builtin/yield-curve/client.ts +4 -6
  108. package/src/plugins/installer.ts +50 -39
  109. package/src/renderers/dom/desktop/icons.tsx +3 -2
  110. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  111. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  112. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  113. package/src/renderers/electrobun/bun/window/create.ts +2 -0
  114. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  115. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  116. package/src/renderers/electrobun/view/ui-host.tsx +9 -2
  117. package/src/series/resolve.ts +2 -1
  118. package/src/series/types.ts +5 -0
  119. package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
  120. package/src/telemetry/stall-reports-node.ts +13 -2
  121. package/src/telemetry/stall-reports.ts +189 -13
  122. package/src/test-support/outage.ts +8 -0
  123. package/src/tickers/company-name-listing.ts +59 -29
  124. package/src/tickers/company-name.ts +65 -1
  125. package/src/tickers/search/home-listing.ts +93 -8
  126. package/src/tickers/search/index.ts +1 -1
  127. package/src/time-series/market.ts +5 -3
  128. package/src/time-series/price-comparison.ts +20 -2
  129. package/src/time-series/resolve.ts +20 -1
  130. package/src/time-series/types.ts +7 -0
  131. package/src/types/financials.ts +18 -3
  132. package/src/ui/host.tsx +6 -1
  133. package/src/utils/financial-statements.ts +73 -16
  134. package/src/utils/fundamentals.ts +17 -5
  135. package/src/utils/lease-liabilities.ts +54 -0
  136. package/src/utils/main-thread-stall.ts +47 -2
  137. package/src/utils/perf-marks.ts +23 -11
  138. package/src/utils/price-history-integrity.ts +47 -1
@@ -29,6 +29,7 @@ export {
29
29
  DELAYED_SEGMENT,
30
30
  delayedFooterSegment,
31
31
  hasPaneFooterContent,
32
+ savedCopyFooterInfo,
32
33
  savedCopyFooterSegment,
33
34
  splitDelayedStatus,
34
35
  type CombinedPaneFooter,
@@ -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
- const STALE_SEGMENT: PaneFooterSegment = { id: "stale", parts: [{ text: "stale", tone: "warning" }] };
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
- <Box height={1} flexDirection="row" gap={1}>{actions}</Box>
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
- {/* The card has no room for both keys: like a toast, the dismiss key
326
- is in the desktop tooltip. F10 also dismisses it. */}
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
- title={notificationDismissShortcut ? `${t("Keep exploring")} (${notificationDismissShortcut})` : undefined}
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 && !icon ? "center" : undefined}
237
- style={nativePaneChrome ? icon ? NATIVE_ICON_BADGE_STYLE : NATIVE_BADGE_STYLE : undefined}>
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} style={nativePaneChrome ? NATIVE_BADGE_TEXT_STYLE : undefined}>{shown}</Text>
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
 
@@ -10,8 +10,17 @@ import { createAppRuntime } from "./app-runtime";
10
10
 
11
11
  export type AppServices = ReturnType<typeof createAppServices>;
12
12
 
13
- export function createAppServices({ config, plugins, externalPlugins }: AppServicesFactoryOptions) {
14
- const persistence = new AppPersistence(join(config.dataDir, ".gloomberb-cache.db"));
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
- this.tickers = new TickerStore(this.database.connection);
17
- this.resources = new ResourceStore(this.database.connection);
18
- this.pluginState = new PluginStateStore(this.database.connection);
19
- this.sessions = new SessionStore(this.database.connection);
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
  }
@@ -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(private readonly db: Database) {}
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 = `${pluginId}:${key}`;
34
- const row = withSqliteBusyRetry("load plugin state", () => (
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 records = entries.map((entry) => ({
105
+ const writes: PendingPluginState[] = entries.map((entry) => ({
81
106
  pluginId: entry.pluginId,
82
107
  key: entry.key,
83
- value: entry.value,
84
- schemaVersion: entry.schemaVersion ?? DEFAULT_PLUGIN_STATE_SCHEMA_VERSION,
85
- rawValue: serializeJson(entry.value),
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
- withSqliteBusyRetry("save plugin state", () => {
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 records) {
95
- insert.run(record.pluginId, record.key, record.schemaVersion, record.rawValue, updatedAt);
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 records) {
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
- withSqliteBusyRetry("delete plugin state", () => {
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(`${pluginId}:${key}`);
136
+ this.cache.delete(stateKey(pluginId, key));
116
137
  }
117
138
 
118
139
  keys(pluginId: string): string[] {
119
- return withSqliteBusyRetry("list plugin state keys", () => (
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
- return withSqliteBusyRetry("list plugin state ids", () => (
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
- withSqliteBusyRetry("clear plugin state", () => {
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
  }