bunqueue-dashboard 0.0.34 → 0.0.36

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 (174) hide show
  1. package/README.md +20 -13
  2. package/agent/backup/processExecutor.ts +90 -0
  3. package/agent/backup/resultValidation.ts +85 -0
  4. package/agent/backup/routes.ts +160 -0
  5. package/agent/backup/runner.ts +91 -0
  6. package/agent/backup/standaloneWorker.ts +23 -0
  7. package/agent/backup/workerExecutor.ts +40 -0
  8. package/agent/backup/workerFactory.ts +21 -0
  9. package/agent/backup/workerProtocol.ts +10 -0
  10. package/agent/db/core.ts +141 -0
  11. package/agent/db/csvExport.ts +172 -0
  12. package/agent/db/exportTimeout.ts +190 -0
  13. package/agent/db/query.ts +82 -0
  14. package/agent/db/queryTimeout.ts +76 -0
  15. package/agent/db/schema.ts +79 -0
  16. package/agent/db/tables.ts +157 -0
  17. package/agent/db/types.ts +118 -0
  18. package/agent/db/workerFactory.ts +25 -0
  19. package/agent/db.ts +43 -1099
  20. package/agent/errorMessage.ts +9 -0
  21. package/agent/flow/routes.ts +250 -0
  22. package/agent/flow/service.ts +266 -0
  23. package/agent/flow/types.ts +34 -0
  24. package/agent/flow/validation.ts +230 -0
  25. package/agent/index.ts +5 -17
  26. package/agent/logger.ts +121 -17
  27. package/agent/managedTarget.ts +43 -0
  28. package/agent/manager/config.ts +84 -0
  29. package/agent/manager/logs.ts +83 -0
  30. package/agent/manager/process.ts +233 -0
  31. package/agent/manager/storage.ts +28 -0
  32. package/agent/manager/types.ts +38 -0
  33. package/agent/manager.ts +10 -490
  34. package/agent/queue/routes.ts +128 -0
  35. package/agent/queue/runtime.ts +119 -0
  36. package/agent/queue/types.ts +34 -0
  37. package/agent/queue/validation.ts +100 -0
  38. package/agent/server/controlRoutes.ts +153 -0
  39. package/agent/server/databaseRoutes.ts +165 -0
  40. package/agent/server/errors.ts +48 -0
  41. package/agent/server/handler.ts +146 -0
  42. package/agent/server/jsonBody.ts +109 -0
  43. package/agent/server/lifecycle.ts +72 -0
  44. package/agent/server/managedRuntime.ts +41 -0
  45. package/agent/server/policy.ts +60 -0
  46. package/agent/server/router.ts +150 -0
  47. package/agent/server/types.ts +39 -0
  48. package/agent/server/workflowReadRoutes.ts +80 -0
  49. package/agent/server.ts +16 -369
  50. package/agent/shutdown.ts +156 -0
  51. package/agent/workflow/codec.ts +78 -0
  52. package/agent/workflow/detail.ts +63 -0
  53. package/agent/workflow/list.ts +87 -0
  54. package/agent/workflow/runtime.ts +274 -0
  55. package/agent/workflow/runtimeConfig.ts +103 -0
  56. package/agent/workflow/runtimeRoutes.ts +162 -0
  57. package/agent/workflow/sqlite.ts +52 -0
  58. package/agent/workflow/stats.ts +79 -0
  59. package/agent/workflow/types.ts +81 -0
  60. package/agent/workflow/validation.ts +114 -0
  61. package/agent/workflows.ts +16 -0
  62. package/dist/.vite/manifest.json +619 -246
  63. package/dist/assets/AddJob-UUa2fSXE.js +3 -0
  64. package/dist/assets/Alerts-7oGYfQMb.js +1 -0
  65. package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
  66. package/dist/assets/Benchmark-C2NFp_kj.js +1 -0
  67. package/dist/assets/BulkAddJobs-CL4yBae-.js +5 -0
  68. package/dist/assets/Button-BPqpRZJD.js +1 -0
  69. package/dist/assets/Card-C7NMhqUt.js +1 -0
  70. package/dist/assets/{CopilotPanel-CIXpcwkH.js → CopilotPanel-BQ2Cgo9p.js} +3 -3
  71. package/dist/assets/Cron-DfWBhoBf.js +1 -0
  72. package/dist/assets/CronManager-DAK7RSOX.js +1 -0
  73. package/dist/assets/Database-DqL7giEj.js +3 -0
  74. package/dist/assets/Diagnostics-D98YwN-6.js +1 -0
  75. package/dist/assets/Dlq-BHmA5PTI.js +1 -0
  76. package/dist/assets/DlqControl-DihxvP-i.js +1 -0
  77. package/dist/assets/DlqPro-ncgnDtpE.js +1 -0
  78. package/dist/assets/Flows-B5N5Cil0.js +2 -0
  79. package/dist/assets/JobInspector-B40nFiGh.js +2 -0
  80. package/dist/assets/Jobs-Bla3IEef.js +1 -0
  81. package/dist/assets/JobsPro-CnczVx7x.js +1 -0
  82. package/dist/assets/Logs-T91Q3cH0.js +1 -0
  83. package/dist/assets/LogsPro-CMn6-Wwv.js +1 -0
  84. package/dist/assets/{McpServer-D5rwBg74.js → McpServer-BQBabwW-.js} +2 -2
  85. package/dist/assets/Metrics-DxT_RI2B.js +1 -0
  86. package/dist/assets/MetricsPro-Cjen-nAP.js +1 -0
  87. package/dist/assets/Overview-XN2ej8ph.js +1 -0
  88. package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
  89. package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
  90. package/dist/assets/QueueActions-C-Aferd6.js +1 -0
  91. package/dist/assets/QueueControl-B8Q9NX6t.js +1 -0
  92. package/dist/assets/QueueDetail-BMhG0jUB.js +1 -0
  93. package/dist/assets/QueueDetailPro-BKTTyLR4.js +1 -0
  94. package/dist/assets/Queues-COqPMoY9.js +1 -0
  95. package/dist/assets/QueuesOverview-pWdrPy8Z.js +1 -0
  96. package/dist/assets/S3Backup-BmsZ8xtq.js +1 -0
  97. package/dist/assets/S3BackupPro-AtlsPUJL.js +3 -0
  98. package/dist/assets/ServerControl-D3XwHr72.js +3 -0
  99. package/dist/assets/Settings-BofKZoHn.js +1 -0
  100. package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
  101. package/dist/assets/Usage-DAR9E22v.js +1 -0
  102. package/dist/assets/UsagePro-Bc30N2Lo.js +1 -0
  103. package/dist/assets/Webhooks-Bg2k1PU4.js +1 -0
  104. package/dist/assets/Workers-BqUZMJVQ.js +1 -0
  105. package/dist/assets/WorkersPro-C9aSadUh.js +1 -0
  106. package/dist/assets/Workflows-BEFMfHmo.js +1 -0
  107. package/dist/assets/api-CQ5Z7xR7.js +1 -0
  108. package/dist/assets/bq-BeBxa743.js +1 -0
  109. package/dist/assets/cn-zFEt5SO8.js +1 -0
  110. package/dist/assets/connectionStore-DbBYqHEY.js +1 -0
  111. package/dist/assets/controlConnectionEpoch-CFSO6237.js +1 -0
  112. package/dist/assets/feedback-BnGE2oW2.js +1 -0
  113. package/dist/assets/{flowMutationSafety-DVD1IMr-.js → flowMutationSafety-CoOp94wS.js} +1 -1
  114. package/dist/assets/{form-DsUq0w8s.js → form-tC20r4Ma.js} +1 -1
  115. package/dist/assets/format-Cg7VCIZW.js +1 -0
  116. package/dist/assets/icons-CIvFfU2b.js +1 -0
  117. package/dist/assets/index-Cui9IwWy.js +8 -0
  118. package/dist/assets/index-DOze4TKg.css +1 -0
  119. package/dist/assets/install-Cd9Is10G.js +3 -0
  120. package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
  121. package/dist/assets/responses-kPO4rSu5.js +1 -0
  122. package/dist/assets/toastStore-CS_9Xndn.js +1 -0
  123. package/dist/assets/{upstreamPaths-nG8N1XAz.js → upstreamPaths-D_LPsv7b.js} +1 -1
  124. package/dist/assets/usePolledData-DzlUpBOO.js +1 -0
  125. package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-BlH-Kso1.js} +1 -1
  126. package/dist/assets/useThroughputSeries-C2pa1Wd_.js +1 -0
  127. package/dist/index.html +17 -6
  128. package/package.json +15 -3
  129. package/scripts/embedded.gen.ts +152 -124
  130. package/scripts/serve.ts +96 -484
  131. package/scripts/serveHandler.ts +217 -0
  132. package/scripts/servePolicy.ts +133 -0
  133. package/dist/assets/AddJob-Cih9F84t.js +0 -3
  134. package/dist/assets/Alerts-Dp4Vclez.js +0 -1
  135. package/dist/assets/Benchmark-X_gdZun5.js +0 -1
  136. package/dist/assets/BulkAddJobs-CXTpYMZf.js +0 -5
  137. package/dist/assets/Cron-DUHkse77.js +0 -1
  138. package/dist/assets/CronManager-BeYrLK2E.js +0 -1
  139. package/dist/assets/Database-CXPb3XGr.js +0 -3
  140. package/dist/assets/Diagnostics-DDtoiDYT.js +0 -1
  141. package/dist/assets/Dlq-CX_uRblW.js +0 -1
  142. package/dist/assets/DlqControl-CazFmSEd.js +0 -1
  143. package/dist/assets/DlqPro-HSffdtLJ.js +0 -1
  144. package/dist/assets/Flows-CGmyghKF.js +0 -1
  145. package/dist/assets/JobInspector-B9i1HnG5.js +0 -2
  146. package/dist/assets/Jobs-BZGtMnJI.js +0 -1
  147. package/dist/assets/JobsPro-tmEN8f-P.js +0 -1
  148. package/dist/assets/Logs-DxUD9V2A.js +0 -1
  149. package/dist/assets/LogsPro-BTQhvPKj.js +0 -1
  150. package/dist/assets/Metrics-CSPpBf1m.js +0 -1
  151. package/dist/assets/MetricsPro-DSn-3qNT.js +0 -1
  152. package/dist/assets/Overview-StvHYRZ5.js +0 -1
  153. package/dist/assets/Pagination-eotgGQf9.js +0 -1
  154. package/dist/assets/QueueActions-CibRLzrz.js +0 -1
  155. package/dist/assets/QueueControl-DwYNcMfI.js +0 -1
  156. package/dist/assets/QueueDetail-NMfKwj6E.js +0 -1
  157. package/dist/assets/QueueDetailPro-C8SBjX09.js +0 -1
  158. package/dist/assets/Queues-BeTAi8V8.js +0 -1
  159. package/dist/assets/QueuesOverview-BfQO8NS4.js +0 -1
  160. package/dist/assets/S3Backup-BYkAtONl.js +0 -1
  161. package/dist/assets/S3BackupPro-BP1Ch54R.js +0 -2
  162. package/dist/assets/ServerControl-C85jYgyN.js +0 -3
  163. package/dist/assets/Settings-DjP28c_5.js +0 -1
  164. package/dist/assets/Usage-DECeEsO2.js +0 -1
  165. package/dist/assets/UsagePro-CD7dgt3d.js +0 -1
  166. package/dist/assets/Webhooks-Ct-x8DD1.js +0 -1
  167. package/dist/assets/Workers-lP1BXHFq.js +0 -1
  168. package/dist/assets/WorkersPro-CAUqy3Cp.js +0 -1
  169. package/dist/assets/cn-CMFYpumL.js +0 -1
  170. package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
  171. package/dist/assets/index-BWoUj86M.css +0 -1
  172. package/dist/assets/index-Dr__4DsT.js +0 -8
  173. package/dist/assets/install-DPCcfOYo.js +0 -3
  174. package/dist/assets/useThroughputSeries-Bpyg6Jaq.js +0 -1
