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.
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
@@ -1,8 +1,9 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import type { CachePolicy, PersistedResourceValue } from "../types/persistence";
3
3
  import type { JsonValue } from "./sqlite/json";
4
+ import { createSqliteAccess, type SqliteAccess } from "./sqlite/access";
4
5
  import { safeParseJson, serializeJson } from "./sqlite/json";
5
- import { trySqliteBusyOperation, withSqliteBusyRetry } from "./sqlite/retry";
6
+ import { logFailure } from "../utils/log-failure";
6
7
 
7
8
  const RESOURCE_CACHE_SOFT_ROW_LIMIT = 25_000;
8
9
  const RESOURCE_CACHE_SOFT_SIZE_LIMIT = 100 * 1024 * 1024;
@@ -13,6 +14,13 @@ const RESOURCE_CACHE_MAINTENANCE_BYTE_INTERVAL = Math.floor(
13
14
  const RESOURCE_CACHE_MAINTENANCE_TIME_INTERVAL_MS = 5 * 60_000;
14
15
  /** Rows read per eviction pass, so a full cache is evicted in slices. */
15
16
  const RESOURCE_CACHE_PRUNE_BATCH_SIZE = 512;
17
+ /**
18
+ * A read moves its row up the eviction order in memory, and the rows read
19
+ * since are written in one transaction this often, before an eviction and
20
+ * at close: a read writes nothing. A crash loses at most this much of the
21
+ * order, which only decides what is evicted first.
22
+ */
23
+ const RESOURCE_CACHE_TOUCH_FLUSH_MS = 30_000;
16
24
  const DEFAULT_RESOURCE_SCHEMA_VERSION = 1;
17
25
  const EMPTY_VARIANT_KEY = "";
18
26
  const EMPTY_SOURCE_KEY = "";
@@ -93,6 +101,10 @@ function isStale(staleAt: number): boolean {
93
101
  return staleAt < Date.now();
94
102
  }
95
103
 
104
+ function rowMapKey(key: ResourceCacheKey): string {
105
+ return [key.namespace, key.kind, key.entityKey, normalizeVariantKey(key.variantKey), normalizeSourceKey(key.sourceKey)].join("\u0000");
106
+ }
107
+
96
108
  function sortRows(rows: ResourceCacheRow[]): ResourceCacheRow[] {
97
109
  return [...rows].sort((a, b) => {
98
110
  if (a.expires_at !== b.expires_at) return b.expires_at - a.expires_at;
@@ -106,8 +118,18 @@ export class ResourceStore {
106
118
  private writesSinceMaintenance = 0;
107
119
  private bytesWrittenSinceMaintenance = 0;
108
120
  private lastMaintenanceAt = 0;
121
+ /** Reads not yet written to the eviction order, by row. */
122
+ private readonly pendingTouches = new Map<string, { key: ResourceCacheKey; touchedAt: number }>();
123
+ private touchTimer: ReturnType<typeof setTimeout> | null = null;
109
124
 
110
- constructor(private readonly db: Database) {}
125
+ /**
126
+ * Everything here can be fetched again, so no statement waits for another
127
+ * program's lock: a locked read is a miss, and a locked write is skipped.
128
+ */
129
+ constructor(
130
+ private readonly db: Database,
131
+ private readonly access: SqliteAccess = createSqliteAccess(db),
132
+ ) {}
111
133
 
112
134
  private toCachedResource<T>(row: ResourceCacheRow): CachedResourceRecord<T> | null {
113
135
  const value = safeParseJson<T>(row.payload);
@@ -132,7 +154,8 @@ export class ResourceStore {
132
154
  }
133
155
 
134
156
  private deleteExact(key: ResourceCacheKey): void {
135
- withSqliteBusyRetry("delete cached resource", () => {
157
+ this.pendingTouches.delete(rowMapKey(key));
158
+ this.access.writeCache("delete cached resource", () => {
136
159
  this.db
137
160
  .query(
138
161
  `DELETE FROM resource_cache
@@ -148,57 +171,111 @@ export class ResourceStore {
148
171
  });
149
172
  }
150
173
 
151
- private touchDue(record: { lastAccessedAt: number }, options: GetResourceOptions): boolean {
152
- return options.touchIntervalMs == null || Date.now() - record.lastAccessedAt >= options.touchIntervalMs;
174
+ /** Moves a read row up the eviction order, unless it moved within `touchIntervalMs`. */
175
+ private touchRead(record: CachedResourceRecord, options: GetResourceOptions): void {
176
+ const mapKey = rowMapKey(record);
177
+ record.lastAccessedAt = Math.max(record.lastAccessedAt, this.pendingTouches.get(mapKey)?.touchedAt ?? 0);
178
+ if (options.touch === false) return;
179
+ const now = Date.now();
180
+ if (options.touchIntervalMs != null && now - record.lastAccessedAt < options.touchIntervalMs) return;
181
+ const { namespace, kind, entityKey, variantKey, sourceKey } = record;
182
+ this.pendingTouches.set(mapKey, { key: { namespace, kind, entityKey, variantKey, sourceKey }, touchedAt: now });
183
+ record.lastAccessedAt = now;
184
+ if (this.touchTimer) return;
185
+ this.touchTimer = setTimeout(() => {
186
+ this.touchTimer = null;
187
+ try {
188
+ this.flushTouches();
189
+ } catch (error) {
190
+ logFailure("data.resource-store", "Saving the cache's eviction order failed")(error);
191
+ }
192
+ }, RESOURCE_CACHE_TOUCH_FLUSH_MS);
193
+ (this.touchTimer as { unref?: () => void }).unref?.();
153
194
  }
154
195
 
155
- private touch(key: ResourceCacheKey): number | null {
156
- const touchedAt = Date.now();
157
- const result = trySqliteBusyOperation("touch cached resource", () => {
158
- this.db
159
- .query(
160
- `UPDATE resource_cache
161
- SET last_accessed_at = ?
162
- WHERE namespace = ? AND kind = ? AND entity_key = ? AND variant_key = ? AND source_key = ?`,
163
- )
164
- .run(
165
- touchedAt,
166
- key.namespace,
167
- key.kind,
168
- key.entityKey,
169
- normalizeVariantKey(key.variantKey),
170
- normalizeSourceKey(key.sourceKey),
171
- );
172
- return touchedAt;
196
+ /**
197
+ * Writes the reads since the last flush into the eviction order, in one
198
+ * transaction. False when another program had the cache locked; they are
199
+ * kept for the next one.
200
+ */
201
+ flushTouches(): boolean {
202
+ if (this.touchTimer) {
203
+ clearTimeout(this.touchTimer);
204
+ this.touchTimer = null;
205
+ }
206
+ if (this.pendingTouches.size === 0) return true;
207
+ const touches = [...this.pendingTouches.values()];
208
+ const written = this.access.writeCache("touch cached resources", () => {
209
+ const update = this.db.query(
210
+ `UPDATE resource_cache
211
+ SET last_accessed_at = MAX(last_accessed_at, ?)
212
+ WHERE namespace = ? AND kind = ? AND entity_key = ? AND variant_key = ? AND source_key = ?`,
213
+ );
214
+ this.db.transaction(() => {
215
+ for (const { key, touchedAt } of touches) {
216
+ update.run(
217
+ touchedAt,
218
+ key.namespace,
219
+ key.kind,
220
+ key.entityKey,
221
+ normalizeVariantKey(key.variantKey),
222
+ normalizeSourceKey(key.sourceKey),
223
+ );
224
+ }
225
+ })();
173
226
  });
174
- return result;
227
+ if (written) this.pendingTouches.clear();
228
+ return written;
229
+ }
230
+
231
+ /**
232
+ * Writes the reads still held in memory; the connection closes after. A
233
+ * failure loses only that order: the queued user writes and the
234
+ * connection still have to close.
235
+ */
236
+ close(): void {
237
+ try {
238
+ this.flushTouches();
239
+ } catch (error) {
240
+ logFailure("data.resource-store", "Saving the cache's eviction order at close failed")(error);
241
+ }
242
+ if (this.touchTimer) clearTimeout(this.touchTimer);
243
+ this.touchTimer = null;
175
244
  }
176
245
 
177
- private readCacheFootprint(operationName: string): { count: number; totalSize: number } {
178
- const stats = withSqliteBusyRetry(operationName, () => (
246
+ /** Null when another program has the cache locked; maintenance waits for the next write. */
247
+ private readCacheFootprint(operationName: string): { count: number; totalSize: number } | null {
248
+ const stats = this.access.readCache(operationName, () => (
179
249
  this.db
180
250
  .query<{ count: number; total_size: number }, []>(
181
251
  "SELECT COUNT(*) as count, COALESCE(SUM(size_bytes), 0) as total_size FROM resource_cache",
182
252
  )
183
253
  .get()
184
254
  ));
185
- return { count: stats?.count ?? 0, totalSize: stats?.total_size ?? 0 };
255
+ return stats ? { count: stats.count ?? 0, totalSize: stats.total_size ?? 0 } : null;
186
256
  }
187
257
 
188
258
  private withinSoftLimits(count: number, totalSize: number): boolean {
189
259
  return count <= RESOURCE_CACHE_SOFT_ROW_LIMIT && totalSize <= RESOURCE_CACHE_SOFT_SIZE_LIMIT;
190
260
  }
191
261
 
192
- private pruneIfNeeded(): void {
193
- const { count, totalSize } = this.readCacheFootprint("read resource cache size");
194
- if (this.withinSoftLimits(count, totalSize)) return;
262
+ /** False when another program had the cache locked and pruning has to try again later. */
263
+ private pruneIfNeeded(): boolean {
264
+ const footprint = this.readCacheFootprint("read resource cache size");
265
+ if (!footprint) return false;
266
+ if (this.withinSoftLimits(footprint.count, footprint.totalSize)) return true;
195
267
 
196
- withSqliteBusyRetry("prune expired cached resources", () => {
268
+ const prunedExpired = this.access.writeCache("prune expired cached resources", () => {
197
269
  this.db.query("DELETE FROM resource_cache WHERE expires_at < ?").run(Date.now());
198
270
  });
271
+ if (!prunedExpired) return false;
199
272
 
200
- let { count: remainingCount, totalSize: remainingSize } = this.readCacheFootprint("read pruned resource cache size");
201
- if (this.withinSoftLimits(remainingCount, remainingSize)) return;
273
+ const remaining = this.readCacheFootprint("read pruned resource cache size");
274
+ if (!remaining) return false;
275
+ let { count: remainingCount, totalSize: remainingSize } = remaining;
276
+ if (this.withinSoftLimits(remainingCount, remainingSize)) return true;
277
+ // Eviction goes by the order on disk, which has to hold the latest reads.
278
+ if (!this.flushTouches()) return false;
202
279
 
203
280
  const candidates = this.db.query<{
204
281
  namespace: string;
@@ -223,10 +300,11 @@ export class ResourceStore {
223
300
  // for more only if it is still over. Reading every row first turned the
224
301
  // moment the cache filled up into a multi-second pause.
225
302
  while (!this.withinSoftLimits(remainingCount, remainingSize)) {
226
- const batch = withSqliteBusyRetry("select cached resources to prune", () => (
303
+ const batch = this.access.readCache("select cached resources to prune", () => (
227
304
  candidates.all(RESOURCE_CACHE_PRUNE_BATCH_SIZE)
228
305
  ));
229
- if (batch.length === 0) return;
306
+ if (!batch) return false;
307
+ if (batch.length === 0) return true;
230
308
 
231
309
  const rowsToDelete = [] as typeof batch;
232
310
  for (const row of batch) {
@@ -235,9 +313,9 @@ export class ResourceStore {
235
313
  remainingCount -= 1;
236
314
  remainingSize -= row.size_bytes;
237
315
  }
238
- if (rowsToDelete.length === 0) return;
316
+ if (rowsToDelete.length === 0) return true;
239
317
 
240
- withSqliteBusyRetry("prune least recently used cached resources", () => {
318
+ const pruned = this.access.writeCache("prune least recently used cached resources", () => {
241
319
  const tx = this.db.transaction(() => {
242
320
  for (const row of rowsToDelete) {
243
321
  remove.run(row.namespace, row.kind, row.entity_key, row.variant_key, row.source_key);
@@ -245,7 +323,9 @@ export class ResourceStore {
245
323
  });
246
324
  tx();
247
325
  });
326
+ if (!pruned) return false;
248
327
  }
328
+ return true;
249
329
  }
250
330
 
251
331
  private maintainCacheAfterWrite(now: number, sizeBytes: number): void {
@@ -257,14 +337,14 @@ export class ResourceStore {
257
337
  || now - this.lastMaintenanceAt >= RESOURCE_CACHE_MAINTENANCE_TIME_INTERVAL_MS;
258
338
  if (!maintenanceDue) return;
259
339
 
260
- this.pruneIfNeeded();
340
+ if (!this.pruneIfNeeded()) return;
261
341
  this.writesSinceMaintenance = 0;
262
342
  this.bytesWrittenSinceMaintenance = 0;
263
343
  this.lastMaintenanceAt = now;
264
344
  }
265
345
 
266
346
  get<T>(key: ResourceCacheKey, options: GetResourceOptions = {}): CachedResourceRecord<T> | null {
267
- const row = withSqliteBusyRetry("load cached resource", () => (
347
+ const row = this.access.readCache("load cached resource", () => (
268
348
  this.db
269
349
  .query<ResourceCacheRow, [string, string, string, string, string]>(
270
350
  `SELECT namespace, kind, entity_key, variant_key, source_key, schema_version, payload, provenance,
@@ -294,12 +374,7 @@ export class ResourceStore {
294
374
  return null;
295
375
  }
296
376
 
297
- if (options.touch !== false && this.touchDue(record, options)) {
298
- const touchedAt = this.touch(key);
299
- if (touchedAt != null) {
300
- record.lastAccessedAt = touchedAt;
301
- }
302
- }
377
+ this.touchRead(record, options);
303
378
  return record;
304
379
  }
305
380
 
@@ -307,7 +382,7 @@ export class ResourceStore {
307
382
  key: Pick<ResourceCacheKey, "namespace" | "kind" | "entityKey">,
308
383
  options: ListResourceOptions = {},
309
384
  ): CachedResourceRecord<T>[] {
310
- const rows = withSqliteBusyRetry("list cached resources", () => (
385
+ const rows = this.access.readCache("list cached resources", () => (
311
386
  this.db
312
387
  .query<ResourceCacheRow, [string, string, string]>(
313
388
  `SELECT namespace, kind, entity_key, variant_key, source_key, schema_version, payload, provenance,
@@ -316,7 +391,7 @@ export class ResourceStore {
316
391
  WHERE namespace = ? AND kind = ? AND entity_key = ?`,
317
392
  )
318
393
  .all(key.namespace, key.kind, key.entityKey)
319
- ));
394
+ )) ?? [];
320
395
 
321
396
  const allowedVariantKeys = options.variantKeys ? new Set(options.variantKeys.map(normalizeVariantKey)) : null;
322
397
  const allowedSourceKeys = options.sourceKeys ? new Set(options.sourceKeys.map(normalizeSourceKey)) : null;
@@ -349,13 +424,7 @@ export class ResourceStore {
349
424
  }
350
425
  records.push(record);
351
426
  }
352
- if (options.touch !== false) {
353
- for (const record of records) {
354
- if (!this.touchDue(record, options)) continue;
355
- const touchedAt = this.touch(record);
356
- if (touchedAt != null) record.lastAccessedAt = touchedAt;
357
- }
358
- }
427
+ for (const record of records) this.touchRead(record, options);
359
428
  return records;
360
429
  }
361
430
 
@@ -375,7 +444,8 @@ export class ResourceStore {
375
444
  sourceKey: normalizeSourceKey(key.sourceKey),
376
445
  };
377
446
 
378
- withSqliteBusyRetry("save cached resource", () => {
447
+ this.pendingTouches.delete(rowMapKey(rowKey));
448
+ const saved = this.access.writeCache("save cached resource", () => {
379
449
  this.db
380
450
  .query(
381
451
  `INSERT OR REPLACE INTO resource_cache (
@@ -400,7 +470,7 @@ export class ResourceStore {
400
470
  );
401
471
  });
402
472
 
403
- this.maintainCacheAfterWrite(Date.now(), sizeBytes);
473
+ if (saved) this.maintainCacheAfterWrite(Date.now(), sizeBytes);
404
474
  return {
405
475
  ...rowKey,
406
476
  value: JSON.parse(payload) as T,
@@ -420,14 +490,16 @@ export class ResourceStore {
420
490
  this.deleteExact(key);
421
491
  }
422
492
 
493
+ /** Asked for by the user, so it waits for another program's lock, and throws when it cannot get it. */
423
494
  clear(namespace?: string): void {
424
495
  if (!namespace) {
425
- withSqliteBusyRetry("clear cached resources", () => {
496
+ this.pendingTouches.clear();
497
+ this.access.run("clear cached resources", () => {
426
498
  this.db.query("DELETE FROM resource_cache").run();
427
499
  });
428
500
  return;
429
501
  }
430
- withSqliteBusyRetry("clear namespaced cached resources", () => {
502
+ this.access.run("clear namespaced cached resources", () => {
431
503
  this.db.query("DELETE FROM resource_cache WHERE namespace = ?").run(namespace);
432
504
  });
433
505
  }
@@ -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_SESSION_SCHEMA_VERSION = 1;
6
6
 
@@ -11,11 +11,23 @@ export interface SessionSnapshotRecord<T = unknown> {
11
11
  updatedAt: number;
12
12
  }
13
13
 
14
+ /** A queued write's row (null for a delete), one object per write. */
15
+ interface PendingSession {
16
+ row: { schema_version: number; value: string; updated_at: number } | null;
17
+ }
18
+
14
19
  export class SessionStore {
15
- constructor(private readonly db: Database) {}
20
+ /** Writes queued behind another program's lock, read back until they land. */
21
+ private readonly pending = new Map<string, PendingSession>();
22
+
23
+ constructor(
24
+ private readonly db: Database,
25
+ private readonly access: SqliteAccess = createSqliteAccess(db),
26
+ ) {}
16
27
 
17
28
  get<T>(sessionId = "app", schemaVersion = DEFAULT_SESSION_SCHEMA_VERSION): SessionSnapshotRecord<T> | null {
18
- const row = withSqliteBusyRetry("load session snapshot", () => (
29
+ const pending = this.pending.get(sessionId);
30
+ const row = pending ? pending.row : this.access.run("load session snapshot", () => (
19
31
  this.db
20
32
  .query<{ schema_version: number; value: string; updated_at: number }, [string]>(
21
33
  "SELECT schema_version, value, updated_at FROM session_snapshots WHERE session_id = ?",
@@ -24,12 +36,12 @@ export class SessionStore {
24
36
  ));
25
37
  if (!row) return null;
26
38
  if (row.schema_version !== schemaVersion) {
27
- this.delete(sessionId);
39
+ if (!pending) this.delete(sessionId);
28
40
  return null;
29
41
  }
30
42
  const value = safeParseJson<T>(row.value);
31
43
  if (value == null) {
32
- this.delete(sessionId);
44
+ if (!pending) this.delete(sessionId);
33
45
  return null;
34
46
  }
35
47
  return {
@@ -41,19 +53,30 @@ export class SessionStore {
41
53
  }
42
54
 
43
55
  set(sessionId: string, value: unknown, schemaVersion = DEFAULT_SESSION_SCHEMA_VERSION): void {
44
- withSqliteBusyRetry("save session snapshot", () => {
56
+ const row = { schema_version: schemaVersion, value: serializeJson(value), updated_at: Date.now() };
57
+ this.write("save session snapshot", sessionId, { row }, () => {
45
58
  this.db
46
59
  .query(
47
60
  `INSERT OR REPLACE INTO session_snapshots (session_id, schema_version, value, updated_at)
48
61
  VALUES (?, ?, ?, ?)`,
49
62
  )
50
- .run(sessionId, schemaVersion, serializeJson(value), Date.now());
63
+ .run(sessionId, row.schema_version, row.value, row.updated_at);
51
64
  });
52
65
  }
53
66
 
54
67
  delete(sessionId: string): void {
55
- withSqliteBusyRetry("delete session snapshot", () => {
68
+ this.write("delete session snapshot", sessionId, { row: null }, () => {
56
69
  this.db.query("DELETE FROM session_snapshots WHERE session_id = ?").run(sessionId);
57
70
  });
58
71
  }
72
+
73
+ private write(operationName: string, sessionId: string, entry: PendingSession, statement: () => void): void {
74
+ this.pending.set(sessionId, entry);
75
+ this.access.write(operationName, statement, {
76
+ key: `session_snapshots\u0000${sessionId}`,
77
+ settled: () => {
78
+ if (this.pending.get(sessionId) === entry) this.pending.delete(sessionId);
79
+ },
80
+ });
81
+ }
59
82
  }