enqiu 0.1.2 → 0.4.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 CHANGED
@@ -3,6 +3,304 @@
3
3
  All notable changes to this project are documented here. The project follows
4
4
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## Unreleased
7
+
8
+ ## [0.4.0] - 2026-08-30
9
+
10
+ Promoted out of beta. No API or behaviour changes since `0.4.0-beta.0` — the
11
+ beta ran without edge-case reports against the mapping layer, so the `beta`
12
+ dist-tag and the `release:beta` script are gone and `npm install enqiu` now
13
+ resolves here.
14
+
15
+ ## [0.4.0-beta.0] - 2026-08-07
16
+
17
+ Rewritten from a blank file. The idea is unchanged — define a job once, call it
18
+ like a function — but the previous version was the accumulated result of a
19
+ queue implementation that became a wrapper, and it showed. This is the shape it
20
+ would have had if it had been a wrapper from the start.
21
+
22
+ ### Breaking
23
+
24
+ - **`enqiu()` returns `{ jobs, queue, worker, bull, close }`**, meant to be
25
+ destructured. Your jobs live under `jobs`, so **no job name is reserved any
26
+ more** — a job called `queue` is `jobs.queue`. The runtime check that used to
27
+ throw on `queue`, `worker` and `bull` is gone, along with the two casts that
28
+ were needed to build the old combined object at all.
29
+ - **`close()` is returned by `enqiu()`** rather than sitting on `worker`. It
30
+ ends the queue, the worker and the event stream, which is what it always did
31
+ — including on a producer-only queue that has no worker to close.
32
+ - **`worker.onIdle()` is now `queue.onIdle()`.** It measures the queue, and
33
+ every process sharing that queue gets the same answer.
34
+ - **`queue.list()` requires a `status`,** and its cursor carries one offset per
35
+ underlying BullMQ state. A single number meant "position within each state",
36
+ which skipped and repeated jobs whenever a status spanned more than one.
37
+ - **`JobHandle.status` is gone.** It could only report what was true when the
38
+ handle was made, and went quietly stale while you held it. Use `refresh()`.
39
+ - **`validatePayloads` is gone** and the check always runs. Measured honestly it
40
+ is worth about a point of a two-point overhead, which is not a trade worth
41
+ offering.
42
+ - **Telemetry is removed.** Every event it emitted was either BullMQ's own —
43
+ available on `bull.worker.on(...)` — or reconstructible from one. A hook that
44
+ forwards another library's events under new names is a second vocabulary for
45
+ the same thing.
46
+ - **`queue.pause`, `queue.resume`, `queue.setConcurrency`, `worker.pause` and
47
+ `worker.resume` are removed.** All five forwarded to BullMQ and added
48
+ nothing. They are `bull.queue.pause()`, `.resume()`,
49
+ `.setGlobalConcurrency(n)`, `bull.worker.pause()` and `.resume()`. What
50
+ remains on `queue` and `worker` is what Enqiu types or computes.
51
+ - Dead surface removed: `encodeJobValue`, `decodeJobValue`, `cloneJobValue`,
52
+ `serializeError`, `SubmitOptions.timeout`/`expiresIn`, the `"expired"` status,
53
+ `JobSnapshot.logs`, `TelemetryEvent.job`, `SerializedError.stack`,
54
+ `ScheduleHandle.nextRunAt`, and `BulkOptions.idempotencyKey` — which applied
55
+ one key to a whole batch and collapsed it into a single job.
56
+
57
+ ### Fixed
58
+
59
+ - **A job's failure is no longer reconstructed by reading its error message.**
60
+ `awaitResult` matched on the strings `"timed out after"` and `"expired before
61
+ it could start"`, both produced elsewhere in this repo — so rewording either
62
+ message silently changed which class callers caught, with no test to notice.
63
+ The kind is now written into `failedReason` and read back. Snapshots gained
64
+ real error names as a side effect, instead of every failure being `"Error"`.
65
+ - **A cancelled job keeps its own name and input.** The marker stored only a
66
+ reason, so once the job was removed the snapshot was invented: `name:
67
+ "unknown"`, `input: undefined`, `attempt: 0`. It now stores the snapshot taken
68
+ before removal.
69
+ - **`cleanup({ status })` cleaned the wrong jobs** for every status but
70
+ `"failed"`, so `{ status: "running" }` deleted successes. It now cleans every
71
+ state a status is made of — cleaning only the first left prioritized jobs
72
+ behind and reported success.
73
+ - **`EnqiuOptions.retry` had no effect**, though its neighbour `timeout` did.
74
+ - **Cancellation markers used ioredis' positional `HSET`**, which would throw on
75
+ the node-redis and Bun clients BullMQ 6 adapts.
76
+ - **`worker.running` and the closed check were mirrored state**, so pausing or
77
+ closing through `bull` left them claiming otherwise. Both read from BullMQ.
78
+ - **`worker.start()` left a paused worker resumed but not running.** BullMQ's
79
+ `resume()` restarts the main loop only if it has already exited, and right
80
+ after a pause it is still unwinding; the resume was not awaited, so the
81
+ `isRunning()` check saw a live loop that was about to die. A wrapper that has
82
+ since been deleted was adding the microtask that hid this.
83
+ - **`test/**` was never type-checked.** The base config excludes `test`, and
84
+ `exclude` is inherited rather than replaced by a child's `include` — so
85
+ `typecheck:test` silently skipped the suite. Every `expectTypeOf` and
86
+ `@ts-expect-error` in it was inert. They are checked now.
87
+
88
+ ### Packaging
89
+
90
+ - **`dist` is cleaned before every build.** `tsc` never removes files from its
91
+ `outDir`, so every module deleted since 0.1.x was still sitting there —
92
+ `dist/memory/`, `dist/drivers/`, `dist/redis/`, `dist/cron.*` — and would have
93
+ shipped. The tarball went from 86 files / 84.5 kB to 26 / 31.5 kB.
94
+ - **The beta dist-tag lives in a `release:beta` script.** npm 11 does not honour
95
+ `publishConfig.tag`: a plain `npm publish` resolves to `latest`, which would
96
+ have handed a beta to every `npm install enqiu`.
97
+
98
+ ### Changed
99
+
100
+ - `src/` is ten modules by concern rather than one 1,000-line facade. The
101
+ BullMQ vocabulary mapping is pure and now has its own suite: a run without
102
+ `ENQIU_TEST_REDIS_URL` verifies something instead of nothing, and holds
103
+ `mapping.ts` and `serialize.ts` to their own coverage thresholds.
104
+ - 92 tests, 99% statements and 91% branches against a real Redis; 45 of them
105
+ need no server at all.
106
+ - The cancellation marker stores the finished snapshot itself rather than a
107
+ reason beside a copy of one. It was three records of one event that had to
108
+ agree, reassembled differently by each of the two readers.
109
+ - `AbortSignal.any` replaces twelve lines of hand-rolled signal forwarding.
110
+ - `queue.get()` and `handle.refresh()` fetch the job and its state together
111
+ instead of one after the other — `getJobState` needs only the id, so waiting
112
+ for the job first spent a round trip for nothing (~300µs on a local Redis).
113
+ - `handle.result` checks the stored failure envelope before reading the
114
+ cancellation marker: the envelope is free and the marker is a round trip.
115
+ - The benchmark uses BullMQ's own `removeAllQueueData` rather than `KEYS`,
116
+ which is O(keyspace) and blocks the server being timed.
117
+ - Telemetry has one module that owns the event vocabulary and the envelope.
118
+ The names were inline string literals at three sites across two files, with
119
+ `{ type, queue, timestamp, fields }` rebuilt by hand at each — adding an
120
+ event meant knowing which file to open. Where events fire is unchanged;
121
+ telemetry is cross-cutting and centralising that would be worse.
122
+ - The paging arithmetic behind `queue.list()` is a pure function, so the
123
+ calculation a single-number cursor got wrong is now covered without a server.
124
+ It takes each state's offset attached to its items rather than as a second
125
+ array, which makes a length mismatch between the two unrepresentable.
126
+ - `queue.on()` no longer asks Redis for a state its own event already carried.
127
+ - `close()` shuts the three connections down concurrently; `queue.onIdle()`
128
+ backs its poll off from 20ms to 250ms.
129
+ - Telemetry now receives the worker's completions, failures and stalls, not
130
+ only what Enqiu itself does.
131
+ - The overhead benchmark interleaves its contestants and gives raw BullMQ the
132
+ same connection handling as Enqiu. Sharing one ioredis between Queue and
133
+ Worker had made the floor read slower than the wrapper built on it, inflating
134
+ every previously published figure. The measured cost is about 2%, or 3% with
135
+ Zod validation.
136
+
137
+ ## [0.3.0-alpha.0] - 2026-08-06
138
+
139
+ Enqiu is now a typed layer over [BullMQ](https://bullmq.io) rather than a queue
140
+ implementation. BullMQ owns storage, scheduling and execution; Enqiu owns the
141
+ developer experience — inferred job names, schema-validated input, and one
142
+ object you call like a function.
143
+
144
+ This reverses the direction taken in 0.1.0, which replaced an earlier BullMQ
145
+ wrapper with first-party drivers.
146
+
147
+ ### Breaking
148
+
149
+ - **`connection` is required.** `enqiu()` now takes a BullMQ connection instead
150
+ of a driver. `driver`, `redis()` and `DriverEnqiuOptions` are gone.
151
+ - **The in-memory driver is removed, and with it browser support.** BullMQ
152
+ requires Redis and Node, so the `browser` export has been dropped. An in-tab,
153
+ non-durable queue is no longer possible.
154
+ - **Zero runtime dependencies is no longer true.** `bullmq` and `ioredis` are
155
+ peer dependencies.
156
+ - **Per-key concurrency, per-key throttling and debounce are removed.** BullMQ's
157
+ open-source tier offers one global `{ max, duration }` limiter per worker;
158
+ per-key grouping is a BullMQ Pro feature. These are absent rather than faked.
159
+ - **`historyLimit`, `rateLimit` and `catchUp` schedules are removed**, having no
160
+ open-source equivalent. `logLimit` maps onto BullMQ's `keepLogs`.
161
+ - `JobSnapshot` is reshaped around BullMQ's job model: no `priority`, `retries`,
162
+ `runAt` or `expiresAt`; `logs` are strings rather than structured entries.
163
+
164
+ ### Kept
165
+
166
+ - `job()`, full type inference, and Standard Schema validation.
167
+ - Per-attempt `timeout` with an `AbortSignal`, and `expiresIn`. BullMQ has
168
+ neither; Enqiu enforces both around the handler.
169
+ - The serialization guard, which still reports the exact path of an
170
+ unserialisable value.
171
+ - `queue` and `worker` control surfaces, mapped onto BullMQ.
172
+
173
+ ### Removed
174
+
175
+ - `src/memory/*`, `src/redis/*`, `src/drivers/*`, the `QueueDriver` seam, the
176
+ cron parser and 728 lines of Lua — roughly 4,000 lines.
177
+ - Five of the ten scenarios, which demonstrated features that no longer exist.
178
+ - The BullMQ comparison benchmark, which no longer has two things to compare.
179
+
180
+ ## [0.2.0-alpha.0] - 2026-08-06
181
+
182
+ Enqiu is now labelled alpha and published under the `alpha` dist-tag, so
183
+ `npm install enqiu` will not resolve to it. It is not suitable for production:
184
+ the API will keep breaking without a deprecation period, and neither the
185
+ durability guarantees nor the Redis driver have been validated under sustained
186
+ real-world load.
187
+
188
+ ### Breaking
189
+
190
+ - `RedisEnqiuOptions` is now `DriverEnqiuOptions`, and its `driver` field is
191
+ typed as `DriverFactory` rather than `RedisDriver`. Any driver factory is
192
+ accepted, which is what makes the backend an extension point rather than a
193
+ hard-coded pair.
194
+ - The Redis driver now rejects a `historyLimit` below 1 instead of silently
195
+ clamping it. The in-memory driver still accepts 0; Redis trims its terminal
196
+ lists with `LTRIM`, which cannot express "retain nothing".
197
+
198
+ ### Added
199
+
200
+ - A `QueueDriver` seam (`src/driver.ts`) that both backends implement, with
201
+ `DriverFactory` carrying its own queue constructor.
202
+ - Cursor pagination for `queue.list()` on the in-memory driver, matching the
203
+ Redis driver's offset semantics.
204
+ - `pnpm run test:coverage`, with thresholds enforced in `vitest.config.ts`.
205
+
206
+ ### Changed
207
+
208
+ - The package is tree-shakable. `api.ts` no longer imports the Redis driver, so
209
+ a memory-only bundle drops from 137,786 to 72,562 bytes (-47%), losing all 215
210
+ lines of Lua and the `RedisQueue` class. Importing `redis()` still pulls them
211
+ in, as it should.
212
+ - `MemoryQueue` keeps live per-status counters instead of scanning its record
213
+ map, so `size` and `stats` are O(1). Pushing 20,000 jobs through a
214
+ concurrency-64 queue went from 3,102ms to 144ms (6,447 to 139,370 jobs/sec).
215
+ - Validators, error serialization, and backoff arithmetic moved into
216
+ `src/internal/`, replacing per-driver copies. Both drivers now reject a
217
+ non-finite or negative backoff delay rather than the Redis driver clamping it.
218
+
219
+ ### Fixed
220
+
221
+ - **`enqiu()` did not type-check with a real schema.** Its copy of the Standard
222
+ Schema types omitted the explicit `| undefined` the spec puts on every
223
+ optional property, so a Zod, Valibot or ArkType schema failed to assign under
224
+ `exactOptionalPropertyTypes`. `JobDefinition` also pinned the schema to
225
+ `StandardSchemaV1<unknown, unknown>`, and since `JobHandler` is contravariant
226
+ in its input — and the generic is invariant in `Schema` — a handler typed to
227
+ its own schema was rejected. The result was that the README's headline
228
+ example did not compile: `jobs.sendEmail` came out `possibly undefined` and
229
+ its result `unknown`. No test caught it because every test used a hand-rolled
230
+ schema with `types: undefined`.
231
+ - **`when` retry predicates and function backoffs were silently dropped by the
232
+ Redis driver.** They were treated as data to be serialised, when they are
233
+ code: a worker always holds the definition of the job it runs, so it can
234
+ resolve them locally. It now does, and a function backoff is no longer
235
+ written to Redis at all.
236
+ - **`historyLimit: 0` was rejected by the Redis driver.** `LTRIM` cannot express
237
+ an empty window (`LTRIM k 0 -1` keeps everything), so the scripts now delete
238
+ the list instead. Both drivers accept 0 and retain nothing.
239
+ - **`close({ drain: true })` abandoned queued work on Redis.** It stopped the
240
+ worker before draining, so only already-claimed jobs finished while the
241
+ in-memory driver finished the whole backlog. The Redis driver now keeps its
242
+ worker running until the backlog clears, ignoring other workers' in-flight
243
+ jobs so a deploy does not block on them.
244
+ - **`worker.onIdle()` was effectively a no-op on the Redis driver.** It waited
245
+ only on work this process had already claimed, so calling it right after
246
+ submitting — before the poll loop had claimed anything — returned immediately
247
+ with a full backlog. The in-memory driver waited on queued work too, so the
248
+ same code behaved differently on each driver. `onIdle()` now waits for the
249
+ queue to be drained; `close()` still drains only in-flight work, because the
250
+ worker is already stopped by then.
251
+ - **Queue events never fired on the Redis driver.** `node-redis` returns `XREAD`
252
+ as an object keyed by stream name (and a `Map` under RESP3), while RESP2
253
+ clients and Bun's return a nested array. The stream parser bailed on anything
254
+ that was not an array, so it always produced zero entries — `queue.on(...)`
255
+ and telemetry forwarding subscribed successfully and then stayed silent
256
+ forever. `XREVRANGE` does return an array, which is why cursor setup worked
257
+ and hid the fault.
258
+ - A bad `logLimit` was reported as `historyLimit must be a non-negative integer`,
259
+ because the validator hardcoded the wrong field name.
260
+ - `queue.list({ cursor })` was honoured by Redis and silently ignored in memory,
261
+ so code that paginated correctly against Redis re-read page one forever
262
+ against the in-memory driver.
263
+ - `list.limit` was range-checked by Redis but not by the in-memory driver.
264
+ - `MemoryQueue.cleanup` kept scanning every remaining record after it reached
265
+ the requested limit.
266
+ - Removed two unused functions from the Redis driver that `noUnusedLocals`,
267
+ being disabled, never flagged.
268
+
269
+ ### Internal
270
+
271
+ - Test count rose from 36 to 136, all of which now run: the Redis suite was
272
+ verified against Redis 7.4.10 through `node-redis`. Coverage is 96.65% of
273
+ statements without Redis and 95.26% with it. `src/internal/` is fully covered.
274
+ - Added ten runnable, self-asserting usage scenarios under `examples/scenarios/`,
275
+ chosen from documented workloads rather than intuition. The reasoning and its
276
+ evidence grades are in `docs/use-case-research.md`. Running them against a
277
+ live Redis is what surfaced the `onIdle()` defect above.
278
+ - Fixed test isolation in the Redis suite. It called `flushDb()` before every
279
+ test, wiping a database that Vitest's parallel test files were using at the
280
+ same time, which made `examples/testing/jobs.redis.test.ts` fail roughly one
281
+ run in three. Each test now owns a namespace and cleans up only its own keys,
282
+ which is what the README already claimed. Doing that exposed a second latent
283
+ coupling: the cron test hardcoded the default `enqiu:` key prefix.
284
+ - Resolved Airbnb style-guide violations in `src`: `no-plusplus`,
285
+ `no-underscore-dangle`, `no-nested-ternary`, and an unused import.
286
+
287
+ ## [0.1.3] - 2026-08-03
288
+
289
+ ### Added
290
+
291
+ - Added an explicit browser export for the in-memory queue, with browser usage
292
+ guidance and a live React landing example powered by the packaged Enqiu runtime.
293
+ Browser queues run in the current tab and are intentionally non-durable;
294
+ Redis remains a server-runtime driver.
295
+ - Added a React queue playground at `/playground` for composing, running,
296
+ inspecting, cancelling, retrying, and redriving real in-browser jobs.
297
+
298
+ ### Fixed
299
+
300
+ - Allowed strict TypeScript handlers to combine typed inputs with `JobContext`.
301
+ - Made memory-driver cleanup honor requested terminal statuses instead of
302
+ removing every terminal job.
303
+
6
304
  ## [0.1.2] - 2026-08-02
7
305
 
8
306
  ### Added