enqiu 0.1.2 → 0.4.0-beta.0
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/CHANGELOG.md +291 -0
- package/README.md +143 -235
- package/dist/api.d.ts +16 -236
- package/dist/api.js +383 -478
- package/dist/backend.d.ts +25 -0
- package/dist/backend.js +18 -0
- package/dist/definition.d.ts +13 -0
- package/dist/definition.js +51 -0
- package/dist/errors.d.ts +57 -0
- package/dist/errors.js +83 -0
- package/dist/events.d.ts +30 -0
- package/dist/events.js +53 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +3 -3
- package/dist/mapping.d.ts +99 -0
- package/dist/mapping.js +167 -0
- package/dist/markers.d.ts +25 -0
- package/dist/markers.js +51 -0
- package/dist/runner.d.ts +20 -0
- package/dist/runner.js +101 -0
- package/dist/serialize.d.ts +15 -0
- package/dist/serialize.js +90 -0
- package/dist/types.d.ts +326 -0
- package/dist/types.js +9 -0
- package/package.json +24 -15
- package/dist/codec.d.ts +0 -8
- package/dist/codec.js +0 -74
- package/dist/cron.d.ts +0 -19
- package/dist/cron.js +0 -217
- package/dist/memory-scheduler.d.ts +0 -24
- package/dist/memory-scheduler.js +0 -163
- package/dist/memory.d.ts +0 -344
- package/dist/memory.js +0 -1201
- package/dist/redis.d.ts +0 -202
- package/dist/redis.js +0 -2180
package/README.md
CHANGED
|
@@ -1,286 +1,194 @@
|
|
|
1
1
|
# Enqiu
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/enqiu)
|
|
4
|
+
[](#status-beta)
|
|
5
|
+
[](https://bullmq.io)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
A type-safe job API on top of [BullMQ](https://bullmq.io). Define each job once
|
|
9
|
+
with a schema, then call it like a function — the name, input and result types
|
|
10
|
+
are inferred, so there is no separate registry and no string-keyed dispatch.
|
|
11
|
+
|
|
12
|
+
BullMQ owns storage, scheduling and execution. Enqiu owns the developer
|
|
13
|
+
experience.
|
|
14
|
+
|
|
15
|
+
[npm](https://www.npmjs.com/package/enqiu) ·
|
|
16
|
+
[Issues](https://github.com/moji2002/enqiu/issues)
|
|
17
|
+
|
|
18
|
+
## Status: beta
|
|
19
|
+
|
|
20
|
+
> [!NOTE]
|
|
21
|
+
> **Enqiu is beta software.** The shape of the API is settled and the hard
|
|
22
|
+
> parts — storage, retries, scheduling and crash recovery — are BullMQ's,
|
|
23
|
+
> which is mature and widely deployed.
|
|
24
|
+
>
|
|
25
|
+
> What is new is the layer in between. Expect edge-case bugs there, and pin an
|
|
26
|
+
> exact version: it is published under the `beta` dist-tag, so a plain
|
|
27
|
+
> `npm install enqiu` will not install it.
|
|
28
|
+
>
|
|
29
|
+
> The layer in between is new. It is covered at 99% of statements and 91% of
|
|
30
|
+
> branches against a real Redis, and the two parts that need no server — the
|
|
31
|
+
> BullMQ vocabulary mapping and the serialization check — are held to their own
|
|
32
|
+
> thresholds in either mode.
|
|
5
33
|
|
|
6
34
|
```bash
|
|
7
|
-
|
|
35
|
+
npm install enqiu@beta bullmq ioredis
|
|
8
36
|
```
|
|
9
37
|
|
|
10
|
-
|
|
11
|
-
|
|
38
|
+
`bullmq` and `ioredis` are peer dependencies — Enqiu does not pick versions or
|
|
39
|
+
open connections for you.
|
|
12
40
|
|
|
13
41
|
## Quick start
|
|
14
42
|
|
|
15
|
-
Define each job once, then call it like a function. The name, input, and result
|
|
16
|
-
types are inferred.
|
|
17
|
-
|
|
18
43
|
```ts
|
|
19
44
|
import { enqiu, job } from "enqiu";
|
|
20
45
|
import { z } from "zod";
|
|
21
46
|
|
|
22
|
-
const jobs = enqiu(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
to: z.
|
|
26
|
-
|
|
47
|
+
const { jobs, queue, worker, close } = enqiu(
|
|
48
|
+
{
|
|
49
|
+
sendEmail: job({
|
|
50
|
+
input: z.object({ to: z.string(), subject: z.string() }),
|
|
51
|
+
retry: { attempts: 3, backoff: { type: "exponential", delay: 500 } },
|
|
52
|
+
timeout: 30_000,
|
|
53
|
+
run: async (input, { log }) => {
|
|
54
|
+
log.info("sending", { to: input.to });
|
|
55
|
+
return { delivered: true, subject: input.subject };
|
|
56
|
+
},
|
|
27
57
|
}),
|
|
28
|
-
run: async (email, { signal, log }) => {
|
|
29
|
-
log.info("Sending email", { to: email.to });
|
|
30
|
-
|
|
31
|
-
const response = await fetch("https://example.com/email", {
|
|
32
|
-
method: "POST",
|
|
33
|
-
body: JSON.stringify(email),
|
|
34
|
-
signal,
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
return { delivered: response.ok };
|
|
38
|
-
},
|
|
39
|
-
}),
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const delivery = await jobs.sendEmail({
|
|
43
|
-
to: "hello@example.com",
|
|
44
|
-
subject: "Welcome",
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
const result = await delivery.result;
|
|
48
|
-
console.log(result.delivered);
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
`await jobs.sendEmail(input)` waits until the queue accepts the job and returns
|
|
52
|
-
a handle. It does not wait for the handler. Await `handle.result` only when the
|
|
53
|
-
caller needs the result. Ignoring a handle is safe and does not create an
|
|
54
|
-
unhandled rejected promise.
|
|
55
|
-
|
|
56
|
-
Schemas are optional. A plain handler also infers its result:
|
|
57
|
-
|
|
58
|
-
```ts
|
|
59
|
-
const jobs = enqiu({
|
|
60
|
-
resizeImage: async (input: { key: string; width: number }) => {
|
|
61
|
-
return { key: input.key, width: input.width };
|
|
62
58
|
},
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
between cases. This Vitest example uses the in-memory driver, awaits the public
|
|
70
|
-
job handle, checks the persisted status, and always closes the worker:
|
|
71
|
-
|
|
72
|
-
```ts
|
|
73
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
74
|
-
import { createJobs } from "./jobs.js";
|
|
75
|
-
|
|
76
|
-
let jobs: ReturnType<typeof createJobs> | undefined;
|
|
77
|
-
|
|
78
|
-
afterEach(async () => {
|
|
79
|
-
await jobs?.worker.close();
|
|
80
|
-
jobs = undefined;
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
describe("email jobs", () => {
|
|
84
|
-
it("returns and stores the handler result", async () => {
|
|
85
|
-
jobs = createJobs();
|
|
86
|
-
const handle = await jobs.sendWelcome({ name: "Ada" });
|
|
87
|
-
|
|
88
|
-
await expect(handle.result).resolves.toEqual({ subject: "Welcome, Ada" });
|
|
89
|
-
expect((await handle.refresh()).status).toBe("succeeded");
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
Install and run it with:
|
|
59
|
+
{
|
|
60
|
+
name: "notifications",
|
|
61
|
+
connection: { host: "localhost", port: 6379 },
|
|
62
|
+
worker: { concurrency: 10 },
|
|
63
|
+
},
|
|
64
|
+
);
|
|
95
65
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
pnpm add -D vitest
|
|
99
|
-
pnpm vitest run
|
|
66
|
+
const handle = await jobs.sendEmail({ to: "a@b.c", subject: "Welcome" });
|
|
67
|
+
const result = await handle.result; // { delivered: boolean; subject: string }
|
|
100
68
|
```
|
|
101
69
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
namespace instead of flushing the database, and closes the worker before the
|
|
106
|
-
injected Redis client.
|
|
107
|
-
|
|
108
|
-
## Redis
|
|
70
|
+
`await jobs.sendEmail(input)` resolves once BullMQ accepts the job and returns a
|
|
71
|
+
handle. It does not wait for the handler. Await `handle.result` only when the
|
|
72
|
+
caller needs the result.
|
|
109
73
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
74
|
+
`jobs` holds your jobs and nothing else, which is why no job name is reserved —
|
|
75
|
+
`jobs.queue` is a job you called `queue`. The queue and worker controls sit
|
|
76
|
+
beside it:
|
|
113
77
|
|
|
114
78
|
```ts
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const client = createClient({ url: process.env.REDIS_URL });
|
|
119
|
-
await client.connect();
|
|
120
|
-
|
|
121
|
-
const jobs = enqiu(definitions, {
|
|
122
|
-
name: "notifications",
|
|
123
|
-
driver: redis(client),
|
|
124
|
-
worker: { concurrency: 20 },
|
|
125
|
-
});
|
|
79
|
+
await queue.stats(); // counts by status
|
|
80
|
+
await queue.onIdle(); // resolves when nothing is outstanding
|
|
81
|
+
await close(); // queue, worker and event stream
|
|
126
82
|
```
|
|
127
83
|
|
|
128
|
-
|
|
84
|
+
Only what Enqiu types or computes is here. Pausing a queue or a worker, setting
|
|
85
|
+
global concurrency and anything else BullMQ already exposes is `bull.queue.*`
|
|
86
|
+
and `bull.worker.*` — a second name for the same call would be one more thing
|
|
87
|
+
to learn and nothing else.
|
|
129
88
|
|
|
130
|
-
|
|
131
|
-
const jobs = enqiu(definitions, {
|
|
132
|
-
name: "notifications",
|
|
133
|
-
driver: redis(client),
|
|
134
|
-
worker: false,
|
|
135
|
-
});
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
Redis jobs use atomic Lua transitions, visibility leases, and deterministic
|
|
139
|
-
recovery so multiple Node.js or Bun workers can safely share a queue.
|
|
140
|
-
|
|
141
|
-
## Job policies
|
|
142
|
-
|
|
143
|
-
Policies live beside the handler and keep call sites clean. Durations are
|
|
144
|
-
numbers in milliseconds, so applications may use plain numbers or a helper
|
|
145
|
-
such as `ms("30s")` without making it an Enqiu dependency.
|
|
89
|
+
A plain handler works too, with input and output still inferred:
|
|
146
90
|
|
|
147
91
|
```ts
|
|
148
|
-
const jobs = enqiu(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
accountId: z.string(),
|
|
153
|
-
}),
|
|
154
|
-
retry: {
|
|
155
|
-
attempts: 5,
|
|
156
|
-
backoff: { type: "exponential", delay: 250, jitter: 0.2 },
|
|
157
|
-
},
|
|
158
|
-
timeout: 30_000,
|
|
159
|
-
expiresIn: 5 * 60_000,
|
|
160
|
-
concurrency: {
|
|
161
|
-
limit: 2,
|
|
162
|
-
by: (input) => input.tenantId,
|
|
163
|
-
},
|
|
164
|
-
throttle: {
|
|
165
|
-
limit: 100,
|
|
166
|
-
per: 60_000,
|
|
167
|
-
burst: 10,
|
|
168
|
-
by: (input) => input.tenantId,
|
|
169
|
-
},
|
|
170
|
-
run: async (input, context) => {
|
|
171
|
-
return syncAccount(input, { signal: context.signal });
|
|
172
|
-
},
|
|
173
|
-
}),
|
|
174
|
-
});
|
|
92
|
+
const { jobs } = enqiu(
|
|
93
|
+
{ resizeImage: async (input: { key: string; width: number }) => input },
|
|
94
|
+
{ connection },
|
|
95
|
+
);
|
|
175
96
|
```
|
|
176
97
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
-
|
|
180
|
-
|
|
181
|
-
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
Per-
|
|
98
|
+
## What Enqiu adds
|
|
99
|
+
|
|
100
|
+
- **Inferred types end to end.** Job names come from the object keys; input and
|
|
101
|
+
result types come from the schema and handler. No generics to write.
|
|
102
|
+
- **Standard Schema validation at the boundary.** Zod, Valibot, ArkType or
|
|
103
|
+
anything else implementing the spec. Invalid input is rejected before a job
|
|
104
|
+
is queued.
|
|
105
|
+
- **Per-attempt `timeout`,** with an `AbortSignal` handed to the handler. BullMQ
|
|
106
|
+
has no job timeout; Enqiu enforces this itself.
|
|
107
|
+
- **`expiresIn`,** which fails a job that waited too long without running it.
|
|
108
|
+
Also enforced by Enqiu.
|
|
109
|
+
- **A serialization guard** that rejects functions, symbols, cycles and sparse
|
|
110
|
+
arrays with the exact path, instead of failing later inside the queue.
|
|
111
|
+
- **A `cancelled` status,** which BullMQ has no state for: cancelling a job that
|
|
112
|
+
has not started removes it, so Enqiu records the finished snapshot and
|
|
113
|
+
`refresh()` can still tell "cancelled" from "never existed".
|
|
114
|
+
- **Failures that survive as classes.** BullMQ hands a failure to another
|
|
115
|
+
process as one string; a timeout or an expiry writes its kind down, so
|
|
116
|
+
`handle.result` rejects with `JobTimeoutError` rather than a bare `Error`.
|
|
117
|
+
|
|
118
|
+
## Escaping the layer
|
|
119
|
+
|
|
120
|
+
Enqiu models a deliberate subset. Everything else BullMQ can do — flows, Pro
|
|
121
|
+
groups, metrics, raw job options — is one property away, with no wrapper in
|
|
122
|
+
between and no fork required:
|
|
185
123
|
|
|
186
124
|
```ts
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
priority: "high",
|
|
192
|
-
});
|
|
125
|
+
const { bull } = enqiu(definitions, { connection });
|
|
126
|
+
|
|
127
|
+
bull.queue // the real BullMQ Queue
|
|
128
|
+
bull.worker // the real BullMQ Worker, or undefined for a producer
|
|
193
129
|
```
|
|
194
130
|
|
|
195
|
-
|
|
131
|
+
Enqiu reads its own state from those objects rather than mirroring it, so
|
|
132
|
+
pausing `bull.worker` or closing `bull.queue` is seen on the Enqiu side too —
|
|
133
|
+
the two cannot drift apart.
|
|
196
134
|
|
|
197
|
-
|
|
135
|
+
Measured against raw BullMQ on the same Redis — 10,000 jobs, concurrency 32,
|
|
136
|
+
contestants interleaved, median of 7 — the typed path costs about 2%, and Zod
|
|
137
|
+
validation about 3%, varying by a point between runs. Calls through `bull` cost
|
|
138
|
+
nothing, because nothing is in the way.
|
|
198
139
|
|
|
199
|
-
|
|
200
|
-
const jobs = enqiu({
|
|
201
|
-
importRows: async (rows: string[], context) => {
|
|
202
|
-
for (let index = 0; index < rows.length; index += 1) {
|
|
203
|
-
await importRow(rows[index]);
|
|
204
|
-
await context.reportProgress({
|
|
205
|
-
completed: index + 1,
|
|
206
|
-
total: rows.length,
|
|
207
|
-
message: "Importing rows",
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
context.log.info("Import complete", { rows: rows.length });
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
```
|
|
140
|
+
Reproduce with `pnpm tsx bench/overhead.ts`.
|
|
215
141
|
|
|
216
|
-
|
|
217
|
-
inside the process; Redis events are shared between producers and workers.
|
|
142
|
+
## What BullMQ provides
|
|
218
143
|
|
|
219
|
-
|
|
144
|
+
Retries and backoff, priorities, delays, cron schedules, deduplication, bulk
|
|
145
|
+
submission, progress, logs, events and cleanup are BullMQ's, surfaced through
|
|
146
|
+
Enqiu's API.
|
|
220
147
|
|
|
221
|
-
|
|
148
|
+
## Compatibility notes
|
|
222
149
|
|
|
223
|
-
|
|
224
|
-
const schedule = await jobs.sendDigest.schedule({
|
|
225
|
-
id: "weekday-digest",
|
|
226
|
-
cron: "0 9 * * 1-5",
|
|
227
|
-
timezone: "Europe/Nicosia",
|
|
228
|
-
input: { audience: "daily" },
|
|
229
|
-
catchUp: true,
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
await schedule.pause();
|
|
233
|
-
await schedule.resume();
|
|
234
|
-
await schedule.remove();
|
|
235
|
-
```
|
|
150
|
+
Enqiu deliberately does not paper over gaps in BullMQ's open-source tier:
|
|
236
151
|
|
|
237
|
-
|
|
238
|
-
|
|
152
|
+
| Not available | Why |
|
|
153
|
+
| --- | --- |
|
|
154
|
+
| Per-key concurrency (`concurrency: { by }`) | BullMQ groups are a **BullMQ Pro** feature. |
|
|
155
|
+
| Per-key rate limiting (`throttle: { by }`) | The OSS limiter is one global `{ max, duration }` per worker. |
|
|
156
|
+
| Debounce | No open-source equivalent. |
|
|
157
|
+
| In-browser queues | BullMQ requires Redis and Node. |
|
|
239
158
|
|
|
240
|
-
|
|
159
|
+
If you need any of those, use BullMQ Pro directly, or pin Enqiu 0.2.x, which
|
|
160
|
+
shipped first-party memory and Redis drivers that implemented them.
|
|
241
161
|
|
|
242
|
-
|
|
243
|
-
schema can validate an HTTP route without redefining a type:
|
|
162
|
+
## Testing
|
|
244
163
|
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
"/emails",
|
|
250
|
-
sValidator("json", jobs.sendEmail.input),
|
|
251
|
-
async (c) => {
|
|
252
|
-
const handle = await jobs.sendEmail(c.req.valid("json"));
|
|
253
|
-
return c.json({ id: handle.id }, 202);
|
|
254
|
-
},
|
|
255
|
-
);
|
|
164
|
+
```bash
|
|
165
|
+
docker run -d -p 6379:6379 redis:7-alpine
|
|
166
|
+
ENQIU_TEST_REDIS_URL=redis://localhost:6379 pnpm run check
|
|
167
|
+
ENQIU_TEST_REDIS_URL=redis://localhost:6379 pnpm run scenarios
|
|
256
168
|
```
|
|
257
169
|
|
|
258
|
-
|
|
170
|
+
Most tests need a real Redis, because most code paths go through BullMQ. The
|
|
171
|
+
exceptions are the vocabulary mapping and the serialization check, which are
|
|
172
|
+
pure and stay covered without a server — so a run without
|
|
173
|
+
`ENQIU_TEST_REDIS_URL` still verifies something rather than nothing.
|
|
259
174
|
|
|
260
|
-
|
|
175
|
+
Five runnable, self-asserting scenarios live in
|
|
176
|
+
[`examples/scenarios/`](examples/scenarios): webhook ingestion, notification
|
|
177
|
+
campaigns, report progress, a transcoding pool, and failure triage. The
|
|
178
|
+
reasoning behind the workload choices is in
|
|
179
|
+
[`docs/use-case-research.md`](docs/use-case-research.md).
|
|
261
180
|
|
|
262
|
-
|
|
263
|
-
await jobs.queue.pause();
|
|
264
|
-
await jobs.queue.resume();
|
|
265
|
-
await jobs.queue.setConcurrency(50);
|
|
266
|
-
|
|
267
|
-
const page = await jobs.queue.list({ status: "failed", limit: 100 });
|
|
268
|
-
const snapshot = await jobs.queue.get(handle.id);
|
|
269
|
-
await jobs.queue.redrive(handle.id);
|
|
270
|
-
await jobs.queue.cleanup({ olderThan: Date.now() - 7 * 24 * 60 * 60_000 });
|
|
271
|
-
|
|
272
|
-
await jobs.worker.pause();
|
|
273
|
-
await jobs.worker.resume();
|
|
274
|
-
await jobs.worker.onIdle();
|
|
275
|
-
await jobs.worker.close();
|
|
276
|
-
```
|
|
181
|
+
## Releasing
|
|
277
182
|
|
|
278
|
-
|
|
183
|
+
```bash
|
|
184
|
+
pnpm run release:beta # npm publish --tag beta
|
|
185
|
+
```
|
|
279
186
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
187
|
+
The tag is in the script rather than in `publishConfig`, because npm 11 does not
|
|
188
|
+
honour `publishConfig.tag` — a plain `npm publish` resolves to `latest` and would
|
|
189
|
+
hand a beta to every `npm install enqiu`. `prepack` runs the full check first,
|
|
190
|
+
and `build` cleans `dist` before compiling, since `tsc` leaves deleted modules
|
|
191
|
+
behind and they would otherwise ship.
|
|
284
192
|
|
|
285
193
|
## License
|
|
286
194
|
|