bunqueue-dashboard 0.0.35 → 0.0.37
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 +36 -16
- package/agent/backup/processExecutor.ts +90 -0
- package/agent/backup/resultValidation.ts +85 -0
- package/agent/backup/routes.ts +161 -0
- package/agent/backup/runner.ts +91 -0
- package/agent/backup/standaloneWorker.ts +23 -0
- package/agent/backup/workerExecutor.ts +40 -0
- package/agent/backup/workerFactory.ts +21 -0
- package/agent/backup/workerProtocol.ts +10 -0
- package/agent/db/core.ts +141 -0
- package/agent/db/csvExport.ts +172 -0
- package/agent/db/exportTimeout.ts +190 -0
- package/agent/db/query.ts +82 -0
- package/agent/db/queryTimeout.ts +76 -0
- package/agent/db/schema.ts +79 -0
- package/agent/db/tables.ts +157 -0
- package/agent/db/types.ts +118 -0
- package/agent/db/workerFactory.ts +25 -0
- package/agent/db.ts +43 -1099
- package/agent/errorMessage.ts +9 -0
- package/agent/flow/routes.ts +251 -0
- package/agent/flow/service.ts +266 -0
- package/agent/flow/types.ts +34 -0
- package/agent/flow/validation.ts +230 -0
- package/agent/index.ts +22 -21
- package/agent/logger.ts +121 -17
- package/agent/managedTarget.ts +82 -0
- package/agent/manager/config.ts +84 -0
- package/agent/manager/logs.ts +83 -0
- package/agent/manager/process.ts +233 -0
- package/agent/manager/storage.ts +28 -0
- package/agent/manager/types.ts +38 -0
- package/agent/manager.ts +10 -490
- package/agent/queue/routes.ts +135 -0
- package/agent/queue/runtime.ts +119 -0
- package/agent/queue/types.ts +34 -0
- package/agent/queue/validation.ts +100 -0
- package/agent/server/controlRoutes.ts +185 -0
- package/agent/server/controlTarget.ts +116 -0
- package/agent/server/databaseRoutes.ts +165 -0
- package/agent/server/errors.ts +49 -0
- package/agent/server/handler.ts +155 -0
- package/agent/server/jsonBody.ts +109 -0
- package/agent/server/lifecycle.ts +72 -0
- package/agent/server/managedRuntime.ts +41 -0
- package/agent/server/policy.ts +60 -0
- package/agent/server/router.ts +161 -0
- package/agent/server/types.ts +46 -0
- package/agent/server/workflowReadRoutes.ts +80 -0
- package/agent/server.ts +26 -441
- package/agent/shutdown.ts +156 -0
- package/agent/workflow/codec.ts +78 -0
- package/agent/workflow/detail.ts +63 -0
- package/agent/workflow/list.ts +87 -0
- package/agent/workflow/runtime.ts +274 -0
- package/agent/workflow/runtimeConfig.ts +103 -0
- package/agent/workflow/runtimeRoutes.ts +163 -0
- package/agent/workflow/sqlite.ts +52 -0
- package/agent/workflow/stats.ts +79 -0
- package/agent/workflow/types.ts +81 -0
- package/agent/workflow/validation.ts +114 -0
- package/agent/workflows.ts +14 -489
- package/dist/.vite/manifest.json +611 -250
- package/dist/assets/AddJob-CWkkVHmf.js +3 -0
- package/dist/assets/Alerts-Gh1pVbqj.js +1 -0
- package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
- package/dist/assets/Benchmark-Dir2A1CQ.js +1 -0
- package/dist/assets/BulkAddJobs-Di_IgRSH.js +5 -0
- package/dist/assets/Button-BPqpRZJD.js +1 -0
- package/dist/assets/Card-C7NMhqUt.js +1 -0
- package/dist/assets/{CopilotPanel-COLw-atS.js → CopilotPanel-Db5nYpo1.js} +3 -3
- package/dist/assets/Cron-BRgWcBGk.js +1 -0
- package/dist/assets/CronManager-CbM0x_s0.js +1 -0
- package/dist/assets/Database-BeoEreW0.js +3 -0
- package/dist/assets/Diagnostics-Cw9iJ84f.js +1 -0
- package/dist/assets/Dlq-DfCg04Jd.js +1 -0
- package/dist/assets/DlqControl-BRCkUXvb.js +1 -0
- package/dist/assets/DlqPro-BHJwBYsN.js +1 -0
- package/dist/assets/Flows-BAzbFP_W.js +2 -0
- package/dist/assets/JobInspector-qvW_APO6.js +2 -0
- package/dist/assets/Jobs-URpnFjC6.js +1 -0
- package/dist/assets/JobsPro-pQ_RFSU3.js +1 -0
- package/dist/assets/Logs-C5jX2OPE.js +1 -0
- package/dist/assets/LogsPro-Cq_npmmS.js +1 -0
- package/dist/assets/{McpServer-CbaU52l2.js → McpServer-DtCTmQBu.js} +2 -2
- package/dist/assets/Metrics-BdxitePr.js +1 -0
- package/dist/assets/MetricsPro-orHfW0mg.js +1 -0
- package/dist/assets/Overview-DaNns1aj.js +1 -0
- package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
- package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
- package/dist/assets/QueueActions-jiTzndPH.js +1 -0
- package/dist/assets/QueueControl-l5bSdMgb.js +1 -0
- package/dist/assets/QueueDetail-URK2pjtH.js +1 -0
- package/dist/assets/QueueDetailPro-C9M5CV4K.js +1 -0
- package/dist/assets/Queues-DeLXlww2.js +1 -0
- package/dist/assets/QueuesOverview-DoQ-MUfT.js +1 -0
- package/dist/assets/S3Backup-D2agV9_z.js +1 -0
- package/dist/assets/S3BackupPro--WSZWTQn.js +3 -0
- package/dist/assets/ServerControl-QVd6m_iR.js +3 -0
- package/dist/assets/Settings-CiJRj_SM.js +1 -0
- package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
- package/dist/assets/Usage-DbHJgA4t.js +1 -0
- package/dist/assets/UsagePro-LoPo3k1L.js +1 -0
- package/dist/assets/Webhooks-CuzIJHwk.js +1 -0
- package/dist/assets/Workers-BpUgpwUH.js +1 -0
- package/dist/assets/WorkersPro-BYY0P1Pt.js +1 -0
- package/dist/assets/Workflows-ByJw4FFH.js +1 -0
- package/dist/assets/api-V_sVpHDo.js +1 -0
- package/dist/assets/bq-DYmbVkgR.js +1 -0
- package/dist/assets/cn-zFEt5SO8.js +1 -0
- package/dist/assets/connectionStore-CKFQrxDo.js +1 -0
- package/dist/assets/controlConnectionEpoch-JM4omZ6j.js +1 -0
- package/dist/assets/feedback-BnGE2oW2.js +1 -0
- package/dist/assets/{flowMutationSafety-CoOp94wS.js → flowMutationSafety-DObp9nJo.js} +1 -1
- package/dist/assets/{form-DsUq0w8s.js → form-tC20r4Ma.js} +1 -1
- package/dist/assets/format-Cg7VCIZW.js +1 -0
- package/dist/assets/icons-CIvFfU2b.js +1 -0
- package/dist/assets/index-D64v2vNO.js +8 -0
- package/dist/assets/index-DOze4TKg.css +1 -0
- package/dist/assets/install-jXi1SOYY.js +3 -0
- package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
- package/dist/assets/responses-CBLyy1Ob.js +1 -0
- package/dist/assets/toastStore-B7n0zS0y.js +1 -0
- package/dist/assets/{upstreamPaths-D_LPsv7b.js → upstreamPaths-CDVsu6IM.js} +1 -1
- package/dist/assets/usePolledData-c53tx1jw.js +1 -0
- package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-OTv6_fU0.js} +1 -1
- package/dist/assets/useThroughputSeries-DWHQl7cM.js +1 -0
- package/dist/index.html +17 -6
- package/package.json +15 -4
- package/scripts/embedded.gen.ts +160 -134
- package/scripts/serve.ts +127 -484
- package/scripts/serveHandler.ts +237 -0
- package/scripts/servePolicy.ts +187 -0
- package/dist/assets/AddJob-CG28MSxU.js +0 -3
- package/dist/assets/Alerts-D9JojJrj.js +0 -1
- package/dist/assets/Benchmark-BVjUH9dG.js +0 -1
- package/dist/assets/BulkAddJobs-BHzUCMj_.js +0 -5
- package/dist/assets/Cron-DoEHDHwy.js +0 -1
- package/dist/assets/CronManager-vulcesqJ.js +0 -1
- package/dist/assets/Database-DlB_t04Z.js +0 -3
- package/dist/assets/Diagnostics-DddCUgeR.js +0 -1
- package/dist/assets/Dlq-DMKrsSMP.js +0 -1
- package/dist/assets/DlqControl-9m0Re_TH.js +0 -1
- package/dist/assets/DlqPro-Dmw-0wta.js +0 -1
- package/dist/assets/Flows-C3-9nXW4.js +0 -1
- package/dist/assets/JobInspector-DwIfABmI.js +0 -2
- package/dist/assets/Jobs-j8tTS3NC.js +0 -1
- package/dist/assets/JobsPro-CS__CJJl.js +0 -1
- package/dist/assets/Logs-BB-VDPA1.js +0 -1
- package/dist/assets/LogsPro-RNtSQZRT.js +0 -1
- package/dist/assets/Metrics-ivcddPwT.js +0 -1
- package/dist/assets/MetricsPro-xjZno4vg.js +0 -1
- package/dist/assets/Overview-D84aD2ph.js +0 -1
- package/dist/assets/Pagination-Bfi4vkpp.js +0 -1
- package/dist/assets/QueueActions-DTbD6uek.js +0 -1
- package/dist/assets/QueueControl-B5xK_SQ-.js +0 -1
- package/dist/assets/QueueDetail-BH59kwm9.js +0 -1
- package/dist/assets/QueueDetailPro-DoN25ndy.js +0 -1
- package/dist/assets/Queues-CKMJqFHX.js +0 -1
- package/dist/assets/QueuesOverview-BX1lzojl.js +0 -1
- package/dist/assets/S3Backup-Bz3GfoZ9.js +0 -1
- package/dist/assets/S3BackupPro-BC_6-uX-.js +0 -2
- package/dist/assets/ServerControl-CSSrN8G5.js +0 -3
- package/dist/assets/Settings-DkqmZUIS.js +0 -1
- package/dist/assets/Usage-Ba20p0xd.js +0 -1
- package/dist/assets/UsagePro-C2pQwjN9.js +0 -1
- package/dist/assets/Webhooks-CVuuvb0L.js +0 -1
- package/dist/assets/Workers-C1wPU6CS.js +0 -1
- package/dist/assets/WorkersPro-B0vEkC0T.js +0 -1
- package/dist/assets/Workflows-C8Qmkflf.js +0 -1
- package/dist/assets/cn-CMFYpumL.js +0 -1
- package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
- package/dist/assets/index-BCNzbhhD.css +0 -1
- package/dist/assets/index-CAtsVmyh.js +0 -8
- package/dist/assets/install-D0eRLf9F.js +0 -3
- package/dist/assets/useThroughputSeries-DjxGN8ob.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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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';
|