Releases: C9up/bay
Release list
v0.2.5
Build against the published ream, require 0.2.26 at run time
The devDependency pinned ^0.2.26, which is not on npm, so pnpm install
failed before a single test ran and CI could not say whether the code was
good. Nothing here imports a symbol that 0.2.26 added: the only reference
is the @c9up/ream/types module augmentation, which 0.2.25 carries.
The peer stays ^0.2.26 — that is where configure()'s makeUsingStub
requirement belongs — and the caret picks 0.2.26 up on its own once it is
published.
Generate the config from a stub
The file this package writes lived as a template literal inside its own
TypeScript — every backtick and every ${ escaped, and no way for an
application to change it without forking the package.
It is a stub now, read through codemods.makeUsingStub, the same route
ream-cli has always used for the make: generators. An application that
publishes stubs/<path> gets its copy instead, and the generated file is
byte-identical to what the literal produced.
The ream peer moves to ^0.2.26: that is the release the codemod appears in.
Changes since v0.2.4.
v0.2.4
release: bay 0.2.4
Require Node 24, and build the crates for production
Node 24, not because it is the current LTS — that is AdonisJS v7's own
stated reason and it is not one for us, since 22 still receives security
fixes until 2027, npm 11 is irrelevant under pnpm, and node:sqlite is
not what atlas uses. The reason is measurable and it is the framework's:
AsyncLocalStorage is on the request hot path, and Node 24 backs it with
AsyncContextFrame by default — 0.61 us per request instead of 1.55 us on
that exact pattern. Before 24 the same mechanism sat behind an
experimental flag, and a framework cannot base its performance on a flag
the application has to remember to pass. The reason travels with the
constraint, in a "//engines" key beside it.
Where there are crates: the default release profile leaves lto = false
and codegen-units = 16, so nothing inlines across crate boundaries —
and here the hot loop and the N-API binding that calls it are always two
different crates. Measured on atom, a scalar call through the binding
went from 18.85 ms to 13.59 ms for 50 000 operations. No panic = "abort": napi-rs catches panics and turns them into JavaScript
exceptions.
CI moves to Node 24 with them, since that is what the packages now ask
for.
Changes since v0.2.3.
v0.2.3
Admit quasar 0.2 alongside 0.1
Its subscribe answers void there instead of a promise that resolved even
on failure. Nothing here awaited that return value, so both majors work.
Keep the dev-dependency alignment, drop the workspace: protocol
The internal ranges had been rewritten to workspace:^. That resolves inside
this monorepo and nowhere else: every package CI checks out its own repository
alone and runs pnpm install, where the protocol has no workspace to point at
and fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND before a single test runs. The
concrete ranges are back; the dev-dependency bumps that came with the same edit
are kept, and now match what the lockfile already resolved.
Changes since v0.2.2.
v0.2.2
fix(provider): discover jobs in ready(), which is what runs after the preloads
The previous move put discovery in start() on the belief that preloads run
between boot and start. They do not. Upstream's warm-up is
providers.start() -> the starting hooks -> the preloads, so start() is
still BEFORE them -- a job reaching for a service a preload registers found
nothing, exactly as it did from boot.
ready() is the phase that runs after all of it, and it is also the phase an
inspection does not run: ream inspect and a codegen pass no longer import
every job in the application.
Coverage: the boot/start split and the branches that reject a file left the
branch floor short, so the paths are covered rather than the floor lowered --
a nested directory (which is what make:job emails/X writes), a .d.ts beside
a job, and a module that is not an object at all.
fix: discover jobs in start(), and refuse to come up with none
Discovery IMPORTS application modules and it ran in boot(), before preloads. A
job reaching for a container service -- the ordinary way to write one -- then
waited on a boot that was waiting on its own import. It moves to start(),
which is what the phase is for: by then the application is assembled and a job
may depend on it. Setting the make:job directory stays in boot, being
bookkeeping.
The "no handlers" guard only fired when every file failed to LOAD. A file that
imported cleanly and exported the wrong thing was skipped in silence -- the
shape a rename or a forgotten export default takes -- so a directory of files
that all compiled and none of which held a Job produced an empty discovery and a
worker that accepted jobs and processed none.
The reason no longer matters: scanning files and finding no Job is a failure.
Each skipped file also says what it exported instead, so the fix is the next
thing you read rather than something to go looking for.
Changes since v0.2.1.
v0.2.1
Accept a manager that answers only through a get trap
Quasar's service accessor is a Proxy over an empty null-prototype object with
a get trap and nothing else, so "connection" in manager is FALSE while
reading it returns the function. The probe was gated on in — added to stop a
mocked module namespace from raising on an export it does not have — and that
rejected the real manager. Only the live-Redis suite noticed, because every
unit test used a plain object.
Reading through a catch satisfies both shapes, and both are now pinned: fixing
either one alone breaks the other, which is exactly what happened.
Take the quasar loader from the vendored copy
Seven packages carried the same optional-peer loader: the runtime specifier,
the manager guard, the command check and the messages around them. Only three
things differed — the commands each issues, what it does with them, and how it
builds an error — so those are passed in and the rest is generated from
scripts/vendor/quasarConnection.ts.
Two things the packages' own tests caught while it was being unified, and both
are now properties of the shared copy rather than of one package:
The module namespace is probed with in before it is read. Reading an export a
namespace does not have is not always harmless — under a test double it raises
instead of answering undefined, so the probe failed on the mock rather than
falling through to the default export.
The error is built by the caller. nova raises NovaError with E_NOVA_* codes
that a caller catches on, and a shared helper throwing a bare Error would have
dropped them silently. Packages that offer a client object instead of a
connection name keep saying so, too: unifying the wording had removed the
alternative from the one message where it was actionable.
Take nodeEnv from the vendored copy
The file was identical to the other packages' copies, line for line, and they
had drifted apart only in their comments — each explaining its own
consequence, which is what made three identical files impossible to diff.
It is generated from scripts/vendor/nodeEnv.ts now and never edited here; the
reason this package cares moved to the decision it explains. It cannot be a
dependency: this package is published and built from its own repository, where
nothing of the workspace exists.
Find the jobs where the application is, and say when you cannot
Discovery resolved its locations against process.cwd(), so app/jobs meant
whatever directory the process started in. A worker launched from anywhere
else found nothing, and found it silently. The context takes the host's
makePath, which is what upstream provides for exactly this; a host without one
keeps the old behaviour, since bay is agnostic.
Two silences went with it. Every readdir failure read as an empty directory,
so a permission denial, a broken mount or a path naming a file produced a
worker with no handlers that accepted jobs and processed none — only ENOENT
means 'not written yet' now. And skipping a broken job file so the others
still run is deliberate, but skipping ALL of them is a broken deploy, not a
warning: discovery refuses to return nothing when every file it found failed
to load.
Changes since v0.2.0.
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, an...
v0.1.13
Keep a job in processing when its lease cannot be written
pop() wrapped the JSON parse and the lease write in one try, and the catch
deleted the entry from the processing list before returning null. LMOVE had
already taken it out of pending, so a Redis blip on the lease write destroyed
the job outright — no retry, no failed record, nothing to recover.
Only an unparseable or malformed payload is purged now. A lease failure
propagates and leaves the job in processing, where recoverStale() reclaims it:
a missing lease is what it already treats as recoverable.
Say it out loud when the lossy pop is running in production
The opt-in silenced the warning, so a process deliberately running with
at-most-once delivery said nothing about it. Agreeing once in a config file
is not the same as being reminded, in the logs of the incident, that this is
how the process was running. The README now states the guarantee and the two
ways to keep it.
Refuse the lossy pop in production, and stop the worker on shutdown
Without LMOVE the pop is lpop then rpush, and a crash between the two
deletes the job from pending before it reaches processing: nothing recovers
it, because nothing knows it existed. That is a different delivery
guarantee, and in production it now has to be asked for.
stop() waits for the loop to actually be gone rather than only the job in
flight, and its sleep is cancellable, so a teardown does not return with a
worker still polling. The provider shuts down the queue IT booted, so two
applications in one process cannot stop each other's.
Declare the environment variables the generated config reads
Checked against @adonisjs/mail 10.4.0, whose configure() publishes the config
AND calls defineEnvVariables beside it. Mine wrote a config full of
env.get('QUEUE_STORE') and declared nothing, which is the half-installation
the hook exists to prevent: the application boots, the config asks the
environment for something nothing ever put there, and the fallback answers.
addEnvVars was already on ream's codemods and simply went unused.
Say that ream add sets this up, because it does now
Ship the configure hook ream add expects
ream add <pkg> installs, then imports <pkg>/configure and runs it. Nine
packages provided that hook and this one did not, so ream add left an
application with a provider registered and no config file for it to read —
falling back to a default that is rarely the one anybody wanted, silently.
The hook registers the provider and writes the config stub beside it, because
the two are one step: a provider without its file is not installed, it is half
installed.
Name the queue backend in the config, like every other package
Bay took driver: "memory" and threw for anything else, telling the
application to build the QueueManager itself — so Redis could not be reached
from a config file at all, and the environment could not choose.
AdonisJS gives a package with several backends and one selected the same shape
everywhere (session, limiter, lock, mail, drive): a factory namespace imported
beside defineConfig, each entry that namespace's result, the selection read
from the environment.
import { defineConfig, stores } from '@c9up/bay'
default: env.get('QUEUE_STORE'),
stores: {
memory: stores.memory(),
redis: stores.redis({ connection: 'main' }),
}
Only the selected store is built, and a default naming nothing throws listing
what exists — falling back to memory would look like it worked until a restart
dropped every pending job. The quasar connection name goes through the bridge
bay already had, so quasar stays an optional peer.
The single driver form still works.
Changes since v0.1.12.
v0.1.12
Use real private fields, not the TypeScript keyword
private is erased at compile time: at runtime the field is public,
enumerable, and shows up in Object.keys and JSON.stringify. # is enforced by
the engine. The difference is not cosmetic, and one test proved it — photon's
renderer test reached into a private ssrModule through an
as unknown as { ssrModule } cast, which the keyword never prevented. It now
goes through useSsrModule(), a real seam, and the cast is gone.
Constructor parameter properties are expanded into a field plus an assignment,
since # cannot be declared in a parameter list.
Three private CONSTRUCTORS stay as they are, annotated: that form has no native
equivalent, and it is the one place the keyword expresses something # cannot.
Create the GitHub release from the publish workflow
A published version arrived with no notes: npm showed a number, GitHub showed
nothing, and the only way to learn what changed was to read a diff. The commit
messages already carry the reasoning, so the release is built from the commits
the tag contains rather than written twice.
Skips a pure version bump, leaves an existing release alone, and does nothing
when the run was not built from a tag. The job takes contents:write for this;
the workflow default stays read.
Changes since v0.1.11.
bay v0.1.11
bay 0.1.11
Version bump only — no behavioural change.
Changes since v0.1.10.