gloomberb 0.19.1 → 0.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/api-client/errors.ts +38 -0
- package/src/app/runtime/startup.ts +3 -1
- package/src/capabilities/chart-series.ts +2 -1
- package/src/cli/commands/market.ts +9 -4
- package/src/cli/commands/overview.ts +5 -6
- package/src/cli/commands/search.ts +13 -13
- package/src/cli/context.ts +1 -0
- package/src/cli/glossary.ts +2 -1
- package/src/cli/history-rows.ts +35 -7
- package/src/cli/known-gaps.ts +11 -0
- package/src/cli/listing-arg.ts +17 -1
- package/src/cli/quote-answer.ts +29 -6
- package/src/components/command-bar/routes/root/runtime.ts +27 -5
- package/src/components/command-bar/selection-runtime.ts +12 -29
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/header.tsx +2 -2
- package/src/components/layout/pane/footer/index.tsx +1 -0
- package/src/components/layout/pane/footer/model.ts +33 -1
- package/src/components/layout/pane/header.tsx +2 -2
- package/src/components/layout/pane/status-footer.ts +2 -1
- package/src/components/onboarding/onboarding-frame.tsx +2 -1
- package/src/components/onboarding/onboarding-wizard.tsx +6 -3
- package/src/components/ui/display.tsx +16 -5
- package/src/components/ui/icon.tsx +8 -2
- package/src/core/app-services.ts +11 -2
- package/src/data/app-persistence.ts +9 -7
- package/src/data/plugin-cache.ts +15 -0
- package/src/data/plugin-state-store.ts +81 -24
- package/src/data/resource-store.ts +130 -58
- package/src/data/session-store.ts +31 -8
- package/src/data/sqlite/access.ts +313 -0
- package/src/data/sqlite/checkpointer.ts +198 -0
- package/src/data/sqlite/database.ts +47 -4
- package/src/data/sqlite/retry.ts +50 -9
- package/src/data/ticker-store.ts +57 -23
- package/src/market-data/realized-volatility.ts +4 -2
- package/src/plugins/auto-update-node.ts +10 -6
- package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
- package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
- package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
- package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
- package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
- package/src/plugins/builtin/commodity-balances/client.ts +4 -1
- package/src/plugins/builtin/cot/client.ts +28 -2
- package/src/plugins/builtin/cot/headless.ts +4 -1
- package/src/plugins/builtin/cot/index.tsx +4 -2
- package/src/plugins/builtin/cot/model.ts +58 -7
- package/src/plugins/builtin/cot/pane.tsx +6 -2
- package/src/plugins/builtin/cpi/client.ts +6 -5
- package/src/plugins/builtin/credit-boards/series.ts +3 -4
- package/src/plugins/builtin/credit-conditions/client.ts +22 -14
- package/src/plugins/builtin/crypto-board/client.ts +5 -5
- package/src/plugins/builtin/crypto-board/series.ts +4 -4
- package/src/plugins/builtin/debt-maturities/series.ts +3 -3
- package/src/plugins/builtin/doe/client.ts +6 -5
- package/src/plugins/builtin/doe/prices.ts +2 -3
- package/src/plugins/builtin/doe/series.ts +4 -4
- package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
- package/src/plugins/builtin/earnings-calls/data.ts +19 -11
- package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
- package/src/plugins/builtin/econ/detail-view.tsx +16 -2
- package/src/plugins/builtin/executives/data.ts +6 -1
- package/src/plugins/builtin/executives/pane.tsx +9 -21
- package/src/plugins/builtin/fear-greed/cache.ts +4 -0
- package/src/plugins/builtin/fear-greed/series.ts +6 -4
- package/src/plugins/builtin/gpu/client.ts +4 -1
- package/src/plugins/builtin/gpu/series.ts +2 -3
- package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
- package/src/plugins/builtin/jodi/client.ts +4 -1
- package/src/plugins/builtin/market-movers/client.ts +1 -0
- package/src/plugins/builtin/market-movers/index.tsx +10 -5
- package/src/plugins/builtin/market-movers/screener.ts +23 -6
- package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
- package/src/plugins/builtin/money-markets/series.ts +3 -5
- package/src/plugins/builtin/power/gas-client.ts +2 -1
- package/src/plugins/builtin/power/prices-client.ts +3 -2
- package/src/plugins/builtin/risk-factors/data.ts +8 -4
- package/src/plugins/builtin/risk-factors/index.tsx +3 -1
- package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
- package/src/plugins/builtin/scanner/lock-note.ts +26 -3
- package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
- package/src/plugins/builtin/shared/fred-points.ts +6 -1
- package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
- package/src/plugins/builtin/shared/series-warning.ts +13 -4
- package/src/plugins/builtin/short-volume/series.ts +2 -3
- package/src/plugins/builtin/social-mentions/client.ts +15 -10
- package/src/plugins/builtin/social-mentions/series.ts +2 -3
- package/src/plugins/builtin/thirteenf/api.ts +11 -16
- package/src/plugins/builtin/thirteenf/data.ts +34 -6
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
- package/src/plugins/builtin/thirteenf/types.ts +2 -0
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
- package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
- package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
- package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
- package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
- package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
- package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
- package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
- package/src/plugins/builtin/volatility/client.ts +43 -16
- package/src/plugins/builtin/volatility/headless.ts +5 -2
- package/src/plugins/builtin/volatility/model.ts +17 -6
- package/src/plugins/builtin/volatility/pane.tsx +6 -2
- package/src/plugins/builtin/volatility/series.ts +2 -1
- package/src/plugins/builtin/yield-curve/client.ts +4 -6
- package/src/plugins/installer.ts +50 -39
- package/src/renderers/dom/desktop/icons.tsx +3 -2
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
- package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
- package/src/renderers/electrobun/bun/window/attention.ts +54 -0
- package/src/renderers/electrobun/bun/window/create.ts +2 -0
- package/src/renderers/electrobun/launcher/preload.ts +13 -0
- package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
- package/src/renderers/electrobun/view/ui-host.tsx +9 -2
- package/src/series/resolve.ts +2 -1
- package/src/series/types.ts +5 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
- package/src/telemetry/stall-reports-node.ts +13 -2
- package/src/telemetry/stall-reports.ts +189 -13
- package/src/test-support/outage.ts +8 -0
- package/src/tickers/company-name-listing.ts +59 -29
- package/src/tickers/company-name.ts +65 -1
- package/src/tickers/search/home-listing.ts +93 -8
- package/src/tickers/search/index.ts +1 -1
- package/src/time-series/market.ts +5 -3
- package/src/time-series/price-comparison.ts +20 -2
- package/src/time-series/resolve.ts +20 -1
- package/src/time-series/types.ts +7 -0
- package/src/types/financials.ts +18 -3
- package/src/ui/host.tsx +6 -1
- package/src/utils/financial-statements.ts +73 -16
- package/src/utils/fundamentals.ts +17 -5
- package/src/utils/lease-liabilities.ts +54 -0
- package/src/utils/main-thread-stall.ts +47 -2
- package/src/utils/perf-marks.ts +23 -11
- package/src/utils/price-history-integrity.ts +47 -1
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
if (
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
63
|
+
.run(sessionId, row.schema_version, row.value, row.updated_at);
|
|
51
64
|
});
|
|
52
65
|
}
|
|
53
66
|
|
|
54
67
|
delete(sessionId: string): void {
|
|
55
|
-
|
|
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
|
}
|