package/agent/db.ts CHANGED
@@ -1,1102 +1,46 @@
1
1
  /**
2
2
  * Read-only SQLite inspector backing the dashboard's Database section.
3
3
  *
4
- * Every connection is opened with `{ readonly: true }`, so even a hand-typed
5
- * query physically cannot mutate the store — SQLite rejects INSERT/UPDATE/DDL
6
- * with SQLITE_READONLY at the engine level. Connections are opened per request
7
- * and closed immediately, so the inspector never holds a lock against the
8
- * running bunqueue server (WAL allows concurrent readers).
9
- *
10
- * Defense in depth for the arbitrary-query endpoint (dbQuery):
11
- * - a positive statement allowlist rejects ATTACH/DETACH/CREATE-TEMP/etc.
12
- * before execution — but it inspects only the FIRST keyword, so it does NOT
13
- * stop a trailing statement smuggled in after a legal one. Everything here
14
- * must therefore compile SQL through db.query() (one statement, the rest
15
- * inert) and never db.run() (executes them all). Do not weaken this comment
16
- * into "the allowlist makes the store safe": believing that is what once
17
- * turned the dup-column recovery path into an arbitrary file write;
18
- * - results are streamed with an early break at MAX_ROWS, so a huge or
19
- * row-bomb query can't materialize the whole set in agent memory;
20
- * - queryWithTimeout() runs it in a disposable Worker raced against a wall
21
- * clock, so an O(N^k) scan can't pin the agent's event loop and freeze the
22
- * process-control endpoints. Worker startup failures surface instead of
23
- * falling back to a synchronous scan. The timeout aborts the response, not
24
- * the query — a synchronous sqlite3_step never yields to terminate() — so
25
- * live worker threads are counted and capped by MAX_CONCURRENT_QUERIES.
26
- */
27
- import { existsSync } from 'node:fs';
28
- import { Database } from 'bun:sqlite';
29
-
30
- /** Hard cap on rows returned by any endpoint — bounds payload and memory. */
31
- export const MAX_ROWS = 500;
32
- /** Cell strings longer than this are truncated server-side (payload bound). */
33
- const MAX_CELL = 2000;
34
- /** Full single-cell fetch (detail view) hard cap — bounds one pathological blob. */
35
- const MAX_FULL_CELL = 1_000_000;
36
- /** Full-table CSV export cap. The cursor probes one extra row to report truncation exactly. */
37
- export const DB_EXPORT_MAX_ROWS = 200_000;
38
- /** Maximum CSV response body. The exporter writes into one fixed-size buffer. */
39
- export const DB_EXPORT_MAX_BYTES = 16 * 1024 * 1024;
40
- /** Refuse pathological schemas before one row can create excessive transient allocations. */
41
- export const DB_EXPORT_MAX_COLUMNS = 256;
42
- /** Bound SQL/header construction even for a hostile or corrupt local schema. */
43
- export const DB_EXPORT_MAX_IDENTIFIER_CHARS = 1024;
44
- /** Bound a user-supplied filter before it is retained by SQLite for a long export. */
45
- export const DB_EXPORT_MAX_FILTER_CHARS = 4096;
46
- /** Response deadline; timed-out SQLite work remains isolated and accounted for in its Worker. */
47
- export const DB_EXPORT_TIMEOUT_MS = 15_000;
48
- /** Wall-clock budget for an arbitrary query before it is aborted. */
49
- export const QUERY_TIMEOUT_MS = 5000;
50
-
51
- /** Thrown when the database file does not exist yet (maps to HTTP 404). */
52
- export class MissingDbError extends Error {
53
- readonly missing = true;
54
- }
55
-
56
- /** Maps a saturated export worker pool to HTTP 429. */
57
- export class DbExportBusyError extends Error {}
58
-
59
- /** Maps export worker startup/crash/timeout failures to HTTP 503. */
60
- export class DbExportUnavailableError extends Error {}
61
-
62
- export interface DbTableInfo {
63
- name: string;
64
- rows: number;
65
- columns: number;
66
- }
67
-
68
- export interface DbFilter {
69
- column: string;
70
- op: 'contains' | 'eq' | 'ne';
71
- value: string;
72
- }
73
-
74
- export interface DbRowsPage {
75
- table: string;
76
- columns: string[];
77
- rows: unknown[][];
78
- /** rowid per row (null for WITHOUT ROWID tables) — keys the full-cell fetch. */
79
- rowids: (DbRowId | null)[];
80
- /** Which cells were truncated server-side, so the UI can flag them. */
81
- truncatedCells: boolean[][];
82
- total: number;
83
- limit: number;
84
- offset: number;
85
- orderBy: string | null;
86
- dir: 'asc' | 'desc';
87
- filter: DbFilter | null;
88
- }
89
-
90
- export type DbExportCap = 'rows' | 'bytes' | null;
91
-
92
- /** Bounded CSV generated from one SQLite read transaction. */
93
- export interface DbCsvExport {
94
- table: string;
95
- content: Uint8Array<ArrayBuffer>;
96
- rowCount: number;
97
- bytes: number;
98
- cap: DbExportCap;
99
- }
100
-
101
- /** Tests may lower a cap, but callers can never raise the production ceiling. */
102
- export interface DbExportLimits {
103
- maxRows?: number;
104
- maxBytes?: number;
105
- /** Test synchronization only: runs after the transaction's first read pins its snapshot. */
106
- onSnapshot?: () => void;
107
- }
108
-
109
- /** Test synchronization for proving that a browse page is one SQLite snapshot. */
110
- export interface DbRowsOptions {
111
- /** Runs after COUNT, while the same read transaction remains open for SELECT. */
112
- afterCount?: () => void;
113
- }
114
-
115
- /** JSON/query-string-safe SQLite rowid. Unsafe integers are represented as decimal strings. */
116
- export type DbRowId = number | string;
117
-
118
- export interface DbColumnInfo {
119
- name: string;
120
- type: string;
121
- notNull: boolean;
122
- defaultValue: string | null;
123
- primaryKey: boolean;
124
- }
125
-
126
- export interface DbIndexInfo {
127
- name: string;
128
- unique: boolean;
129
- columns: string[];
130
- }
131
-
132
- export interface DbTableSchema {
133
- table: string;
134
- columns: DbColumnInfo[];
135
- indexes: DbIndexInfo[];
136
- /** Original CREATE TABLE statement from sqlite_master. */
137
- sql: string | null;
138
- rowCount: number;
139
- }
140
-
141
- export interface DbInfo {
142
- sqliteVersion: string;
143
- pageSize: number;
144
- pageCount: number;
145
- journalMode: string;
146
- freelistPages: number;
147
- tables: number;
148
- indexes: number;
149
- fileSize: number;
150
- walSize: number;
151
- }
152
-
153
- export interface DbQueryResult {
154
- columns: string[];
155
- rows: unknown[][];
156
- /** Row count actually returned. A lower bound (">= this") when `truncated`. */
157
- rowCount: number;
158
- truncated: boolean;
159
- ms: number;
160
- }
161
-
162
- function open(path: string): Database {
163
- // Distinguish "file missing" (expected before first server start → 404) from
164
- // "file present but unreadable/corrupt" (a real error → 400/500).
165
- try {
166
- if (!Bun.file(path).size && !existsSync(path)) {
167
- throw new MissingDbError(
168
- `Database not found at "${path}" — start the server once to create it.`
169
- );
170
- }
171
- } catch (e) {
172
- if (e instanceof MissingDbError) throw e;
173
- // Bun.file().size can throw on some paths; fall through to the open attempt.
174
- }
175
- try {
176
- return new Database(path, { readonly: true });
177
- } catch (e) {
178
- if (!existsSync(path)) {
179
- throw new MissingDbError(
180
- `Database not found at "${path}" — start the server once to create it.`
181
- );
182
- }
183
- throw new Error(`Could not open database: ${(e as Error).message}`);
184
- }
185
- }
186
-
187
- /** Quote an identifier for SQLite ("" escapes embedded quotes). */
188
- const ident = (name: string) => `"${name.replaceAll('"', '""')}"`;
189
-
190
- /** True when a value is over the cell truncation bound. */
191
- function isTruncated(v: unknown): boolean {
192
- return (v instanceof Uint8Array && v.byteLength > 0) || (typeof v === 'string' && v.length > MAX_CELL);
193
- }
194
-
195
- /** JSON-safe cell: BLOBs become a marker, long strings are truncated. */
196
- function cell(v: unknown): unknown {
197
- if (v instanceof Uint8Array) return `<blob ${v.byteLength} B>`;
198
- if (typeof v === 'string' && v.length > MAX_CELL) return `${v.slice(0, MAX_CELL)}…`;
199
- // Read statements run with safeIntegers, so INTEGERs arrive as bigint: keep the
200
- // familiar number shape when it round-trips exactly, and fall back to a string
201
- // beyond 2^53 (where a double would silently round the stored value).
202
- if (typeof v === 'bigint') return bigintCell(v);
203
- return v;
204
- }
205
-
206
- /** Exact-value-preserving JSON form of a SQLite INTEGER read with safeIntegers. */
207
- function bigintCell(v: bigint): number | string {
208
- return v >= BigInt(Number.MIN_SAFE_INTEGER) && v <= BigInt(Number.MAX_SAFE_INTEGER)
209
- ? Number(v)
210
- : v.toString();
211
- }
212
-
213
- const MIN_SQLITE_INTEGER = -(1n << 63n);
214
- const MAX_SQLITE_INTEGER = (1n << 63n) - 1n;
215
-
216
- /**
217
- * Validate a rowid without converting an unsafe decimal string through Number.
218
- * Strings stay strings when bound; SQLite applies INTEGER affinity exactly.
219
- */
220
- function boundRowid(rowid: DbRowId): DbRowId {
221
- if (typeof rowid === 'number') {
222
- if (!Number.isSafeInteger(rowid)) throw new Error('Invalid rowid');
223
- return rowid;
224
- }
225
- if (rowid.length === 0 || rowid.length > 20 || !/^-?\d+$/.test(rowid)) {
226
- throw new Error('Invalid rowid');
227
- }
228
- const integer = BigInt(rowid);
229
- if (integer < MIN_SQLITE_INTEGER || integer > MAX_SQLITE_INTEGER) {
230
- throw new Error('Invalid rowid');
231
- }
232
- return rowid;
233
- }
234
-
235
- /**
236
- * bun:sqlite exposes Statement.safeIntegers() at runtime but does not type it.
237
- * Enabled per statement (never connection-wide) so the COUNT(*) reads that are
238
- * consumed as numbers keep returning numbers.
239
- */
240
- function safeIntegers(stmt: unknown): void {
241
- (stmt as { safeIntegers?: (on: boolean) => void }).safeIntegers?.(true);
242
- }
243
-
244
- const sanitize = (rows: unknown[][]): unknown[][] => rows.map((r) => r.map(cell));
245
-
246
- /** All user tables with row + column counts. */
247
- export function dbTables(path: string): DbTableInfo[] {
248
- const db = open(path);
249
- try {
250
- const tables = db
251
- .query(
252
- "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
253
- )
254
- .all() as { name: string }[];
255
- return tables.map(({ name }) => {
256
- const { c } = db.query(`SELECT COUNT(*) AS c FROM ${ident(name)}`).get() as { c: number };
257
- const cols = db.query(`PRAGMA table_info(${ident(name)})`).all();
258
- return { name, rows: c, columns: cols.length };
259
- });
260
- } finally {
261
- db.close();
262
- }
263
- }
264
-
265
- /** Resolve a caller-supplied table name against sqlite_master (identifier allowlist). */
266
- function knownTable(db: Database, table: string): string {
267
- const known = db
268
- .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
269
- .get(table) as { name: string } | null;
270
- if (!known) throw new Error(`No such table: ${table}`);
271
- return known.name;
272
- }
273
-
274
- /** Build a validated WHERE clause + bound param for a browse filter (never interpolates the value). */
275
- function buildFilter(
276
- columns: string[],
277
- filter: DbFilter | undefined
278
- ): { clause: string; params: unknown[]; applied: DbFilter | null } {
279
- if (!filter || !filter.value) return { clause: '', params: [], applied: null };
280
- const col = columns.find((c) => c === filter.column);
281
- if (!col) throw new Error(`No such column: ${filter.column}`);
282
- const q = ident(col);
283
- if (filter.op === 'eq') return { clause: ` WHERE ${q} = ?`, params: [filter.value], applied: filter };
284
- if (filter.op === 'ne') return { clause: ` WHERE ${q} <> ?`, params: [filter.value], applied: filter };
285
- // contains (default) — escape LIKE metacharacters so a value containing % or _
286
- // matches literally instead of turning into a wildcard pattern.
287
- const needle = filter.value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
288
- return {
289
- clause: ` WHERE ${q} LIKE ? ESCAPE '\\'`,
290
- params: [`%${needle}%`],
291
- applied: { ...filter, op: 'contains' },
292
- };
293
- }
294
-
295
- function boundedExportLimit(value: number | undefined, hardCap: number, label: string): number {
296
- if (value === undefined) return hardCap;
297
- if (!Number.isSafeInteger(value) || value < 1) {
298
- throw new Error(`${label} must be a positive safe integer`);
299
- }
300
- return Math.min(value, hardCap);
301
- }
302
-
303
- /** RFC-4180 cell with spreadsheet-formula neutralization for textual values. */
304
- function csvCell(value: unknown): string {
305
- let text: string;
306
- let textual = false;
307
- if (value == null) text = '';
308
- else if (typeof value === 'string') {
309
- text = value;
310
- textual = true;
311
- } else if (typeof value === 'bigint' || typeof value === 'number' || typeof value === 'boolean') {
312
- text = String(value);
313
- } else if (value instanceof Uint8Array) {
314
- // The SELECT below converts BLOBs before they cross into JS. Keep this
315
- // fallback bounded and deterministic if SQLite changes the expression type.
316
- text = `<blob ${value.byteLength} B>`;
317
- textual = true;
318
- } else {
319
- throw new Error('Database export produced an unsupported SQLite value');
320
- }
321
-
322
- // Excel and Sheets may execute strings beginning with these characters. A
323
- // leading TAB/CR is dangerous too because spreadsheet import strips it first.
324
- const safe = textual && /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
325
- return /[",\r\n]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
326
- }
327
-
328
- /**
329
- * Export one filtered/sorted table view from a single SQLite snapshot.
330
- *
331
- * The readonly connection and explicit deferred transaction pin sqlite_master,
332
- * schema, filter and row iteration to one WAL snapshot. Unlike client-side
333
- * OFFSET pagination, concurrent inserts/deletes cannot duplicate or omit rows.
334
- * The cursor is streamed into a fixed-size buffer and probes at most one row
335
- * beyond the row cap, so both retained output and cardinality are bounded.
336
- */
337
- export function dbExportCsv(
338
- path: string,
339
- table: string,
340
- orderBy?: string,
341
- dir: 'asc' | 'desc' = 'asc',
342
- filter?: DbFilter,
343
- limits: Readonly<DbExportLimits> = {}
344
- ): DbCsvExport {
345
- if (typeof table !== 'string' || table.length === 0) throw new Error('Table is required');
346
- if (table.length > DB_EXPORT_MAX_IDENTIFIER_CHARS) {
347
- throw new Error(`Table name exceeds ${DB_EXPORT_MAX_IDENTIFIER_CHARS} characters`);
348
- }
349
- if (dir !== 'asc' && dir !== 'desc') throw new Error('Invalid export sort direction');
350
- if (filter) {
351
- if (
352
- typeof filter.column !== 'string' ||
353
- !filter.column ||
354
- (filter.op !== 'contains' && filter.op !== 'eq' && filter.op !== 'ne') ||
355
- typeof filter.value !== 'string'
356
- ) {
357
- throw new Error('Invalid database export filter');
358
- }
359
- if (filter.column.length > DB_EXPORT_MAX_IDENTIFIER_CHARS) {
360
- throw new Error(`Filter column exceeds ${DB_EXPORT_MAX_IDENTIFIER_CHARS} characters`);
361
- }
362
- if (filter.value.length > DB_EXPORT_MAX_FILTER_CHARS) {
363
- throw new Error(
364
- `Database export filter exceeds ${DB_EXPORT_MAX_FILTER_CHARS} characters`
365
- );
366
- }
367
- }
368
-
369
- const maxRows = boundedExportLimit(limits.maxRows, DB_EXPORT_MAX_ROWS, 'Export row limit');
370
- const maxBytes = boundedExportLimit(limits.maxBytes, DB_EXPORT_MAX_BYTES, 'Export byte limit');
371
- const db = open(path);
372
- try {
373
- const snapshot = db.transaction((): DbCsvExport => {
374
- const name = knownTable(db, table);
375
- const columns = (
376
- db.query(`PRAGMA table_info(${ident(name)})`).all() as { name: string }[]
377
- ).map((column) => column.name);
378
- if (columns.length === 0) throw new Error(`Table has no exportable columns: ${name}`);
379
- if (columns.length > DB_EXPORT_MAX_COLUMNS) {
380
- throw new Error(
381
- `Table has ${columns.length} columns; database export supports at most ${DB_EXPORT_MAX_COLUMNS}`
382
- );
383
- }
384
- if (columns.some((column) => column.length > DB_EXPORT_MAX_IDENTIFIER_CHARS)) {
385
- throw new Error(
386
- `Database export column names must not exceed ${DB_EXPORT_MAX_IDENTIFIER_CHARS} characters`
387
- );
388
- }
389
- limits.onSnapshot?.();
390
-
391
- let order = '';
392
- if (orderBy !== undefined) {
393
- if (typeof orderBy !== 'string' || !orderBy) {
394
- throw new Error('Invalid database export sort column');
395
- }
396
- if (orderBy.length > DB_EXPORT_MAX_IDENTIFIER_CHARS) {
397
- throw new Error(`Sort column exceeds ${DB_EXPORT_MAX_IDENTIFIER_CHARS} characters`);
398
- }
399
- const sortColumn = columns.find((column) => column === orderBy);
400
- if (!sortColumn) throw new Error(`No such column: ${orderBy}`);
401
- order = ` ORDER BY ${ident(sortColumn)} ${dir === 'desc' ? 'DESC' : 'ASC'}`;
402
- }
403
- const { clause, params } = buildFilter(columns, filter);
404
-
405
- // Generated aliases avoid object-property hazards for legal SQLite column
406
- // names such as "__proto__". Text and BLOB expressions ensure one cell
407
- // cannot materialize an unbounded JS value before the byte cap is checked.
408
- const aliases = columns.map((_, index) => `__bq_export_${index}`);
409
- const selected = columns.map((column, index) => {
410
- const quoted = ident(column);
411
- return (
412
- `CASE ` +
413
- `WHEN typeof(${quoted}) = 'blob' THEN '<blob ' || length(${quoted}) || ' B>' ` +
414
- `WHEN typeof(${quoted}) = 'text' AND length(CAST(${quoted} AS BLOB)) > ${MAX_CELL} ` +
415
- `THEN substr(${quoted}, 1, ${MAX_CELL}) || '…' ` +
416
- `ELSE ${quoted} END AS ${ident(aliases[index])}`
417
- );
418
- });
419
- const stmt = db.query<Record<string, unknown>, Array<string | number>>(
420
- `SELECT ${selected.join(', ')} FROM ${ident(name)}${clause}${order} LIMIT ?`
421
- );
422
- safeIntegers(stmt);
423
-
424
- const encoder = new TextEncoder();
425
- const buffer = new Uint8Array(maxBytes);
426
- let used = 0;
427
- const append = (text: string): boolean => {
428
- const result = encoder.encodeInto(text, buffer.subarray(used));
429
- if (result.read !== text.length) return false;
430
- used += result.written;
431
- return true;
432
- };
433
- if (!append(columns.map(csvCell).join(','))) {
434
- throw new Error(`Database export header exceeds the ${maxBytes}-byte export limit`);
435
- }
436
-
437
- let rowCount = 0;
438
- let cap: DbExportCap = null;
439
- const bindings = [...params, maxRows + 1] as Array<string | number>;
440
- const iterator = stmt.iterate(...bindings);
441
- try {
442
- for (;;) {
443
- const next = iterator.next();
444
- if (next.done) break;
445
- if (rowCount >= maxRows) {
446
- cap = 'rows';
447
- break;
448
- }
449
- const line = `\r\n${aliases.map((alias) => csvCell(next.value[alias])).join(',')}`;
450
- if (!append(line)) {
451
- cap = 'bytes';
452
- break;
453
- }
454
- rowCount++;
455
- }
456
- } finally {
457
- iterator.return?.();
458
- }
459
-
460
- // Slice releases the unused portion of the fixed working buffer before
461
- // the Response is retained by the HTTP server. Peak memory remains a
462
- // small constant multiple of DB_EXPORT_MAX_BYTES, never table size.
463
- const content = buffer.slice(0, used);
464
- return { table: name, content, rowCount, bytes: content.byteLength, cap };
465
- });
466
- // On a readonly connection BEGIN DEFERRED is a read transaction. Bun's
467
- // wrapper commits on success and rolls back on every thrown validation,
468
- // cursor or encoding error.
469
- return snapshot.deferred();
470
- } catch (error) {
471
- throw translateError(error);
472
- } finally {
473
- db.close();
474
- }
475
- }
476
-
477
- /**
478
- * One page of a table's rows. Table, orderBy and filter-column identifiers are
479
- * validated against sqlite_master / table_info — never interpolated from raw
480
- * input; the filter value is always bound as a parameter.
481
- */
482
- export function dbRows(
483
- path: string,
484
- table: string,
485
- limit: number,
486
- offset: number,
487
- orderBy?: string,
488
- dir: 'asc' | 'desc' = 'asc',
489
- filter?: DbFilter,
490
- options: Readonly<DbRowsOptions> = {}
491
- ): DbRowsPage {
492
- const lim = Math.max(1, Math.min(MAX_ROWS, Math.floor(limit) || 50));
493
- // Non-finite / out-of-range offsets must be clamped here: SQLite rejects a
494
- // bound non-safe-integer double with a raw "datatype mismatch".
495
- const off = Number.isFinite(offset)
496
- ? Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(offset) || 0))
497
- : 0;
498
- const db = open(path);
499
- try {
500
- const snapshot = db.transaction((): DbRowsPage => {
501
- const name = knownTable(db, table);
502
- const columns = (
503
- db.query(`PRAGMA table_info(${ident(name)})`).all() as { name: string }[]
504
- ).map((c) => c.name);
505
-
506
- let order = '';
507
- let sortCol: string | null = null;
508
- if (orderBy) {
509
- sortCol = columns.find((c) => c === orderBy) ?? null;
510
- if (!sortCol) throw new Error(`No such column: ${orderBy}`);
511
- order = ` ORDER BY ${ident(sortCol)} ${dir === 'desc' ? 'DESC' : 'ASC'}`;
512
- }
513
-
514
- const { clause, params, applied } = buildFilter(columns, filter);
515
-
516
- const { c: total } = db
517
- .query(`SELECT COUNT(*) AS c FROM ${ident(name)}${clause}`)
518
- .get(...(params as [])) as { c: number };
519
- options.afterCount?.();
520
-
521
- // Prefer selecting rowid so the detail view can fetch untruncated cells;
522
- // WITHOUT ROWID tables have no rowid column — fall back to a plain select.
523
- let rowids: (DbRowId | null)[] = [];
524
- let rawRows: unknown[][];
525
- try {
526
- const stmt = db.query(
527
- `SELECT rowid AS __rid, * FROM ${ident(name)}${clause}${order} LIMIT ? OFFSET ?`
528
- );
529
- safeIntegers(stmt);
530
- const withRowid = stmt.values(...(params as []), lim, off) as unknown[][];
531
- rowids = withRowid.map((r) =>
532
- typeof r[0] === 'bigint'
533
- ? bigintCell(r[0])
534
- : typeof r[0] === 'number' && Number.isSafeInteger(r[0])
535
- ? r[0]
536
- : null
537
- );
538
- rawRows = withRowid.map((r) => r.slice(1));
539
- } catch {
540
- const stmt = db.query(`SELECT * FROM ${ident(name)}${clause}${order} LIMIT ? OFFSET ?`);
541
- safeIntegers(stmt);
542
- rawRows = stmt.values(...(params as []), lim, off) as unknown[][];
543
- rowids = rawRows.map(() => null);
544
- }
545
-
546
- const truncatedCells = rawRows.map((r) => r.map(isTruncated));
547
- return {
548
- table: name,
549
- columns,
550
- rows: sanitize(rawRows),
551
- rowids,
552
- truncatedCells,
553
- total,
554
- limit: lim,
555
- offset: off,
556
- orderBy: sortCol,
557
- dir: dir === 'desc' ? 'desc' : 'asc',
558
- filter: applied,
559
- };
560
- });
561
- // COUNT and page SELECT must describe the same live view. Without this read
562
- // transaction, a concurrent WAL commit between them could make a valid
563
- // response internally contradictory (or shift rows across page boundaries).
564
- return snapshot.deferred();
565
- } finally {
566
- db.close();
567
- }
568
- }
569
-
570
- /** Full, untruncated value of a single cell, keyed by rowid (for the detail view). */
571
- export function dbCell(
572
- path: string,
573
- table: string,
574
- rowid: DbRowId,
575
- column: string
576
- ): { value: unknown } {
577
- const db = open(path);
578
- try {
579
- const name = knownTable(db, table);
580
- const columns = (db.query(`PRAGMA table_info(${ident(name)})`).all() as { name: string }[]).map(
581
- (c) => c.name
582
- );
583
- const col = columns.find((c) => c === column);
584
- if (!col) throw new Error(`No such column: ${column}`);
585
- const stmt = db.query(`SELECT ${ident(col)} AS v FROM ${ident(name)} WHERE rowid = ?`);
586
- safeIntegers(stmt);
587
- const row = stmt.get(boundRowid(rowid)) as { v: unknown } | null;
588
- if (!row) throw new Error('Row not found');
589
- let v = row.v;
590
- if (v instanceof Uint8Array) v = `<blob ${v.byteLength} B — binary, not shown>`;
591
- else if (typeof v === 'bigint') v = bigintCell(v);
592
- else if (typeof v === 'string' && v.length > MAX_FULL_CELL) v = `${v.slice(0, MAX_FULL_CELL)}…`;
593
- return { value: v };
594
- } finally {
595
- db.close();
596
- }
597
- }
598
-
599
- /** Column definitions, indexes and original DDL for one table. */
600
- export function dbSchema(path: string, table: string): DbTableSchema {
601
- const db = open(path);
602
- try {
603
- const name = knownTable(db, table);
604
- const columns = (
605
- db.query(`PRAGMA table_info(${ident(name)})`).all() as {
606
- name: string;
607
- type: string;
608
- notnull: number;
609
- dflt_value: string | null;
610
- pk: number;
611
- }[]
612
- ).map((c) => ({
613
- name: c.name,
614
- type: c.type || 'ANY',
615
- notNull: c.notnull === 1,
616
- defaultValue: c.dflt_value,
617
- primaryKey: c.pk > 0,
618
- }));
619
- const indexes = (
620
- db.query(`PRAGMA index_list(${ident(name)})`).all() as { name: string; unique: number }[]
621
- ).map((ix) => ({
622
- name: ix.name,
623
- unique: ix.unique === 1,
624
- columns: (db.query(`PRAGMA index_info(${ident(ix.name)})`).all() as { name: string }[]).map(
625
- (c) => c.name
626
- ),
627
- }));
628
- const master = db
629
- .query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
630
- .get(name) as { sql: string | null } | null;
631
- const { c: rowCount } = db.query(`SELECT COUNT(*) AS c FROM ${ident(name)}`).get() as {
632
- c: number;
633
- };
634
- return { table: name, columns, indexes, sql: master?.sql ?? null, rowCount };
635
- } finally {
636
- db.close();
637
- }
638
- }
639
-
640
- /** Store-level metadata: engine version, pragmas, object counts, on-disk size. */
641
- export function dbInfo(path: string): DbInfo {
642
- const db = open(path);
643
- try {
644
- const one = (sql: string) => (db.query(sql).values()[0] as unknown[])[0];
645
- const count = (type: string) =>
646
- (
647
- db
648
- .query(
649
- "SELECT COUNT(*) AS c FROM sqlite_master WHERE type = ? AND name NOT LIKE 'sqlite_%'"
650
- )
651
- .get(type) as { c: number }
652
- ).c;
653
- const size = (p: string) => {
654
- try {
655
- return Bun.file(p).size || 0;
656
- } catch {
657
- return 0;
658
- }
659
- };
660
- return {
661
- sqliteVersion: String(one('SELECT sqlite_version()')),
662
- pageSize: Number(one('PRAGMA page_size')),
663
- pageCount: Number(one('PRAGMA page_count')),
664
- journalMode: String(one('PRAGMA journal_mode')),
665
- freelistPages: Number(one('PRAGMA freelist_count')),
666
- tables: count('table'),
667
- indexes: count('index'),
668
- fileSize: size(path),
669
- walSize: size(`${path}-wal`),
670
- };
671
- } finally {
672
- db.close();
673
- }
674
- }
675
-
676
- /** Leading keyword of a statement, ignoring leading whitespace/comments. */
677
- function leadingKeyword(sql: string): string {
678
- const head = sql.replace(/^(?:\s|--[^\n]*\n|\/\*[\s\S]*?\*\/)*/, '');
679
- return head.slice(0, 12).toUpperCase();
680
- }
681
-
682
- const READ_ONLY_LEAD = /^(?:SELECT|WITH|EXPLAIN|VALUES|PRAGMA)\b/;
683
-
684
- /** Temp-view name used to recover colliding output column names (see dbQuery). */
685
- const DUP_VIEW = '__bq_query_columns';
686
-
687
- /**
688
- * Re-prepare a statement whose output column names collide through a TEMP VIEW,
689
- * so SQLite disambiguates them itself ("id", "id:1") and every column survives
690
- * the by-name row rebuild. The view lives in the private temp schema (nothing is
691
- * written to the readonly store) and dies with the connection. Returns null when
692
- * the statement can't be a view (PRAGMA / EXPLAIN — neither can collide anyway).
693
- */
694
- function disambiguateColumns(
695
- db: Database,
696
- sql: string
697
- ): { stmt: ReturnType<Database['query']>; cols: string[] } | null {
698
- try {
699
- // MUST be query().run() (single prepared statement), never db.run(): db.run
700
- // executes EVERY statement in the string, and the front-door allowlist only
701
- // inspects the FIRST keyword. `SELECT 1 a, 2 a; VACUUM INTO '/tmp/x'` would
702
- // otherwise reach a readonly connection as an arbitrary-file WRITE, and
703
- // `…; ATTACH '/any.db' AS l; DROP VIEW …; CREATE TEMP VIEW … AS SELECT * FROM l.x`
704
- // as an arbitrary-SQLite-file READ. query() compiles only the first
705
- // statement, so the trailing payload is inert.
706
- db.query(`CREATE TEMP VIEW ${ident(DUP_VIEW)} AS ${sql.replace(/;\s*$/, '')}`).run();
707
- const cols = (
708
- db.query(`PRAGMA table_info(${ident(DUP_VIEW)})`).all() as { name: string }[]
709
- ).map((c) => c.name);
710
- return { stmt: db.query(`SELECT * FROM ${ident(DUP_VIEW)}`), cols };
711
- } catch {
712
- return null;
713
- }
714
- }
715
-
716
- /**
717
- * Run an arbitrary read-only query. Rejects non-read statements up front,
718
- * streams rows with an early break at MAX_ROWS, and translates the raw SQLite
719
- * read-only error into a friendly message. Synchronous + pure (see
720
- * queryWithTimeout for the off-thread, time-boxed wrapper the route uses).
721
- */
722
- export function dbQuery(path: string, sql: string): DbQueryResult {
723
- const trimmed = sql.trim();
724
- if (!trimmed) throw new Error('Empty query');
725
- if (!READ_ONLY_LEAD.test(leadingKeyword(trimmed))) {
726
- throw new Error('Only read-only SELECT / WITH / EXPLAIN / VALUES / PRAGMA queries are allowed here.');
727
- }
728
- const db = open(path);
729
- try {
730
- const started = performance.now();
731
- let stmt: ReturnType<Database['query']>;
732
- try {
733
- stmt = db.query(trimmed);
734
- } catch (e) {
735
- throw translateError(e);
736
- }
737
- let cols = stmt.columnNames;
738
- // Bun de-duplicates columnNames while columnTypes keeps the true arity, and
739
- // rows are rebuilt BY NAME below — so without this, a statement with colliding
740
- // output names (`SELECT a.id, b.id`, any self-join) silently loses columns.
741
- if (stmt.columnTypes.length > cols.length) {
742
- const wide = disambiguateColumns(db, trimmed);
743
- if (wide) {
744
- stmt = wide.stmt;
745
- cols = wide.cols;
746
- }
747
- }
748
- safeIntegers(stmt);
749
- const rows: unknown[][] = [];
750
- let truncated = false;
751
- try {
752
- // Rebuild positional arrays from the object iterator so memory is bounded
753
- // to MAX_ROWS regardless of the query's true cardinality.
754
- for (const row of stmt.iterate() as IterableIterator<Record<string, unknown>>) {
755
- if (rows.length >= MAX_ROWS) {
756
- truncated = true;
757
- break;
758
- }
759
- rows.push(cols.map((c) => (row as Record<string, unknown>)[c]));
760
- }
761
- } catch (e) {
762
- throw translateError(e);
763
- }
764
- const ms = performance.now() - started;
765
- return {
766
- columns: cols,
767
- rows: sanitize(rows),
768
- rowCount: rows.length,
769
- truncated,
770
- ms: Math.round(ms * 10) / 10,
771
- };
772
- } finally {
773
- db.close();
774
- }
775
- }
776
-
777
- function translateError(e: unknown): Error {
778
- const msg = String((e as Error)?.message ?? e);
779
- if (/readonly|read-only|SQLITE_READONLY/i.test(msg)) {
780
- return new Error('Connection is read-only — INSERT / UPDATE / DELETE / DDL are not permitted here.');
781
- }
782
- return e instanceof Error ? e : new Error(msg);
783
- }
784
-
785
- /**
786
- * Shared URL of the disposable database worker. It accepts both arbitrary
787
- * query and atomic CSV-export messages; scripts/serve.ts points this at the
788
- * secondary embedded entrypoint in compiled builds.
789
- *
790
- * The timeout aborts the RESPONSE, not the query: terminate() is delivered to
791
- * the worker's event loop, which a synchronous sqlite3_step never yields back
792
- * to, so a runaway scan keeps burning its own thread until SQLite finishes it.
793
- * That thread is therefore accounted for (a slot is held until the worker really
794
- * exits) and capped by the respective query/export pool, so abandoned work can
795
- * pin only the configured number of cores instead of one new core per request.
796
- */
797
- let queryWorkerUrl = new URL('./dbQueryWorker.ts', import.meta.url).href;
798
-
799
- /** Hard cap on query worker threads alive at once (see queryWithTimeout). */
800
- export const MAX_CONCURRENT_QUERIES = 2;
801
- let liveQueryWorkers = 0;
802
-
803
- /** Query worker threads currently alive — includes timed-out, still-running ones. */
804
- export const queryWorkerLoad = (): number => liveQueryWorkers;
805
-
806
- /**
807
- * The standalone executable's bundle root is scripts/serve.ts, so it supplies
808
- * the embedded worker URL relative to that entrypoint. Source-mode agent runs
809
- * keep the module-relative default above.
810
- */
811
- export function setQueryWorkerUrl(url: string): void {
812
- queryWorkerUrl = url;
813
- }
814
-
815
- const spawnRealWorker = (): Worker => new Worker(queryWorkerUrl, { type: 'module' });
816
- let queryWorkerFactory: () => Worker = spawnRealWorker;
817
-
818
- /**
819
- * Swap how a query worker is constructed. Tests only: spawning a real Bun
820
- * Worker in a process where happy-dom's globals are installed panics the
821
- * runtime (allocator abort), so the timeout/accounting behaviour below is
822
- * exercised through a stand-in worker instead of a real thread. Pass null to
823
- * restore the real one.
824
- */
825
- export function setQueryWorkerFactory(factory: (() => Worker) | null): void {
826
- queryWorkerFactory = factory ?? spawnRealWorker;
827
- }
828
-
829
- /** Only one potentially expensive table scan/sort may run at a time. */
830
- export const MAX_CONCURRENT_EXPORTS = 1;
831
- let liveExportWorkers = 0;
832
-
833
- /** Export workers alive now, including timed-out/aborted SQLite calls still unwinding. */
834
- export const exportWorkerLoad = (): number => liveExportWorkers;
835
-
836
- const spawnRealExportWorker = (): Worker => new Worker(queryWorkerUrl, { type: 'module' });
837
- let exportWorkerFactory: () => Worker = spawnRealExportWorker;
838
-
839
- /** Test-only export-worker factory; pass null to restore the real worker. */
840
- export function setExportWorkerFactory(factory: (() => Worker) | null): void {
841
- exportWorkerFactory = factory ?? spawnRealExportWorker;
842
- }
843
-
844
- function validatedWorkerExport(value: unknown, expectedTable: string): DbCsvExport {
845
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
846
- throw new DbExportUnavailableError('Database export worker returned a malformed result');
847
- }
848
- const result = value as Record<string, unknown>;
849
- if (
850
- typeof result.table !== 'string' ||
851
- result.table !== expectedTable ||
852
- !(result.content instanceof Uint8Array) ||
853
- !Number.isSafeInteger(result.rowCount) ||
854
- (result.rowCount as number) < 0 ||
855
- (result.rowCount as number) > DB_EXPORT_MAX_ROWS ||
856
- !Number.isSafeInteger(result.bytes) ||
857
- (result.bytes as number) < 1 ||
858
- (result.bytes as number) > DB_EXPORT_MAX_BYTES ||
859
- result.content.byteLength !== result.bytes ||
860
- (result.cap !== null && result.cap !== 'rows' && result.cap !== 'bytes') ||
861
- (result.cap === 'rows' && result.rowCount !== DB_EXPORT_MAX_ROWS)
862
- ) {
863
- throw new DbExportUnavailableError('Database export worker returned a malformed result');
864
- }
865
- const content =
866
- result.content.buffer instanceof ArrayBuffer
867
- ? (result.content as Uint8Array<ArrayBuffer>)
868
- : Uint8Array.from(result.content);
869
- return {
870
- table: result.table as string,
871
- content,
872
- rowCount: result.rowCount as number,
873
- bytes: result.bytes as number,
874
- cap: result.cap as DbExportCap,
875
- };
876
- }
877
-
878
- /**
879
- * Run an atomic CSV export off the control-agent event loop.
880
- *
881
- * A timeout or caller abort abandons the response, not sqlite3_step: the Worker
882
- * keeps its only slot until `close`, preventing repeated abandoned scans from
883
- * pinning additional cores. Row/cell/byte caps remain enforced inside
884
- * dbExportCsv before its ArrayBuffer is transferred back without JSON expansion.
885
- */
886
- export async function exportWithTimeout(
887
- path: string,
888
- table: string,
889
- orderBy?: string,
890
- dir: 'asc' | 'desc' = 'asc',
891
- filter?: DbFilter,
892
- signal?: AbortSignal
893
- ): Promise<DbCsvExport> {
894
- signal?.throwIfAborted();
895
- if (liveExportWorkers >= MAX_CONCURRENT_EXPORTS) {
896
- throw new DbExportBusyError(
897
- 'A database export is already running. Wait for it to finish, or restart the agent if it was abandoned.'
898
- );
899
- }
900
-
901
- let worker: Worker;
902
- try {
903
- worker = exportWorkerFactory();
904
- } catch (error) {
905
- throw new DbExportUnavailableError(
906
- `Database export worker unavailable: ${(error as Error).message ?? String(error)}`
907
- );
908
- }
909
- liveExportWorkers++;
910
- let released = false;
911
- const release = () => {
912
- if (released) return;
913
- released = true;
914
- liveExportWorkers--;
915
- };
916
-
917
- try {
918
- return await new Promise<DbCsvExport>((resolve, reject) => {
919
- let settled = false;
920
- let timer: ReturnType<typeof setTimeout> | undefined;
921
- const cleanup = () => {
922
- if (timer !== undefined) clearTimeout(timer);
923
- signal?.removeEventListener('abort', onAbort);
924
- };
925
- const abandon = (error: Error) => {
926
- if (settled) return;
927
- settled = true;
928
- cleanup();
929
- worker.terminate();
930
- // Do NOT release here. A synchronous sqlite3_step may still be running;
931
- // the close event is the proof that its thread has really exited.
932
- reject(error);
933
- };
934
- const onAbort = () =>
935
- abandon(
936
- signal?.reason instanceof Error
937
- ? signal.reason
938
- : new Error('Database export request was aborted')
939
- );
940
-
941
- worker.addEventListener('close', () => {
942
- release();
943
- if (!settled) {
944
- settled = true;
945
- cleanup();
946
- reject(new DbExportUnavailableError('Database export worker exited unexpectedly'));
947
- }
948
- });
949
- worker.addEventListener('message', (event: MessageEvent) => {
950
- if (settled) return;
951
- settled = true;
952
- cleanup();
953
- release();
954
- const data = event.data as
955
- | {
956
- ok?: unknown;
957
- export?: unknown;
958
- error?: unknown;
959
- missing?: unknown;
960
- }
961
- | null;
962
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
963
- reject(
964
- new DbExportUnavailableError('Database export worker returned a malformed response')
965
- );
966
- return;
967
- }
968
- const response: {
969
- ok?: unknown;
970
- export?: unknown;
971
- error?: unknown;
972
- missing?: unknown;
973
- } = data;
974
- if (response.ok === true) {
975
- try {
976
- resolve(validatedWorkerExport(response.export, table));
977
- } catch (error) {
978
- reject(error);
979
- }
980
- } else if (response.missing === true) {
981
- reject(
982
- new MissingDbError(
983
- typeof response.error === 'string' ? response.error : 'Database not found'
984
- )
985
- );
986
- } else {
987
- reject(
988
- new Error(
989
- typeof response.error === 'string' ? response.error : 'Database export failed'
990
- )
991
- );
992
- }
993
- });
994
- worker.addEventListener('error', (event: ErrorEvent) => {
995
- if (settled) return;
996
- event.preventDefault?.();
997
- abandon(
998
- new DbExportUnavailableError(
999
- `Database export worker failed: ${event.message || 'unknown worker error'}`
1000
- )
1001
- );
1002
- });
1003
-
1004
- timer = setTimeout(
1005
- () =>
1006
- abandon(
1007
- new DbExportUnavailableError(
1008
- `Database export exceeded the ${DB_EXPORT_TIMEOUT_MS / 1000}s time limit and was abandoned (SQLite may keep scanning until the worker exits).`
1009
- )
1010
- ),
1011
- DB_EXPORT_TIMEOUT_MS
1012
- );
1013
- signal?.addEventListener('abort', onAbort, { once: true });
1014
- if (signal?.aborted) {
1015
- onAbort();
1016
- return;
1017
- }
1018
- try {
1019
- worker.postMessage({ kind: 'export', path, table, orderBy, dir, filter });
1020
- } catch (error) {
1021
- settled = true;
1022
- cleanup();
1023
- release();
1024
- reject(
1025
- new DbExportUnavailableError(
1026
- `Could not start database export: ${(error as Error).message ?? String(error)}`
1027
- )
1028
- );
1029
- }
1030
- });
1031
- } finally {
1032
- worker.terminate();
1033
- }
1034
- }
1035
-
1036
- export async function queryWithTimeout(path: string, sql: string): Promise<DbQueryResult> {
1037
- if (liveQueryWorkers >= MAX_CONCURRENT_QUERIES) {
1038
- throw new Error(
1039
- `Too many queries running (${liveQueryWorkers}/${MAX_CONCURRENT_QUERIES}). A previous query timed out and is still running inside SQLite — wait for it to finish, or restart the agent.`
1040
- );
1041
- }
1042
- let worker: Worker;
1043
- try {
1044
- worker = queryWorkerFactory();
1045
- } catch (e) {
1046
- throw new Error(`Query worker unavailable: ${(e as Error).message ?? String(e)}`);
1047
- }
1048
- liveQueryWorkers++;
1049
- let released = false;
1050
- const release = () => {
1051
- if (released) return;
1052
- released = true;
1053
- liveQueryWorkers--;
1054
- };
1055
- // A worker that never answers still holds its slot until the thread really
1056
- // exits ('close' fires when SQLite finally returns) — that is the whole point.
1057
- worker.addEventListener('close', release);
1058
- try {
1059
- return await new Promise<DbQueryResult>((resolve, reject) => {
1060
- let settled = false;
1061
- const timer = setTimeout(() => {
1062
- if (settled) return;
1063
- settled = true;
1064
- worker.terminate();
1065
- reject(
1066
- new Error(
1067
- `Query exceeded the ${QUERY_TIMEOUT_MS / 1000}s time limit and was abandoned (it may keep running inside SQLite until it completes).`
1068
- )
1069
- );
1070
- }, QUERY_TIMEOUT_MS);
1071
- worker.addEventListener('message', (ev: MessageEvent) => {
1072
- if (settled) return;
1073
- settled = true;
1074
- clearTimeout(timer);
1075
- // The query is done — its thread is idle, so free the slot immediately.
1076
- release();
1077
- const d = ev.data as {
1078
- ok: boolean;
1079
- result?: DbQueryResult;
1080
- error?: string;
1081
- missing?: boolean;
1082
- };
1083
- if (d.ok && d.result) resolve(d.result);
1084
- // The worker boundary is a string channel, so the error CLASS is carried
1085
- // as a flag — server.ts maps MissingDbError to 404, everything else 400.
1086
- else if (d.missing) reject(new MissingDbError(d.error ?? 'Database not found'));
1087
- else reject(new Error(d.error ?? 'Query failed'));
1088
- });
1089
- worker.addEventListener('error', (ev: ErrorEvent) => {
1090
- if (settled) return;
1091
- settled = true;
1092
- clearTimeout(timer);
1093
- release();
1094
- ev.preventDefault?.();
1095
- reject(new Error(`Query worker failed: ${ev.message || 'unknown worker error'}`));
1096
- });
1097
- worker.postMessage({ path, sql });
1098
- });
1099
- } finally {
1100
- worker.terminate();
1101
- }
1102
- }
4
+ * This compatibility entrypoint keeps the original public API stable while the
5
+ * implementations live in focused modules under agent/db/. Every connection is
6
+ * readonly and disposable; arbitrary queries and exports run in bounded workers.
7
+ */
8
+ export { dbExportCsv } from './db/csvExport';
9
+ export { exportWithTimeout, exportWorkerLoad } from './db/exportTimeout';
10
+ export { dbQuery } from './db/query';
11
+ export { queryWithTimeout, queryWorkerLoad } from './db/queryTimeout';
12
+ export { dbInfo, dbSchema } from './db/schema';
13
+ export { dbCell, dbRows, dbTables } from './db/tables';
14
+ export {
15
+ setExportWorkerFactory,
16
+ setQueryWorkerFactory,
17
+ setQueryWorkerUrl,
18
+ } from './db/workerFactory';
19
+ export {
20
+ DB_EXPORT_MAX_BYTES,
21
+ DB_EXPORT_MAX_COLUMNS,
22
+ DB_EXPORT_MAX_FILTER_CHARS,
23
+ DB_EXPORT_MAX_IDENTIFIER_CHARS,
24
+ DB_EXPORT_MAX_ROWS,
25
+ DB_EXPORT_TIMEOUT_MS,
26
+ DbExportBusyError,
27
+ DbExportUnavailableError,
28
+ MAX_CONCURRENT_EXPORTS,
29
+ MAX_CONCURRENT_QUERIES,
30
+ MAX_ROWS,
31
+ MissingDbError,
32
+ QUERY_TIMEOUT_MS,
33
+ type DbColumnInfo,
34
+ type DbCsvExport,
35
+ type DbExportCap,
36
+ type DbExportLimits,
37
+ type DbFilter,
38
+ type DbIndexInfo,
39
+ type DbInfo,
40
+ type DbQueryResult,
41
+ type DbRowId,
42
+ type DbRowsOptions,
43
+ type DbRowsPage,
44
+ type DbTableInfo,
45
+ type DbTableSchema,
46
+ } from './db/types';