bunqueue-dashboard 0.0.34 → 0.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +20 -13
  2. package/agent/backup/processExecutor.ts +90 -0
  3. package/agent/backup/resultValidation.ts +85 -0
  4. package/agent/backup/routes.ts +160 -0
  5. package/agent/backup/runner.ts +91 -0
  6. package/agent/backup/standaloneWorker.ts +23 -0
  7. package/agent/backup/workerExecutor.ts +40 -0
  8. package/agent/backup/workerFactory.ts +21 -0
  9. package/agent/backup/workerProtocol.ts +10 -0
  10. package/agent/db/core.ts +141 -0
  11. package/agent/db/csvExport.ts +172 -0
  12. package/agent/db/exportTimeout.ts +190 -0
  13. package/agent/db/query.ts +82 -0
  14. package/agent/db/queryTimeout.ts +76 -0
  15. package/agent/db/schema.ts +79 -0
  16. package/agent/db/tables.ts +157 -0
  17. package/agent/db/types.ts +118 -0
  18. package/agent/db/workerFactory.ts +25 -0
  19. package/agent/db.ts +43 -1099
  20. package/agent/errorMessage.ts +9 -0
  21. package/agent/flow/routes.ts +250 -0
  22. package/agent/flow/service.ts +266 -0
  23. package/agent/flow/types.ts +34 -0
  24. package/agent/flow/validation.ts +230 -0
  25. package/agent/index.ts +5 -17
  26. package/agent/logger.ts +121 -17
  27. package/agent/managedTarget.ts +43 -0
  28. package/agent/manager/config.ts +84 -0
  29. package/agent/manager/logs.ts +83 -0
  30. package/agent/manager/process.ts +233 -0
  31. package/agent/manager/storage.ts +28 -0
  32. package/agent/manager/types.ts +38 -0
  33. package/agent/manager.ts +10 -490
  34. package/agent/queue/routes.ts +128 -0
  35. package/agent/queue/runtime.ts +119 -0
  36. package/agent/queue/types.ts +34 -0
  37. package/agent/queue/validation.ts +100 -0
  38. package/agent/server/controlRoutes.ts +153 -0
  39. package/agent/server/databaseRoutes.ts +165 -0
  40. package/agent/server/errors.ts +48 -0
  41. package/agent/server/handler.ts +146 -0
  42. package/agent/server/jsonBody.ts +109 -0
  43. package/agent/server/lifecycle.ts +72 -0
  44. package/agent/server/managedRuntime.ts +41 -0
  45. package/agent/server/policy.ts +60 -0
  46. package/agent/server/router.ts +150 -0
  47. package/agent/server/types.ts +39 -0
  48. package/agent/server/workflowReadRoutes.ts +80 -0
  49. package/agent/server.ts +16 -369
  50. package/agent/shutdown.ts +156 -0
  51. package/agent/workflow/codec.ts +78 -0
  52. package/agent/workflow/detail.ts +63 -0
  53. package/agent/workflow/list.ts +87 -0
  54. package/agent/workflow/runtime.ts +274 -0
  55. package/agent/workflow/runtimeConfig.ts +103 -0
  56. package/agent/workflow/runtimeRoutes.ts +162 -0
  57. package/agent/workflow/sqlite.ts +52 -0
  58. package/agent/workflow/stats.ts +79 -0
  59. package/agent/workflow/types.ts +81 -0
  60. package/agent/workflow/validation.ts +114 -0
  61. package/agent/workflows.ts +16 -0
  62. package/dist/.vite/manifest.json +619 -246
  63. package/dist/assets/AddJob-UUa2fSXE.js +3 -0
  64. package/dist/assets/Alerts-7oGYfQMb.js +1 -0
  65. package/dist/assets/{AreaChart-REK4ZsTC.js → AreaChart-w0ruozXH.js} +1 -1
  66. package/dist/assets/Benchmark-C2NFp_kj.js +1 -0
  67. package/dist/assets/BulkAddJobs-CL4yBae-.js +5 -0
  68. package/dist/assets/Button-BPqpRZJD.js +1 -0
  69. package/dist/assets/Card-C7NMhqUt.js +1 -0
  70. package/dist/assets/{CopilotPanel-CIXpcwkH.js → CopilotPanel-BQ2Cgo9p.js} +3 -3
  71. package/dist/assets/Cron-DfWBhoBf.js +1 -0
  72. package/dist/assets/CronManager-DAK7RSOX.js +1 -0
  73. package/dist/assets/Database-DqL7giEj.js +3 -0
  74. package/dist/assets/Diagnostics-D98YwN-6.js +1 -0
  75. package/dist/assets/Dlq-BHmA5PTI.js +1 -0
  76. package/dist/assets/DlqControl-DihxvP-i.js +1 -0
  77. package/dist/assets/DlqPro-ncgnDtpE.js +1 -0
  78. package/dist/assets/Flows-B5N5Cil0.js +2 -0
  79. package/dist/assets/JobInspector-B40nFiGh.js +2 -0
  80. package/dist/assets/Jobs-Bla3IEef.js +1 -0
  81. package/dist/assets/JobsPro-CnczVx7x.js +1 -0
  82. package/dist/assets/Logs-T91Q3cH0.js +1 -0
  83. package/dist/assets/LogsPro-CMn6-Wwv.js +1 -0
  84. package/dist/assets/{McpServer-D5rwBg74.js → McpServer-BQBabwW-.js} +2 -2
  85. package/dist/assets/Metrics-DxT_RI2B.js +1 -0
  86. package/dist/assets/MetricsPro-Cjen-nAP.js +1 -0
  87. package/dist/assets/Overview-XN2ej8ph.js +1 -0
  88. package/dist/assets/PageHeader-BXlZSBcc.js +1 -0
  89. package/dist/assets/Pagination-Q-vf4hsX.js +1 -0
  90. package/dist/assets/QueueActions-C-Aferd6.js +1 -0
  91. package/dist/assets/QueueControl-B8Q9NX6t.js +1 -0
  92. package/dist/assets/QueueDetail-BMhG0jUB.js +1 -0
  93. package/dist/assets/QueueDetailPro-BKTTyLR4.js +1 -0
  94. package/dist/assets/Queues-COqPMoY9.js +1 -0
  95. package/dist/assets/QueuesOverview-pWdrPy8Z.js +1 -0
  96. package/dist/assets/S3Backup-BmsZ8xtq.js +1 -0
  97. package/dist/assets/S3BackupPro-AtlsPUJL.js +3 -0
  98. package/dist/assets/ServerControl-D3XwHr72.js +3 -0
  99. package/dist/assets/Settings-BofKZoHn.js +1 -0
  100. package/dist/assets/StatusBadge-B0I7hNBX.js +1 -0
  101. package/dist/assets/Usage-DAR9E22v.js +1 -0
  102. package/dist/assets/UsagePro-Bc30N2Lo.js +1 -0
  103. package/dist/assets/Webhooks-Bg2k1PU4.js +1 -0
  104. package/dist/assets/Workers-BqUZMJVQ.js +1 -0
  105. package/dist/assets/WorkersPro-C9aSadUh.js +1 -0
  106. package/dist/assets/Workflows-BEFMfHmo.js +1 -0
  107. package/dist/assets/api-CQ5Z7xR7.js +1 -0
  108. package/dist/assets/bq-BeBxa743.js +1 -0
  109. package/dist/assets/cn-zFEt5SO8.js +1 -0
  110. package/dist/assets/connectionStore-DbBYqHEY.js +1 -0
  111. package/dist/assets/controlConnectionEpoch-CFSO6237.js +1 -0
  112. package/dist/assets/feedback-BnGE2oW2.js +1 -0
  113. package/dist/assets/{flowMutationSafety-DVD1IMr-.js → flowMutationSafety-CoOp94wS.js} +1 -1
  114. package/dist/assets/{form-DsUq0w8s.js → form-tC20r4Ma.js} +1 -1
  115. package/dist/assets/format-Cg7VCIZW.js +1 -0
  116. package/dist/assets/icons-CIvFfU2b.js +1 -0
  117. package/dist/assets/index-Cui9IwWy.js +8 -0
  118. package/dist/assets/index-DOze4TKg.css +1 -0
  119. package/dist/assets/install-Cd9Is10G.js +3 -0
  120. package/dist/assets/{react-vendor-Bjui4wS4.js → react-vendor-CyjgmGTq.js} +3 -3
  121. package/dist/assets/responses-kPO4rSu5.js +1 -0
  122. package/dist/assets/toastStore-CS_9Xndn.js +1 -0
  123. package/dist/assets/{upstreamPaths-nG8N1XAz.js → upstreamPaths-D_LPsv7b.js} +1 -1
  124. package/dist/assets/usePolledData-DzlUpBOO.js +1 -0
  125. package/dist/assets/{useServerActionGuard-CItj3hvh.js → useServerActionGuard-BlH-Kso1.js} +1 -1
  126. package/dist/assets/useThroughputSeries-C2pa1Wd_.js +1 -0
  127. package/dist/index.html +17 -6
  128. package/package.json +15 -3
  129. package/scripts/embedded.gen.ts +152 -124
  130. package/scripts/serve.ts +96 -484
  131. package/scripts/serveHandler.ts +217 -0
  132. package/scripts/servePolicy.ts +133 -0
  133. package/dist/assets/AddJob-Cih9F84t.js +0 -3
  134. package/dist/assets/Alerts-Dp4Vclez.js +0 -1
  135. package/dist/assets/Benchmark-X_gdZun5.js +0 -1
  136. package/dist/assets/BulkAddJobs-CXTpYMZf.js +0 -5
  137. package/dist/assets/Cron-DUHkse77.js +0 -1
  138. package/dist/assets/CronManager-BeYrLK2E.js +0 -1
  139. package/dist/assets/Database-CXPb3XGr.js +0 -3
  140. package/dist/assets/Diagnostics-DDtoiDYT.js +0 -1
  141. package/dist/assets/Dlq-CX_uRblW.js +0 -1
  142. package/dist/assets/DlqControl-CazFmSEd.js +0 -1
  143. package/dist/assets/DlqPro-HSffdtLJ.js +0 -1
  144. package/dist/assets/Flows-CGmyghKF.js +0 -1
  145. package/dist/assets/JobInspector-B9i1HnG5.js +0 -2
  146. package/dist/assets/Jobs-BZGtMnJI.js +0 -1
  147. package/dist/assets/JobsPro-tmEN8f-P.js +0 -1
  148. package/dist/assets/Logs-DxUD9V2A.js +0 -1
  149. package/dist/assets/LogsPro-BTQhvPKj.js +0 -1
  150. package/dist/assets/Metrics-CSPpBf1m.js +0 -1
  151. package/dist/assets/MetricsPro-DSn-3qNT.js +0 -1
  152. package/dist/assets/Overview-StvHYRZ5.js +0 -1
  153. package/dist/assets/Pagination-eotgGQf9.js +0 -1
  154. package/dist/assets/QueueActions-CibRLzrz.js +0 -1
  155. package/dist/assets/QueueControl-DwYNcMfI.js +0 -1
  156. package/dist/assets/QueueDetail-NMfKwj6E.js +0 -1
  157. package/dist/assets/QueueDetailPro-C8SBjX09.js +0 -1
  158. package/dist/assets/Queues-BeTAi8V8.js +0 -1
  159. package/dist/assets/QueuesOverview-BfQO8NS4.js +0 -1
  160. package/dist/assets/S3Backup-BYkAtONl.js +0 -1
  161. package/dist/assets/S3BackupPro-BP1Ch54R.js +0 -2
  162. package/dist/assets/ServerControl-C85jYgyN.js +0 -3
  163. package/dist/assets/Settings-DjP28c_5.js +0 -1
  164. package/dist/assets/Usage-DECeEsO2.js +0 -1
  165. package/dist/assets/UsagePro-CD7dgt3d.js +0 -1
  166. package/dist/assets/Webhooks-Ct-x8DD1.js +0 -1
  167. package/dist/assets/Workers-lP1BXHFq.js +0 -1
  168. package/dist/assets/WorkersPro-CAUqy3Cp.js +0 -1
  169. package/dist/assets/cn-CMFYpumL.js +0 -1
  170. package/dist/assets/connectionStore-Cd9-3Fw0.js +0 -1
  171. package/dist/assets/index-BWoUj86M.css +0 -1
  172. package/dist/assets/index-Dr__4DsT.js +0 -8
  173. package/dist/assets/install-DPCcfOYo.js +0 -3
  174. package/dist/assets/useThroughputSeries-Bpyg6Jaq.js +0 -1
