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/README.md CHANGED
@@ -1,286 +1,194 @@
1
1
  # Enqiu
2
2
 
3
- A small, type-safe job queue for Node.js and Bun. Start in memory, move to
4
- Redis without changing your job API.
3
+ [![npm](https://img.shields.io/npm/v/enqiu/beta?style=flat-square&label=beta)](https://www.npmjs.com/package/enqiu)
4
+ [![status](https://img.shields.io/badge/status-beta-2563eb?style=flat-square)](#status-beta)
5
+ [![built on](https://img.shields.io/badge/built_on-BullMQ-b91c1c?style=flat-square)](https://bullmq.io)
6
+ [![license](https://img.shields.io/npm/l/enqiu?style=flat-square)](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
- pnpm add enqiu
35
+ npm install enqiu@beta bullmq ioredis
8
36
  ```
9
37
 
10
- Enqiu has no runtime dependencies. Redis, schema, Hono, and telemetry packages
11
- remain your choice.
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
- sendEmail: job({
24
- input: z.object({
25
- to: z.email(),
26
- subject: z.string(),
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
- ## Testing in your project
67
-
68
- Create a fresh queue for each test so workers and queued state never leak
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
- ```bash
97
- pnpm add enqiu
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
- The repository keeps the complete, runnable
103
- [memory and opt-in Redis examples](https://github.com/moji2002/enqiu/tree/main/examples/testing).
104
- The Redis test runs only when `ENQIU_TEST_REDIS_URL` is set, uses a unique
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
- Inject an existing client; Enqiu does not create connections or install a Redis
111
- library. It accepts Bun's `send(command, args)` client shape and node-redis'
112
- `sendCommand(args)` shape.
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
- import { createClient } from "redis";
116
- import { enqiu, redis } from "enqiu";
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
- Use the same definitions in a producer-only process:
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
- ```ts
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
- syncAccount: job({
150
- input: z.object({
151
- tenantId: z.string(),
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
- - `concurrency` limits simultaneous work globally or by a key such as tenant.
178
- - `throttle` limits starts over time; `burst` allows short spikes.
179
- - `debounce: { mode: "leading" }` keeps the first call in a window.
180
- - `debounce: { mode: "trailing" }` keeps the most recent call in a window.
181
- - `expiresIn` prevents stale jobs from starting.
182
- - `idempotencyKey` makes repeated submissions return the same job.
183
-
184
- Per-call delivery options are available when needed:
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 handle = await jobs.syncAccount(input, {
188
- idempotencyKey: `sync:${input.accountId}`,
189
- idempotencyTtl: 24 * 60 * 60_000,
190
- delay: 5_000,
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
- ## Progress and logs
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
- Progress uses real units rather than an ambiguous fraction:
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
- ```ts
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
- Subscribe to lifecycle events with `jobs.queue.on(...)`. Memory events stay
217
- inside the process; Redis events are shared between producers and workers.
142
+ ## What BullMQ provides
218
143
 
219
- ## Cron schedules
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
- Schedules use standard five-field cron expressions and IANA time zones:
148
+ ## Compatibility notes
222
149
 
223
- ```ts
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
- Memory schedules live for the process lifetime. Redis schedules are durable
238
- and use deterministic occurrence IDs to avoid duplicate runs.
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
- ## Hono
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
- Enqiu uses Standard Schema and exposes each job's input schema, so the same
243
- schema can validate an HTTP route without redefining a type:
162
+ ## Testing
244
163
 
245
- ```ts
246
- import { sValidator } from "@hono/standard-validator";
247
-
248
- app.post(
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
- Hono and `@hono/standard-validator` are optional application dependencies.
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
- ## Queue and worker controls
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
- ```ts
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
- ## Runtime support
183
+ ```bash
184
+ pnpm run release:beta # npm publish --tag beta
185
+ ```
279
186
 
280
- - Node.js 20 and newer
281
- - Current stable Bun
282
- - Memory and Redis drivers
283
- - ESM and TypeScript declarations
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