bunqueue-dashboard 0.0.38 → 0.0.40
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 +17 -9
- package/agent/backup/resultValidation.ts +1 -1
- package/agent/backup/routes.ts +63 -17
- package/agent/db/query.ts +2 -1
- package/agent/index.ts +3 -0
- package/agent/manager/config.ts +2 -1
- package/agent/manager/process.ts +51 -5
- package/agent/manager/storageMode.ts +41 -0
- package/agent/manager/types.ts +2 -0
- package/agent/manager.ts +1 -0
- package/agent/queue/routes.ts +56 -2
- package/agent/queue/runtime.ts +67 -1
- package/agent/queue/types.ts +32 -0
- package/agent/queue/validation.ts +16 -0
- package/agent/server/controlRoutes.ts +55 -45
- package/agent/server/controlStatus.ts +115 -0
- package/agent/server/databaseRoutes.ts +47 -14
- package/agent/server/errors.ts +1 -0
- package/agent/server/lifecycle.ts +25 -8
- package/agent/server/managedRuntime.ts +27 -0
- package/agent/server/router.ts +90 -40
- package/agent/server/workflowReadRoutes.ts +28 -11
- package/agent/workflows.ts +1 -1
- package/dist/.vite/manifest.json +490 -476
- package/dist/assets/AddJob-DkbI_J5p.js +3 -0
- package/dist/assets/{Alerts-C2bAi-Kj.js → Alerts-CFj-u0iV.js} +1 -1
- package/dist/assets/{AreaChart-w0ruozXH.js → AreaChart-DEAalJ6P.js} +1 -1
- package/dist/assets/Benchmark-Ey2sq57q.js +1 -0
- package/dist/assets/{BulkAddJobs-BBWqQpP7.js → BulkAddJobs-BwPoeSB6.js} +1 -1
- package/dist/assets/{Button-BPqpRZJD.js → Button-Ch1W94fJ.js} +1 -1
- package/dist/assets/{Card-C7NMhqUt.js → Card-CWxrC8OY.js} +1 -1
- package/dist/assets/{CopilotPanel-Bhh-uCpN.js → CopilotPanel-CniuIIPn.js} +2 -2
- package/dist/assets/{Cron-pJJ5KXFG.js → Cron-B5XFBvU-.js} +1 -1
- package/dist/assets/CronManager-Bdg2BECM.js +1 -0
- package/dist/assets/{Database-DxiXsUl4.js → Database-CB0oOEFB.js} +1 -1
- package/dist/assets/Diagnostics-BDRftXQy.js +1 -0
- package/dist/assets/{Dlq-Bx4YXTp5.js → Dlq-CJO9itzJ.js} +1 -1
- package/dist/assets/{DlqControl-DJGhuyu3.js → DlqControl-DZL2lHOU.js} +1 -1
- package/dist/assets/{DlqPro-BhXIKtrt.js → DlqPro-OqBlzCnN.js} +1 -1
- package/dist/assets/Flows-BItJzbVQ.js +2 -0
- package/dist/assets/JobInspector-CJHX63p_.js +2 -0
- package/dist/assets/{Jobs-Blavwd1H.js → Jobs-BwmTsjJx.js} +1 -1
- package/dist/assets/{JobsPro-D8dH9um7.js → JobsPro-eFVAZJ10.js} +1 -1
- package/dist/assets/{Logs-B3dsGSF8.js → Logs-CXIf58M4.js} +1 -1
- package/dist/assets/{LogsPro-BCB7etG2.js → LogsPro-Xm6rylAd.js} +1 -1
- package/dist/assets/{McpServer-Cz1-GwD5.js → McpServer-Rf3i6lRn.js} +2 -2
- package/dist/assets/{Metrics-Bkuy6Ntb.js → Metrics-Cj3VYxzL.js} +1 -1
- package/dist/assets/{MetricsPro-BbJmy0oO.js → MetricsPro-C63eY00A.js} +1 -1
- package/dist/assets/{Overview-Ba5odzYe.js → Overview-CUM3XG7A.js} +1 -1
- package/dist/assets/{PageHeader-BXlZSBcc.js → PageHeader-CvIyaFOO.js} +1 -1
- package/dist/assets/{Pagination-Q-vf4hsX.js → Pagination-x-Cwgrul.js} +1 -1
- package/dist/assets/QueueActions-b19K0vCT.js +1 -0
- package/dist/assets/QueueControl-DusMoa05.js +1 -0
- package/dist/assets/QueueDetail-CpFIG1PU.js +1 -0
- package/dist/assets/QueueDetailPro-BNlBOj5s.js +1 -0
- package/dist/assets/{Queues-CGPgEkVm.js → Queues-CZ2qc7FY.js} +1 -1
- package/dist/assets/{QueuesOverview-ChC5Dtqh.js → QueuesOverview-CBFqQ3N_.js} +1 -1
- package/dist/assets/{S3Backup-5OKa8G13.js → S3Backup-x4mOV-AO.js} +1 -1
- package/dist/assets/S3BackupPro-BtvbPVg-.js +3 -0
- package/dist/assets/ServerControl-C5nQ7CCp.js +3 -0
- package/dist/assets/{Settings-C1HtCK6h.js → Settings-B5kCRSBH.js} +1 -1
- package/dist/assets/{StatusBadge-B0I7hNBX.js → StatusBadge-BoOVTnKu.js} +1 -1
- package/dist/assets/{Usage-QyZkUhgs.js → Usage-UYovs3D-.js} +1 -1
- package/dist/assets/{UsagePro-DMxrXxur.js → UsagePro-bT82DS-d.js} +1 -1
- package/dist/assets/{Webhooks-D0JQQf0q.js → Webhooks-BjsUMJAn.js} +1 -1
- package/dist/assets/{Workers-Cn8Bkgy1.js → Workers-CD4YZpca.js} +1 -1
- package/dist/assets/WorkersPro-hCPhfb1Z.js +1 -0
- package/dist/assets/Workflows-DOpeo-TP.js +1 -0
- package/dist/assets/{api-V_sVpHDo.js → api-BxYPEynh.js} +1 -1
- package/dist/assets/bq-C7wdABtE.js +1 -0
- package/dist/assets/{connectionStore-CKFQrxDo.js → connectionStore-CFuNYZ_8.js} +1 -1
- package/dist/assets/{controlConnectionEpoch-JM4omZ6j.js → controlConnectionEpoch-DzbuWOhe.js} +1 -1
- package/dist/assets/{feedback-BnGE2oW2.js → feedback-QDN1AsYt.js} +1 -1
- package/dist/assets/flowMutationSafety-DSd3JLAV.js +1 -0
- package/dist/assets/{form-tC20r4Ma.js → form-C4oby9pI.js} +1 -1
- package/dist/assets/{icons-CIvFfU2b.js → icons-BVzwsfbG.js} +1 -1
- package/dist/assets/{index-Cue53Myw.js → index-AUHH2nTG.js} +3 -3
- package/dist/assets/index-BK7fjWKK.css +1 -0
- package/dist/assets/install-C5y2ohOo.js +3 -0
- package/dist/assets/{react-vendor-CyjgmGTq.js → react-vendor-CvwrP0tU.js} +2 -2
- package/dist/assets/{responses-CBLyy1Ob.js → responses-DiIfPXXq.js} +1 -1
- package/dist/assets/{toastStore-B7n0zS0y.js → toastStore-B31o6Z5J.js} +1 -1
- package/dist/assets/{upstreamPaths-CDVsu6IM.js → upstreamPaths-B_cyinGI.js} +1 -1
- package/dist/assets/useControlActionGuard-DT7uudrj.js +1 -0
- package/dist/assets/{usePolledData-g80Z_UB-.js → usePolledData-CNtNtd0a.js} +1 -1
- package/dist/assets/useServerActionGuard-Db7PPgaI.js +1 -0
- package/dist/assets/{useThroughputSeries-CrsZYKbT.js → useThroughputSeries-y7Yyi7Zh.js} +1 -1
- package/dist/index.html +21 -18
- package/package.json +15 -7
- package/scripts/bunVersion.ts +10 -0
- package/scripts/embedded.gen.ts +152 -150
- package/scripts/serve.ts +3 -0
- package/scripts/serveHandler.ts +22 -1
- package/scripts/servePolicy.ts +12 -3
- package/dist/assets/AddJob-CC4bfHLH.js +0 -3
- package/dist/assets/Benchmark-ClJmYZoC.js +0 -1
- package/dist/assets/CronManager-lNZ4lHh1.js +0 -1
- package/dist/assets/Diagnostics-DLvCT7zI.js +0 -1
- package/dist/assets/Flows-Bb9j-YhZ.js +0 -2
- package/dist/assets/JobInspector-DfqBq4O5.js +0 -2
- package/dist/assets/QueueActions-B6M0ByGt.js +0 -1
- package/dist/assets/QueueControl-CWHsVfWU.js +0 -1
- package/dist/assets/QueueDetail-BClRbWC5.js +0 -1
- package/dist/assets/QueueDetailPro-CIimG0pX.js +0 -1
- package/dist/assets/S3BackupPro-BkwTXdtw.js +0 -3
- package/dist/assets/ServerControl-DJHKMaf9.js +0 -3
- package/dist/assets/WorkersPro-BOTuX9EP.js +0 -1
- package/dist/assets/Workflows-DqTKWPmT.js +0 -1
- package/dist/assets/bq-Bmysbg0r.js +0 -1
- package/dist/assets/flowMutationSafety-DObp9nJo.js +0 -1
- package/dist/assets/index-DOze4TKg.css +0 -1
- package/dist/assets/install-CFiYX86d.js +0 -3
- package/dist/assets/useServerActionGuard-OTv6_fU0.js +0 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ The full dashboard running on sample data, no server needed.
|
|
|
26
26
|
|
|
27
27
|

