gloomberb 0.4.1 → 0.5.1

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 (212) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +292 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +24 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. package/src/version.ts +1 -1
@@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite";
2
2
  import type { CachePolicy, PersistedResourceValue } from "../types/persistence";
3
3
  import type { JsonValue } from "./sqlite-json";
4
4
  import { safeParseJson, serializeJson } from "./sqlite-json";
5
+ import { trySqliteBusyOperation, withSqliteBusyRetry } from "./sqlite-retry";
5
6
 
6
7
  const RESOURCE_CACHE_SOFT_ROW_LIMIT = 25_000;
7
8
  const RESOURCE_CACHE_SOFT_SIZE_LIMIT = 100 * 1024 * 1024;
@@ -113,95 +114,114 @@ export class ResourceStore {
113
114
  }
114
115
 
115
116
  private deleteExact(key: ResourceCacheKey): void {
116
- this.db
117
- .query(
118
- `DELETE FROM resource_cache
119
- WHERE namespace = ? AND kind = ? AND entity_key = ? AND variant_key = ? AND source_key = ?`,
120
- )
121
- .run(
122
- key.namespace,
123
- key.kind,
124
- key.entityKey,
125
- normalizeVariantKey(key.variantKey),
126
- normalizeSourceKey(key.sourceKey),
127
- );
117
+ withSqliteBusyRetry("delete cached resource", () => {
118
+ this.db
119
+ .query(
120
+ `DELETE FROM resource_cache
121
+ WHERE namespace = ? AND kind = ? AND entity_key = ? AND variant_key = ? AND source_key = ?`,
122
+ )
123
+ .run(
124
+ key.namespace,
125
+ key.kind,
126
+ key.entityKey,
127
+ normalizeVariantKey(key.variantKey),
128
+ normalizeSourceKey(key.sourceKey),
129
+ );
130
+ });
128
131
  }
129
132
 
