v0.2.0
Lint this package the way its own repository will
biome's configuration lived only at the workspace root. This package is
built from its own repository, where that file does not exist and biome
falls back to its defaults — so lint in CI has been checking a different
set of rules from lint here, and the bans this project actually cares
about were never enforced where it counts.
The config is now the package's own, and says the same thing the root one
did.
Declare what CI has to install
Each package is its own repository: pnpm install there sees only this
file, so a dependency the workspace happened to hoist locally is simply
absent in CI. --coverage needs @vitest/coverage-v8 named here, and an
optional peer a test imports has to be a devDependency as well — optional
is exactly what keeps it from being installed.
Run the gates the package already declared
Three guard-rails were configured and never reached CI, so each one was a
gate nothing ran:
tsconfig.jsonincludestests, but CI typechecked only
tsconfig.build.json— every type a test relied on went unchecked.vitest.config.tsdeclares coverage thresholds, but CI ran plain
vitest run, which does not read them.lintpointed atsrc/alone, so no test file was ever linted.
CI now runs pnpm typecheck, pnpm test:coverage and a lint that covers
tests/ as well.
Cover the queue surface, and make the coverage gate a gate
bay declared lines: 98, statements: 97, branches: 92, functions: 98 and CI ran
pnpm test — plain vitest run, no coverage. So nothing ever checked them, and
a threshold nothing runs cannot be wrong. Turned on, the package sat at
86.7/86.1/79.1/87.7: the job classes, the discovery and the two commands added
in 0.2.0 arrived with almost no tests, and the number that should have said so
was decorative.
Sixteen tests for what was uncovered — the loader's metadata (a name the kernel
cannot read is a command nobody can run), queue:work forwarding its queues and
concurrency and naming the missing provider, make:job writing where the config
says jobs live, refusing to overwrite one, and reporting a name that would leave
the directory, and discovery registering a default-exported job class while
skipping a helper, a text file, and a module that will not load. 86.7 → 95.0.
The thresholds now sit just under what the suite reaches, and test:coverage
is the CI step, so a regression trips them.
lint covers tests/ too — the gap that let a bad numeric literal survive in
atom. It surfaced two as any with a biome-ignore apiece, both there because
BayContainer and BayConfigStore were not exported: a host, or a test, could
not describe a BayAppContext without being able to name its members. They are
exported now and the casts are gone.
Make a job a class, with the queue, delay and timeout it declares
The queue took a registered name and a payload — two places to keep in step,
and nothing tying the payload to the handler that reads it. A job is a class
now: it carries its own name, its options, and the type of what it is given.
export default class SendEmail extends Job<{ to: string }> {
static options: JobOptions = { queue: 'emails', maxRetries: 5, timeout: '1m' }
async execute() { await mail.send(this.payload.to) }
async failed(error: Error) { /* after the last attempt, not each one */ }
}
await queue.dispatch(SendEmail, { to: 'user@example.com' })
Registering by name still works, and is what a job whose name is computed at
runtime needs.
With it, the four things the declaration was for:
- Named queues. A worker is told which to serve, in order, so a slow queue
cannot starve a fast one sharing the process. On Redis the default queue keeps
the key it always had — naming itqueue:default:pendingwould have orphaned
every job sitting inqueue:pendingat the moment of the upgrade. delay. Held in a sorted set until due, promoted on the next poll;ZREM
is the claim, so two workers reading the same due entry push it once. A client
without ZADD is refused rather than running the job immediately, which is the
one thing a delay exists to prevent.timeout. The attempt fails; the handler is not killed, because nothing
in Node interrupts a running promise. What it buys is that the WORKER stops
waiting — otherwise one stuck job costs the whole worker.concurrency. One at a time was the only speed available, which is a poor
default for anything that waits on the network.
locations in the config is what lets a worker process resolve a record queued
by an HTTP one: every module under it is imported at boot and a default export
that is a job class is registered under its own name. Written by hand that list
is a directory kept in step by hand, and the job nobody added fails as "no
handler registered".
queue:work and make:job ship from the package, through reamrc.commands,
the way every other package's commands do — never through a change to the
binary.
The record is JobRecord now, so Job can be the class. A minor rather than a
patch: on 0.x that is the breaking axis, and Job was an exported type.
Namespace the container token by the package that owns it
Upstream namespaces a satellite's binding by its own package —
lucid.db, auth.manager, mail.manager, limiter.manager,
cache.manager, queue.manager, drive.manager — and leaves the
namespace off only where the package name IS the service (i18n,
redis, vite). Core's own bindings stay bare. Ours were all bare,
which is the vocabulary of no package in particular and one collision
away from a problem.
The bare token stays bound beside the new one, and typed beside it: it is
what every existing container.make(...) asks for, in this repo and in
applications this repo does not see, and a token is not worth breaking an
application over.
Both names are verified live rather than assumed — a declare module
naming a specifier that does not resolve is silently inert, so renaming
the member has to break the compile, and the provider has to bind both at
runtime.
Reformat what the strictness pass reflowed
Two files-worth of blank lines and one long call the formatter wraps
differently now that a helper sits above them. CI resolves biome from a
caret range and installs a newer one than the lockfile pins.
Turn on noUncheckedIndexedAccess
It was not missing here — it was explicitly false, in sixteen of the
seventeen tsconfigs. eon alone had it on, which is why nobody had seen
what it finds.
It stays a named deviation from upstream: @adonisjs/tsconfig sets
strictNullChecks and noImplicitAny but not this one. We keep it because
turning it on is what caught an as asserting a possibly-absent regex
group was a known value — the exact shape the flag exists to find. Doing
better than upstream is kept and written down, not reverted to parity.
Every site is restated rather than silenced: no !, no cast, no ?? 0
standing in for a branch that cannot happen. A reversed copy read by
value where an index walked a callback list backwards, the winner of a
scan kept as the value it found rather than its position, destructuring
where a length check was doing the proving, and an explicit break where a
loop condition already bounds the read.
Restore package.json formatting
The previous commit's dependency edit round-tripped the file through a
JSON serialiser, which reindented every line and reordered the dependency
keys. The dependencies it added are unchanged; the churn around them is
not mine to leave.
Say what container.make('queue') returns
ream declares ContainerBindings open on purpose: it registers its own
entries and expects each package to contribute the one it owns. Nothing
did — so resolving by the string token answered unknown, and every call
site had to assert a type it could not prove.
Type-only, ream stays an optional peer, and the augmentation is verified
live rather than assumed: a declare module naming a specifier that does
not resolve is silently inert, so renaming the member must break the
compile. It does.
Call the worker knobs what the framework calls them
work() takes { idleDelay, stalledInterval } — the names in upstream's
WorkerConfig — and idleDelay defaults to the 2 s upstream defaults to,
where bay had a second of its own choosing. The positional form is kept.
config/queue.ts grows the matching worker block, read by the provider
as the manager's defaults; an argument to work() still wins.
FakeQueue.retry no longer increments attempts. That counter belongs to
the worker — processOne raises it before any driver sees the job, and
neither real driver touches it again — so the fake was counting one
attempt twice and exhausting maxAttempts in half the tries the real
queue takes.
Release 0.1.14
Stop losing, duplicating and re-running jobs
A queue's promise is that a job it accepted runs, once. Five ways it did
not hold, each reproduced before it was fixed:
Two recovery passes over the same expired entry both pushed it back to
pending — one job, delivered twice, from the mechanism that exists to
make delivery reliable. The LREM is the claim now, and its result decides
who continues.
A stall is not an attempt: a worker that dies never reaches the failure
path, so attempts never moved and maxAttempts never applied. A job
that killed whatever picked it up was recovered forever, taking the queue
with it. Counted separately as stalledCount and bounded by
maxStalledCount — default 1, upstream's default, failing the job once
it is exceeded.
A handler slower than its lease was recovered and re-delivered while it
was still running. The worker renews its own claim for as long as the
handler runs, at half the timeout, and renewal is refused once the lease
is gone or has passed to another worker.
Marking a job complete is a write, and a write can fail on its own. That
failure was read as the HANDLER having failed: the job went round again,
the handler ran a second time, and once attempts ran out the job was
filed as failed carrying the driver's error. A job that succeeded, in the
failed list.
The failed list had no ceiling and no owner — nothing trimmed it, and
failed() read all of it in one LRANGE. maxFailedJobs defaults to the
thousand the memory driver has always kept.
Also aligns the config vocabulary with the framework's: default +
adapters, filled from a drivers namespace, selected by QUEUE_DRIVER.
stores / QUEUE_STORE still resolve, so an existing config keeps
working and cannot silently fall back to an in-process queue.
Turn on noUnusedLocals/noUnusedParameters
Changes since v0.1.13.