|
|
28
28
|

|
|
29
|
-

|
|
30
30
|

|
|
31
31
|

|
|
32
32
|
|
|
@@ -90,13 +90,13 @@ 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
|
-
[
|
|
93
|
+
[Bunqueue 2.9.x](https://github.com/egeominotti/bunqueue/releases/tag/v2.9.2) lacks atomic
|
|
94
94
|
flow-safety guarantees, and also manages the
|
|
95
95
|
server *process* through a separate guarded agent.
|
|
96
96
|
|
|
97
97
|
It uses Bunqueue's public HTTP API (`:6790`) for ordinary remote observability and a small local
|
|
98
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
|
|
99
|
+
agent is pinned to the server it manages and uses the exact installable Bunqueue 2.9.2 npm client
|
|
100
100
|
contracts; it never patches Bunqueue internals.
|
|
101
101
|
|
|
102
102
|
## Features
|
|
@@ -111,22 +111,24 @@ contracts; it never patches Bunqueue internals.
|
|
|
111
111
|
| **Cron** | Control ▸ Cron Manager | Submit acknowledged cron/interval upserts and delete schedules |
|
|
112
112
|
| **Workflow Engine** | Workflow ▸ Overview / Executions / Waiting / Compensation / Archive | Start, recover, signal, inspect, compensate, archive and clean durable executions |
|
|
113
113
|
| **Job Flows** | Workflow ▸ Job Flows | Explore DAGs, run all FlowProducer create modes and operate safe Flow Job methods |
|
|
114
|
-
| **DLQ** | Control ▸ DLQ | Inspect
|
|
114
|
+
| **DLQ** | Control ▸ DLQ | Inspect/export failures; removal, retry and purge stay unavailable without atomic generation/topology preconditions |
|
|
115
115
|
| **Webhooks** | Control ▸ Webhooks | Create / enable / delete job-event webhooks |
|
|
116
116
|
| **Ops** | Control ▸ Diagnostics | Health, ping, storage, memory, connections, totals |
|
|
117
117
|
| **S3 backup** | Management ▸ S3 Backup | Configure, inspect, list, create and guarded-restore official snapshots |
|
|
118
118
|
| **Browse** | Queues / Jobs / DLQ / Cron / Metrics / Workers / Logs | Read-only browsing with basic actions |
|
|
119
119
|
|
|
120
|
-
> Job actions are gated by the v2.
|
|
120
|
+
> Job actions are gated by the v2.9.2 flow contract. Every DLQ retry and completed-job requeue is
|
|
121
121
|
> unavailable: the DLQ GET + POST sequence has no atomic generation/state/topology precondition and
|
|
122
122
|
> can target a job recreated under the same ID, while `retryCompleted` does not rebuild dependency
|
|
123
123
|
> registration or flow order. Cancel, Discard, Drain, Clean, Obliterate and DLQ Purge also fail
|
|
124
|
-
> closed.
|
|
124
|
+
> closed. Bunqueue 2.9's `Queue.removeDlqJob()` also accepts only queue + job ID, so custom-ID reuse
|
|
125
|
+
> prevents the dashboard from proving that a later deletion still targets the observed generation.
|
|
126
|
+
> DLQ `maxAge`/`maxEntries` are read-only, auto-retry can only be disabled, and Copilot's only
|
|
125
127
|
> mutations are Promote, Pause and Resume.
|
|
126
128
|
|
|
127
129
|
## Quick start
|
|
128
130
|
|
|
129
|
-
**Prerequisites:** [Bun](https://bun.sh)
|
|
131
|
+
**Prerequisites:** [Bun](https://bun.sh) 1.4.0 and a reachable bunqueue server (or let the control
|
|
130
132
|
agent start one for you from the **Server** page).
|
|
131
133
|
|
|
132
134
|
### Run from npm (no clone)
|
|
@@ -145,7 +147,7 @@ standalone binaries: `PORT` · `BIND_ADDR` · `BUNQUEUE_URL` · `AGENT_PORT` ·
|
|
|
145
147
|
Install it permanently instead of running via `bunx`:
|
|
146
148
|
|
|
147
149
|
```bash
|
|
148
|
-
bun add -g bunqueue-dashboard
|
|
150
|
+
bun add -g bunqueue-dashboard
|
|
149
151
|
bunqueue-dashboard
|
|
150
152
|
```
|
|
151
153
|
|
|
@@ -297,9 +299,15 @@ The canonical gate must be green before a change is considered done; CI runs the
|
|
|
297
299
|
bun run quality # architecture, lint/build, size, docs, coverage, real E2E, packed-bin smoke, audit
|
|
298
300
|
```
|
|
299
301
|
|
|
300
|
-
The E2E stage starts disposable Bunqueue 2.
|
|
302
|
+
The E2E stage starts disposable Bunqueue 2.9.2 processes and exercises the complete FlowProducer,
|
|
301
303
|
Workflow Engine, and Queue SDK operator bridges. Run it alone with `bun run test:e2e`.
|
|
302
304
|
|
|
305
|
+
The blocking browser job builds the production bundle under `/e2e/dashboard`, starts an
|
|
306
|
+
authenticated disposable Bunqueue server, and drives the UI with Playwright on Chromium, Firefox,
|
|
307
|
+
and WebKit. It covers the token gate, every sidebar route, SSE recovery across a real server
|
|
308
|
+
restart, confirmed Cron creation/deletion, and automated WCAG A/AA checks. Install the browser
|
|
309
|
+
binaries once with `bun run test:e2e:browser:install`, then run `bun run test:e2e:browser`.
|
|
310
|
+
|
|
303
311
|
CI enforces this on every push and pull request. See [Contributing](#contributing).
|
|
304
312
|
|
|
305
313
|
## Project structure
|
package/agent/backup/routes.ts
CHANGED
|
@@ -10,36 +10,82 @@ export interface BackupRouteResponse {
|
|
|
10
10
|
body: Record<string, unknown>;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export
|
|
13
|
+
export type PreparedBackupRequest =
|
|
14
|
+
| { operation: 'status' | 'list' | 'now'; query: URLSearchParams }
|
|
15
|
+
| { operation: 'configure'; query: URLSearchParams; environment: unknown }
|
|
16
|
+
| { operation: 'restore'; query: URLSearchParams; key: string; database: unknown };
|
|
17
|
+
|
|
18
|
+
/** Read and validate the bounded HTTP payload before acquiring a lifecycle lease. */
|
|
19
|
+
export async function prepareBackupRequest(
|
|
14
20
|
request: Request,
|
|
15
21
|
pathname: string,
|
|
16
22
|
method: string,
|
|
17
|
-
config
|
|
18
|
-
serverRunning: boolean,
|
|
19
|
-
database: DbStats,
|
|
20
|
-
runner: BackupRunnerPort,
|
|
21
|
-
configure?: (extraEnv: Record<string, string>) => void,
|
|
23
|
+
config?: ServerConfig,
|
|
22
24
|
targetPolicy?: ManagedTargetPolicy
|
|
23
|
-
): Promise<
|
|
25
|
+
): Promise<PreparedBackupRequest | null> {
|
|
24
26
|
const operation = routeOperation(pathname, method);
|
|
25
27
|
if (!operation) return null;
|
|
26
28
|
const query = exactTargetQuery(request.url);
|
|
27
|
-
assertManagedTarget(query, config, targetPolicy);
|
|
29
|
+
if (config) assertManagedTarget(query, config, targetPolicy);
|
|
28
30
|
if (operation === 'configure') {
|
|
29
|
-
if (!configure) throw new Error('Backup configuration is unavailable');
|
|
30
31
|
const body = await boundedRecord(request, ['environment']);
|
|
31
|
-
|
|
32
|
-
configure(next);
|
|
33
|
-
return success({ configured: true, enabled: next.S3_BACKUP_ENABLED === 'true' });
|
|
32
|
+
return { operation, query, environment: body.environment };
|
|
34
33
|
}
|
|
35
34
|
if (operation === 'restore') {
|
|
36
|
-
if (serverRunning) throw new Error('Stop the managed Bunqueue server before restoring a backup');
|
|
37
35
|
const body = await boundedRecord(request, ['key', 'database']);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
return { operation, query, key: backupKey(body.key), database: body.database };
|
|
37
|
+
}
|
|
38
|
+
return { operation, query };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Execute a prepared operation against one lifecycle-pinned config/database snapshot. */
|
|
42
|
+
export async function executePreparedBackupRequest(
|
|
43
|
+
prepared: PreparedBackupRequest,
|
|
44
|
+
config: ServerConfig,
|
|
45
|
+
serverRunning: boolean,
|
|
46
|
+
database: DbStats,
|
|
47
|
+
runner: BackupRunnerPort,
|
|
48
|
+
configure?: (extraEnv: Record<string, string>) => void | Promise<void>,
|
|
49
|
+
targetPolicy?: ManagedTargetPolicy
|
|
50
|
+
): Promise<BackupRouteResponse> {
|
|
51
|
+
assertManagedTarget(prepared.query, config, targetPolicy);
|
|
52
|
+
if (prepared.operation === 'configure') {
|
|
53
|
+
if (!configure) throw new Error('Backup configuration is unavailable');
|
|
54
|
+
const next = mergeBackupEnvironment(config.extraEnv, prepared.environment);
|
|
55
|
+
await configure(next);
|
|
56
|
+
return success({ configured: true, enabled: next.S3_BACKUP_ENABLED === 'true' });
|
|
57
|
+
}
|
|
58
|
+
if (prepared.operation === 'restore') {
|
|
59
|
+
if (serverRunning) throw new Error('Stop the managed Bunqueue server before restoring a backup');
|
|
60
|
+
assertDatabaseSnapshot(prepared.database, database);
|
|
61
|
+
return success(await runner.execute(config, prepared.operation, prepared.key));
|
|
41
62
|
}
|
|
42
|
-
return success(await runner.execute(config, operation));
|
|
63
|
+
return success(await runner.execute(config, prepared.operation));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function routeBackupRequest(
|
|
67
|
+
request: Request,
|
|
68
|
+
pathname: string,
|
|
69
|
+
method: string,
|
|
70
|
+
config: ServerConfig,
|
|
71
|
+
serverRunning: boolean,
|
|
72
|
+
database: DbStats,
|
|
73
|
+
runner: BackupRunnerPort,
|
|
74
|
+
configure?: (extraEnv: Record<string, string>) => void,
|
|
75
|
+
targetPolicy?: ManagedTargetPolicy
|
|
76
|
+
): Promise<BackupRouteResponse | null> {
|
|
77
|
+
const prepared = await prepareBackupRequest(request, pathname, method, config, targetPolicy);
|
|
78
|
+
return prepared
|
|
79
|
+
? executePreparedBackupRequest(
|
|
80
|
+
prepared,
|
|
81
|
+
config,
|
|
82
|
+
serverRunning,
|
|
83
|
+
database,
|
|
84
|
+
runner,
|
|
85
|
+
configure,
|
|
86
|
+
targetPolicy
|
|
87
|
+
)
|
|
88
|
+
: null;
|
|
43
89
|
}
|
|
44
90
|
|
|
45
91
|
function routeOperation(pathname: string, method: string) {
|
package/agent/db/query.ts
CHANGED
|
@@ -47,7 +47,8 @@ export function dbQuery(path: string, sql: string): DbQueryResult {
|
|
|
47
47
|
throw translateDbError(error);
|
|
48
48
|
}
|
|
49
49
|
let columns = statement.columnNames;
|
|
50
|
-
|
|
50
|
+
const hasDuplicateNames = new Set(columns).size !== columns.length;
|
|
51
|
+
if (statement.columnTypes.length > columns.length || hasDuplicateNames) {
|
|
51
52
|
const disambiguated = disambiguateColumns(db, trimmed);
|
|
52
53
|
if (disambiguated) {
|
|
53
54
|
statement = disambiguated.stmt;
|
package/agent/index.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* BUNQUEUE_START_CMD, BUNQUEUE_MANAGED, BUNQUEUE_URL, BUNQUEUE_TOKEN,
|
|
13
13
|
* HTTP_PORT, TCP_PORT, BUNQUEUE_DATA_PATH
|
|
14
14
|
*/
|
|
15
|
+
import { assertRequiredBunVersion } from '../scripts/bunVersion';
|
|
15
16
|
import { logger } from './logger';
|
|
16
17
|
import { ProcessManager } from './manager';
|
|
17
18
|
import {
|
|
@@ -22,6 +23,8 @@ import {
|
|
|
22
23
|
} from './server';
|
|
23
24
|
import { installAgentShutdown } from './shutdown';
|
|
24
25
|
|
|
26
|
+
assertRequiredBunVersion();
|
|
27
|
+
|
|
25
28
|
const mgr = new ProcessManager();
|
|
26
29
|
const PORT = Number(process.env.AGENT_PORT) || 6800;
|
|
27
30
|
const allowedOrigins = resolveAllowedOrigins();
|
package/agent/manager/config.ts
CHANGED
|
@@ -10,7 +10,7 @@ const CONFIG_KEYS = new Set<keyof ServerConfig>([
|
|
|
10
10
|
|
|
11
11
|
export function defaultConfig(): ServerConfig {
|
|
12
12
|
return {
|
|
13
|
-
command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.
|
|
13
|
+
command: process.env.BUNQUEUE_START_CMD || 'bunx bunqueue@2.9.2 start',
|
|
14
14
|
httpPort: Number(process.env.HTTP_PORT) || 6790,
|
|
15
15
|
tcpPort: Number(process.env.TCP_PORT) || 6789,
|
|
16
16
|
dataPath: process.env.BUNQUEUE_DATA_PATH || './data/bunq.db',
|
|
@@ -76,6 +76,7 @@ export function validateServerConfig(value: unknown): ServerConfig {
|
|
|
76
76
|
for (const key of CONFIG_KEYS) {
|
|
77
77
|
if (!Object.hasOwn(value, key)) throw new Error(`Missing config key: ${key}`);
|
|
78
78
|
}
|
|
79
|
+
if (config.httpPort === config.tcpPort) throw new Error('httpPort and tcpPort must differ');
|
|
79
80
|
return config as ServerConfig;
|
|
80
81
|
}
|
|
81
82
|
|
package/agent/manager/process.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { copyConfig, defaultConfig, validateConfigPatch, validateServerConfig } from './config';
|
|
2
2
|
import { ProcessLogs } from './logs';
|
|
3
3
|
import { databaseStats } from './storage';
|
|
4
|
+
import { removeSqlitePaths, validateManagedStorage } from './storageMode';
|
|
4
5
|
import type {
|
|
5
6
|
DbStats,
|
|
6
7
|
LogLine,
|
|
@@ -29,6 +30,9 @@ export class ProcessManager {
|
|
|
29
30
|
private startedAt: number | null = null;
|
|
30
31
|
private exitCode: number | null = null;
|
|
31
32
|
private config: ServerConfig = defaultConfig();
|
|
33
|
+
// A per-agent random seed prevents stale compare-and-set tokens from a
|
|
34
|
+
// previous control-agent process matching again after a restart (ABA).
|
|
35
|
+
private configRevision = configRevisionSeed();
|
|
32
36
|
private runningConfig: ServerConfig | null = null;
|
|
33
37
|
private procToken = 0;
|
|
34
38
|
private stopping: Promise<StatusSnapshot> | null = null;
|
|
@@ -42,13 +46,26 @@ export class ProcessManager {
|
|
|
42
46
|
return copyConfig(this.config);
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
|
|
49
|
+
getConfigRevision(): number {
|
|
50
|
+
return this.configRevision;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setConfig(patch: Partial<ServerConfig>, expectedRevision?: number): ServerConfig {
|
|
54
|
+
if (expectedRevision !== undefined && expectedRevision !== this.configRevision) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Configuration changed since revision ${expectedRevision}; current revision is ${this.configRevision}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
46
59
|
const valid = validateConfigPatch(patch);
|
|
47
|
-
|
|
60
|
+
const next = validateServerConfig({
|
|
48
61
|
...this.config,
|
|
49
62
|
...valid,
|
|
50
63
|
extraEnv: valid.extraEnv ?? this.config.extraEnv,
|
|
51
|
-
};
|
|
64
|
+
});
|
|
65
|
+
if (!sameConfig(this.config, next)) {
|
|
66
|
+
this.config = next;
|
|
67
|
+
this.configRevision = nextConfigRevision(this.configRevision);
|
|
68
|
+
}
|
|
52
69
|
return copyConfig(this.config);
|
|
53
70
|
}
|
|
54
71
|
|
|
@@ -56,6 +73,7 @@ export class ProcessManager {
|
|
|
56
73
|
return {
|
|
57
74
|
status: this.status,
|
|
58
75
|
generation: this.procToken,
|
|
76
|
+
configRevision: this.configRevision,
|
|
59
77
|
pid: this.proc?.pid ?? null,
|
|
60
78
|
startedAt: this.startedAt,
|
|
61
79
|
exitCode: this.exitCode,
|
|
@@ -64,8 +82,10 @@ export class ProcessManager {
|
|
|
64
82
|
};
|
|
65
83
|
}
|
|
66
84
|
|
|
67
|
-
dbStats(): Promise<DbStats> {
|
|
68
|
-
|
|
85
|
+
dbStats(dataPath?: string): Promise<DbStats> {
|
|
86
|
+
const effectivePath =
|
|
87
|
+
dataPath ?? (this.runningConfig ?? this.config).dataPath;
|
|
88
|
+
return databaseStats(effectivePath);
|
|
69
89
|
}
|
|
70
90
|
|
|
71
91
|
getLogs(): LogLine[] {
|
|
@@ -90,6 +110,7 @@ export class ProcessManager {
|
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
const launchConfig = validateServerConfig(this.config);
|
|
113
|
+
const storageMode = validateManagedStorage(launchConfig);
|
|
93
114
|
const [command, ...args] = launchConfig.command.trim().split(/\s+/);
|
|
94
115
|
this.status = 'starting';
|
|
95
116
|
this.exitCode = null;
|
|
@@ -101,6 +122,7 @@ export class ProcessManager {
|
|
|
101
122
|
TCP_PORT: String(launchConfig.tcpPort),
|
|
102
123
|
BUNQUEUE_DATA_PATH: launchConfig.dataPath,
|
|
103
124
|
};
|
|
125
|
+
if (storageMode !== 'sqlite') removeSqlitePaths(env);
|
|
104
126
|
|
|
105
127
|
try {
|
|
106
128
|
this.proc = Bun.spawn([command, ...args], {
|
|
@@ -231,3 +253,27 @@ export class ProcessManager {
|
|
|
231
253
|
}
|
|
232
254
|
}
|
|
233
255
|
}
|
|
256
|
+
|
|
257
|
+
function sameConfig(left: ServerConfig, right: ServerConfig): boolean {
|
|
258
|
+
const leftEnv = Object.entries(left.extraEnv).sort(([a], [b]) => a.localeCompare(b));
|
|
259
|
+
const rightEnv = Object.entries(right.extraEnv).sort(([a], [b]) => a.localeCompare(b));
|
|
260
|
+
return (
|
|
261
|
+
left.command === right.command &&
|
|
262
|
+
left.httpPort === right.httpPort &&
|
|
263
|
+
left.tcpPort === right.tcpPort &&
|
|
264
|
+
left.dataPath === right.dataPath &&
|
|
265
|
+
JSON.stringify(leftEnv) === JSON.stringify(rightEnv)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function configRevisionSeed(): number {
|
|
270
|
+
const words = crypto.getRandomValues(new Uint32Array(2));
|
|
271
|
+
return ((words[0] ?? 0) & 0x1f_ffff) * 0x1_0000_0000 + (words[1] ?? 0);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function nextConfigRevision(current: number): number {
|
|
275
|
+
if (current < Number.MAX_SAFE_INTEGER) return current + 1;
|
|
276
|
+
let next = configRevisionSeed();
|
|
277
|
+
while (next === current) next = configRevisionSeed();
|
|
278
|
+
return next;
|
|
279
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ServerConfig } from './types';
|
|
2
|
+
|
|
3
|
+
export type ManagedStorageMode = 'memory' | 'sqlite' | 'postgres';
|
|
4
|
+
|
|
5
|
+
/** Mirrors Bunqueue 2.9's explicit-driver, PostgreSQL-URL, then data-path precedence. */
|
|
6
|
+
export function managedStorageMode(config: ServerConfig): ManagedStorageMode {
|
|
7
|
+
const explicit = config.extraEnv.BUNQUEUE_STORAGE_DRIVER ?? process.env.BUNQUEUE_STORAGE_DRIVER;
|
|
8
|
+
if (explicit === 'postgres' || explicit === 'memory' || explicit === 'sqlite') return explicit;
|
|
9
|
+
if (explicit) throw new Error(`Unsupported storage driver: ${explicit}`);
|
|
10
|
+
const postgresUrl = config.extraEnv.BUNQUEUE_POSTGRES_URL ?? process.env.BUNQUEUE_POSTGRES_URL;
|
|
11
|
+
if (postgresUrl) return 'postgres';
|
|
12
|
+
return config.dataPath ? 'sqlite' : 'memory';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function managedPostgresUrl(config: ServerConfig): string | undefined {
|
|
16
|
+
return (config.extraEnv.BUNQUEUE_POSTGRES_URL ?? process.env.BUNQUEUE_POSTGRES_URL)?.trim() || undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function managedPostgresNamespace(config: ServerConfig): string {
|
|
20
|
+
return (
|
|
21
|
+
(config.extraEnv.BUNQUEUE_POSTGRES_NAMESPACE ?? process.env.BUNQUEUE_POSTGRES_NAMESPACE)?.trim() ||
|
|
22
|
+
'default'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Fail before spawning when Bunqueue would reject an incomplete PostgreSQL config. */
|
|
27
|
+
export function validateManagedStorage(config: ServerConfig): ManagedStorageMode {
|
|
28
|
+
const mode = managedStorageMode(config);
|
|
29
|
+
if (mode === 'postgres' && !managedPostgresUrl(config)) {
|
|
30
|
+
throw new Error('PostgreSQL storage requires BUNQUEUE_POSTGRES_URL');
|
|
31
|
+
}
|
|
32
|
+
return mode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Non-SQLite drivers cannot be combined with Bunqueue's legacy SQLite path aliases. */
|
|
36
|
+
export function removeSqlitePaths(env: Record<string, string>): void {
|
|
37
|
+
delete env.BUNQUEUE_DATA_PATH;
|
|
38
|
+
delete env.BQ_DATA_PATH;
|
|
39
|
+
delete env.DATA_PATH;
|
|
40
|
+
delete env.SQLITE_PATH;
|
|
41
|
+
}
|
package/agent/manager/types.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface StatusSnapshot {
|
|
|
30
30
|
status: Status;
|
|
31
31
|
/** Monotonic identity of the managed process generation. */
|
|
32
32
|
generation: number;
|
|
33
|
+
/** Monotonic desired-config revision used for optimistic updates. */
|
|
34
|
+
configRevision?: number;
|
|
33
35
|
pid: number | null;
|
|
34
36
|
startedAt: number | null;
|
|
35
37
|
exitCode: number | null;
|
package/agent/manager.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Public compatibility entrypoint for managed bunqueue process supervision. */
|
|
2
2
|
export { validateConfigPatch } from './manager/config';
|
|
3
3
|
export { ProcessManager } from './manager/process';
|
|
4
|
+
export { managedPostgresNamespace, managedStorageMode } from './manager/storageMode';
|
|
4
5
|
export type {
|
|
5
6
|
DbStats,
|
|
6
7
|
LogLine,
|
package/agent/queue/routes.ts
CHANGED
|
@@ -13,14 +13,17 @@ import {
|
|
|
13
13
|
exactJsonBody,
|
|
14
14
|
exactQuery,
|
|
15
15
|
metricsRange,
|
|
16
|
+
optionalMaxCount,
|
|
16
17
|
optionalMaxJobs,
|
|
18
|
+
positiveSafeInteger,
|
|
17
19
|
requiredDeduplicationId,
|
|
20
|
+
requiredGroupId,
|
|
18
21
|
validateQueueName,
|
|
19
22
|
} from './validation';
|
|
20
23
|
|
|
21
|
-
const READ_ROUTE = /^\/queue-operations\/([^/]+)\/(limits|deduplication|metrics)$/;
|
|
24
|
+
const READ_ROUTE = /^\/queue-operations\/([^/]+)\/(limits|groups|deduplication|metrics)$/;
|
|
22
25
|
const MUTATION_ROUTE =
|
|
23
|
-
/^\/queue-operations\/([^/]+)\/(deduplication\/remove|events\/trim)$/;
|
|
26
|
+
/^\/queue-operations\/([^/]+)\/(groups\/(?:rate-limit|concurrency)(?:\/remove)?|deduplication\/remove|events\/trim)$/;
|
|
24
27
|
|
|
25
28
|
export async function routeQueueOperationsRequest(
|
|
26
29
|
request: Request,
|
|
@@ -51,6 +54,21 @@ export async function routeQueueOperationsRequest(
|
|
|
51
54
|
ok({ jobId: await runtime.deduplicationJobId(current, queue, id) })
|
|
52
55
|
);
|
|
53
56
|
}
|
|
57
|
+
if (read[2] === 'groups') {
|
|
58
|
+
const query = pinnedQuery(
|
|
59
|
+
request,
|
|
60
|
+
config,
|
|
61
|
+
['target', 'groupId', 'maxJobs', 'maxCount'],
|
|
62
|
+
targetPolicy
|
|
63
|
+
);
|
|
64
|
+
const groupId = requiredGroupId(query.get('groupId'));
|
|
65
|
+
const maxJobs = optionalMaxJobs(query);
|
|
66
|
+
const maxCount = optionalMaxCount(query);
|
|
67
|
+
assertRunning(running);
|
|
68
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
|
69
|
+
ok({ group: await runtime.group(current, queue, groupId, maxJobs, maxCount) })
|
|
70
|
+
);
|
|
71
|
+
}
|
|
54
72
|
const query = pinnedQuery(request, config, ['target', 'type', 'start', 'end'], targetPolicy);
|
|
55
73
|
const type = metricType(query.get('type'));
|
|
56
74
|
const range = metricsRange(query);
|
|
@@ -74,6 +92,42 @@ export async function routeQueueOperationsRequest(
|
|
|
74
92
|
ok({ removed: await runtime.removeDeduplicationKey(current, queue, id) })
|
|
75
93
|
);
|
|
76
94
|
}
|
|
95
|
+
if (mutation[2].startsWith('groups/')) {
|
|
96
|
+
const remove = mutation[2].endsWith('/remove');
|
|
97
|
+
const rateLimit = mutation[2].includes('/rate-limit');
|
|
98
|
+
const allowed = remove
|
|
99
|
+
? ['groupId']
|
|
100
|
+
: rateLimit
|
|
101
|
+
? ['groupId', 'max', 'duration']
|
|
102
|
+
: ['groupId', 'concurrency'];
|
|
103
|
+
const body = await exactJsonBody(request, allowed);
|
|
104
|
+
const groupId = requiredGroupId(body.groupId);
|
|
105
|
+
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) => {
|
|
106
|
+
if (remove) {
|
|
107
|
+
const removed = rateLimit
|
|
108
|
+
? await runtime.removeGroupRateLimit(current, queue, groupId)
|
|
109
|
+
: await runtime.removeGroupConcurrency(current, queue, groupId);
|
|
110
|
+
return ok({ removed });
|
|
111
|
+
}
|
|
112
|
+
if (rateLimit) {
|
|
113
|
+
await runtime.setGroupRateLimit(
|
|
114
|
+
current,
|
|
115
|
+
queue,
|
|
116
|
+
groupId,
|
|
117
|
+
positiveSafeInteger(body.max, 'max'),
|
|
118
|
+
positiveSafeInteger(body.duration, 'duration')
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
await runtime.setGroupConcurrency(
|
|
122
|
+
current,
|
|
123
|
+
queue,
|
|
124
|
+
groupId,
|
|
125
|
+
positiveSafeInteger(body.concurrency, 'concurrency')
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return ok({ applied: true });
|
|
129
|
+
});
|
|
130
|
+
}
|
|
77
131
|
const body = await exactJsonBody(request, ['maxLength']);
|
|
78
132
|
const retention = eventRetention(body.maxLength);
|
|
79
133
|
return admitted(query, config, running, admission, targetPolicy, async ({ config: current }) =>
|
package/agent/queue/runtime.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Queue, type QueueMetricType, type QueueMetrics } from 'bunqueue/client';
|
|
2
2
|
import { managedAuthToken } from '../managedTarget';
|
|
3
3
|
import type { ServerConfig } from '../manager';
|
|
4
|
-
import type { QueueLimitSnapshot, QueueOperationsPort } from './types';
|
|
4
|
+
import type { QueueGroupSnapshot, QueueLimitSnapshot, QueueOperationsPort } from './types';
|
|
5
5
|
import { QueueOperationsUnavailableError } from './types';
|
|
6
6
|
|
|
7
7
|
export type QueueOperationsClient = Pick<
|
|
@@ -11,8 +11,19 @@ export type QueueOperationsClient = Pick<
|
|
|
11
11
|
| 'getGlobalConcurrency'
|
|
12
12
|
| 'getRateLimitTtl'
|
|
13
13
|
| 'isMaxed'
|
|
14
|
+
| 'getGroupJobsCount'
|
|
15
|
+
| 'getGroupsJobsCount'
|
|
16
|
+
| 'getGroupActiveCount'
|
|
17
|
+
| 'setGroupRateLimit'
|
|
18
|
+
| 'getGroupRateLimit'
|
|
19
|
+
| 'removeGroupRateLimit'
|
|
20
|
+
| 'getGroupRateLimitTtl'
|
|
21
|
+
| 'setGroupConcurrency'
|
|
22
|
+
| 'getGroupConcurrency'
|
|
23
|
+
| 'removeGroupConcurrency'
|
|
14
24
|
| 'getDeduplicationJobId'
|
|
15
25
|
| 'removeDeduplicationKey'
|
|
26
|
+
| 'removeDlqJob'
|
|
16
27
|
| 'getMetrics'
|
|
17
28
|
| 'trimEvents'
|
|
18
29
|
| 'close'
|
|
@@ -41,6 +52,57 @@ export class QueueOperationsRuntime implements QueueOperationsPort {
|
|
|
41
52
|
});
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
group(
|
|
56
|
+
config: ServerConfig,
|
|
57
|
+
queue: string,
|
|
58
|
+
groupId: string,
|
|
59
|
+
maxJobs?: number,
|
|
60
|
+
maxCount?: number
|
|
61
|
+
): Promise<QueueGroupSnapshot> {
|
|
62
|
+
return this.withQueue(config, queue, async (client) => {
|
|
63
|
+
const [jobs, active, totalGrouped, rateLimit, rateLimitTtl, concurrency] = await Promise.all([
|
|
64
|
+
client.getGroupJobsCount(groupId),
|
|
65
|
+
client.getGroupActiveCount(groupId),
|
|
66
|
+
client.getGroupsJobsCount(maxCount),
|
|
67
|
+
client.getGroupRateLimit(groupId),
|
|
68
|
+
client.getGroupRateLimitTtl(groupId, maxJobs),
|
|
69
|
+
client.getGroupConcurrency(groupId),
|
|
70
|
+
]);
|
|
71
|
+
return { jobs, active, totalGrouped, rateLimit, rateLimitTtl, concurrency };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setGroupRateLimit(
|
|
76
|
+
config: ServerConfig,
|
|
77
|
+
queue: string,
|
|
78
|
+
groupId: string,
|
|
79
|
+
max: number,
|
|
80
|
+
duration: number
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
return this.withQueue(config, queue, (client) =>
|
|
83
|
+
client.setGroupRateLimit(groupId, max, duration)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
removeGroupRateLimit(config: ServerConfig, queue: string, groupId: string): Promise<number> {
|
|
88
|
+
return this.withQueue(config, queue, (client) => client.removeGroupRateLimit(groupId));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setGroupConcurrency(
|
|
92
|
+
config: ServerConfig,
|
|
93
|
+
queue: string,
|
|
94
|
+
groupId: string,
|
|
95
|
+
concurrency: number
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
return this.withQueue(config, queue, (client) =>
|
|
98
|
+
client.setGroupConcurrency(groupId, concurrency)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
removeGroupConcurrency(config: ServerConfig, queue: string, groupId: string): Promise<number> {
|
|
103
|
+
return this.withQueue(config, queue, (client) => client.removeGroupConcurrency(groupId));
|
|
104
|
+
}
|
|
105
|
+
|
|
44
106
|
deduplicationJobId(
|
|
45
107
|
config: ServerConfig,
|
|
46
108
|
queue: string,
|
|
@@ -59,6 +121,10 @@ export class QueueOperationsRuntime implements QueueOperationsPort {
|
|
|
59
121
|
return this.withQueue(config, queue, (client) => client.removeDeduplicationKey(deduplicationId));
|
|
60
122
|
}
|
|
61
123
|
|
|
124
|
+
removeDlqJob(config: ServerConfig, queue: string, jobId: string): Promise<boolean> {
|
|
125
|
+
return this.withQueue(config, queue, (client) => client.removeDlqJob(jobId));
|
|
126
|
+
}
|
|
127
|
+
|
|
62
128
|
metrics(
|
|
63
129
|
config: ServerConfig,
|
|
64
130
|
queue: string,
|
package/agent/queue/types.ts
CHANGED
|
@@ -8,8 +8,39 @@ export interface QueueLimitSnapshot {
|
|
|
8
8
|
maxed: boolean;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export interface QueueGroupSnapshot {
|
|
12
|
+
jobs: number;
|
|
13
|
+
active: number;
|
|
14
|
+
totalGrouped: number;
|
|
15
|
+
rateLimit: { max: number; duration: number } | null;
|
|
16
|
+
rateLimitTtl: number;
|
|
17
|
+
concurrency: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
export interface QueueOperationsPort {
|
|
12
21
|
limits(config: ServerConfig, queue: string, maxJobs?: number): Promise<QueueLimitSnapshot>;
|
|
22
|
+
group(
|
|
23
|
+
config: ServerConfig,
|
|
24
|
+
queue: string,
|
|
25
|
+
groupId: string,
|
|
26
|
+
maxJobs?: number,
|
|
27
|
+
maxCount?: number
|
|
28
|
+
): Promise<QueueGroupSnapshot>;
|
|
29
|
+
setGroupRateLimit(
|
|
30
|
+
config: ServerConfig,
|
|
31
|
+
queue: string,
|
|
32
|
+
groupId: string,
|
|
33
|
+
max: number,
|
|
34
|
+
duration: number
|
|
35
|
+
): Promise<void>;
|
|
36
|
+
removeGroupRateLimit(config: ServerConfig, queue: string, groupId: string): Promise<number>;
|
|
37
|
+
setGroupConcurrency(
|
|
38
|
+
config: ServerConfig,
|
|
39
|
+
queue: string,
|
|
40
|
+
groupId: string,
|
|
41
|
+
concurrency: number
|
|
42
|
+
): Promise<void>;
|
|
43
|
+
removeGroupConcurrency(config: ServerConfig, queue: string, groupId: string): Promise<number>;
|
|
13
44
|
deduplicationJobId(
|
|
14
45
|
config: ServerConfig,
|
|
15
46
|
queue: string,
|
|
@@ -20,6 +51,7 @@ export interface QueueOperationsPort {
|
|
|
20
51
|
queue: string,
|
|
21
52
|
deduplicationId: string
|
|
22
53
|
): Promise<number>;
|
|
54
|
+
removeDlqJob(config: ServerConfig, queue: string, jobId: string): Promise<boolean>;
|
|
23
55
|
metrics(
|
|
24
56
|
config: ServerConfig,
|
|
25
57
|
queue: string,
|
|
@@ -40,6 +40,22 @@ export function optionalMaxJobs(query: URLSearchParams): number | undefined {
|
|
|
40
40
|
return raw === null ? undefined : boundedInteger(raw, 'maxJobs', 0, 1_000_000);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export function optionalMaxCount(query: URLSearchParams): number | undefined {
|
|
44
|
+
const raw = query.get('maxCount');
|
|
45
|
+
return raw === null ? undefined : boundedInteger(raw, 'maxCount', 1, 1_000_000);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function requiredGroupId(value: unknown): string {
|
|
49
|
+
if (typeof value !== 'string' || !value || value.length > 256 || value.includes('\0')) {
|
|
50
|
+
throw new Error('groupId must be a non-empty string of at most 256 characters without NUL');
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function positiveSafeInteger(value: unknown, label: string): number {
|
|
56
|
+
return bodyInteger(value, label, 1, Number.MAX_SAFE_INTEGER);
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
export function metricsRange(query: URLSearchParams): { start: number; end: number } {
|
|
44
60
|
const start = optionalInteger(query.get('start'), 'start', 0, MAX_PAGE_INDEX, 0);
|
|
45
61
|
const rawEnd = query.get('end');
|