gloomberb 0.19.0 → 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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +2 -1
- package/patches/electrobun@1.18.1.patch +23 -1
- package/src/api-client/app-attention.ts +3 -0
- package/src/api-client/beneficial-owners.ts +3 -0
- package/src/api-client/company-kpis.ts +3 -0
- package/src/api-client/data.ts +2 -1
- package/src/api-client/errors.ts +38 -0
- package/src/api-client/hiring.ts +3 -0
- package/src/api-client/index.ts +4 -0
- package/src/api-client/ipo.ts +24 -1
- package/src/api-client/mna.ts +3 -0
- package/src/api-client/paths.ts +9 -1
- package/src/api-client/roadmap.ts +85 -0
- package/src/api-client/types.ts +12 -0
- package/src/app/runtime/startup.ts +3 -1
- package/src/architecture/core-builtin-allowlist.json +2 -2
- package/src/capabilities/chart-series.ts +2 -1
- package/src/cli/commands/market.ts +44 -16
- package/src/cli/commands/news-search.ts +22 -0
- package/src/cli/commands/overview.ts +84 -19
- package/src/cli/commands/search.ts +13 -13
- package/src/cli/commands/ticker.ts +32 -2
- package/src/cli/context.ts +1 -0
- package/src/cli/errors.ts +17 -2
- package/src/cli/glossary.ts +8 -1
- package/src/cli/history-rows.ts +35 -7
- package/src/cli/index-roots.ts +0 -7
- package/src/cli/known-gaps.ts +11 -0
- package/src/cli/listing-arg.ts +74 -11
- package/src/cli/pane-functions/capabilities.ts +1 -0
- package/src/cli/pane-functions/discovery.ts +21 -1
- package/src/cli/pane-functions/freshness.ts +7 -1
- package/src/cli/pane-functions/function-help.ts +7 -0
- package/src/cli/pane-functions/index.ts +6 -3
- package/src/cli/quote-answer.ts +30 -7
- package/src/cli/saved-copy-persistence.ts +88 -0
- package/src/components/command-bar/list/model.ts +5 -0
- package/src/components/command-bar/routes/root/runtime.ts +29 -8
- package/src/components/command-bar/selection-runtime.ts +64 -32
- package/src/components/command-bar/surface/index.tsx +2 -0
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/header.tsx +2 -2
- package/src/components/layout/pane/capture-stamp.ts +2 -2
- package/src/components/layout/pane/footer/index.tsx +23 -6
- package/src/components/layout/pane/footer/model.ts +116 -16
- package/src/components/layout/pane/header.tsx +2 -2
- package/src/components/layout/pane/status-footer.ts +37 -3
- package/src/components/layout/status-widget-space.ts +9 -0
- package/src/components/onboarding/onboarding-frame.tsx +2 -1
- package/src/components/onboarding/onboarding-wizard.tsx +6 -3
- package/src/components/ticker/list-table-view.tsx +4 -20
- package/src/components/ui/display.tsx +29 -3
- package/src/components/ui/icon.tsx +12 -3
- package/src/core/app-services.ts +11 -2
- package/src/data/app-persistence.ts +9 -7
- package/src/data/plugin-cache.ts +20 -3
- package/src/data/plugin-state-store.ts +81 -24
- package/src/data/resource-store.ts +130 -58
- package/src/data/saved-copies.ts +8 -0
- 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/docs/snapshot.json +32 -5
- package/src/i18n/es.ts +2 -4
- package/src/i18n/ja.ts +2 -4
- package/src/i18n/ko.ts +2 -4
- package/src/i18n/zh-cn.ts +2 -4
- package/src/i18n/zh-tw.ts +2 -4
- package/src/market-data/depositary-pair.ts +25 -1
- package/src/market-data/quotes/metadata.ts +7 -0
- package/src/market-data/realized-volatility.ts +4 -2
- package/src/news/aggregator.ts +7 -1
- package/src/news/types.ts +6 -0
- package/src/plugins/auto-update-node.ts +10 -6
- package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
- package/src/plugins/builtin/alerts/cli.ts +34 -11
- package/src/plugins/builtin/alerts/event-rows.ts +24 -3
- package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
- package/src/plugins/builtin/alerts/headless.ts +10 -6
- package/src/plugins/builtin/cds/client.ts +142 -30
- package/src/plugins/builtin/cds/headless.ts +35 -8
- package/src/plugins/builtin/cds/model.ts +145 -18
- package/src/plugins/builtin/cds/pane.tsx +16 -5
- 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/follow-binding.ts +14 -2
- package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
- package/src/plugins/builtin/chart-composer/index.tsx +4 -5
- 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/chat/controller/index.ts +5 -1
- package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
- package/src/plugins/builtin/chat/controller/state.ts +25 -2
- package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
- package/src/plugins/builtin/chat/controller/utils.ts +7 -2
- package/src/plugins/builtin/chat/status-preview.ts +79 -0
- package/src/plugins/builtin/chat/status-widget.tsx +147 -10
- package/src/plugins/builtin/chat/unread-badge.ts +51 -10
- package/src/plugins/builtin/clinical-trials/client.ts +49 -4
- package/src/plugins/builtin/clinical-trials/model.ts +86 -22
- package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
- package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
- package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
- package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
- package/src/plugins/builtin/commodity-balances/client.ts +4 -1
- package/src/plugins/builtin/company-attention/headless.ts +2 -1
- package/src/plugins/builtin/company-attention/pane.tsx +3 -0
- package/src/plugins/builtin/company-kpis/headless.ts +8 -3
- package/src/plugins/builtin/company-kpis/model.ts +73 -5
- package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
- package/src/plugins/builtin/composite-plugins.ts +2 -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/credit-documents/headless.ts +3 -3
- 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/headless.ts +3 -2
- package/src/plugins/builtin/doe/pane.tsx +13 -3
- 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 +37 -17
- package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
- package/src/plugins/builtin/earnings-calls/pane.tsx +34 -14
- package/src/plugins/builtin/econ/calendar-model.ts +48 -1
- package/src/plugins/builtin/econ/calendar-source.ts +4 -2
- package/src/plugins/builtin/econ/detail-view.tsx +18 -4
- package/src/plugins/builtin/econ/figures.ts +48 -0
- package/src/plugins/builtin/econ/index.tsx +14 -5
- package/src/plugins/builtin/econ/print-watch.ts +3 -1
- package/src/plugins/builtin/econ-statistics/client.ts +11 -0
- package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
- package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
- package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
- 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/help/index.tsx +61 -59
- package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
- package/src/plugins/builtin/help/table-cells.tsx +13 -2
- package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
- package/src/plugins/builtin/holders/beneficial-view.tsx +4 -2
- package/src/plugins/builtin/holders/headless.ts +4 -2
- package/src/plugins/builtin/holders/pane.tsx +3 -0
- package/src/plugins/builtin/holders/report-rows.ts +4 -1
- package/src/plugins/builtin/insider/headless.ts +2 -2
- package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
- package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
- package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
- package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
- package/src/plugins/builtin/jodi/client.ts +4 -1
- package/src/plugins/builtin/macro-day/headless.ts +12 -5
- package/src/plugins/builtin/macro-day/index.tsx +1 -1
- package/src/plugins/builtin/macro-day/model.ts +39 -5
- package/src/plugins/builtin/macro-day/pane.tsx +27 -19
- package/src/plugins/builtin/market-movers/client.ts +3 -0
- package/src/plugins/builtin/market-movers/headless.ts +3 -1
- package/src/plugins/builtin/market-movers/index.tsx +10 -5
- package/src/plugins/builtin/market-movers/screener.ts +27 -6
- package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
- package/src/plugins/builtin/mna/headless.ts +2 -0
- package/src/plugins/builtin/mna/pane.tsx +3 -1
- package/src/plugins/builtin/money-markets/series.ts +3 -5
- package/src/plugins/builtin/news/headless.ts +3 -1
- package/src/plugins/builtin/news/index.tsx +3 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
- package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
- package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
- package/src/plugins/builtin/power/gas-client.ts +2 -1
- package/src/plugins/builtin/power/prices-client.ts +3 -2
- package/src/plugins/builtin/realized-vol/client.ts +13 -5
- package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
- package/src/plugins/builtin/realized-vol/headless.ts +8 -4
- package/src/plugins/builtin/realized-vol/model.ts +37 -6
- package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
- package/src/plugins/builtin/research/event-model.ts +30 -4
- package/src/plugins/builtin/research/index.tsx +7 -3
- package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
- package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
- package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
- package/src/plugins/builtin/risk-factors/data.ts +12 -6
- package/src/plugins/builtin/risk-factors/headless.ts +5 -2
- package/src/plugins/builtin/risk-factors/index.tsx +3 -1
- package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
- package/src/plugins/builtin/roadmap/index.tsx +28 -0
- package/src/plugins/builtin/roadmap/model.ts +67 -0
- package/src/plugins/builtin/roadmap/pane.tsx +244 -0
- package/src/plugins/builtin/scanner/live-headless.ts +21 -2
- package/src/plugins/builtin/scanner/lock-note.ts +26 -3
- package/src/plugins/builtin/sec/headless.ts +2 -1
- package/src/plugins/builtin/sec/index.tsx +3 -0
- package/src/plugins/builtin/shared/cloud-resource.ts +20 -7
- package/src/plugins/builtin/shared/fred-points.ts +6 -1
- package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
- package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
- package/src/plugins/builtin/shared/report-freshness.ts +8 -0
- package/src/plugins/builtin/shared/research-cells.tsx +1 -1
- package/src/plugins/builtin/shared/series-cache.ts +4 -4
- 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 +15 -9
- package/src/plugins/builtin/thirteenf/data.ts +39 -11
- package/src/plugins/builtin/thirteenf/headless.ts +31 -4
- 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/ticker-detail/overview/model.ts +10 -2
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
- package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
- 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 +24 -13
- package/src/plugins/builtin/treasury-auctions/pane.tsx +18 -6
- 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 +11 -4
- package/src/plugins/builtin/volatility/series.ts +2 -1
- package/src/plugins/builtin/yield-curve/client.ts +59 -1
- package/src/plugins/builtin/yield-curve/headless.ts +9 -6
- package/src/plugins/builtin/yield-curve/index.tsx +34 -10
- package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
- package/src/plugins/catalog-browser.ts +2 -1
- package/src/plugins/host-link.ts +1 -1
- package/src/plugins/installer.ts +50 -39
- package/src/portfolio/column-values.ts +11 -7
- package/src/renderers/dom/desktop/icons.tsx +10 -2
- package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
- package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
- package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
- package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
- package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
- package/src/renderers/electrobun/bun/index.ts +26 -2
- package/src/renderers/electrobun/bun/window/attention.ts +54 -0
- package/src/renderers/electrobun/bun/window/create.ts +45 -4
- package/src/renderers/electrobun/bun/window/events.ts +20 -0
- package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
- package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
- package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
- package/src/renderers/electrobun/launcher/preload.ts +13 -0
- package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
- package/src/renderers/electrobun/shared/protocol.ts +2 -0
- package/src/renderers/electrobun/view/news-source.ts +2 -0
- package/src/renderers/electrobun/view/ui-host.tsx +16 -1
- package/src/series/resolve.ts +2 -1
- package/src/series/types.ts +5 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +8 -4
- package/src/sources/gloomberb-cloud/index.ts +42 -8
- 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 +110 -40
- package/src/tickers/company-name.ts +71 -2
- package/src/tickers/index-roots.ts +7 -0
- package/src/tickers/listing-fate.ts +2 -2
- package/src/tickers/search/home-listing.ts +93 -8
- package/src/tickers/search/index.ts +7 -3
- package/src/tickers/search/ranking.ts +5 -1
- package/src/tickers/search/upsert.ts +13 -0
- package/src/tickers/ticker-changes.ts +8 -4
- 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/company-from.ts +40 -0
- package/src/types/data-provider.ts +7 -0
- package/src/types/financials.ts +45 -3
- package/src/types/headless.ts +6 -0
- package/src/ui/host.tsx +6 -1
- package/src/utils/crypto-pair.ts +121 -7
- package/src/utils/declared-dividends.ts +1 -1
- package/src/utils/financial-statements.ts +73 -16
- package/src/utils/fundamentals.ts +53 -10
- 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
- package/src/version.ts +2 -2
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { debugLog } from "../../utils/debug-log";
|
|
3
|
+
import { logFailure } from "../../utils/log-failure";
|
|
4
|
+
import { isSqliteBusyError, trySqliteBusyOperation, withSqliteBusyRetry, withSqliteLockWait } from "./retry";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How long a write of the app waits for a lock another program holds (a
|
|
8
|
+
* second copy of the app, the `gloom` command, a restart's old process). The
|
|
9
|
+
* app runs SQLite on its only thread, so every millisecond of it is a frozen
|
|
10
|
+
* window: past this, the write waits in a queue instead.
|
|
11
|
+
*/
|
|
12
|
+
const SQLITE_APP_WRITE_WAIT_MS = 200;
|
|
13
|
+
/**
|
|
14
|
+
* Reads are not held up by another program's writes (WAL); only its opening
|
|
15
|
+
* or recovering the database locks them out, briefly. A read that gives up
|
|
16
|
+
* throws, and user data that does not load reads as gone, so it waits longer.
|
|
17
|
+
*/
|
|
18
|
+
const SQLITE_APP_READ_WAIT_MS = 1_000;
|
|
19
|
+
/**
|
|
20
|
+
* How long one timer tick of the queue may hold the thread: queued writes
|
|
21
|
+
* run in order until this is spent, and the rest wait for the next tick.
|
|
22
|
+
*/
|
|
23
|
+
const QUEUED_WRITE_TICK_BUDGET_MS = 40;
|
|
24
|
+
/** How long a queued write waits for the lock within a tick. */
|
|
25
|
+
const QUEUED_WRITE_LOCK_WAIT_MS = 25;
|
|
26
|
+
const QUEUED_WRITE_FIRST_RETRY_MS = 100;
|
|
27
|
+
const QUEUED_WRITE_MAX_RETRY_MS = 2_000;
|
|
28
|
+
/** At shutdown nothing waits on the thread any more, and what is queued has nowhere else to go. */
|
|
29
|
+
const CLOSE_LOCK_WAIT_MS = 5_000;
|
|
30
|
+
|
|
31
|
+
const sqliteLog = debugLog.createLogger("sqlite");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How a store reaches its tables, by what the data is.
|
|
35
|
+
*
|
|
36
|
+
* The cache can always be fetched again, so it never waits: a read another
|
|
37
|
+
* program has locked is a miss, and a write is skipped. User data (tickers
|
|
38
|
+
* with their positions, plugin state, the session) waits briefly; a write of
|
|
39
|
+
* it that still finds the lock taken is queued and retried in the background,
|
|
40
|
+
* a little at a time, and each store reads its own queued writes back until
|
|
41
|
+
* they land. At shutdown the app waits up to 5 s for the lock to write what
|
|
42
|
+
* is left; a write still locked out then is lost, and the log says how many.
|
|
43
|
+
*
|
|
44
|
+
* In WAL mode the connection commits with `synchronous=NORMAL`: a commit no
|
|
45
|
+
* longer flushes the disk, and a power cut can cost the last few, never the
|
|
46
|
+
* database. A write of user data flushes as before (`FULL`), which also
|
|
47
|
+
* flushes everything committed ahead of it. Where WAL does not take (a
|
|
48
|
+
* network share), every commit flushes.
|
|
49
|
+
*
|
|
50
|
+
* On the app's connection the cache writes of one turn of the event loop
|
|
51
|
+
* share a transaction and commit together once the turn is over: a batch of
|
|
52
|
+
* quotes, a list's touched rows. A write of user data, the queue and close
|
|
53
|
+
* commit them first, so a user write still commits on its own and flushes.
|
|
54
|
+
*/
|
|
55
|
+
export interface SqliteAccess {
|
|
56
|
+
/** A read of cached data; null when another program has it locked. */
|
|
57
|
+
readCache<T>(operationName: string, read: () => T): T | null;
|
|
58
|
+
/** A write of cached data; false when another program has it locked, and nothing was written. */
|
|
59
|
+
writeCache(operationName: string, write: () => void): boolean;
|
|
60
|
+
/** A read of user data, or a statement asked for by the user: waits for a lock up to a second, then throws. */
|
|
61
|
+
run<T>(operationName: string, statement: () => T): T;
|
|
62
|
+
/**
|
|
63
|
+
* A write of user data. Returns whether it landed now; when it did not, it
|
|
64
|
+
* is queued behind the lock, in order with every write after it, and
|
|
65
|
+
* `settled` runs once it is written, superseded or given up on.
|
|
66
|
+
*/
|
|
67
|
+
write(operationName: string, write: () => void, options?: SqliteWriteOptions): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* One timer tick's worth of the queue: queued writes in order, for up to
|
|
70
|
+
* the tick's budget. True when the queue is empty.
|
|
71
|
+
*/
|
|
72
|
+
flush(): boolean;
|
|
73
|
+
/** Writes what is still queued, waiting up to 5 s for the lock. */
|
|
74
|
+
close(): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface SqliteWriteOptions {
|
|
78
|
+
/**
|
|
79
|
+
* The one row this write replaces or deletes in full. A queued write with
|
|
80
|
+
* the same key is superseded and dropped from the queue: the later write
|
|
81
|
+
* is all that row will hold, and it runs after every write queued before.
|
|
82
|
+
* A write that touches several rows has no key.
|
|
83
|
+
*/
|
|
84
|
+
key?: string;
|
|
85
|
+
settled?: () => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SqliteAccessOptions {
|
|
89
|
+
/**
|
|
90
|
+
* The app's own connection, on the thread that answers the user: no
|
|
91
|
+
* statement waits on another program for more than a moment. Without it
|
|
92
|
+
* (commands, tests) statements wait out the busy timeout and retry, as a
|
|
93
|
+
* command has nothing better to do.
|
|
94
|
+
*/
|
|
95
|
+
interactive?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* The connection runs `synchronous=NORMAL`, as it does once WAL took, and
|
|
98
|
+
* a write of user data switches to `FULL` for its commit. Without it the
|
|
99
|
+
* connection's own level is left alone.
|
|
100
|
+
*/
|
|
101
|
+
relaxedCommits?: boolean;
|
|
102
|
+
/** After each commit of the app's connection (see checkpointer.ts). */
|
|
103
|
+
onCommit?: () => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface QueuedWrite {
|
|
107
|
+
operationName: string;
|
|
108
|
+
write: () => void;
|
|
109
|
+
key?: string;
|
|
110
|
+
settled?: () => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function settle(write: { settled?: () => void }): void {
|
|
114
|
+
try {
|
|
115
|
+
write.settled?.();
|
|
116
|
+
} catch (error) {
|
|
117
|
+
logFailure("data.sqlite", "Settling a queued write failed")(error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function now(): number {
|
|
122
|
+
return typeof performance !== "undefined" && typeof performance.now === "function"
|
|
123
|
+
? performance.now()
|
|
124
|
+
: Date.now();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createSqliteAccess(db: Database, options: SqliteAccessOptions = {}): SqliteAccess {
|
|
128
|
+
/** The app's cache writes of this turn, committed together once it is over (interactive only). */
|
|
129
|
+
let cacheBatch: ReturnType<typeof setTimeout> | null = null;
|
|
130
|
+
/** User writes running now: a cache write inside one commits on its own, outside any batch. */
|
|
131
|
+
let durableWrites = 0;
|
|
132
|
+
|
|
133
|
+
const commitCacheBatch = (): void => {
|
|
134
|
+
if (!cacheBatch) return;
|
|
135
|
+
clearTimeout(cacheBatch);
|
|
136
|
+
cacheBatch = null;
|
|
137
|
+
// A failed statement can roll the whole transaction back; its writes are
|
|
138
|
+
// gone then, which a cache can afford.
|
|
139
|
+
if (!db.inTransaction) return;
|
|
140
|
+
try {
|
|
141
|
+
db.exec("COMMIT");
|
|
142
|
+
options.onCommit?.();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
logFailure("data.sqlite", "Committing cached writes failed")(error);
|
|
145
|
+
if (!db.inTransaction) return;
|
|
146
|
+
try {
|
|
147
|
+
db.exec("ROLLBACK");
|
|
148
|
+
} catch (rollbackError) {
|
|
149
|
+
logFailure("data.sqlite", "Rolling back cached writes failed")(rollbackError);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Commits `write` with a full flush when the connection's own level is
|
|
156
|
+
* NORMAL. The cache writes of the turn commit first, wherever they came
|
|
157
|
+
* from (a queued write's `settled`, the write before), so the user write
|
|
158
|
+
* never lands inside their batch at NORMAL.
|
|
159
|
+
*/
|
|
160
|
+
const durable = <T>(write: () => T): T => {
|
|
161
|
+
commitCacheBatch();
|
|
162
|
+
durableWrites += 1;
|
|
163
|
+
try {
|
|
164
|
+
if (!options.relaxedCommits || db.inTransaction) return write();
|
|
165
|
+
db.exec("PRAGMA synchronous=FULL");
|
|
166
|
+
try {
|
|
167
|
+
return write();
|
|
168
|
+
} finally {
|
|
169
|
+
db.exec("PRAGMA synchronous=NORMAL");
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
durableWrites -= 1;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
if (!options.interactive) {
|
|
177
|
+
return {
|
|
178
|
+
readCache: (operationName, read) => trySqliteBusyOperation(operationName, read),
|
|
179
|
+
writeCache: (operationName, write) => trySqliteBusyOperation(operationName, () => {
|
|
180
|
+
write();
|
|
181
|
+
return true;
|
|
182
|
+
}) ?? false,
|
|
183
|
+
run: (operationName, statement) => withSqliteBusyRetry(operationName, statement),
|
|
184
|
+
write: (operationName, write, writeOptions) => {
|
|
185
|
+
try {
|
|
186
|
+
durable(() => withSqliteBusyRetry(operationName, write));
|
|
187
|
+
} finally {
|
|
188
|
+
settle({ settled: writeOptions?.settled });
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
},
|
|
192
|
+
flush: () => true,
|
|
193
|
+
close: () => {},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const queue: QueuedWrite[] = [];
|
|
198
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
199
|
+
let retryMs = QUEUED_WRITE_FIRST_RETRY_MS;
|
|
200
|
+
let closed = false;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Writes the queue in order for up to `budgetMs`, each write waiting for
|
|
204
|
+
* the lock no longer than `lockWaitMs` or what is left of the budget.
|
|
205
|
+
* Says why it stopped: the queue is empty, the lock is still taken, or the
|
|
206
|
+
* budget is spent. A write already running finishes past the budget.
|
|
207
|
+
*/
|
|
208
|
+
const drain = (budgetMs: number, lockWaitMs: number): "empty" | "locked" | "budget" => {
|
|
209
|
+
const startedAt = now();
|
|
210
|
+
while (queue.length > 0) {
|
|
211
|
+
const leftMs = budgetMs - (now() - startedAt);
|
|
212
|
+
if (leftMs <= 0) return "budget";
|
|
213
|
+
const next = queue[0]!;
|
|
214
|
+
try {
|
|
215
|
+
durable(() => withSqliteLockWait(next.operationName, next.write, Math.min(lockWaitMs, leftMs)));
|
|
216
|
+
options.onCommit?.();
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (isSqliteBusyError(error)) return "locked";
|
|
219
|
+
logFailure("data.sqlite", "A queued write failed")(error);
|
|
220
|
+
}
|
|
221
|
+
queue.shift();
|
|
222
|
+
settle(next);
|
|
223
|
+
}
|
|
224
|
+
return "empty";
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const flush = (): boolean => {
|
|
228
|
+
const result = drain(QUEUED_WRITE_TICK_BUDGET_MS, QUEUED_WRITE_LOCK_WAIT_MS);
|
|
229
|
+
if (result === "empty") {
|
|
230
|
+
retryMs = QUEUED_WRITE_FIRST_RETRY_MS;
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
// A spent budget comes back at once, after whatever else waits on the
|
|
234
|
+
// thread; a lock still taken backs off.
|
|
235
|
+
if (result === "locked") retryMs = Math.min(QUEUED_WRITE_MAX_RETRY_MS, retryMs * 2);
|
|
236
|
+
schedule(result === "budget" ? 0 : retryMs);
|
|
237
|
+
return false;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
function schedule(delayMs: number): void {
|
|
241
|
+
if (timer || closed) return;
|
|
242
|
+
timer = setTimeout(() => {
|
|
243
|
+
timer = null;
|
|
244
|
+
flush();
|
|
245
|
+
}, delayMs);
|
|
246
|
+
(timer as { unref?: () => void }).unref?.();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const tryCache = <T>(operationName: string, statement: () => T): { value: T } | null => {
|
|
250
|
+
try {
|
|
251
|
+
return { value: withSqliteLockWait(operationName, statement, 0) };
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (!isSqliteBusyError(error)) throw error;
|
|
254
|
+
logFailure("data.sqlite", "Skipped a cache statement: another program holds the database")(error);
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** False when another program holds the lock, and the write is skipped. */
|
|
260
|
+
const joinCacheBatch = (): boolean => {
|
|
261
|
+
// Inside a user write, a batch would hold that write's own statements until the turn is over.
|
|
262
|
+
if (durableWrites > 0) return true;
|
|
263
|
+
if (cacheBatch && db.inTransaction) return true;
|
|
264
|
+
if (cacheBatch) commitCacheBatch();
|
|
265
|
+
// Inside a transaction a statement of this connection opened, the write is part of it.
|
|
266
|
+
if (db.inTransaction) return true;
|
|
267
|
+
if (!tryCache("begin cached writes", () => db.exec("BEGIN IMMEDIATE"))) return false;
|
|
268
|
+
cacheBatch = setTimeout(commitCacheBatch, 0);
|
|
269
|
+
(cacheBatch as { unref?: () => void }).unref?.();
|
|
270
|
+
return true;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
readCache: (operationName, read) => tryCache(operationName, read)?.value ?? null,
|
|
275
|
+
writeCache: (operationName, write) => joinCacheBatch() && tryCache(operationName, write) !== null,
|
|
276
|
+
run: (operationName, statement) => withSqliteLockWait(operationName, statement, SQLITE_APP_READ_WAIT_MS),
|
|
277
|
+
write: (operationName, write, writeOptions = {}) => {
|
|
278
|
+
const { key, settled } = writeOptions;
|
|
279
|
+
if (queue.length === 0) {
|
|
280
|
+
try {
|
|
281
|
+
durable(() => withSqliteLockWait(operationName, write, SQLITE_APP_WRITE_WAIT_MS));
|
|
282
|
+
options.onCommit?.();
|
|
283
|
+
settle({ settled });
|
|
284
|
+
return true;
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (!isSqliteBusyError(error)) {
|
|
287
|
+
settle({ settled });
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (key !== undefined) {
|
|
293
|
+
const superseded = queue.findIndex((queued) => queued.key === key);
|
|
294
|
+
if (superseded >= 0) settle(queue.splice(superseded, 1)[0]!);
|
|
295
|
+
}
|
|
296
|
+
queue.push({ operationName, write, key, settled });
|
|
297
|
+
if (queue.length === 1) sqliteLog.warn("A write is waiting for another program to release the database", { operation: operationName });
|
|
298
|
+
schedule(retryMs);
|
|
299
|
+
return false;
|
|
300
|
+
},
|
|
301
|
+
flush,
|
|
302
|
+
close: () => {
|
|
303
|
+
if (closed) return;
|
|
304
|
+
closed = true;
|
|
305
|
+
commitCacheBatch();
|
|
306
|
+
if (timer) clearTimeout(timer);
|
|
307
|
+
timer = null;
|
|
308
|
+
if (drain(CLOSE_LOCK_WAIT_MS, CLOSE_LOCK_WAIT_MS) === "empty") return;
|
|
309
|
+
sqliteLog.error("Writes could not be saved: another program kept the database locked", { count: queue.length });
|
|
310
|
+
for (const write of queue.splice(0)) settle(write);
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { resolve } from "path";
|
|
2
|
+
import { debugLog } from "../../utils/debug-log";
|
|
3
|
+
import { logFailure } from "../../utils/log-failure";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pages SQLite lets the WAL grow to before the connection that commits
|
|
7
|
+
* copies them into the database itself. The app's connection keeps this as
|
|
8
|
+
* a backstop only: the checkpointer below does the copying long before.
|
|
9
|
+
*/
|
|
10
|
+
export const SQLITE_DEFAULT_AUTOCHECKPOINT_PAGES = 1_000;
|
|
11
|
+
export const SQLITE_BACKSTOP_AUTOCHECKPOINT_PAGES = 10_000;
|
|
12
|
+
/** How soon after a commit the WAL is copied into the database, at most once per this. */
|
|
13
|
+
const CHECKPOINT_DELAY_MS = 1_000;
|
|
14
|
+
/** How long closing waits for the checkpointer to let go of the file (Windows cannot delete an open one). */
|
|
15
|
+
const CLOSE_WAIT_MS = 1_000;
|
|
16
|
+
|
|
17
|
+
const checkpointLog = debugLog.createLogger("sqlite");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Runs on its own thread with its own connection. A passive checkpoint
|
|
21
|
+
* copies what it can without waiting on a lock and never stops the app's
|
|
22
|
+
* connection from reading or writing meanwhile.
|
|
23
|
+
*/
|
|
24
|
+
const WORKER_SOURCE = `
|
|
25
|
+
import { Database } from "bun:sqlite";
|
|
26
|
+
let db = null;
|
|
27
|
+
let closedFlag = null;
|
|
28
|
+
self.onmessage = (event) => {
|
|
29
|
+
const message = event.data;
|
|
30
|
+
if (message.type === "open") {
|
|
31
|
+
closedFlag = new Int32Array(message.closed);
|
|
32
|
+
try {
|
|
33
|
+
db = new Database(message.path);
|
|
34
|
+
db.exec("PRAGMA busy_timeout=1000");
|
|
35
|
+
postMessage({ type: "ready" });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
postMessage({ type: "failed", message: String(error && error.message || error) });
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (message.type === "checkpoint") {
|
|
42
|
+
if (!db) return;
|
|
43
|
+
const startedAt = performance.now();
|
|
44
|
+
try {
|
|
45
|
+
const result = db.query("PRAGMA wal_checkpoint(PASSIVE)").get();
|
|
46
|
+
postMessage({ type: "checkpointed", ms: performance.now() - startedAt, result });
|
|
47
|
+
} catch (error) {
|
|
48
|
+
postMessage({ type: "failed", message: String(error && error.message || error) });
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (message.type === "close") {
|
|
53
|
+
try {
|
|
54
|
+
db?.close();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// Intentional ignore: the thread ends either way, and a failed close only leaves the handle to the OS.
|
|
57
|
+
}
|
|
58
|
+
db = null;
|
|
59
|
+
if (closedFlag) {
|
|
60
|
+
Atomics.store(closedFlag, 0, 1);
|
|
61
|
+
Atomics.notify(closedFlag, 0);
|
|
62
|
+
}
|
|
63
|
+
self.close();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
export interface WalCheckpointer {
|
|
69
|
+
/** Writes were committed: the WAL is copied into the database shortly, off this thread. */
|
|
70
|
+
nudge(): void;
|
|
71
|
+
/** Stops the thread, waiting briefly for it to close its connection. */
|
|
72
|
+
close(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface WorkerMessage {
|
|
76
|
+
type: "ready" | "failed" | "checkpointed";
|
|
77
|
+
message?: string;
|
|
78
|
+
ms?: number;
|
|
79
|
+
result?: { busy?: number; log?: number; checkpointed?: number };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Copies the WAL into the database file on a thread of its own.
|
|
84
|
+
*
|
|
85
|
+
* SQLite does this inside a commit once the WAL passes 1,000 pages: one
|
|
86
|
+
* write in a few hundred copies about 4 MB into the database and flushes the
|
|
87
|
+
* disk, on the thread that asked for the write. That is the app's only
|
|
88
|
+
* thread, and the copy takes 150 ms to a second on a laptop, more under an
|
|
89
|
+
* antivirus. The app's connection leaves it to this instead (see
|
|
90
|
+
* database.ts), and `onFailure` puts SQLite's own schedule back when the
|
|
91
|
+
* thread cannot run.
|
|
92
|
+
*
|
|
93
|
+
* Null where there is no Worker to run it on.
|
|
94
|
+
*/
|
|
95
|
+
export function startWalCheckpointer(dbPath: string, onFailure: () => void): WalCheckpointer | null {
|
|
96
|
+
if (typeof Worker === "undefined" || typeof Blob === "undefined" || typeof URL.createObjectURL !== "function") return null;
|
|
97
|
+
let worker: Worker;
|
|
98
|
+
const source = URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "application/javascript" }));
|
|
99
|
+
try {
|
|
100
|
+
worker = new Worker(source);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
checkpointLog.warn("Could not start the WAL checkpointer; commits checkpoint themselves", { error: String(error) });
|
|
103
|
+
URL.revokeObjectURL(source);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
(worker as { unref?: () => void }).unref?.();
|
|
107
|
+
const closed = new SharedArrayBuffer(4);
|
|
108
|
+
let failed = false;
|
|
109
|
+
let stopped = false;
|
|
110
|
+
let running = false;
|
|
111
|
+
let pending = false;
|
|
112
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
113
|
+
|
|
114
|
+
const fail = (reason: string) => {
|
|
115
|
+
if (failed || stopped) return;
|
|
116
|
+
failed = true;
|
|
117
|
+
checkpointLog.warn("The WAL checkpointer stopped; commits checkpoint themselves", { reason });
|
|
118
|
+
try {
|
|
119
|
+
onFailure();
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logFailure("data.sqlite", "Could not give commits their checkpoints back")(error);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** False when the worker is gone (a crashed thread throws on a message), which ends the checkpointer. */
|
|
126
|
+
const post = (message: { type: string; path?: string; closed?: SharedArrayBuffer }): boolean => {
|
|
127
|
+
try {
|
|
128
|
+
worker.postMessage(message);
|
|
129
|
+
return true;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const terminate = () => {
|
|
137
|
+
try {
|
|
138
|
+
worker.terminate();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
logFailure("data.sqlite", "Stopping the WAL checkpointer failed")(error);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const schedule = () => {
|
|
145
|
+
if (timer || running || failed || stopped) return;
|
|
146
|
+
timer = setTimeout(() => {
|
|
147
|
+
timer = null;
|
|
148
|
+
if (failed || stopped) return;
|
|
149
|
+
pending = false;
|
|
150
|
+
running = post({ type: "checkpoint" });
|
|
151
|
+
}, CHECKPOINT_DELAY_MS);
|
|
152
|
+
(timer as { unref?: () => void }).unref?.();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
worker.onmessage = (event: MessageEvent<WorkerMessage>) => {
|
|
156
|
+
const message = event.data;
|
|
157
|
+
if (message.type === "ready") {
|
|
158
|
+
URL.revokeObjectURL(source);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (message.type === "failed") {
|
|
162
|
+
fail(message.message ?? "unknown error");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (message.type === "checkpointed") {
|
|
166
|
+
running = false;
|
|
167
|
+
if (pending) schedule();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
worker.onerror = (event) => {
|
|
171
|
+
fail(event.message);
|
|
172
|
+
};
|
|
173
|
+
if (!post({ type: "open", path: resolve(dbPath), closed })) {
|
|
174
|
+
terminate();
|
|
175
|
+
URL.revokeObjectURL(source);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
nudge() {
|
|
181
|
+
if (failed || stopped) return;
|
|
182
|
+
pending = true;
|
|
183
|
+
schedule();
|
|
184
|
+
},
|
|
185
|
+
close() {
|
|
186
|
+
if (stopped) return;
|
|
187
|
+
if (timer) clearTimeout(timer);
|
|
188
|
+
timer = null;
|
|
189
|
+
const asked = !failed && post({ type: "close" });
|
|
190
|
+
stopped = true;
|
|
191
|
+
if (!asked) {
|
|
192
|
+
terminate();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
Atomics.wait(new Int32Array(closed), 0, 0, CLOSE_WAIT_MS);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { createSqliteAccess, type SqliteAccess } from "./access";
|
|
3
|
+
import {
|
|
4
|
+
SQLITE_BACKSTOP_AUTOCHECKPOINT_PAGES,
|
|
5
|
+
SQLITE_DEFAULT_AUTOCHECKPOINT_PAGES,
|
|
6
|
+
startWalCheckpointer,
|
|
7
|
+
type WalCheckpointer,
|
|
8
|
+
} from "./checkpointer";
|
|
2
9
|
import { SQLITE_BUSY_TIMEOUT_MS, withSqliteBusyRetry } from "./retry";
|
|
3
10
|
|
|
4
11
|
const SCHEMA = `
|
|
@@ -60,21 +67,57 @@ const SCHEMA = `
|
|
|
60
67
|
);
|
|
61
68
|
`;
|
|
62
69
|
|
|
70
|
+
export interface SqliteDatabaseOptions {
|
|
71
|
+
/**
|
|
72
|
+
* The app's own connection, on the thread that answers the user (the
|
|
73
|
+
* terminal, the desktop app's Bun process): statements do not wait on
|
|
74
|
+
* another program's lock (see access.ts). Commands and tests wait it out.
|
|
75
|
+
*/
|
|
76
|
+
interactive?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
export class SqliteDatabase {
|
|
64
80
|
readonly connection: Database;
|
|
81
|
+
readonly access: SqliteAccess;
|
|
82
|
+
private readonly checkpointer: WalCheckpointer | null = null;
|
|
65
83
|
|
|
66
|
-
constructor(dbPath: string) {
|
|
84
|
+
constructor(dbPath: string, options: SqliteDatabaseOptions = {}) {
|
|
67
85
|
this.connection = new Database(dbPath);
|
|
86
|
+
// Opening waits for another program's lock: it happens once, and the app
|
|
87
|
+
// has nothing to show without its data.
|
|
68
88
|
this.connection.exec(`PRAGMA busy_timeout=${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
69
|
-
withSqliteBusyRetry("enable sqlite wal", () =>
|
|
70
|
-
this.connection.
|
|
71
|
-
|
|
89
|
+
const journalMode = withSqliteBusyRetry("enable sqlite wal", () => (
|
|
90
|
+
this.connection.query<{ journal_mode: string }, []>("PRAGMA journal_mode=WAL").get()?.journal_mode
|
|
91
|
+
));
|
|
92
|
+
// In WAL mode commits can stop flushing the disk and the database stays
|
|
93
|
+
// whole; user data still flushes per write (access.ts). Where WAL does not
|
|
94
|
+
// take (a network share, an in-memory database), every commit flushes.
|
|
95
|
+
const wal = journalMode?.toLowerCase() === "wal";
|
|
96
|
+
this.connection.exec(wal ? "PRAGMA synchronous=NORMAL" : "PRAGMA synchronous=FULL");
|
|
72
97
|
withSqliteBusyRetry("initialize sqlite schema", () => {
|
|
73
98
|
this.connection.exec(SCHEMA);
|
|
74
99
|
});
|
|
100
|
+
// The app's statements choose how long to wait for a lock themselves.
|
|
101
|
+
if (options.interactive) this.connection.exec("PRAGMA busy_timeout=0");
|
|
102
|
+
// The app's commits leave copying the WAL into the database to a thread
|
|
103
|
+
// of its own, and only do it themselves past a WAL ten times the usual.
|
|
104
|
+
if (options.interactive && wal && dbPath !== ":memory:") {
|
|
105
|
+
this.checkpointer = startWalCheckpointer(dbPath, () => {
|
|
106
|
+
this.connection.exec(`PRAGMA wal_autocheckpoint=${SQLITE_DEFAULT_AUTOCHECKPOINT_PAGES}`);
|
|
107
|
+
});
|
|
108
|
+
if (this.checkpointer) this.connection.exec(`PRAGMA wal_autocheckpoint=${SQLITE_BACKSTOP_AUTOCHECKPOINT_PAGES}`);
|
|
109
|
+
}
|
|
110
|
+
const checkpointer = this.checkpointer;
|
|
111
|
+
this.access = createSqliteAccess(this.connection, {
|
|
112
|
+
interactive: options.interactive,
|
|
113
|
+
relaxedCommits: wal,
|
|
114
|
+
...(checkpointer ? { onCommit: () => checkpointer.nudge() } : {}),
|
|
115
|
+
});
|
|
75
116
|
}
|
|
76
117
|
|
|
77
118
|
close(): void {
|
|
119
|
+
this.access.close();
|
|
120
|
+
this.checkpointer?.close();
|
|
78
121
|
this.connection.close();
|
|
79
122
|
}
|
|
80
123
|
}
|
package/src/data/sqlite/retry.ts
CHANGED
|
@@ -3,6 +3,9 @@ import { recordPerfSample } from "../../utils/perf-marks";
|
|
|
3
3
|
const SQLITE_BUSY_RETRY_ATTEMPTS = 4;
|
|
4
4
|
const SQLITE_BUSY_RETRY_INITIAL_DELAY_MS = 25;
|
|
5
5
|
const SQLITE_BUSY_RETRY_MAX_DELAY_MS = 250;
|
|
6
|
+
/** Between two tries for a lock another connection holds, while a bounded wait lasts. */
|
|
7
|
+
const SQLITE_LOCK_POLL_INITIAL_DELAY_MS = 2;
|
|
8
|
+
const SQLITE_LOCK_POLL_MAX_DELAY_MS = 25;
|
|
6
9
|
const SQLITE_BUSY_SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
7
10
|
|
|
8
11
|
export const SQLITE_BUSY_TIMEOUT_MS = 2000;
|
|
@@ -30,7 +33,7 @@ export function isSqliteBusyError(error: unknown): boolean {
|
|
|
30
33
|
if (!error || typeof error !== "object") return false;
|
|
31
34
|
const candidate = error as { code?: unknown; errno?: unknown; message?: unknown };
|
|
32
35
|
const code = typeof candidate.code === "string" ? candidate.code.toUpperCase() : "";
|
|
33
|
-
if (code
|
|
36
|
+
if (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED")) return true;
|
|
34
37
|
if (candidate.errno === 5 || candidate.errno === 6) return true;
|
|
35
38
|
const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
|
|
36
39
|
return message.includes("database is locked")
|
|
@@ -43,6 +46,17 @@ function sleepSync(delayMs: number): void {
|
|
|
43
46
|
Atomics.wait(SQLITE_BUSY_SLEEP_BUFFER, 0, 0, delayMs);
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Names a statement that held the thread for a while: which one, how many
|
|
51
|
+
* tries it took, and whether it waited on another connection's lock (a busy
|
|
52
|
+
* answer came back) or was simply slow (a large write, the disk).
|
|
53
|
+
*/
|
|
54
|
+
function reportIfBlocking(operationName: string, startedAt: number, attempts: number, lockWait: boolean): void {
|
|
55
|
+
const elapsedMs = now() - startedAt;
|
|
56
|
+
if (elapsedMs < SQLITE_BLOCKING_REPORT_MS) return;
|
|
57
|
+
recordPerfSample("sqlite.blocked", elapsedMs, { operation: operationName, attempts, lockWait });
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
export function withSqliteBusyRetry<T>(
|
|
47
61
|
operationName: string,
|
|
48
62
|
operation: () => T,
|
|
@@ -52,20 +66,18 @@ export function withSqliteBusyRetry<T>(
|
|
|
52
66
|
const maxDelayMs = Math.max(0, options.maxDelayMs ?? SQLITE_BUSY_RETRY_MAX_DELAY_MS);
|
|
53
67
|
let delayMs = Math.max(0, options.initialDelayMs ?? SQLITE_BUSY_RETRY_INITIAL_DELAY_MS);
|
|
54
68
|
const startedAt = now();
|
|
55
|
-
|
|
56
|
-
const elapsedMs = now() - startedAt;
|
|
57
|
-
if (elapsedMs < SQLITE_BLOCKING_REPORT_MS) return;
|
|
58
|
-
recordPerfSample("sqlite.blocked", elapsedMs, { operation: operationName, attempts: attempt });
|
|
59
|
-
};
|
|
69
|
+
let lockWait = false;
|
|
60
70
|
|
|
61
71
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
62
72
|
try {
|
|
63
73
|
const result = operation();
|
|
64
|
-
reportIfBlocking(attempt);
|
|
74
|
+
reportIfBlocking(operationName, startedAt, attempt, lockWait);
|
|
65
75
|
return result;
|
|
66
76
|
} catch (error) {
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
const busy = isSqliteBusyError(error);
|
|
78
|
+
lockWait ||= busy;
|
|
79
|
+
if (!busy || attempt >= attempts) {
|
|
80
|
+
reportIfBlocking(operationName, startedAt, attempt, lockWait);
|
|
69
81
|
throw error;
|
|
70
82
|
}
|
|
71
83
|
sleepSync(delayMs);
|
|
@@ -88,3 +100,32 @@ export function trySqliteBusyOperation<T>(
|
|
|
88
100
|
throw error;
|
|
89
101
|
}
|
|
90
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Runs `operation` on a connection that does not wait for locks itself (a
|
|
106
|
+
* busy timeout of 0), trying again while another connection holds the lock
|
|
107
|
+
* it needs, for `waitMs` at most. Then the busy error is thrown. A wait of 0
|
|
108
|
+
* is one try.
|
|
109
|
+
*/
|
|
110
|
+
export function withSqliteLockWait<T>(operationName: string, operation: () => T, waitMs: number): T {
|
|
111
|
+
const startedAt = now();
|
|
112
|
+
let delayMs = SQLITE_LOCK_POLL_INITIAL_DELAY_MS;
|
|
113
|
+
let lockWait = false;
|
|
114
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
115
|
+
try {
|
|
116
|
+
const result = operation();
|
|
117
|
+
reportIfBlocking(operationName, startedAt, attempt, lockWait);
|
|
118
|
+
return result;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const busy = isSqliteBusyError(error);
|
|
121
|
+
lockWait ||= busy;
|
|
122
|
+
const leftMs = waitMs - (now() - startedAt);
|
|
123
|
+
if (!busy || leftMs <= 0) {
|
|
124
|
+
reportIfBlocking(operationName, startedAt, attempt, lockWait);
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
sleepSync(Math.min(delayMs, leftMs));
|
|
128
|
+
delayMs = Math.min(SQLITE_LOCK_POLL_MAX_DELAY_MS, delayMs * 2);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|