130
- private touch(key: ResourceCacheKey): void {
131
- this.db
132
- .query(
133
- `UPDATE resource_cache
134
- SET last_accessed_at = ?
135
- WHERE namespace = ? AND kind = ? AND entity_key = ? AND variant_key = ? AND source_key = ?`,
136
- )
137
- .run(
138
- Date.now(),
139
- key.namespace,
140
- key.kind,
141
- key.entityKey,
142
- normalizeVariantKey(key.variantKey),
143
- normalizeSourceKey(key.sourceKey),
144
- );
133
+ private touch(key: ResourceCacheKey): number | null {
134
+ const touchedAt = Date.now();
135
+ const result = trySqliteBusyOperation("touch cached resource", () => {
136
+ this.db
137
+ .query(
138
+ `UPDATE resource_cache
139
+ SET last_accessed_at = ?
140
+ WHERE namespace = ? AND kind = ? AND entity_key = ? AND variant_key = ? AND source_key = ?`,
141
+ )
142
+ .run(
143
+ touchedAt,
144
+ key.namespace,
145
+ key.kind,
146
+ key.entityKey,
147
+ normalizeVariantKey(key.variantKey),
148
+ normalizeSourceKey(key.sourceKey),
149
+ );
150
+ return touchedAt;
151
+ });
152
+ return result;
145
153
  }
146
154
 
147
155
  private pruneIfNeeded(): void {
148
- const stats = this.db
149
- .query<{ count: number; total_size: number }, []>(
150
- "SELECT COUNT(*) as count, COALESCE(SUM(size_bytes), 0) as total_size FROM resource_cache",
151
- )
152
- .get();
156
+ const stats = withSqliteBusyRetry("read resource cache size", () => (
157
+ this.db
158
+ .query<{ count: number; total_size: number }, []>(
159
+ "SELECT COUNT(*) as count, COALESCE(SUM(size_bytes), 0) as total_size FROM resource_cache",
160
+ )
161
+ .get()
162
+ ));
153
163
  const count = stats?.count ?? 0;
154
164
  const totalSize = stats?.total_size ?? 0;
155
165
  if (count <= RESOURCE_CACHE_SOFT_ROW_LIMIT && totalSize <= RESOURCE_CACHE_SOFT_SIZE_LIMIT) return;
156
166
 
157
- this.db.query("DELETE FROM resource_cache WHERE expires_at < ?").run(Date.now());
167
+ withSqliteBusyRetry("prune expired cached resources", () => {
168
+ this.db.query("DELETE FROM resource_cache WHERE expires_at < ?").run(Date.now());
169
+ });
158
170
 
159
- const remaining = this.db
160
- .query<{ count: number; total_size: number }, []>(
161
- "SELECT COUNT(*) as count, COALESCE(SUM(size_bytes), 0) as total_size FROM resource_cache",
162
- )
163
- .get();
171
+ const remaining = withSqliteBusyRetry("read pruned resource cache size", () => (
172
+ this.db
173
+ .query<{ count: number; total_size: number }, []>(
174
+ "SELECT COUNT(*) as count, COALESCE(SUM(size_bytes), 0) as total_size FROM resource_cache",
175
+ )
176
+ .get()
177
+ ));
164
178
  const remainingCount = remaining?.count ?? 0;
165
179
  const remainingSize = remaining?.total_size ?? 0;
166
180
  if (remainingCount <= RESOURCE_CACHE_SOFT_ROW_LIMIT && remainingSize <= RESOURCE_CACHE_SOFT_SIZE_LIMIT) return;
167
181
 
168
- const rowsToDelete = this.db
169
- .query<{ namespace: string; kind: string; entity_key: string; variant_key: string; source_key: string }, [number]>(
170
- `SELECT namespace, kind, entity_key, variant_key, source_key
171
- FROM resource_cache
172
- ORDER BY last_accessed_at ASC, fetched_at ASC
173
- LIMIT ?`,
174
- )
175
- .all(Math.max(Math.ceil((remainingCount - RESOURCE_CACHE_SOFT_ROW_LIMIT) + 250), 250));
182
+ const rowsToDelete = withSqliteBusyRetry("select cached resources to prune", () => (
183
+ this.db
184
+ .query<{ namespace: string; kind: string; entity_key: string; variant_key: string; source_key: string }, [number]>(
185
+ `SELECT namespace, kind, entity_key, variant_key, source_key
186
+ FROM resource_cache
187
+ ORDER BY last_accessed_at ASC, fetched_at ASC
188
+ LIMIT ?`,
189
+ )
190
+ .all(Math.max(Math.ceil((remainingCount - RESOURCE_CACHE_SOFT_ROW_LIMIT) + 250), 250))
191
+ ));
176
192
 
177
193
  if (rowsToDelete.length === 0) return;
178
- const remove = this.db.query(
179
- `DELETE FROM resource_cache
180
- WHERE namespace = ? AND kind = ? AND entity_key = ? AND variant_key = ? AND source_key = ?`,
181
- );
182
- const tx = this.db.transaction(() => {
183
- for (const row of rowsToDelete) {
184
- remove.run(row.namespace, row.kind, row.entity_key, row.variant_key, row.source_key);
185
- }
194
+ withSqliteBusyRetry("prune least recently used cached resources", () => {
195
+ const remove = this.db.query(
196
+ `DELETE FROM resource_cache
197
+ WHERE namespace = ? AND kind = ? AND entity_key = ? AND variant_key = ? AND source_key = ?`,
198
+ );
199
+ const tx = this.db.transaction(() => {
200
+ for (const row of rowsToDelete) {
201
+ remove.run(row.namespace, row.kind, row.entity_key, row.variant_key, row.source_key);
202
+ }
203
+ });
204
+ tx();
186
205
  });
187
- tx();
188
206
  }
189
207
 
190
208
  get<T>(key: ResourceCacheKey, options: GetResourceOptions = {}): CachedResourceRecord<T> | null {
191
- const row = this.db
192
- .query<ResourceCacheRow, [string, string, string, string, string]>(
193
- `SELECT namespace, kind, entity_key, variant_key, source_key, schema_version, payload, provenance,
194
- fetched_at, stale_at, expires_at, last_accessed_at, size_bytes
195
- FROM resource_cache
196
- WHERE namespace = ? AND kind = ? AND entity_key = ? AND variant_key = ? AND source_key = ?`,
197
- )
198
- .get(
199
- key.namespace,
200
- key.kind,
201
- key.entityKey,
202
- normalizeVariantKey(key.variantKey),
203
- normalizeSourceKey(key.sourceKey),
204
- );
209
+ const row = withSqliteBusyRetry("load cached resource", () => (
210
+ this.db
211
+ .query<ResourceCacheRow, [string, string, string, string, string]>(
212
+ `SELECT namespace, kind, entity_key, variant_key, source_key, schema_version, payload, provenance,
213
+ fetched_at, stale_at, expires_at, last_accessed_at, size_bytes
214
+ FROM resource_cache
215
+ WHERE namespace = ? AND kind = ? AND entity_key = ? AND variant_key = ? AND source_key = ?`,
216
+ )
217
+ .get(
218
+ key.namespace,
219
+ key.kind,
220
+ key.entityKey,
221
+ normalizeVariantKey(key.variantKey),
222
+ normalizeSourceKey(key.sourceKey),
223
+ )
224
+ ));
205
225
 
206
226
  if (!row) return null;
207
227
  if (!options.allowExpired && isExpired(row.expires_at)) return null;
@@ -217,8 +237,10 @@ export class ResourceStore {
217
237
  }
218
238
 
219
239
  if (options.touch !== false) {
220
- this.touch(key);
221
- record.lastAccessedAt = Date.now();
240
+ const touchedAt = this.touch(key);
241
+ if (touchedAt != null) {
242
+ record.lastAccessedAt = touchedAt;
243
+ }
222
244
  }
223
245
  return record;
224
246
  }
@@ -227,14 +249,16 @@ export class ResourceStore {
227
249
  key: Pick<ResourceCacheKey, "namespace" | "kind" | "entityKey">,
228
250
  options: ListResourceOptions = {},
229
251
  ): CachedResourceRecord<T>[] {
230
- const rows = this.db
231
- .query<ResourceCacheRow, [string, string, string]>(
232
- `SELECT namespace, kind, entity_key, variant_key, source_key, schema_version, payload, provenance,
233
- fetched_at, stale_at, expires_at, last_accessed_at, size_bytes
234
- FROM resource_cache
235
- WHERE namespace = ? AND kind = ? AND entity_key = ?`,
236
- )
237
- .all(key.namespace, key.kind, key.entityKey);
252
+ const rows = withSqliteBusyRetry("list cached resources", () => (
253
+ this.db
254
+ .query<ResourceCacheRow, [string, string, string]>(
255
+ `SELECT namespace, kind, entity_key, variant_key, source_key, schema_version, payload, provenance,
256
+ fetched_at, stale_at, expires_at, last_accessed_at, size_bytes
257
+ FROM resource_cache
258
+ WHERE namespace = ? AND kind = ? AND entity_key = ?`,
259
+ )
260
+ .all(key.namespace, key.kind, key.entityKey)
261
+ ));
238
262
 
239
263
  const allowedVariantKeys = options.variantKeys ? new Set(options.variantKeys.map(normalizeVariantKey)) : null;
240
264
  const allowedSourceKeys = options.sourceKeys ? new Set(options.sourceKeys.map(normalizeSourceKey)) : null;
@@ -282,28 +306,30 @@ export class ResourceStore {
282
306
  sourceKey: normalizeSourceKey(key.sourceKey),
283
307
  };
284
308
 
285
- this.db
286
- .query(
287
- `INSERT OR REPLACE INTO resource_cache (
288
- namespace, kind, entity_key, variant_key, source_key, schema_version,
289
- payload, provenance, fetched_at, stale_at, expires_at, last_accessed_at, size_bytes
290
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
291
- )
292
- .run(
293
- rowKey.namespace,
294
- rowKey.kind,
295
- rowKey.entityKey,
296
- rowKey.variantKey,
297
- rowKey.sourceKey,
298
- options.schemaVersion ?? DEFAULT_RESOURCE_SCHEMA_VERSION,
299
- payload,
300
- provenance,
301
- now,
302
- now + options.cachePolicy.staleMs,
303
- now + options.cachePolicy.expireMs,
304
- now,
305
- Buffer.byteLength(payload, "utf8"),
306
- );
309
+ withSqliteBusyRetry("save cached resource", () => {
310
+ this.db
311
+ .query(
312
+ `INSERT OR REPLACE INTO resource_cache (
313
+ namespace, kind, entity_key, variant_key, source_key, schema_version,
314
+ payload, provenance, fetched_at, stale_at, expires_at, last_accessed_at, size_bytes
315
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
316
+ )
317
+ .run(
318
+ rowKey.namespace,
319
+ rowKey.kind,
320
+ rowKey.entityKey,
321
+ rowKey.variantKey,
322
+ rowKey.sourceKey,
323
+ options.schemaVersion ?? DEFAULT_RESOURCE_SCHEMA_VERSION,
324
+ payload,
325
+ provenance,
326
+ now,
327
+ now + options.cachePolicy.staleMs,
328
+ now + options.cachePolicy.expireMs,
329
+ now,
330
+ Buffer.byteLength(payload, "utf8"),
331
+ );
332
+ });
307
333
 
308
334
  this.pruneIfNeeded();
309
335
  return this.get<T>(rowKey, { allowExpired: true, touch: false })!;
@@ -315,9 +341,13 @@ export class ResourceStore {
315
341
 
316
342
  clear(namespace?: string): void {
317
343
  if (!namespace) {
318
- this.db.query("DELETE FROM resource_cache").run();
344
+ withSqliteBusyRetry("clear cached resources", () => {
345
+ this.db.query("DELETE FROM resource_cache").run();
346
+ });
319
347
  return;
320
348
  }
321
- this.db.query("DELETE FROM resource_cache WHERE namespace = ?").run(namespace);
349
+ withSqliteBusyRetry("clear namespaced cached resources", () => {
350
+ this.db.query("DELETE FROM resource_cache WHERE namespace = ?").run(namespace);
351
+ });
322
352
  }
323
353
  }
@@ -1,5 +1,6 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import { safeParseJson, serializeJson } from "./sqlite-json";
3
+ import { withSqliteBusyRetry } from "./sqlite-retry";
3
4
 
4
5
  export const DEFAULT_SESSION_SCHEMA_VERSION = 1;
5
6
 
@@ -14,11 +15,13 @@ export class SessionStore {
14
15
  constructor(private readonly db: Database) {}
15
16
 
16
17
  get<T>(sessionId = "app", schemaVersion = DEFAULT_SESSION_SCHEMA_VERSION): SessionSnapshotRecord<T> | null {
17
- const row = this.db
18
- .query<{ schema_version: number; value: string; updated_at: number }, [string]>(
19
- "SELECT schema_version, value, updated_at FROM session_snapshots WHERE session_id = ?",
20
- )
21
- .get(sessionId);
18
+ const row = withSqliteBusyRetry("load session snapshot", () => (
19
+ this.db
20
+ .query<{ schema_version: number; value: string; updated_at: number }, [string]>(
21
+ "SELECT schema_version, value, updated_at FROM session_snapshots WHERE session_id = ?",
22
+ )
23
+ .get(sessionId)
24
+ ));
22
25
  if (!row) return null;
23
26
  if (row.schema_version !== schemaVersion) {
24
27
  this.delete(sessionId);
@@ -38,15 +41,19 @@ export class SessionStore {
38
41
  }
39
42
 
40
43
  set(sessionId: string, value: unknown, schemaVersion = DEFAULT_SESSION_SCHEMA_VERSION): void {
41
- this.db
42
- .query(
43
- `INSERT OR REPLACE INTO session_snapshots (session_id, schema_version, value, updated_at)
44
- VALUES (?, ?, ?, ?)`,
45
- )
46
- .run(sessionId, schemaVersion, serializeJson(value), Date.now());
44
+ withSqliteBusyRetry("save session snapshot", () => {
45
+ this.db
46
+ .query(
47
+ `INSERT OR REPLACE INTO session_snapshots (session_id, schema_version, value, updated_at)
48
+ VALUES (?, ?, ?, ?)`,
49
+ )
50
+ .run(sessionId, schemaVersion, serializeJson(value), Date.now());
51
+ });
47
52
  }
48
53
 
49
54
  delete(sessionId: string): void {
50
- this.db.query("DELETE FROM session_snapshots WHERE session_id = ?").run(sessionId);
55
+ withSqliteBusyRetry("delete session snapshot", () => {
56
+ this.db.query("DELETE FROM session_snapshots WHERE session_id = ?").run(sessionId);
57
+ });
51
58
  }
52
59
  }
@@ -1,4 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
2
3
  import { existsSync, rmSync } from "fs";
3
4
  import { join } from "path";
4
5
  import { tmpdir } from "os";
@@ -14,7 +15,9 @@ function createTempDbPath(name: string): string {
14
15
 
15
16
  afterEach(() => {
16
17
  for (const path of tempPaths.splice(0)) {
17
- if (existsSync(path)) rmSync(path, { force: true });
18
+ for (const candidate of [path, `${path}-wal`, `${path}-shm`]) {
19
+ if (existsSync(candidate)) rmSync(candidate, { force: true });
20
+ }
18
21
  }
19
22
  });
20
23
 
@@ -43,6 +46,37 @@ describe("AppPersistence", () => {
43
46
  persistence.close();
44
47
  });
45
48
 
49
+ test("returns cached resources when last-access touch is locked", () => {
50
+ const dbPath = createTempDbPath("resource-touch-lock");
51
+ const persistence = new AppPersistence(dbPath);
52
+ const key = {
53
+ namespace: "market",
54
+ kind: "quote",
55
+ entityKey: "AAPL",
56
+ sourceKey: "provider:yahoo",
57
+ };
58
+
59
+ persistence.database.connection.exec("PRAGMA busy_timeout=1");
60
+ persistence.resources.set(key, {
61
+ price: 123,
62
+ }, {
63
+ cachePolicy: { staleMs: 60_000, expireMs: 120_000 },
64
+ schemaVersion: 1,
65
+ });
66
+
67
+ const locker = new Database(dbPath);
68
+ locker.exec("PRAGMA busy_timeout=1");
69
+ locker.exec("BEGIN IMMEDIATE");
70
+ try {
71
+ const record = persistence.resources.get<{ price: number }>(key, { allowExpired: true });
72
+ expect(record?.value.price).toBe(123);
73
+ } finally {
74
+ locker.exec("ROLLBACK");
75
+ locker.close();
76
+ persistence.close();
77
+ }
78
+ });
79
+
46
80
  test("invalidates plugin state when schema versions differ", () => {
47
81
  const dbPath = createTempDbPath("plugin-version");
48
82
  const persistence = new AppPersistence(dbPath);
@@ -88,7 +122,8 @@ describe("AppPersistence", () => {
88
122
 
89
123
  expect(first).not.toBeNull();
90
124
  expect(second).not.toBeNull();
91
- expect(second?.value).toBe(first?.value);
125
+ if (!first || !second) throw new Error("Expected persisted plugin state");
126
+ expect(second.value).toBe(first.value);
92
127
  persistence.close();
93
128
  });
94
129
 
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { SQLITE_BUSY_TIMEOUT_MS, withSqliteBusyRetry } from "./sqlite-retry";
2
3
 
3
4
  const SCHEMA = `
4
5
  CREATE TABLE IF NOT EXISTS tickers (
@@ -52,8 +53,13 @@ export class SqliteDatabase {
52
53
 
53
54
  constructor(dbPath: string) {
54
55
  this.connection = new Database(dbPath);
55
- this.connection.exec("PRAGMA journal_mode=WAL");
56
- this.connection.exec(SCHEMA);
56
+ this.connection.exec(`PRAGMA busy_timeout=${SQLITE_BUSY_TIMEOUT_MS}`);
57
+ withSqliteBusyRetry("enable sqlite wal", () => {
58
+ this.connection.exec("PRAGMA journal_mode=WAL");
59
+ });
60
+ withSqliteBusyRetry("initialize sqlite schema", () => {
61
+ this.connection.exec(SCHEMA);
62
+ });
57
63
  }
58
64
 
59
65
  close(): void {
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isSqliteBusyError, trySqliteBusyOperation, withSqliteBusyRetry } from "./sqlite-retry";
3
+
4
+ function createBusyError(): Error & { code: string; errno: number } {
5
+ const error = new Error("database is locked") as Error & { code: string; errno: number };
6
+ error.code = "SQLITE_BUSY";
7
+ error.errno = 5;
8
+ return error;
9
+ }
10
+
11
+ describe("sqlite busy retry", () => {
12
+ test("recognizes Bun SQLite busy errors", () => {
13
+ expect(isSqliteBusyError(createBusyError())).toBe(true);
14
+ expect(isSqliteBusyError(new Error("other failure"))).toBe(false);
15
+ });
16
+
17
+ test("retries busy errors until the operation succeeds", () => {
18
+ let attempts = 0;
19
+
20
+ const result = withSqliteBusyRetry("test retry", () => {
21
+ attempts += 1;
22
+ if (attempts < 3) throw createBusyError();
23
+ return "ok";
24
+ }, {
25
+ attempts: 3,
26
+ initialDelayMs: 0,
27
+ maxDelayMs: 0,
28
+ });
29
+
30
+ expect(result).toBe("ok");
31
+ expect(attempts).toBe(3);
32
+ });
33
+
34
+ test("stops after the configured busy retry attempts", () => {
35
+ let attempts = 0;
36
+
37
+ expect(() => withSqliteBusyRetry("test exhaustion", () => {
38
+ attempts += 1;
39
+ throw createBusyError();
40
+ }, {
41
+ attempts: 2,
42
+ initialDelayMs: 0,
43
+ maxDelayMs: 0,
44
+ })).toThrow("database is locked");
45
+
46
+ expect(attempts).toBe(2);
47
+ });
48
+
49
+ test("optional operations only swallow exhausted busy errors", () => {
50
+ const result = trySqliteBusyOperation("optional busy", () => {
51
+ throw createBusyError();
52
+ }, {
53
+ attempts: 1,
54
+ initialDelayMs: 0,
55
+ maxDelayMs: 0,
56
+ });
57
+
58
+ expect(result).toBeNull();
59
+ expect(() => trySqliteBusyOperation("optional non-busy", () => {
60
+ throw new Error("not sqlite");
61
+ })).toThrow("not sqlite");
62
+ });
63
+ });
@@ -0,0 +1,66 @@
1
+ const SQLITE_BUSY_RETRY_ATTEMPTS = 4;
2
+ const SQLITE_BUSY_RETRY_INITIAL_DELAY_MS = 25;
3
+ const SQLITE_BUSY_RETRY_MAX_DELAY_MS = 250;
4
+ const SQLITE_BUSY_SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
5
+
6
+ export const SQLITE_BUSY_TIMEOUT_MS = 2000;
7
+
8
+ export interface SqliteBusyRetryOptions {
9
+ attempts?: number;
10
+ initialDelayMs?: number;
11
+ maxDelayMs?: number;
12
+ }
13
+
14
+ export function isSqliteBusyError(error: unknown): boolean {
15
+ if (!error || typeof error !== "object") return false;
16
+ const candidate = error as { code?: unknown; errno?: unknown; message?: unknown };
17
+ const code = typeof candidate.code === "string" ? candidate.code.toUpperCase() : "";
18
+ if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") return true;
19
+ if (candidate.errno === 5 || candidate.errno === 6) return true;
20
+ const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
21
+ return message.includes("database is locked")
22
+ || message.includes("database is busy")
23
+ || message.includes("database table is locked");
24
+ }
25
+
26
+ function sleepSync(delayMs: number): void {
27
+ if (delayMs <= 0) return;
28
+ Atomics.wait(SQLITE_BUSY_SLEEP_BUFFER, 0, 0, delayMs);
29
+ }
30
+
31
+ export function withSqliteBusyRetry<T>(
32
+ operationName: string,
33
+ operation: () => T,
34
+ options: SqliteBusyRetryOptions = {},
35
+ ): T {
36
+ const attempts = Math.max(1, Math.floor(options.attempts ?? SQLITE_BUSY_RETRY_ATTEMPTS));
37
+ const maxDelayMs = Math.max(0, options.maxDelayMs ?? SQLITE_BUSY_RETRY_MAX_DELAY_MS);
38
+ let delayMs = Math.max(0, options.initialDelayMs ?? SQLITE_BUSY_RETRY_INITIAL_DELAY_MS);
39
+
40
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
41
+ try {
42
+ return operation();
43
+ } catch (error) {
44
+ if (!isSqliteBusyError(error) || attempt >= attempts) {
45
+ throw error;
46
+ }
47
+ sleepSync(delayMs);
48
+ delayMs = Math.min(maxDelayMs, delayMs === 0 ? maxDelayMs : delayMs * 2);
49
+ }
50
+ }
51
+
52
+ throw new Error(`SQLite operation failed without an error: ${operationName}`);
53
+ }
54
+
55
+ export function trySqliteBusyOperation<T>(
56
+ operationName: string,
57
+ operation: () => T,
58
+ options?: SqliteBusyRetryOptions,
59
+ ): T | null {
60
+ try {
61
+ return withSqliteBusyRetry(operationName, operation, options);
62
+ } catch (error) {
63
+ if (isSqliteBusyError(error)) return null;
64
+ throw error;
65
+ }
66
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import { serializeJson } from "./sqlite-json";
3
+ import { withSqliteBusyRetry } from "./sqlite-retry";
3
4
 
4
5
  export interface StoredTickerRecord {
5
6
  symbol: string;
@@ -10,37 +11,47 @@ export class TickerStore {
10
11
  constructor(private readonly db: Database) {}
11
12
 
12
13
  getAll(): StoredTickerRecord[] {
13
- return this.db
14
- .query<StoredTickerRecord, []>(
15
- "SELECT symbol, metadata FROM tickers ORDER BY symbol",
16
- )
17
- .all();
14
+ return withSqliteBusyRetry("load tickers", () => (
15
+ this.db
16
+ .query<StoredTickerRecord, []>(
17
+ "SELECT symbol, metadata FROM tickers ORDER BY symbol",
18
+ )
19
+ .all()
20
+ ));
18
21
  }
19
22
 
20
23
  get(symbol: string): string | null {
21
- const row = this.db
22
- .query<{ metadata: string }, [string]>(
23
- "SELECT metadata FROM tickers WHERE symbol = ?",
24
- )
25
- .get(symbol);
24
+ const row = withSqliteBusyRetry("load ticker", () => (
25
+ this.db
26
+ .query<{ metadata: string }, [string]>(
27
+ "SELECT metadata FROM tickers WHERE symbol = ?",
28
+ )
29
+ .get(symbol)
30
+ ));
26
31
  return row?.metadata ?? null;
27
32
  }
28
33
 
29
34
  save(symbol: string, metadata: unknown): void {
30
- this.db
31
- .query(
32
- `INSERT OR REPLACE INTO tickers (symbol, metadata, updated_at)
33
- VALUES (?, ?, ?)`,
34
- )
35
- .run(symbol, serializeJson(metadata), Date.now());
35
+ withSqliteBusyRetry("save ticker", () => {
36
+ this.db
37
+ .query(
38
+ `INSERT OR REPLACE INTO tickers (symbol, metadata, updated_at)
39
+ VALUES (?, ?, ?)`,
40
+ )
41
+ .run(symbol, serializeJson(metadata), Date.now());
42
+ });
36
43
  }
37
44
 
38
45
  delete(symbol: string): void {
39
- this.db.query("DELETE FROM tickers WHERE symbol = ?").run(symbol);
46
+ withSqliteBusyRetry("delete ticker", () => {
47
+ this.db.query("DELETE FROM tickers WHERE symbol = ?").run(symbol);
48
+ });
40
49
  }
41
50
 
42
51
  count(): number {
43
- const row = this.db.query<{ count: number }, []>("SELECT COUNT(*) as count FROM tickers").get();
52
+ const row = withSqliteBusyRetry("count tickers", () => (
53
+ this.db.query<{ count: number }, []>("SELECT COUNT(*) as count FROM tickers").get()
54
+ ));
44
55
  return row?.count ?? 0;
45
56
  }
46
57
  }