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.
- package/README.md +20 -13
- package/agent/backup/processExecutor.ts +90 -0
- package/agent/backup/resultValidation.ts +85 -0
- package/agent/backup/routes.ts +160 -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 +250 -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 +5 -17
- package/agent/logger.ts +121 -17
- package/agent/managedTarget.ts +43 -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 +128 -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 +153 -0
- package/agent/server/databaseRoutes.ts +165 -0
- package/agent/server/errors.ts +48 -0
- package/agent/server/handler.ts +146 -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 +150 -0
- package/agent/server/types.ts +39 -0
- package/agent/server/workflowReadRoutes.ts +80 -0
- package/agent/server.ts +16 -369
- 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 +162 -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 +16 -0
- package/dist/.vite/manifest.json +619 -246
- package/dist/assets/AddJob-UUa2fSXE.js +3 -0
- package/dist/assets/Alerts-7oGYfQMb.js +1 -0
- package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
- package/dist/assets/Benchmark-C2NFp_kj.js +1 -0
- package/dist/assets/BulkAddJobs-CL4yBae-.js +5 -0
- package/dist/assets/Button-BPqpRZJD.js +1 -0
- package/dist/assets/Card-C7NMhqUt.js +1 -0
- package/dist/assets/{CopilotPanel-CIXpcwkH.js → CopilotPanel-BQ2Cgo9p.js} +3 -3
- package/dist/assets/Cron-DfWBhoBf.js +1 -0
- package/dist/assets/CronManager-DAK7RSOX.js +1 -0
- package/dist/assets/Database-DqL7giEj.js +3 -0
- package/dist/assets/Diagnostics-D98YwN-6.js +1 -0
- package/dist/assets/Dlq-BHmA5PTI.js +1 -0
- package/dist/assets/DlqControl-DihxvP-i.js +1 -0
- package/dist/assets/DlqPro-ncgnDtpE.js +1 -0
- package/dist/assets/Flows-B5N5Cil0.js +2 -0
- package/dist/assets/JobInspector-B40nFiGh.js +2 -0
- package/dist/assets/Jobs-Bla3IEef.js +1 -0
- package/dist/assets/JobsPro-CnczVx7x.js +1 -0
- package/dist/assets/Logs-T91Q3cH0.js +1 -0
- package/dist/assets/LogsPro-CMn6-Wwv.js +1 -0
- package/dist/assets/{McpServer-D5rwBg74.js → McpServer-BQBabwW-.js} +2 -2
- package/dist/assets/Metrics-DxT_RI2B.js +1 -0
- package/dist/assets/MetricsPro-Cjen-nAP.js +1 -0
- package/dist/assets/Overview-XN2ej8ph.js +1 -0
- package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
- package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
- package/dist/assets/QueueActions-C-Aferd6.js +1 -0
- package/dist/assets/QueueControl-B8Q9NX6t.js +1 -0
- package/dist/assets/QueueDetail-BMhG0jUB.js +1 -0
- package/dist/assets/QueueDetailPro-BKTTyLR4.js +1 -0
- package/dist/assets/Queues-COqPMoY9.js +1 -0
- package/dist/assets/QueuesOverview-pWdrPy8Z.js +1 -0
- package/dist/assets/S3Backup-BmsZ8xtq.js +1 -0
- package/dist/assets/S3BackupPro-AtlsPUJL.js +3 -0
- package/dist/assets/ServerControl-D3XwHr72.js +3 -0
- package/dist/assets/Settings-BofKZoHn.js +1 -0
- package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
- package/dist/assets/Usage-DAR9E22v.js +1 -0
- package/dist/assets/UsagePro-Bc30N2Lo.js +1 -0
- package/dist/assets/Webhooks-Bg2k1PU4.js +1 -0
- package/dist/assets/Workers-BqUZMJVQ.js +1 -0
- package/dist/assets/WorkersPro-C9aSadUh.js +1 -0
- package/dist/assets/Workflows-BEFMfHmo.js +1 -0
- package/dist/assets/api-CQ5Z7xR7.js +1 -0
- package/dist/assets/bq-BeBxa743.js +1 -0
- package/dist/assets/cn-zFEt5SO8.js +1 -0
- package/dist/assets/connectionStore-DbBYqHEY.js +1 -0
- package/dist/assets/controlConnectionEpoch-CFSO6237.js +1 -0
- package/dist/assets/feedback-BnGE2oW2.js +1 -0
- package/dist/assets/{flowMutationSafety-DVD1IMr-.js → flowMutationSafety-CoOp94wS.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-Cui9IwWy.js +8 -0
- package/dist/assets/index-DOze4TKg.css +1 -0
- package/dist/assets/install-Cd9Is10G.js +3 -0
- package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
- package/dist/assets/responses-kPO4rSu5.js +1 -0
- package/dist/assets/toastStore-CS_9Xndn.js +1 -0
- package/dist/assets/{upstreamPaths-nG8N1XAz.js → upstreamPaths-D_LPsv7b.js} +1 -1
- package/dist/assets/usePolledData-DzlUpBOO.js +1 -0
- package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-BlH-Kso1.js} +1 -1
- package/dist/assets/useThroughputSeries-C2pa1Wd_.js +1 -0
- package/dist/index.html +17 -6
- package/package.json +15 -3
- package/scripts/embedded.gen.ts +152 -124
- package/scripts/serve.ts +96 -484
- package/scripts/serveHandler.ts +217 -0
- package/scripts/servePolicy.ts +133 -0
- package/dist/assets/AddJob-Cih9F84t.js +0 -3
- package/dist/assets/Alerts-Dp4Vclez.js +0 -1
- package/dist/assets/Benchmark-X_gdZun5.js +0 -1
- package/dist/assets/BulkAddJobs-CXTpYMZf.js +0 -5
- package/dist/assets/Cron-DUHkse77.js +0 -1
- package/dist/assets/CronManager-BeYrLK2E.js +0 -1
- package/dist/assets/Database-CXPb3XGr.js +0 -3
- package/dist/assets/Diagnostics-DDtoiDYT.js +0 -1
- package/dist/assets/Dlq-CX_uRblW.js +0 -1
- package/dist/assets/DlqControl-CazFmSEd.js +0 -1
- package/dist/assets/DlqPro-HSffdtLJ.js +0 -1
- package/dist/assets/Flows-CGmyghKF.js +0 -1
- package/dist/assets/JobInspector-B9i1HnG5.js +0 -2
- package/dist/assets/Jobs-BZGtMnJI.js +0 -1
- package/dist/assets/JobsPro-tmEN8f-P.js +0 -1
- package/dist/assets/Logs-DxUD9V2A.js +0 -1
- package/dist/assets/LogsPro-BTQhvPKj.js +0 -1
- package/dist/assets/Metrics-CSPpBf1m.js +0 -1
- package/dist/assets/MetricsPro-DSn-3qNT.js +0 -1
- package/dist/assets/Overview-StvHYRZ5.js +0 -1
- package/dist/assets/Pagination-eotgGQf9.js +0 -1
- package/dist/assets/QueueActions-CibRLzrz.js +0 -1
- package/dist/assets/QueueControl-DwYNcMfI.js +0 -1
- package/dist/assets/QueueDetail-NMfKwj6E.js +0 -1
- package/dist/assets/QueueDetailPro-C8SBjX09.js +0 -1
- package/dist/assets/Queues-BeTAi8V8.js +0 -1
- package/dist/assets/QueuesOverview-BfQO8NS4.js +0 -1
- package/dist/assets/S3Backup-BYkAtONl.js +0 -1
- package/dist/assets/S3BackupPro-BP1Ch54R.js +0 -2
- package/dist/assets/ServerControl-C85jYgyN.js +0 -3
- package/dist/assets/Settings-DjP28c_5.js +0 -1
- package/dist/assets/Usage-DECeEsO2.js +0 -1
- package/dist/assets/UsagePro-CD7dgt3d.js +0 -1
- package/dist/assets/Webhooks-Ct-x8DD1.js +0 -1
- package/dist/assets/Workers-lP1BXHFq.js +0 -1
- package/dist/assets/WorkersPro-CAUqy3Cp.js +0 -1
- package/dist/assets/cn-CMFYpumL.js +0 -1
- package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
- package/dist/assets/index-BWoUj86M.css +0 -1
- package/dist/assets/index-Dr__4DsT.js +0 -8
- package/dist/assets/install-DPCcfOYo.js +0 -3
- package/dist/assets/useThroughputSeries-Bpyg6Jaq.js +0 -1
package/agent/db/core.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { Database } from 'bun:sqlite';
|
|
3
|
+
import {
|
|
4
|
+
DB_EXPORT_MAX_BYTES,
|
|
5
|
+
DB_EXPORT_MAX_ROWS,
|
|
6
|
+
MAX_CELL,
|
|
7
|
+
MissingDbError,
|
|
8
|
+
type DbFilter,
|
|
9
|
+
type DbRowId,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
export function openDb(path: string): Database {
|
|
13
|
+
try {
|
|
14
|
+
if (!Bun.file(path).size && !existsSync(path)) {
|
|
15
|
+
throw new MissingDbError(
|
|
16
|
+
`Database not found at "${path}" — start the server once to create it.`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error instanceof MissingDbError) throw error;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
return new Database(path, { readonly: true });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
throw new MissingDbError(
|
|
27
|
+
`Database not found at "${path}" — start the server once to create it.`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`Could not open database: ${(error as Error).message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const ident = (name: string): string => `"${name.replaceAll('"', '""')}"`;
|
|
35
|
+
|
|
36
|
+
export function isTruncated(value: unknown): boolean {
|
|
37
|
+
return (
|
|
38
|
+
(value instanceof Uint8Array && value.byteLength > 0) ||
|
|
39
|
+
(typeof value === 'string' && value.length > MAX_CELL)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function bigintCell(value: bigint): number | string {
|
|
44
|
+
return value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)
|
|
45
|
+
? Number(value)
|
|
46
|
+
: value.toString();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function jsonCell(value: unknown): unknown {
|
|
50
|
+
if (value instanceof Uint8Array) return `<blob ${value.byteLength} B>`;
|
|
51
|
+
if (typeof value === 'string' && value.length > MAX_CELL) {
|
|
52
|
+
return `${value.slice(0, MAX_CELL)}…`;
|
|
53
|
+
}
|
|
54
|
+
if (typeof value === 'bigint') return bigintCell(value);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const sanitize = (rows: unknown[][]): unknown[][] =>
|
|
59
|
+
rows.map((row) => row.map(jsonCell));
|
|
60
|
+
|
|
61
|
+
const MIN_SQLITE_INTEGER = -(1n << 63n);
|
|
62
|
+
const MAX_SQLITE_INTEGER = (1n << 63n) - 1n;
|
|
63
|
+
|
|
64
|
+
export function boundRowid(rowid: DbRowId): DbRowId {
|
|
65
|
+
if (typeof rowid === 'number') {
|
|
66
|
+
if (!Number.isSafeInteger(rowid)) throw new Error('Invalid rowid');
|
|
67
|
+
return rowid;
|
|
68
|
+
}
|
|
69
|
+
if (rowid.length === 0 || rowid.length > 20 || !/^-?\d+$/.test(rowid)) {
|
|
70
|
+
throw new Error('Invalid rowid');
|
|
71
|
+
}
|
|
72
|
+
const integer = BigInt(rowid);
|
|
73
|
+
if (integer < MIN_SQLITE_INTEGER || integer > MAX_SQLITE_INTEGER) {
|
|
74
|
+
throw new Error('Invalid rowid');
|
|
75
|
+
}
|
|
76
|
+
return rowid;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** bun:sqlite exposes this at runtime but does not type it. */
|
|
80
|
+
export function safeIntegers(statement: unknown): void {
|
|
81
|
+
(statement as { safeIntegers?: (on: boolean) => void }).safeIntegers?.(true);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function knownTable(db: Database, table: string): string {
|
|
85
|
+
const known = db
|
|
86
|
+
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
87
|
+
.get(table) as { name: string } | null;
|
|
88
|
+
if (!known) throw new Error(`No such table: ${table}`);
|
|
89
|
+
return known.name;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildFilter(
|
|
93
|
+
columns: string[],
|
|
94
|
+
filter: DbFilter | undefined
|
|
95
|
+
): { clause: string; params: unknown[]; applied: DbFilter | null } {
|
|
96
|
+
if (!filter || !filter.value) return { clause: '', params: [], applied: null };
|
|
97
|
+
const column = columns.find((candidate) => candidate === filter.column);
|
|
98
|
+
if (!column) throw new Error(`No such column: ${filter.column}`);
|
|
99
|
+
const quoted = ident(column);
|
|
100
|
+
if (filter.op === 'eq') {
|
|
101
|
+
return { clause: ` WHERE ${quoted} = ?`, params: [filter.value], applied: filter };
|
|
102
|
+
}
|
|
103
|
+
if (filter.op === 'ne') {
|
|
104
|
+
return { clause: ` WHERE ${quoted} <> ?`, params: [filter.value], applied: filter };
|
|
105
|
+
}
|
|
106
|
+
const needle = filter.value
|
|
107
|
+
.replaceAll('\\', '\\\\')
|
|
108
|
+
.replaceAll('%', '\\%')
|
|
109
|
+
.replaceAll('_', '\\_');
|
|
110
|
+
return {
|
|
111
|
+
clause: ` WHERE ${quoted} LIKE ? ESCAPE '\\'`,
|
|
112
|
+
params: [`%${needle}%`],
|
|
113
|
+
applied: { ...filter, op: 'contains' },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function boundedExportLimit(
|
|
118
|
+
value: number | undefined,
|
|
119
|
+
hardCap: number,
|
|
120
|
+
label: string
|
|
121
|
+
): number {
|
|
122
|
+
if (value === undefined) return hardCap;
|
|
123
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
124
|
+
throw new Error(`${label} must be a positive safe integer`);
|
|
125
|
+
}
|
|
126
|
+
return Math.min(value, hardCap);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function validateWorkerBounds(rows: number, bytes: number): boolean {
|
|
130
|
+
return rows >= 0 && rows <= DB_EXPORT_MAX_ROWS && bytes >= 1 && bytes <= DB_EXPORT_MAX_BYTES;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function translateDbError(error: unknown): Error {
|
|
134
|
+
const message = String((error as Error)?.message ?? error);
|
|
135
|
+
if (/readonly|read-only|SQLITE_READONLY/i.test(message)) {
|
|
136
|
+
return new Error(
|
|
137
|
+
'Connection is read-only — INSERT / UPDATE / DELETE / DDL are not permitted here.'
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return error instanceof Error ? error : new Error(message);
|
|
141
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
boundedExportLimit,
|
|
3
|
+
buildFilter,
|
|
4
|
+
ident,
|
|
5
|
+
knownTable,
|
|
6
|
+
openDb,
|
|
7
|
+
safeIntegers,
|
|
8
|
+
translateDbError,
|
|
9
|
+
} from './core';
|
|
10
|
+
import {
|
|
11
|
+
DB_EXPORT_MAX_BYTES,
|
|
12
|
+
DB_EXPORT_MAX_COLUMNS,
|
|
13
|
+
DB_EXPORT_MAX_FILTER_CHARS,
|
|
14
|
+
DB_EXPORT_MAX_IDENTIFIER_CHARS,
|
|
15
|
+
DB_EXPORT_MAX_ROWS,
|
|
16
|
+
MAX_CELL,
|
|
17
|
+
type DbCsvExport,
|
|
18
|
+
type DbExportCap,
|
|
19
|
+
type DbExportLimits,
|
|
20
|
+
type DbFilter,
|
|
21
|
+
} from './types';
|
|
22
|
+
|
|
23
|
+
function csvCell(value: unknown): string {
|
|
24
|
+
let text: string;
|
|
25
|
+
let textual = false;
|
|
26
|
+
if (value == null) text = '';
|
|
27
|
+
else if (typeof value === 'string') {
|
|
28
|
+
text = value;
|
|
29
|
+
textual = true;
|
|
30
|
+
} else if (
|
|
31
|
+
typeof value === 'bigint' ||
|
|
32
|
+
typeof value === 'number' ||
|
|
33
|
+
typeof value === 'boolean'
|
|
34
|
+
) {
|
|
35
|
+
text = String(value);
|
|
36
|
+
} else if (value instanceof Uint8Array) {
|
|
37
|
+
text = `<blob ${value.byteLength} B>`;
|
|
38
|
+
textual = true;
|
|
39
|
+
} else {
|
|
40
|
+
throw new Error('Database export produced an unsupported SQLite value');
|
|
41
|
+
}
|
|
42
|
+
const safe = textual && /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
|
|
43
|
+
return /[",\r\n]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateRequest(table: string, dir: string, filter?: DbFilter): void {
|
|
47
|
+
if (typeof table !== 'string' || table.length === 0) throw new Error('Table is required');
|
|
48
|
+
if (table.length > DB_EXPORT_MAX_IDENTIFIER_CHARS) {
|
|
49
|
+
throw new Error(`Table name exceeds ${DB_EXPORT_MAX_IDENTIFIER_CHARS} characters`);
|
|
50
|
+
}
|
|
51
|
+
if (dir !== 'asc' && dir !== 'desc') throw new Error('Invalid export sort direction');
|
|
52
|
+
if (!filter) return;
|
|
53
|
+
if (
|
|
54
|
+
typeof filter.column !== 'string' ||
|
|
55
|
+
!filter.column ||
|
|
56
|
+
!['contains', 'eq', 'ne'].includes(filter.op) ||
|
|
57
|
+
typeof filter.value !== 'string'
|
|
58
|
+
) {
|
|
59
|
+
throw new Error('Invalid database export filter');
|
|
60
|
+
}
|
|
61
|
+
if (filter.column.length > DB_EXPORT_MAX_IDENTIFIER_CHARS) {
|
|
62
|
+
throw new Error(`Filter column exceeds ${DB_EXPORT_MAX_IDENTIFIER_CHARS} characters`);
|
|
63
|
+
}
|
|
64
|
+
if (filter.value.length > DB_EXPORT_MAX_FILTER_CHARS) {
|
|
65
|
+
throw new Error(`Database export filter exceeds ${DB_EXPORT_MAX_FILTER_CHARS} characters`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Export one filtered/sorted table view from a single SQLite snapshot. */
|
|
70
|
+
export function dbExportCsv(
|
|
71
|
+
path: string,
|
|
72
|
+
table: string,
|
|
73
|
+
orderBy?: string,
|
|
74
|
+
dir: 'asc' | 'desc' = 'asc',
|
|
75
|
+
filter?: DbFilter,
|
|
76
|
+
limits: Readonly<DbExportLimits> = {}
|
|
77
|
+
): DbCsvExport {
|
|
78
|
+
validateRequest(table, dir, filter);
|
|
79
|
+
const maxRows = boundedExportLimit(limits.maxRows, DB_EXPORT_MAX_ROWS, 'Export row limit');
|
|
80
|
+
const maxBytes = boundedExportLimit(limits.maxBytes, DB_EXPORT_MAX_BYTES, 'Export byte limit');
|
|
81
|
+
const db = openDb(path);
|
|
82
|
+
try {
|
|
83
|
+
const snapshot = db.transaction((): DbCsvExport => {
|
|
84
|
+
const name = knownTable(db, table);
|
|
85
|
+
const columns = (
|
|
86
|
+
db.query(`PRAGMA table_info(${ident(name)})`).all() as { name: string }[]
|
|
87
|
+
).map((column) => column.name);
|
|
88
|
+
if (columns.length === 0) throw new Error(`Table has no exportable columns: ${name}`);
|
|
89
|
+
if (columns.length > DB_EXPORT_MAX_COLUMNS) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Table has ${columns.length} columns; database export supports at most ${DB_EXPORT_MAX_COLUMNS}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (columns.some((column) => column.length > DB_EXPORT_MAX_IDENTIFIER_CHARS)) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Database export column names must not exceed ${DB_EXPORT_MAX_IDENTIFIER_CHARS} characters`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
limits.onSnapshot?.();
|
|
100
|
+
|
|
101
|
+
let order = '';
|
|
102
|
+
if (orderBy !== undefined) {
|
|
103
|
+
if (typeof orderBy !== 'string' || !orderBy) {
|
|
104
|
+
throw new Error('Invalid database export sort column');
|
|
105
|
+
}
|
|
106
|
+
if (orderBy.length > DB_EXPORT_MAX_IDENTIFIER_CHARS) {
|
|
107
|
+
throw new Error(`Sort column exceeds ${DB_EXPORT_MAX_IDENTIFIER_CHARS} characters`);
|
|
108
|
+
}
|
|
109
|
+
const sortColumn = columns.find((column) => column === orderBy);
|
|
110
|
+
if (!sortColumn) throw new Error(`No such column: ${orderBy}`);
|
|
111
|
+
order = ` ORDER BY ${ident(sortColumn)} ${dir === 'desc' ? 'DESC' : 'ASC'}`;
|
|
112
|
+
}
|
|
113
|
+
const { clause, params } = buildFilter(columns, filter);
|
|
114
|
+
const aliases = columns.map((_, index) => `__bq_export_${index}`);
|
|
115
|
+
const selected = columns.map((column, index) => {
|
|
116
|
+
const quoted = ident(column);
|
|
117
|
+
return (
|
|
118
|
+
`CASE WHEN typeof(${quoted}) = 'blob' THEN '<blob ' || length(${quoted}) || ' B>' ` +
|
|
119
|
+
`WHEN typeof(${quoted}) = 'text' AND length(CAST(${quoted} AS BLOB)) > ${MAX_CELL} ` +
|
|
120
|
+
`THEN substr(${quoted}, 1, ${MAX_CELL}) || '…' ` +
|
|
121
|
+
`ELSE ${quoted} END AS ${ident(aliases[index])}`
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
const statement = db.query<Record<string, unknown>, Array<string | number>>(
|
|
125
|
+
`SELECT ${selected.join(', ')} FROM ${ident(name)}${clause}${order} LIMIT ?`
|
|
126
|
+
);
|
|
127
|
+
safeIntegers(statement);
|
|
128
|
+
|
|
129
|
+
const encoder = new TextEncoder();
|
|
130
|
+
const buffer = new Uint8Array(maxBytes);
|
|
131
|
+
let used = 0;
|
|
132
|
+
const append = (text: string): boolean => {
|
|
133
|
+
const result = encoder.encodeInto(text, buffer.subarray(used));
|
|
134
|
+
if (result.read !== text.length) return false;
|
|
135
|
+
used += result.written;
|
|
136
|
+
return true;
|
|
137
|
+
};
|
|
138
|
+
if (!append(columns.map(csvCell).join(','))) {
|
|
139
|
+
throw new Error(`Database export header exceeds the ${maxBytes}-byte export limit`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let rowCount = 0;
|
|
143
|
+
let cap: DbExportCap = null;
|
|
144
|
+
const iterator = statement.iterate(...([...params, maxRows + 1] as Array<string | number>));
|
|
145
|
+
try {
|
|
146
|
+
for (;;) {
|
|
147
|
+
const next = iterator.next();
|
|
148
|
+
if (next.done) break;
|
|
149
|
+
if (rowCount >= maxRows) {
|
|
150
|
+
cap = 'rows';
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
const line = `\r\n${aliases.map((alias) => csvCell(next.value[alias])).join(',')}`;
|
|
154
|
+
if (!append(line)) {
|
|
155
|
+
cap = 'bytes';
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
rowCount++;
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
iterator.return?.();
|
|
162
|
+
}
|
|
163
|
+
const content = buffer.slice(0, used);
|
|
164
|
+
return { table: name, content, rowCount, bytes: content.byteLength, cap };
|
|
165
|
+
});
|
|
166
|
+
return snapshot.deferred();
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw translateDbError(error);
|
|
169
|
+
} finally {
|
|
170
|
+
db.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { validateWorkerBounds } from './core';
|
|
2
|
+
import { createExportWorker } from './workerFactory';
|
|
3
|
+
import {
|
|
4
|
+
DB_EXPORT_MAX_ROWS,
|
|
5
|
+
DB_EXPORT_TIMEOUT_MS,
|
|
6
|
+
DbExportBusyError,
|
|
7
|
+
DbExportUnavailableError,
|
|
8
|
+
MAX_CONCURRENT_EXPORTS,
|
|
9
|
+
MissingDbError,
|
|
10
|
+
type DbCsvExport,
|
|
11
|
+
type DbExportCap,
|
|
12
|
+
type DbFilter,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
let liveExportWorkers = 0;
|
|
16
|
+
|
|
17
|
+
/** Export workers alive now, including abandoned SQLite calls still unwinding. */
|
|
18
|
+
export const exportWorkerLoad = (): number => liveExportWorkers;
|
|
19
|
+
|
|
20
|
+
function validatedWorkerExport(value: unknown, expectedTable: string): DbCsvExport {
|
|
21
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
22
|
+
throw new DbExportUnavailableError('Database export worker returned a malformed result');
|
|
23
|
+
}
|
|
24
|
+
const result = value as Record<string, unknown>;
|
|
25
|
+
if (
|
|
26
|
+
typeof result.table !== 'string' ||
|
|
27
|
+
result.table !== expectedTable ||
|
|
28
|
+
!(result.content instanceof Uint8Array) ||
|
|
29
|
+
!Number.isSafeInteger(result.rowCount) ||
|
|
30
|
+
!Number.isSafeInteger(result.bytes) ||
|
|
31
|
+
!validateWorkerBounds(result.rowCount as number, result.bytes as number) ||
|
|
32
|
+
result.content.byteLength !== result.bytes ||
|
|
33
|
+
(result.cap !== null && result.cap !== 'rows' && result.cap !== 'bytes') ||
|
|
34
|
+
(result.cap === 'rows' && result.rowCount !== DB_EXPORT_MAX_ROWS)
|
|
35
|
+
) {
|
|
36
|
+
throw new DbExportUnavailableError('Database export worker returned a malformed result');
|
|
37
|
+
}
|
|
38
|
+
const content =
|
|
39
|
+
result.content.buffer instanceof ArrayBuffer
|
|
40
|
+
? (result.content as Uint8Array<ArrayBuffer>)
|
|
41
|
+
: Uint8Array.from(result.content);
|
|
42
|
+
return {
|
|
43
|
+
table: result.table,
|
|
44
|
+
content,
|
|
45
|
+
rowCount: result.rowCount as number,
|
|
46
|
+
bytes: result.bytes as number,
|
|
47
|
+
cap: result.cap as DbExportCap,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface WorkerResponse {
|
|
52
|
+
ok?: unknown;
|
|
53
|
+
export?: unknown;
|
|
54
|
+
error?: unknown;
|
|
55
|
+
missing?: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Run an atomic CSV export off the control-agent event loop. */
|
|
59
|
+
export async function exportWithTimeout(
|
|
60
|
+
path: string,
|
|
61
|
+
table: string,
|
|
62
|
+
orderBy?: string,
|
|
63
|
+
dir: 'asc' | 'desc' = 'asc',
|
|
64
|
+
filter?: DbFilter,
|
|
65
|
+
signal?: AbortSignal
|
|
66
|
+
): Promise<DbCsvExport> {
|
|
67
|
+
signal?.throwIfAborted();
|
|
68
|
+
if (liveExportWorkers >= MAX_CONCURRENT_EXPORTS) {
|
|
69
|
+
throw new DbExportBusyError(
|
|
70
|
+
'A database export is already running. Wait for it to finish, or restart the agent if it was abandoned.'
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let worker: Worker;
|
|
75
|
+
try {
|
|
76
|
+
worker = createExportWorker();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new DbExportUnavailableError(
|
|
79
|
+
`Database export worker unavailable: ${(error as Error).message ?? String(error)}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
liveExportWorkers++;
|
|
83
|
+
let released = false;
|
|
84
|
+
const release = () => {
|
|
85
|
+
if (released) return;
|
|
86
|
+
released = true;
|
|
87
|
+
liveExportWorkers--;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
return await new Promise<DbCsvExport>((resolve, reject) => {
|
|
92
|
+
let settled = false;
|
|
93
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
94
|
+
const cleanup = () => {
|
|
95
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
96
|
+
signal?.removeEventListener('abort', onAbort);
|
|
97
|
+
};
|
|
98
|
+
const abandon = (error: Error) => {
|
|
99
|
+
if (settled) return;
|
|
100
|
+
settled = true;
|
|
101
|
+
cleanup();
|
|
102
|
+
worker.terminate();
|
|
103
|
+
reject(error);
|
|
104
|
+
};
|
|
105
|
+
const onAbort = () =>
|
|
106
|
+
abandon(
|
|
107
|
+
signal?.reason instanceof Error
|
|
108
|
+
? signal.reason
|
|
109
|
+
: new Error('Database export request was aborted')
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
worker.addEventListener('close', () => {
|
|
113
|
+
release();
|
|
114
|
+
if (!settled) {
|
|
115
|
+
settled = true;
|
|
116
|
+
cleanup();
|
|
117
|
+
reject(new DbExportUnavailableError('Database export worker exited unexpectedly'));
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
worker.addEventListener('message', (event: MessageEvent) => {
|
|
121
|
+
if (settled) return;
|
|
122
|
+
settled = true;
|
|
123
|
+
cleanup();
|
|
124
|
+
release();
|
|
125
|
+
const response = event.data as WorkerResponse | null;
|
|
126
|
+
if (!response || typeof response !== 'object' || Array.isArray(response)) {
|
|
127
|
+
reject(
|
|
128
|
+
new DbExportUnavailableError('Database export worker returned a malformed response')
|
|
129
|
+
);
|
|
130
|
+
} else if (response.ok === true) {
|
|
131
|
+
try {
|
|
132
|
+
resolve(validatedWorkerExport(response.export, table));
|
|
133
|
+
} catch (error) {
|
|
134
|
+
reject(error);
|
|
135
|
+
}
|
|
136
|
+
} else if (response.missing === true) {
|
|
137
|
+
reject(
|
|
138
|
+
new MissingDbError(
|
|
139
|
+
typeof response.error === 'string' ? response.error : 'Database not found'
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
} else {
|
|
143
|
+
reject(
|
|
144
|
+
new Error(
|
|
145
|
+
typeof response.error === 'string' ? response.error : 'Database export failed'
|
|
146
|
+
)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
worker.addEventListener('error', (event: ErrorEvent) => {
|
|
151
|
+
if (settled) return;
|
|
152
|
+
event.preventDefault?.();
|
|
153
|
+
abandon(
|
|
154
|
+
new DbExportUnavailableError(
|
|
155
|
+
`Database export worker failed: ${event.message || 'unknown worker error'}`
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
timer = setTimeout(
|
|
161
|
+
() =>
|
|
162
|
+
abandon(
|
|
163
|
+
new DbExportUnavailableError(
|
|
164
|
+
`Database export exceeded the ${DB_EXPORT_TIMEOUT_MS / 1000}s time limit and was abandoned (SQLite may keep scanning until the worker exits).`
|
|
165
|
+
)
|
|
166
|
+
),
|
|
167
|
+
DB_EXPORT_TIMEOUT_MS
|
|
168
|
+
);
|
|
169
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
170
|
+
if (signal?.aborted) {
|
|
171
|
+
onAbort();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
worker.postMessage({ kind: 'export', path, table, orderBy, dir, filter });
|
|
176
|
+
} catch (error) {
|
|
177
|
+
settled = true;
|
|
178
|
+
cleanup();
|
|
179
|
+
release();
|
|
180
|
+
reject(
|
|
181
|
+
new DbExportUnavailableError(
|
|
182
|
+
`Could not start database export: ${(error as Error).message ?? String(error)}`
|
|
183
|
+
)
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
} finally {
|
|
188
|
+
worker.terminate();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
import { ident, openDb, safeIntegers, sanitize, translateDbError } from './core';
|
|
3
|
+
import { MAX_ROWS, type DbQueryResult } from './types';
|
|
4
|
+
|
|
5
|
+
const READ_ONLY_LEAD = /^(?:SELECT|WITH|EXPLAIN|VALUES|PRAGMA)\b/;
|
|
6
|
+
const DUP_VIEW = '__bq_query_columns';
|
|
7
|
+
|
|
8
|
+
function leadingKeyword(sql: string): string {
|
|
9
|
+
const head = sql.replace(/^(?:\s|--[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*/, '');
|
|
10
|
+
return head.slice(0, 12).toUpperCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Let SQLite disambiguate colliding output names through a private TEMP view. */
|
|
14
|
+
function disambiguateColumns(
|
|
15
|
+
db: Database,
|
|
16
|
+
sql: string
|
|
17
|
+
): { stmt: ReturnType<Database['query']>; cols: string[] } | null {
|
|
18
|
+
try {
|
|
19
|
+
// query().run() compiles one statement; db.run() would execute a trailing
|
|
20
|
+
// statement that the leading-keyword allowlist cannot see.
|
|
21
|
+
db.query(`CREATE TEMP VIEW ${ident(DUP_VIEW)} AS ${sql.replace(/;\s*$/, '')}`).run();
|
|
22
|
+
const columns = (
|
|
23
|
+
db.query(`PRAGMA table_info(${ident(DUP_VIEW)})`).all() as { name: string }[]
|
|
24
|
+
).map((column) => column.name);
|
|
25
|
+
return { stmt: db.query(`SELECT * FROM ${ident(DUP_VIEW)}`), cols: columns };
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Synchronous read-only query implementation used inside a disposable Worker. */
|
|
32
|
+
export function dbQuery(path: string, sql: string): DbQueryResult {
|
|
33
|
+
const trimmed = sql.trim();
|
|
34
|
+
if (!trimmed) throw new Error('Empty query');
|
|
35
|
+
if (!READ_ONLY_LEAD.test(leadingKeyword(trimmed))) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'Only read-only SELECT / WITH / EXPLAIN / VALUES / PRAGMA queries are allowed here.'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const db = openDb(path);
|
|
41
|
+
try {
|
|
42
|
+
const started = performance.now();
|
|
43
|
+
let statement: ReturnType<Database['query']>;
|
|
44
|
+
try {
|
|
45
|
+
statement = db.query(trimmed);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw translateDbError(error);
|
|
48
|
+
}
|
|
49
|
+
let columns = statement.columnNames;
|
|
50
|
+
if (statement.columnTypes.length > columns.length) {
|
|
51
|
+
const disambiguated = disambiguateColumns(db, trimmed);
|
|
52
|
+
if (disambiguated) {
|
|
53
|
+
statement = disambiguated.stmt;
|
|
54
|
+
columns = disambiguated.cols;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
safeIntegers(statement);
|
|
58
|
+
const rows: unknown[][] = [];
|
|
59
|
+
let truncated = false;
|
|
60
|
+
try {
|
|
61
|
+
for (const row of statement.iterate() as IterableIterator<Record<string, unknown>>) {
|
|
62
|
+
if (rows.length >= MAX_ROWS) {
|
|
63
|
+
truncated = true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
rows.push(columns.map((column) => row[column]));
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw translateDbError(error);
|
|
70
|
+
}
|
|
71
|
+
const milliseconds = performance.now() - started;
|
|
72
|
+
return {
|
|
73
|
+
columns,
|
|
74
|
+
rows: sanitize(rows),
|
|
75
|
+
rowCount: rows.length,
|
|
76
|
+
truncated,
|
|
77
|
+
ms: Math.round(milliseconds * 10) / 10,
|
|
78
|
+
};
|
|
79
|
+
} finally {
|
|
80
|
+
db.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createQueryWorker } from './workerFactory';
|
|
2
|
+
import {
|
|
3
|
+
MAX_CONCURRENT_QUERIES,
|
|
4
|
+
MissingDbError,
|
|
5
|
+
QUERY_TIMEOUT_MS,
|
|
6
|
+
type DbQueryResult,
|
|
7
|
+
} from './types';
|
|
8
|
+
|
|
9
|
+
let liveQueryWorkers = 0;
|
|
10
|
+
|
|
11
|
+
/** Query worker threads currently alive, including timed-out queries. */
|
|
12
|
+
export const queryWorkerLoad = (): number => liveQueryWorkers;
|
|
13
|
+
|
|
14
|
+
export async function queryWithTimeout(path: string, sql: string): Promise<DbQueryResult> {
|
|
15
|
+
if (liveQueryWorkers >= MAX_CONCURRENT_QUERIES) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`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.`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
let worker: Worker;
|
|
21
|
+
try {
|
|
22
|
+
worker = createQueryWorker();
|
|
23
|
+
} catch (error) {
|
|
24
|
+
throw new Error(`Query worker unavailable: ${(error as Error).message ?? String(error)}`);
|
|
25
|
+
}
|
|
26
|
+
liveQueryWorkers++;
|
|
27
|
+
let released = false;
|
|
28
|
+
const release = () => {
|
|
29
|
+
if (released) return;
|
|
30
|
+
released = true;
|
|
31
|
+
liveQueryWorkers--;
|
|
32
|
+
};
|
|
33
|
+
worker.addEventListener('close', release);
|
|
34
|
+
try {
|
|
35
|
+
return await new Promise<DbQueryResult>((resolve, reject) => {
|
|
36
|
+
let settled = false;
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
if (settled) return;
|
|
39
|
+
settled = true;
|
|
40
|
+
worker.terminate();
|
|
41
|
+
reject(
|
|
42
|
+
new Error(
|
|
43
|
+
`Query exceeded the ${QUERY_TIMEOUT_MS / 1000}s time limit and was abandoned (it may keep running inside SQLite until it completes).`
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
}, QUERY_TIMEOUT_MS);
|
|
47
|
+
worker.addEventListener('message', (event: MessageEvent) => {
|
|
48
|
+
if (settled) return;
|
|
49
|
+
settled = true;
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
release();
|
|
52
|
+
const response = event.data as {
|
|
53
|
+
ok: boolean;
|
|
54
|
+
result?: DbQueryResult;
|
|
55
|
+
error?: string;
|
|
56
|
+
missing?: boolean;
|
|
57
|
+
};
|
|
58
|
+
if (response.ok && response.result) resolve(response.result);
|
|
59
|
+
else if (response.missing) {
|
|
60
|
+
reject(new MissingDbError(response.error ?? 'Database not found'));
|
|
61
|
+
} else reject(new Error(response.error ?? 'Query failed'));
|
|
62
|
+
});
|
|
63
|
+
worker.addEventListener('error', (event: ErrorEvent) => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
release();
|
|
68
|
+
event.preventDefault?.();
|
|
69
|
+
reject(new Error(`Query worker failed: ${event.message || 'unknown worker error'}`));
|
|
70
|
+
});
|
|
71
|
+
worker.postMessage({ path, sql });
|
|
72
|
+
});
|
|
73
|
+
} finally {
|
|
74
|
+
worker.terminate();
|
|
75
|
+
}
|
|
76
|
+
}
|