package/README.md CHANGED
@@ -90,12 +90,14 @@ The full dashboard running on sample data, no server needed.
90
90
  bunqueue exposes a rich HTTP API, but operating it by hand (curl, ad-hoc scripts) is slow and
91
91
  error-prone. This dashboard is a production-oriented operator console: it exposes the verified,
92
92
  safe subset of the API, fails closed where
93
- [v2.8.55](https://github.com/egeominotti/bunqueue/releases/tag/v2.8.55) lacks atomic
93
+ [v2.8.57](https://github.com/egeominotti/bunqueue/releases/tag/v2.8.57) lacks atomic
94
94
  flow-safety guarantees, and also manages the
95
95
  server *process* through a separate guarded agent.
96
96
 
97
- It talks **only** to bunqueue's public HTTP API (`:6790`) and a small local **control agent**. It
98
- never imports or modifies bunqueue itself, so it tracks any bunqueue server you point it at.
97
+ It uses Bunqueue's public HTTP API (`:6790`) for ordinary remote observability and a small local
98
+ **control agent** for process, FlowProducer, Workflow Engine, database, and backup operations. The
99
+ agent is pinned to the server it manages and uses the exact installed Bunqueue 2.8.57 client
100
+ contracts; it never patches Bunqueue internals.
99
101
 
100
102
  ## Features
101
103
 
@@ -105,14 +107,17 @@ never imports or modifies bunqueue itself, so it tracks any bunqueue server you
105
107
  | **Server** | Control ▸ Server | **Start / stop / restart** the server process, edit its config, tail process logs |
106
108
  | **Enqueue** | Control ▸ Add Job | Add jobs (single or bulk) with every option |
107
109
  | **Inspect** | Control ▸ Job Inspector | Look up any job; promote / re-prioritize / delay; view data & result |
108
- | **Queues** | Control ▸ Queue Control | Pause / resume / promote, explicit desired-state limits, stall config & guarded DLQ policy |
110
+ | **Queues** | Control ▸ Queue Control | Pause/resume/promote; desired-state writes plus live SDK limits, deduplication, metrics and journal retention |
109
111
  | **Cron** | Control ▸ Cron Manager | Submit acknowledged cron/interval upserts and delete schedules |
112
+ | **Workflow Engine** | Workflow ▸ Overview / Executions / Waiting / Compensation / Archive | Start, recover, signal, inspect, compensate, archive and clean durable executions |
113
+ | **Job Flows** | Workflow ▸ Job Flows | Explore DAGs, run all FlowProducer create modes and operate safe Flow Job methods |
110
114
  | **DLQ** | Control ▸ DLQ | Inspect entries, failure history and CSV exports; retry and purge stay unavailable |
111
115
  | **Webhooks** | Control ▸ Webhooks | Create / enable / delete job-event webhooks |
112
116
  | **Ops** | Control ▸ Diagnostics | Health, ping, storage, memory, connections, totals |
117
+ | **S3 backup** | Management ▸ S3 Backup | Configure, inspect, list, create and guarded-restore official snapshots |
113
118
  | **Browse** | Queues / Jobs / DLQ / Cron / Metrics / Workers / Logs | Read-only browsing with basic actions |
114
119
 
115
- > Job actions are gated by the v2.8.55 flow contract. Every DLQ retry and completed-job requeue is
120
+ > Job actions are gated by the v2.8.57 flow contract. Every DLQ retry and completed-job requeue is
116
121
  > unavailable: the DLQ GET + POST sequence has no atomic generation/state/topology precondition and
117
122
  > can target a job recreated under the same ID, while `retryCompleted` does not rebuild dependency
118
123
  > registration or flow order. Cancel, Discard, Drain, Clean, Obliterate and DLQ Purge also fail
@@ -130,7 +135,7 @@ agent start one for you from the **Server** page).
130
135
  bunx bunqueue-dashboard
131
136
  ```
132
137
 
133
- One command, **zero dependencies** (a 543 kB download): serves the prebuilt dashboard on
138
+ One command serves the prebuilt dashboard on
134
139
  http://127.0.0.1:8080, proxies `/api/*` to your bunqueue server (`BUNQUEUE_URL`, default
135
140
  `http://localhost:6790`), and runs the control agent on `127.0.0.1:6800`. Same env knobs as the
136
141
  standalone binaries: `PORT` · `BIND_ADDR` · `BUNQUEUE_URL` · `AGENT_PORT` ·
@@ -271,14 +276,15 @@ Because the deployed build is a static shell, point it at a reachable bunqueue s
271
276
 
272
277
  ## Testing & quality gate
273
278
 
274
- Three commands must be green before a change is considered done, the same checks CI runs:
279
+ The canonical gate must be green before a change is considered done; CI runs the same command:
275
280
 
276
281
  ```bash
277
- bun run build # tsc --noEmit + vite build
278
- bun run check # Biome lint + format (production-grade config)
279
- bun test # unit + agent-lifecycle tests
282
+ bun run quality # architecture, lint/build, size, docs, coverage, real E2E, packed-bin smoke, audit
280
283
  ```
281
284
 
285
+ The E2E stage starts disposable Bunqueue 2.8.57 processes and exercises the complete FlowProducer,
286
+ Workflow Engine, and Queue SDK operator bridges. Run it alone with `bun run test:e2e`.
287
+
282
288
  CI enforces this on every push and pull request. See [Contributing](#contributing).
283
289
 
284
290
  ## Project structure
@@ -286,15 +292,16 @@ CI enforces this on every push and pull request. See [Contributing](#contributin
286
292
  ```
287
293
  bunqueue-dashboard/
288
294
  ├── .github/workflows/ # CI, Pages deploy, Docker publish, Release
289
- ├── agent/ # Bun control agent (process lifecycle), server.ts, manager.ts, index.ts
295
+ ├── agent/ # guarded process, Flow, Workflow, Queue, database and backup adapters
290
296
  ├── docker/ # Caddyfile for the container image
291
297
  ├── docs/ # source-verified reference (architecture, pages, API mapping, known issues)
292
298
  ├── scripts/dev.ts # one-command dev launcher (`bun start`)
293
299
  ├── src/
294
300
  │ ├── lib/ # api.ts, bq.ts, hooks, formatters, job-action gating
295
301
  │ ├── components/ # layout shell, UI kit, Zustand stores
302
+ │ ├── features/ # feature slices with domain, application, infrastructure and UI layers
296
303
  │ └── pages/ # view pages + Control ▸ * operator pages
297
- └── test/ # bun test (format, sse, manager, agent lifecycle, s3 store)
304
+ └── test/ # unit, contract, UI race, agent lifecycle and integration tests
298
305
  ```
299
306
 
300
307
  Full walkthrough in [`docs/README.md`](docs/README.md).
@@ -320,7 +327,7 @@ boundary applies only to `scripts/serve.ts`. Host/Origin checks are not user aut
320
327
 
321
328
  1. Keep it **additive**, prefer new files + minimal glue over rewriting existing ones
322
329
  (see [`CLAUDE.md`](CLAUDE.md)).
323
- 2. Make the [gate](#testing--quality-gate) green: `bun run build && bun run check && bun test`.
330
+ 2. Make the [gate](#testing--quality-gate) green: `bun run quality`.
324
331
  3. Open a PR, the template walks you through the checklist. CI must pass.
325
332
 
326
333
  ## License
@@ -0,0 +1,90 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import type { BackupOperation } from './runner';
3
+
4
+ const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
5
+
6
+ export async function executeBackupProcess(
7
+ environment: Record<string, string>,
8
+ operation: BackupOperation,
9
+ key: string | undefined,
10
+ signal: AbortSignal
11
+ ): Promise<unknown> {
12
+ signal.throwIfAborted();
13
+ const bun = Bun.which('bun');
14
+ if (!bun) throw new Error('The Bun executable is required to run Bunqueue backup commands');
15
+ const child = Bun.spawn(
16
+ [
17
+ bun,
18
+ bunqueueCliPath(),
19
+ '--json',
20
+ 'backup',
21
+ operation,
22
+ ...(operation === 'restore' ? [key as string, '--force'] : []),
23
+ ],
24
+ { env: environment, stdout: 'pipe', stderr: 'pipe' }
25
+ );
26
+ const abort = () => child.kill('SIGKILL');
27
+ signal.addEventListener('abort', abort, { once: true });
28
+ try {
29
+ let stdout: string;
30
+ let stderr: string;
31
+ let exitCode: number;
32
+ try {
33
+ [stdout, stderr, exitCode] = await Promise.all([
34
+ readBounded(child.stdout),
35
+ readBounded(child.stderr),
36
+ child.exited,
37
+ ]);
38
+ } catch (error) {
39
+ child.kill('SIGKILL');
40
+ await child.exited;
41
+ throw error;
42
+ }
43
+ if (signal.aborted) throw abortReason(signal);
44
+ const raw = stdout.trim() || stderr.trim();
45
+ try {
46
+ return JSON.parse(raw);
47
+ } catch {
48
+ throw new Error(
49
+ exitCode === 0
50
+ ? 'Bunqueue backup command returned malformed JSON'
51
+ : `Bunqueue backup command failed with exit code ${exitCode}`
52
+ );
53
+ }
54
+ } finally {
55
+ signal.removeEventListener('abort', abort);
56
+ }
57
+ }
58
+
59
+ function bunqueueCliPath(): string {
60
+ const main = import.meta.resolve('bunqueue');
61
+ return fileURLToPath(new URL('./cli/index.js', main));
62
+ }
63
+
64
+ async function readBounded(stream: ReadableStream<Uint8Array>): Promise<string> {
65
+ const reader = stream.getReader();
66
+ const chunks: Uint8Array[] = [];
67
+ let total = 0;
68
+ try {
69
+ for (;;) {
70
+ const next = await reader.read();
71
+ if (next.done) break;
72
+ total += next.value.byteLength;
73
+ if (total > MAX_OUTPUT_BYTES) throw new Error('Bunqueue backup command output exceeded 2 MiB');
74
+ chunks.push(next.value);
75
+ }
76
+ } finally {
77
+ reader.releaseLock();
78
+ }
79
+ const output = new Uint8Array(total);
80
+ let offset = 0;
81
+ for (const chunk of chunks) {
82
+ output.set(chunk, offset);
83
+ offset += chunk.byteLength;
84
+ }
85
+ return new TextDecoder().decode(output);
86
+ }
87
+
88
+ function abortReason(signal: AbortSignal): Error {
89
+ return signal.reason instanceof Error ? signal.reason : new Error('Backup operation cancelled');
90
+ }
@@ -0,0 +1,85 @@
1
+ import type { BackupCommandResult, BackupOperation } from './runner';
2
+
3
+ const RESULT_KEYS = new Set(['success', 'message', 'data']);
4
+ const STATUS_KEYS = new Set(['enabled', 'bucket', 'endpoint', 'interval', 'retention']);
5
+ const ITEM_KEYS = new Set(['key', 'size', 'date']);
6
+ const MAX_MESSAGE_LENGTH = 16 * 1024;
7
+ const MAX_LIST_ITEMS = 20_000;
8
+ const MAX_LIST_TEXT = 2 * 1024 * 1024;
9
+
10
+ export function validateBackupResult(
11
+ value: unknown,
12
+ operation: BackupOperation
13
+ ): BackupCommandResult {
14
+ const result = exactRecord(value, RESULT_KEYS, 'backup command result');
15
+ if (typeof result.success !== 'boolean') invalid('backup command result success');
16
+ const message = text(result.message, 'backup command result message', MAX_MESSAGE_LENGTH);
17
+ if (!result.success) throw new Error(message);
18
+ const data = operationData(operation, result.data);
19
+ return data === undefined ? { success: true, message } : { success: true, message, data };
20
+ }
21
+
22
+ function operationData(operation: BackupOperation, value: unknown): unknown {
23
+ if (operation === 'status') return statusData(value);
24
+ if (operation === 'list') return listData(value);
25
+ return value;
26
+ }
27
+
28
+ function statusData(value: unknown): Record<string, string | boolean> {
29
+ const status = exactRecord(value, STATUS_KEYS, 'backup status data');
30
+ if (typeof status.enabled !== 'boolean') invalid('backup status enabled');
31
+ return {
32
+ enabled: status.enabled,
33
+ bucket: text(status.bucket, 'backup status bucket', 1_024),
34
+ endpoint: text(status.endpoint, 'backup status endpoint', 4_096),
35
+ interval: text(status.interval, 'backup status interval', 128),
36
+ retention: text(status.retention, 'backup status retention', 128),
37
+ };
38
+ }
39
+
40
+ function listData(value: unknown): Array<Record<string, string>> {
41
+ if (!Array.isArray(value) || value.length > MAX_LIST_ITEMS) invalid('backup list data');
42
+ let textSize = 0;
43
+ const keys = new Set<string>();
44
+ return value.map((entry, index) => {
45
+ const item = exactRecord(entry, ITEM_KEYS, `backup list item ${index}`);
46
+ const key = text(item.key, `backup list item ${index} key`, 2_048);
47
+ const size = text(item.size, `backup list item ${index} size`, 128);
48
+ const date = text(item.date, `backup list item ${index} date`, 128);
49
+ textSize += key.length + size.length + date.length;
50
+ if (textSize > MAX_LIST_TEXT || keys.has(key) || !validIsoDate(date)) {
51
+ invalid(`backup list item ${index}`);
52
+ }
53
+ keys.add(key);
54
+ return { key, size, date };
55
+ });
56
+ }
57
+
58
+ function exactRecord(
59
+ value: unknown,
60
+ allowed: ReadonlySet<string>,
61
+ label: string
62
+ ): Record<string, unknown> {
63
+ if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(label);
64
+ const result = value as Record<string, unknown>;
65
+ if (Object.keys(result).some((key) => !allowed.has(key))) invalid(label);
66
+ return result;
67
+ }
68
+
69
+ function text(value: unknown, label: string, maxLength: number): string {
70
+ if (typeof value !== 'string' || value.length < 1 || value.length > maxLength) invalid(label);
71
+ return value;
72
+ }
73
+
74
+ function validIsoDate(value: string): boolean {
75
+ const timestamp = Date.parse(value);
76
+ return (
77
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) &&
78
+ Number.isFinite(timestamp) &&
79
+ new Date(timestamp).toISOString() === value
80
+ );
81
+ }
82
+
83
+ function invalid(label: string): never {
84
+ throw new Error(`Bunqueue ${label} has an invalid 2.8.57 contract`);
85
+ }
@@ -0,0 +1,160 @@
1
+ import { assertManagedTarget } from '../managedTarget';
2
+ import type { DbStats, ServerConfig } from '../manager';
3
+ import { readLimitedJsonBody } from '../server/jsonBody';
4
+ import type { BackupRunnerPort } from './runner';
5
+
6
+ const MAX_BACKUP_BODY_BYTES = 64 * 1024;
7
+
8
+ export interface BackupRouteResponse {
9
+ status: number;
10
+ body: Record<string, unknown>;
11
+ }
12
+
13
+ export async function routeBackupRequest(
14
+ request: Request,
15
+ pathname: string,
16
+ method: string,
17
+ config: ServerConfig,
18
+ serverRunning: boolean,
19
+ database: DbStats,
20
+ runner: BackupRunnerPort,
21
+ configure?: (extraEnv: Record<string, string>) => void
22
+ ): Promise<BackupRouteResponse | null> {
23
+ const operation = routeOperation(pathname, method);
24
+ if (!operation) return null;
25
+ const query = exactTargetQuery(request.url);
26
+ assertManagedTarget(query, config);
27
+ if (operation === 'configure') {
28
+ if (!configure) throw new Error('Backup configuration is unavailable');
29
+ const body = await boundedRecord(request, ['environment']);
30
+ const next = mergeBackupEnvironment(config.extraEnv, body.environment);
31
+ configure(next);
32
+ return success({ configured: true, enabled: next.S3_BACKUP_ENABLED === 'true' });
33
+ }
34
+ if (operation === 'restore') {
35
+ if (serverRunning) throw new Error('Stop the managed Bunqueue server before restoring a backup');
36
+ const body = await boundedRecord(request, ['key', 'database']);
37
+ const key = backupKey(body.key);
38
+ assertDatabaseSnapshot(body.database, database);
39
+ return success(await runner.execute(config, operation, key));
40
+ }
41
+ return success(await runner.execute(config, operation));
42
+ }
43
+
44
+ function routeOperation(pathname: string, method: string) {
45
+ if (method === 'GET' && pathname === '/backup/status') return 'status' as const;
46
+ if (method === 'GET' && pathname === '/backup/list') return 'list' as const;
47
+ if (method === 'POST' && pathname === '/backup/now') return 'now' as const;
48
+ if (method === 'POST' && pathname === '/backup/restore') return 'restore' as const;
49
+ if (method === 'POST' && pathname === '/backup/configure') return 'configure' as const;
50
+ return null;
51
+ }
52
+
53
+ const BACKUP_ENV_KEYS = new Set([
54
+ 'S3_BACKUP_ENABLED',
55
+ 'S3_ACCESS_KEY_ID',
56
+ 'S3_SECRET_ACCESS_KEY',
57
+ 'S3_SESSION_TOKEN',
58
+ 'S3_BUCKET',
59
+ 'S3_ENDPOINT',
60
+ 'S3_VIRTUAL_HOSTED_STYLE',
61
+ 'S3_REGION',
62
+ 'S3_BACKUP_INTERVAL',
63
+ 'S3_BACKUP_RETENTION',
64
+ 'S3_BACKUP_PREFIX',
65
+ ]);
66
+
67
+ function mergeBackupEnvironment(
68
+ current: Record<string, string>,
69
+ value: unknown
70
+ ): Record<string, string> {
71
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
72
+ throw new Error('Backup environment must be an object');
73
+ }
74
+ const entries = Object.entries(value as Record<string, unknown>);
75
+ for (const [key, entry] of entries) {
76
+ if (!BACKUP_ENV_KEYS.has(key)) throw new Error(`Unknown backup environment key: ${key}`);
77
+ if (typeof entry !== 'string' || entry.length > 4_096) {
78
+ throw new Error(`Backup environment ${key} must be a string of at most 4096 characters`);
79
+ }
80
+ }
81
+ const enabled = (value as Record<string, unknown>).S3_BACKUP_ENABLED;
82
+ if (enabled !== 'true' && enabled !== 'false') {
83
+ throw new Error('S3_BACKUP_ENABLED must be "true" or "false"');
84
+ }
85
+ const retained = Object.fromEntries(
86
+ Object.entries(current).filter(([key]) => !BACKUP_ENV_KEYS.has(key))
87
+ );
88
+ return { ...retained, ...(Object.fromEntries(entries) as Record<string, string>) };
89
+ }
90
+
91
+ function exactTargetQuery(url: string): URLSearchParams {
92
+ const query = new URL(url).searchParams;
93
+ for (const key of query.keys()) {
94
+ if (key !== 'target') throw new Error(`Unknown backup option: ${key}`);
95
+ if (query.getAll(key).length !== 1) throw new Error(`Duplicate backup option: ${key}`);
96
+ }
97
+ return query;
98
+ }
99
+
100
+ function success(result: unknown): BackupRouteResponse {
101
+ return { status: 200, body: { ok: true, result } };
102
+ }
103
+
104
+ function record(value: unknown, allowed: string[]): Record<string, unknown> {
105
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
106
+ throw new Error('Backup restore body must be an object');
107
+ }
108
+ const body = value as Record<string, unknown>;
109
+ const unknown = Object.keys(body).find((key) => !allowed.includes(key));
110
+ if (unknown) throw new Error(`Unknown backup body option: ${unknown}`);
111
+ return body;
112
+ }
113
+
114
+ async function boundedRecord(
115
+ request: Request,
116
+ allowed: string[]
117
+ ): Promise<Record<string, unknown>> {
118
+ const value = await readLimitedJsonBody(request, {
119
+ scope: 'Backup',
120
+ maxBytes: MAX_BACKUP_BODY_BYTES,
121
+ limitLabel: '64 KiB',
122
+ missingMessage: 'Backup request body must be valid JSON',
123
+ invalidUtf8Message: 'Backup request body must be valid JSON',
124
+ invalidJsonMessage: 'Backup request body must be valid JSON',
125
+ });
126
+ return record(value, allowed);
127
+ }
128
+
129
+ function backupKey(value: unknown): string {
130
+ if (
131
+ typeof value !== 'string' ||
132
+ value.length < 1 ||
133
+ value.length > 2_048 ||
134
+ /[\u0000-\u001f\u007f]/.test(value)
135
+ ) {
136
+ throw new Error('Backup key must contain 1–2048 printable characters');
137
+ }
138
+ return value;
139
+ }
140
+
141
+ function assertDatabaseSnapshot(value: unknown, current: DbStats): void {
142
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
143
+ throw new Error('A database snapshot is required to authorize restore');
144
+ }
145
+ const expected = value as Record<string, unknown>;
146
+ const allowed = ['path', 'exists', 'size', 'walSize', 'shmSize', 'totalSize', 'mtimeMs'];
147
+ const unknown = Object.keys(expected).find((key) => !allowed.includes(key));
148
+ if (unknown) throw new Error(`Unknown database snapshot option: ${unknown}`);
149
+ if (
150
+ expected.path !== current.path ||
151
+ expected.exists !== current.exists ||
152
+ expected.size !== current.size ||
153
+ expected.walSize !== current.walSize ||
154
+ expected.shmSize !== current.shmSize ||
155
+ expected.totalSize !== current.totalSize ||
156
+ expected.mtimeMs !== current.mtimeMs
157
+ ) {
158
+ throw new Error('Database changed after restore confirmation; reload status and confirm again');
159
+ }
160
+ }
@@ -0,0 +1,91 @@
1
+ import type { ServerConfig } from '../manager';
2
+ import { validateBackupResult } from './resultValidation';
3
+ import { executeBackupOperation } from './workerFactory';
4
+
5
+ export type BackupOperation = 'status' | 'list' | 'now' | 'restore';
6
+
7
+ export interface BackupCommandResult {
8
+ success: boolean;
9
+ message: string;
10
+ data?: unknown;
11
+ }
12
+
13
+ export interface BackupRunnerPort {
14
+ execute(config: ServerConfig, operation: BackupOperation, key?: string): Promise<BackupCommandResult>;
15
+ close(): Promise<void>;
16
+ }
17
+
18
+ export type BackupOperationExecutor = (
19
+ environment: Record<string, string>,
20
+ operation: BackupOperation,
21
+ key: string | undefined,
22
+ signal: AbortSignal
23
+ ) => Promise<unknown>;
24
+
25
+ const COMMAND_TIMEOUT_MS = 120_000;
26
+
27
+ /** Owns exactly one cancellable local backup operation at a time. */
28
+ export class BunqueueBackupRunner implements BackupRunnerPort {
29
+ private active: Promise<BackupCommandResult> | null = null;
30
+ private controller: AbortController | null = null;
31
+ private closed = false;
32
+ private closing: Promise<void> | null = null;
33
+
34
+ constructor(
35
+ private readonly timeoutMs = COMMAND_TIMEOUT_MS,
36
+ private readonly executor: BackupOperationExecutor = executeBackupOperation
37
+ ) {}
38
+
39
+ execute(
40
+ config: ServerConfig,
41
+ operation: BackupOperation,
42
+ key?: string
43
+ ): Promise<BackupCommandResult> {
44
+ if (this.closed) return Promise.reject(new Error('Backup runner is closed'));
45
+ if (this.active) return Promise.reject(new Error('Another backup operation is already running'));
46
+ if (operation === 'restore' && !key) {
47
+ return Promise.reject(new Error('Backup key is required for restore'));
48
+ }
49
+
50
+ const controller = new AbortController();
51
+ this.controller = controller;
52
+ const timeout = setTimeout(() => {
53
+ controller.abort(new Error(`Bunqueue backup command timed out after ${this.timeoutMs} ms`));
54
+ }, this.timeoutMs);
55
+ const pending = this.executor(
56
+ backupEnvironment(config),
57
+ operation,
58
+ key,
59
+ controller.signal
60
+ ).then((value) => validateBackupResult(value, operation));
61
+ const active = pending.finally(() => {
62
+ clearTimeout(timeout);
63
+ if (this.active === active) this.active = null;
64
+ if (this.controller === controller) this.controller = null;
65
+ });
66
+ this.active = active;
67
+ return active;
68
+ }
69
+
70
+ close(): Promise<void> {
71
+ if (this.closing) return this.closing;
72
+ this.closed = true;
73
+ this.controller?.abort(
74
+ new Error('Bunqueue backup command cancelled because the control agent is closing')
75
+ );
76
+ const active = this.active;
77
+ this.closing = (async () => {
78
+ await active?.catch(() => undefined);
79
+ })();
80
+ return this.closing;
81
+ }
82
+ }
83
+
84
+ function backupEnvironment(config: ServerConfig): Record<string, string> {
85
+ return {
86
+ ...(process.env as Record<string, string>),
87
+ ...config.extraEnv,
88
+ BUNQUEUE_DATA_PATH: config.dataPath,
89
+ NO_COLOR: '1',
90
+ };
91
+ }
@@ -0,0 +1,23 @@
1
+ import { executeBackupCommand } from '../../node_modules/bunqueue/dist/cli/commands/backup.js';
2
+ import type { BackupWorkerReply, BackupWorkerRequest } from './workerProtocol';
3
+
4
+ declare const self: Worker;
5
+
6
+ self.onmessage = async ({ data }: MessageEvent<BackupWorkerRequest>) => {
7
+ let reply: BackupWorkerReply;
8
+ try {
9
+ const args = [
10
+ data.operation,
11
+ ...(data.operation === 'restore' ? [requiredKey(data.key), '--force'] : []),
12
+ ];
13
+ reply = { ok: true, result: await executeBackupCommand(args) };
14
+ } catch (error) {
15
+ reply = { ok: false, error: error instanceof Error ? error.message : String(error) };
16
+ }
17
+ self.postMessage(reply);
18
+ };
19
+
20
+ function requiredKey(value: string | undefined): string {
21
+ if (!value) throw new Error('Backup key is required for restore');
22
+ return value;
23
+ }
@@ -0,0 +1,40 @@
1
+ import type { BackupOperation } from './runner';
2
+ import type { BackupWorkerReply, BackupWorkerRequest } from './workerProtocol';
3
+
4
+ export function executeBackupWorker(
5
+ workerUrl: string,
6
+ environment: Record<string, string>,
7
+ operation: BackupOperation,
8
+ key: string | undefined,
9
+ signal: AbortSignal
10
+ ): Promise<unknown> {
11
+ signal.throwIfAborted();
12
+ return new Promise((resolve, reject) => {
13
+ const worker = new Worker(workerUrl, { type: 'module', env: environment });
14
+ let settled = false;
15
+ const finish = (operation: () => void) => {
16
+ if (settled) return;
17
+ settled = true;
18
+ signal.removeEventListener('abort', abort);
19
+ worker.terminate();
20
+ operation();
21
+ };
22
+ const abort = () =>
23
+ finish(() =>
24
+ reject(
25
+ signal.reason instanceof Error ? signal.reason : new Error('Backup operation cancelled')
26
+ )
27
+ );
28
+ signal.addEventListener('abort', abort, { once: true });
29
+ worker.onmessage = ({ data }: MessageEvent<BackupWorkerReply>) => {
30
+ if (data.ok) finish(() => resolve(data.result));
31
+ else finish(() => reject(new Error(data.error)));
32
+ };
33
+ worker.onerror = (event) => {
34
+ event.preventDefault();
35
+ finish(() => reject(new Error(event.message || 'Embedded backup worker failed')));
36
+ };
37
+ const request: BackupWorkerRequest = { operation, key };
38
+ worker.postMessage(request);
39
+ });
40
+ }
@@ -0,0 +1,21 @@
1
+ import type { BackupOperationExecutor } from './runner';
2
+ import { executeBackupProcess } from './processExecutor';
3
+ import { executeBackupWorker } from './workerExecutor';
4
+
5
+ let embeddedWorkerUrl: string | null = null;
6
+
7
+ export const executeBackupOperation: BackupOperationExecutor = (
8
+ environment,
9
+ operation,
10
+ key,
11
+ signal
12
+ ) => {
13
+ if (embeddedWorkerUrl) {
14
+ return executeBackupWorker(embeddedWorkerUrl, environment, operation, key, signal);
15
+ }
16
+ return executeBackupProcess(environment, operation, key, signal);
17
+ };
18
+
19
+ export function setBackupWorkerUrl(url: string | null): void {
20
+ embeddedWorkerUrl = url;
21
+ }
@@ -0,0 +1,10 @@
1
+ import type { BackupCommandResult, BackupOperation } from './runner';
2
+
3
+ export interface BackupWorkerRequest {
4
+ operation: BackupOperation;
5
+ key?: string;
6
+ }
7
+
8
+ export type BackupWorkerReply =
9
+ | { ok: true; result: BackupCommandResult }
10
+ | { ok: false; error: string };