Releases: C9up/quasar
Release list
v0.2.2
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.1.
v0.2.1
release: quasar 0.2.1
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.
Require the test Redis to be named, never guessed
The integration suites fell back to redis://127.0.0.1:6379 when
REDIS_TEST_URL was unset. That is whichever Redis the machine happens to
be running, which on a developer's box is a live application's server —
these suites connected to one and ran against it, and only an after the
fact check said nothing had been lost.
A missing variable is now a configuration error, raised the way
@adonisjs/env raises one for the same situation: E_INVALID_ENV_VARIABLES,
"Missing environment variable REDIS_TEST_URL". Not a skip either. A suite
that quietly tests nothing is how a broken test sits unnoticed, which is
precisely how the subscribe parity assertion in this package survived
being impossible to pass.
The workflow gains the Redis service that bay, echo and warden already
run, for the reason bay's own comment gives: without it these tests only
ever execute on a developer's machine. quasar was the only package in the
cohort with server-gated tests and nothing behind them.
Assert subscribe answers void, the way upstream does
The parity test wrapped connection.subscribe(...) in
await expect(...).resolves.toBeUndefined(), which vitest refuses on a
value that is not a promise. It could only ever have passed against the
Promise<void> this connection deliberately stopped returning — that
promise resolved even when the subscription had failed, so awaiting it
guaranteed nothing.
@adonisjs/redis 11 declares subscribe(channel, handler, options?): void
in abstract_connection.d.ts, so answering void is the parity, and the
implementation was right. The test now asserts that, and waits for the
failure through onError, which is how a caller actually learns about
it. The subscription:error event is emitted synchronously on the next
line, so it has fired by the time that wait resumes.
Found by running the suite against a real Redis. quasar is the one
package with server-gated tests and no service in its workflow, which is
why this never came up.
Changes since v0.2.0.
v0.2.0
Align the manager, which is the half an application calls
Only QuasarConnection was fixed. QuasarManager.subscribe stayed async and
delegated to the now-void method, which made it WORSE than the promise it
replaced: awaiting undefined resolves before the subscription has even been
attempted, so await manager.subscribe(...) guaranteed less than nothing while
manager.connection().subscribe(...) was correct. The defect survived exactly
where an app meets it.
subscribe / psubscribe answer void here too, subscribed / psubscribed
delegate to the connection's rejecting form, and QuasarService says so — the
contract is what a consumer types against.
Tested at the manager, not only at the connection: that was the gap that let
this through. Three mutations, each felling its own case.
subscribe() answers void, as upstream does
Promise<void> was the worst of both. The catch RESOLVES it, so
await subscribe(...) completed exactly the same when the subscription had
failed — it looked like a guarantee and gave none. Two sibling packages had
already worked around it independently, each capturing onError to turn a
reported failure back into a rejection, and each said so in a comment.
subscribe and psubscribe return void now, which is upstream's shape and
carries no promise it cannot keep. subscribed / psubscribed are the
awaitable form: they resolve when the channel is live and REJECT when it could
not be subscribed, so awaiting one means what awaiting should mean. onError
fires either way.
Breaking, hence 0.2.0.
Give the Promise return its real reason
The note claimed an async method cannot return void, which proves nothing about
whether the upstream signature is reachable — it is, through a private helper.
The actual reason is behavioural. Only the type differs: a caller written the
upstream way, never awaiting and reacting through onSubscription, behaves
identically here — verified against a live server. What the promise buys is the
guarantee that the subscription is live when the next line runs, and sixty-five
call sites across this package and its siblings await it before publishing.
Declaring void would turn every one of them into a race that passes locally
and fails under load.
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.
Name the CI workflow after the package
Every workflow already declared name: <pkg>-ci inside — twenty-eight of
twenty-nine — while the file was ci.yml almost everywhere and
<pkg>-napi-ci.yml in three places, where the -napi said nothing: half the
packages with a Rust engine did not carry it.
The file now matches the name it has always had, so one rule covers every
repository and the publish command no longer depends on remembering which
three were spelled differently.
GitHub keys run history by file path, so the runs recorded under the old name
stay reachable under it and this workflow starts a fresh history.
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.
Changes since v0.1.5.
v0.1.5
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.
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.
Say what container.make() returns for the tokens this package binds
ream declares ContainerBindings open on purpose: it registers its own
entries and expects each package to contribute the ones it owns — its
comment on the interface names auth (warden), logger (spectrum) and db
(atlas) as exactly this. None of them did, and every other package that
binds a string token was in the same state, so container.make('cache'),
make('mail'), make('hash') and the rest all answered unknown and
every call site had to assert a type it could not prove.
Loaded from the barrel AND from the provider, the second of which is where
AdonisJS puts its own (providers/redis_provider.ts carries the
declare module for redis, database_provider.ts for lucid.db).
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. It does.
Release 0.1.5
Stop treating a dead socket as a working one
Seven defects, all on the half of a connection's life no test reached: the
half where the server goes away rather than never answers.
A health check issued its command whatever the connection's state. ioredis
does not fail a command on a connection that is not ready, it QUEUES it —
measured at 73 seconds before it gave up with the defaults, and never with
maxRetriesPerRequest: null, which is what a queue needs. A readiness probe
that hangs for a minute is the outage it exists to report. Both checks now
take Adonis' guard: wait on a connection still dialling, never on one that
already recorded an error.
The memory check defaulted both thresholds to Infinity, so one added and
left unconfigured could never fail — which reads as a monitored server.
Adonis' 100 MB / 120 MB instead.
quit() sent QUIT whatever the socket's status. On an ended one ioredis
rejects every command, and that rejection travelled through Promise.all and
out of the provider's shutdown, so one socket that had died on its own
aborted the shutdown of every other. On a wait one it dialled a server, on
a shutdown path, for the sole purpose of saying goodbye. Both guards are
Adonis'.
The manager cached a connection whose socket had ended and handed it back
for the rest of the process: every command rejected, activeConnections still
reported it open, and nothing ever opened a live one. The subscriber socket
had the same defect one level down — the next subscribe reused an ended
socket, so the application stopped receiving with the failure reported as if
the channel were at fault.
A pattern handler took (message, channel); Adonis passes (channel, message).
Both are strings, so the swap was silent. BREAKING for psubscribe callers.
QuasarLogger asked for pino's error(payload, message), which nothing in this
framework implements, and the provider passed no logger at all — so every
connection failure went to the console. It now takes ream's signature and
the provider resolves one from the container.
Changes since v0.1.4.
v0.1.4
Turn on noUnusedLocals/noUnusedParameters
Isolate a subscriber handler that throws synchronously
Promise.resolve(handler(...)) calls the handler before the promise exists, so
a synchronous throw never reached the .catch: it unwound the dispatch loop and
escaped into the Redis client's own callback, and every later handler on the
channel was skipped. The existing test only covered an async rejection.
Release 0.1.4
Report a subscriber handler that rejects
A message handler is an event callback: nobody awaits it, so a rejection had
nowhere to go. Depending on the runtime that is an unhandledRejection warning
or a process that exits, and either way the failure never reached the logger
that exists for exactly this.
It is reported the way a connection failure is, and the other handlers on the
channel still run.
Changes since v0.1.3.
v0.1.3
Stop the offline connection tests depending on a local Redis
The pub/sub suite says 'without a server answering' but only reached that
state by nothing happening to listen on 6379. On a developer machine with
Redis running the warm-up subscribe succeeded, so the twelve tests passed for
the wrong reason; on CI nothing listens, ioredis retried, and all twelve hit
the 5s timeout — a failure the machine that wrote them could not reproduce.
enableOfflineQueue: false makes a command issued before the socket is up
reject at once instead of queueing, which is the same outcome whatever is
listening. The suite now runs in under a second either way, against a live
Redis or none.
One rejection had been surviving in the offline queue rather than being
raised; awaited now, so it is not reported as an unhandled error for the run.
Cover the connection before it reaches a server
The integration suites skip when nothing answers on 6379, which left the
whole non-network half untested: the status readers, the command dispatcher
and the error wiring — the part whose only job is to keep an error event
from taking the process down.
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.
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.2.
quasar v0.1.2
quasar 0.1.2
docs: bring the README in line with the code
It still said re-subscribing REPLACES a channel's handler — the opposite of
what it now does (handlers stack, as in Adonis). Also missing: commands on
the manager, connection state, LUA scripts, the subscriber:* events, that a
failed subscribe does not reject, and quit/disconnect acting on one
connection with quitAll/disconnectAll for the sweep.
8510a4e
feat(quasar): mirror @adonisjs/redis's subscription events, and stop rejecting
Two corrections to the previous commit, both because it deviated from Adonis
on my judgement rather than on a constraint:
-
subscription:ready / subscription:error / psubscription:ready /
psubscription:error are emitted, with Adonis' payloads ({ connection,
count } and { connection, error }). Skipping them because "await already
tells you" was a preference, not a limitation — and it left an app that
follows the Adonis idiom (never awaiting subscribe) with no way to learn a
subscription failed. -
A failed subscription no longer REJECTS. Adonis' subscribe returns void, so
code written against it does not await the call; a promise rejecting into
nobody is an unhandled rejection, which ends the process. That made a
resilience path into a crash path for exactly the migration this package
exists to make seamless.
The error is not swallowed: onError fires, the event fires, and the
connection logger reports it — one more channel than Adonis has, so an app
wiring neither still sees the failure.
Verified against a live Redis: a subscribe on a closed socket resolves,
reports through both channels, and does not take the process with it.
100a4e0
fix(lint): make the merged interface's type parameter load-bearing
CI failed on biome, not on tests: the merge interface declared a
Connections parameter it never used (TypeScript requires it to match the
generic class, biome flags it as dead).
It now declares managerConfig, which Adonis exposes too — so the parameter
carries something real instead of being silenced with an underscore. That
turns the declaration-merging warning into an error, suppressed with a
reason: the merge IS how the forwarded ioredis commands are typed, exactly
as QuasarConnection does it.
289d2d7
fix(ci): skip the parity suite when no Redis answers
The new integration file connected straight to the default URL instead of
probing first, so CI — which runs no Redis service — failed on it. Same
probe as connection.integration.test.ts: skipped, not failed.
9d6f80f
feat(quasar): match the @adonisjs/redis manager surface
Audited against the published @adonisjs/redis 10.0.0 build — the last
package in the ecosystem that had never been compared to its Adonis
counterpart, and the one four others (bay, echo, warden, ream) sit on.
Two divergences would have broken an Adonis app that only swapped its
imports, both silently:
- quit()/disconnect() with no name closed EVERY connection; Adonis closes
only the default one. An app calling redis.quit() lost connections it
still needed. They now target the default connection, and quitAll() /
disconnectAll() do the sweep (what the provider's shutdown wanted). - Subscribing twice to a channel REPLACED the handler, so the second
subscribe silently stopped the first. Adonis keeps a Set and delivers to
every handler; unsubscribe(channel, handler) drops just one and leaves
the socket subscribed while others remain.
Missing surface, all present upstream: ioredis commands callable on the
manager (redis.get(...) runs on the default connection), defineCommand /
runCommand — applied to open connections AND remembered for later ones,
as Adonis does — doNotLogErrors, activeConnections, activeConnectionsCount,
and on a connection connectionName, status, subscriberStatus,
autoPipelineQueueSize, lastError, isConnecting/isReady/isClosed.
Two named deviations: subscribe also REJECTS on failure (Adonis reports it
only through onError, so an app that never opts in ends up silently
unsubscribed — onError still fires); and the error logger is an optional
structural seam rather than a required framework Logger, because quasar is
an agnostic leaf.
Verified against a live Redis 7, including the semantics that changed.
2bb2418
Changes since v0.1.1.