Skip to content

Releases: C9up/echo

v0.2.1

Choose a tag to compare

@github-actions github-actions released this 10 Sep 14:15

Wait on the callbacks, not on what subscribe returns

The bridge awaited source.subscribe(...) and then read a flag onError may
have set. That worked only because quasar returned a promise; it answers void
now, so the flag would be read before the subscription had even been attempted
and a failed subscribe would read as success.

The callbacks are the signal, and the only one correct against both quasar
majors — the range admits either.

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.0.

v0.2.0

Choose a tag to compare

@github-actions github-actions released this 09 Sep 16:23

Assert null at the driver boundary, which is where a miss is still null

The breaking change that made get() and pull() answer undefined says
plainly where the line is: the DRIVER reports "no entry" as null, and the
manager translates that into "nothing cached". Two assertions in the Redis
driver suite were moved to undefined anyway, so the only test exercising
deleteByTag against a live server had been failing ever since — the sibling
expiry test three lines up asserts toBeNull() on the very same call.

Verified against a real Redis, not inferred: the failure reproduces before the
change and the suite is green after.

fix!: a miss reads back as undefined, and ttl accepts a duration

get() and pull() answered null where upstream answers undefined, so
code written against the documented shape — if (value === undefined)
never matched a miss, silently. They answer undefined now. The DRIVER
boundary still speaks null: a driver reports "no entry", and the manager
translates that into "nothing cached", which is where the two vocabularies
meet.

ttl was the one timing option locked to number while grace, timeout,
hardTimeout and lockTimeout all read a Duration — so a config copied
from the documentation failed to typecheck on the line most likely to be
copied. It takes '30s' now, like the rest.

And a multi-store configuration can declare one ttl that every store
inherits, as upstream's does. Copying a config that used it silently fell
back to the built-in hour instead: a cache with a different lifetime than
the file said.

BREAKING: get()/pull() return undefined rather than null on a miss.

Two mutations, each falling on its own tests.

fix!: a namespace clears only its own subtree, and no operation runs off the bus

namespace() returns a prefixed view over the SAME driver, and clear()
called driver.flush() — so a namespace per tenant, which is the documented
use, gave every tenant the power to empty every other tenant's cache from an
operation they are allowed to run. Upstream scopes it by handing the driver
the prefix, and its Redis driver SCANs on it; flush(prefix?) does the same,
and a driver that cannot scope the removal must refuse rather than flush
everything. The prefix travels on the bus too — a peer that dropped its whole
L1 on a namespaced clear is the same mistake one hop away.

The separator is part of the prefix, so tenant-a does not take tenant-abc
with it.

The connection gate had been added to four methods by hand, and getOrSet,
deleteMany, expire, clear, setWithTags, deleteByTag, prune and
pull went without: a store built after ready() read and wrote before it
had joined the bus, and any invalidation sent in that window was missed for
good. All of them wait now, and a test drives the public surface rather than
a list, so a method added later is covered without anyone remembering.

The gate also OPENS the connection rather than only waiting for one. A late
store's connect can fail, and nothing would ever have tried again.

#connecting was kept after it resolved, so connect() after disconnect()
was a no-op on a connection that had since been closed — a hot reload came
back up on no bus. And TieredDriver.disconnect() read the handler while a
subscribe was still in flight, saw none, and left it to land afterwards on a
store nobody was tracking. Both teardowns wait for the connect they overlap.

BREAKING: CacheDriver.flush takes an optional prefix. A driver that ignores
it silently clears everything, which is the failure this exists to prevent.

Five mutations, each falling on its own test.

style: restore the trailing newline the last two tests dropped

fix: connect every store, once, and forget a subscription only when it is gone

Three findings, all downstream of the previous round's split between
publishing a cache and opening one.

ready() reached the DEFAULT store, because that is what the provider
publishes — so a named tiered store declared beside it never subscribed, and
its L1 kept serving copies of keys other instances had already deleted. And a
store built after ready() — which is every store first touched by a request,
since stores are created on first use — was connected by nobody at all.
connectAll/disconnectAll existed and no production path called them.

connect() was announced idempotent and was not: two callers both saw an
empty handler before the first await resolved, so two subscriptions went on
and one came off. It is single-flight now, and CacheManager.connect()
memoises too.

Both teardowns forgot their handler BEFORE asking the bus to remove it, so a
refusal left a live listener nothing could name again — neither to retry nor
to remove at a second shutdown. Both now forget last.

use() stays synchronous, as upstream's does and as the README chains off —
so a store built after ready connects on its way out, and the manager's own
operations wait for that. Its failure costs staleness rather than every read;
refusing the boot is the provider's job, and it still does.

Five mutations, each falling on its own test.

test: an unreachable bus is reported, and the store still serves reads

subscribe was fire-and-forget, so a test asserted it did not throw when
the quasar package was missing — on the grounds that a bus which is down
costs staleness while throwing would cost every cache read.

Both halves still hold; they belong to different callers now. The subscribe
is a connect() the provider awaits in ready(), so it says so — and the
old test left that rejection unhandled, which vitest reported as an error
beside a green run. The reads are the store's, and a companion test in
tiered-coherence pins that they keep working while the peers are
unreachable.

chore(release): echo 0.2.0

A hand-built TieredDriver must now be connected before it receives peer
invalidations, so the minor moves — on 0.x that is the breaking position.

fix!: publish the cache at boot, open its bus at ready, and make use work

Three findings that turn out to be one shape: what a provider publishes and
when it opens something are different questions, and echo answered them with
one call.

PUBLISHING moves back to boot(). Upstream's own accessor resolves the
manager on app.booted(), and it has to: the HTTP socket opens BEFORE the
providers are readied, so a cache published in ready left a window where a
request could reach a controller and services/main would throw — and a
preload that actually uses the cache failed outright. That was my own
regression from the previous round.

OPENING moves to CacheDriver.connect(), which ready() awaits. Building a
store no longer subscribes to anything, so ream inspect — which runs
register, boot and start but never shutdown — leaves no Redis connection
behind. A failure there fails the boot, deliberately: an instance whose bus
never subscribed keeps serving its own L1 copies of keys other instances
have already deleted.

Which it did, silently. The quasar bus fired its subscribe and swallowed the
rejection, and quasar catches the Redis error itself, calls onError and
resolves — so awaiting proved nothing and no onError was passed. It is
awaited now, a reported failure is a rejection, and a failed attempt is
forgotten so a retry is not refused by bookkeeping for a subscription that
never happened.

And cache.use('tiered') — the second line of the README's first example —
now exists on the object the provider publishes. It bound the default store,
a CacheManager, which had no use. Upstream's manager is one object that
both operates on the default store and reaches the named ones; this one is
too, and it names an unknown store rather than quietly answering with the
default.

BREAKING: a TieredDriver built by hand must be connected before it
receives peer invalidations. Publishing still works untouched.

Four mutations, each falling on its own tests.

fix: let go of the cache bus, and open it only once the app is ready

CacheBus.subscribe handed nothing back and TieredDriver.disconnect()
released only L1 and L2, so there was no way to stop listening. Every hot
reload and every test left another handler on the bus — each holding an L1
nobody would read again, and each acting on invalidations meant for a driver
that no longer exists.

unsubscribe?(handler) is the way out, and it takes the handler because a
bus is shared: quasar keeps a Set of listeners per channel, so "drop
everything here" would silence the sessions and the queues that borrow the
same connection. Optional, so a bus written against the old contract still
works — it just leaks, and that is now the bus's omission rather than a gap
in the contract.

The quasar adapter waits for a subscribe that is still opening the socket
before removing it. Ahead of it, the removal took nothing off and the
handler landed afterwards, untracked.

EchoProvider builds the cache in ready() rather than boot().
Constructing a tiered store SUBSCRIBES, which opens a Redis connection, and
register/boot/start all run during an inspection while shutdown does
not — so a route listing left a subscriber and a connection behind it.
services/main resolves lazily through a proxy, so anything that uses the
cache while serving still finds it.

Four mutations, each falling on its own tests.


Changes since v0.1.16.

v0.1.16

Choose a tag to compare

@github-actions github-actions released this 06 Sep 15:28

Keep the vendored copies out of this package's coverage floor

src/vendor/** is generated and identical in every package that carries it, so
measuring it here counts the same lines N times and holds this package to a
floor for code it cannot change — which is what pushed several suites under
their thresholds the moment the copies landed.

The behaviour is not left unmeasured: it is pinned where it broke, in bay's
quasar-bridge suite, which now covers both manager shapes the loader has to
accept.

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.


Changes since v0.1.15.

v0.1.15

Choose a tag to compare

@github-actions github-actions released this 06 Sep 07:48

Release 0.1.15

Ship the bus the generated config asks for, and bound the memory store

ream configure @c9up/echo wrote a config/cache.ts calling
drivers.redisBus({ connection: 'main' }), which this package did not
export: the very first thing an application did after installing echo left
it with a config that would not load. The same file passed
drivers.memory({ maxItems: 1000 }), an option the driver did not have,
so the file did not typecheck either.

Neither is fixed by editing the generated file, because both name the
right thing. A two-layer store without a bus is wrong the moment a second
instance exists: each process keeps serving its own L1 copy of a key
another has already deleted, and nothing tells it. So redisBus is now
real — Redis pub/sub through quasar, loaded on first use like
drivers.redis, on quasar's own subscriber socket because a subscribed
Redis client accepts nothing else. subscribe does not throw when the bus
is unreachable: a bus that is down costs staleness, and throwing there
would cost every cache read.

And maxItems bounds the memory store by COUNT, which a TTL cannot. Kept
by TTL alone, a cache keyed by a user id or a search term grows until the
process runs out of memory however short each entry's life. Expired
entries are evicted first — dropping a live one while a dead one sits in
the map would throw away a value someone still wants.

The generated file is now checked against the package: the names it
reaches for are read out of the source and looked up, so a config that
calls something echo does not export fails here rather than at a user's.


Changes since v0.1.14.

v0.1.14

Choose a tag to compare

@github-actions github-actions released this 04 Sep 15:42

Take back the version number nothing was published under

Two releases were cut on top of each other without either reaching the
registry: the first tag was never published, and the second incremented
past it instead of folding the work in and moving the tag. That burns a
version number and leaves a tag pointing at something nobody can install.

The registry is at the version below this one, so 0.1.14 is the next release —
one tag, one number, for everything since.

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.json includes tests, but CI typechecked only
    tsconfig.build.json — every type a test relied on went unchecked.
  • vitest.config.ts declares coverage thresholds, but CI ran plain
    vitest run, which does not read them.
  • lint pointed at src/ alone, so no test file was ever linted.

CI now runs pnpm typecheck, pnpm test:coverage and a lint that covers
tests/ as well.

Stop a tiered cache from undoing its own writes

Three things a tiered driver on a bus got wrong, each reproduced before
it was fixed.

A pub/sub bus delivers to every subscriber INCLUDING the publisher —
Redis does, and Redis pub/sub is what this bus is for. BusMessage
carried no sender, so every set published a delete, received it back,
and dropped the L1 copy it had just written. L1 held nothing after any
write and every read went to L2: the local tier was defeated wherever a
bus was configured. Proven, l1=null l2="v". Messages carry the tier
that sent them now, and a tier ignores its own — which is the line
upstream draws one layer down, where each bus transport stamps its id and
skips what it sent (@boringnode/bus, transports/memory.js: if (busId === this.#id) continue). The field is optional, so a bus written before
it keeps working.

The two tiers were awaited in sequence, so a local driver that threw
aborted the call before the SHARED tier was touched: a delete reported
failure and left the copy every OTHER instance reads. Proven, threw=L1 socket dropped l2=v. Upstream reaches the other end by never awaiting L1
at all (this.l1?.set(...), then await this.l2?.set(...)). Both tiers
get their turn now, and the failure is still reported afterwards.

And the bus heard about a write whether or not the shared tier took it.
Telling peers to drop their L1 for a value that never reached L2 sends
every one of them to a tier that does not have it. Upstream gates the
same publish on the same thing (if (this.l2 && l2Success || !this.l2)).

Also drops the as { unref(): void } on the soft-timeout handle for a
guard that actually checks.

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.

Release 0.1.15

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.

Release 0.1.14

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.

Handle a rejecting cache listener, and say so in the type

@adonisjs/events declares emit(): Promise<void> and its body rethrows when
a listener fails and the application registered no error handler. Nobody awaits
a cache event, so that rejection had nowhere to go and ended the process over a
metrics listener — an observer failing the read it was observing.

The type is what hid it: the interface said void, and TypeScript accepts a
promise-returning function for a void return, so the call site read as if
there were nothing to handle. It says unknown now, as warden's AuthManager
already does. Not Promise<void>, because this is a duck-type of an emitter
echo does not own and a Node EventEmitter returns boolean.


Changes since v0.1.13.

v0.1.13

Choose a tag to compare

@github-actions github-actions released this 01 Sep 15:40

Release 0.1.13

Turn on noUnusedLocals/noUnusedParameters

Report an L1 peer invalidation that fails instead of letting it escape

The bus callback nobody awaits: an L1 that rejects — a Redis L1 whose socket
just dropped, a driver mid-shutdown — was an unhandled rejection, which on a
default Node ends the process over a cache failing to forget a key. It is
reported and not retried; the entry keeps its own TTL, so the worst case is one
stale read window on this instance.


Changes since v0.1.12.

v0.1.12

Choose a tag to compare

@github-actions github-actions released this 31 Aug 09:04

Release the cache at shutdown, and cover the tiered driver

The provider never disconnected: the memory driver sweeps on a timer and
the redis driver holds a connection, so every dev reload left another one
behind. It now releases the cache it booted, and only clears the module
singleton while that still points at it.

The tiered driver had almost no tests, and its bugs are the silent kind — a
promotion that loses the remaining TTL leaves an immortal L1 copy serving a
value the shared L2 has already expired.

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.

Describe a cache store a layer at a time

Echo's stores were { driver: drivers.x() }, which says nothing about which
layer a driver is and turns a tiered cache into a driver call rather than a
layering. A cache config elsewhere reads as store().useL1Layer(…).useL2Layer(…),
and the words carry the meaning: L1 is the fast layer, L2 the shared one, and
the bus is what keeps each instance's L1 in step after a write.

stores: {
memory: store().useL1Layer(drivers.memory()),
tiered: store({ ttl: 60 })
.useL1Layer(drivers.memory())
.useL2Layer(drivers.redis({ connection: 'main' })),
}

A store with no layer throws, and so does a bus with a single layer — silently
ignoring it would hide a cache that never invalidates across instances.

The plain { driver } form is still accepted.

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.

echo v0.1.11

Choose a tag to compare

@kaen25 kaen25 released this 28 Aug 08:39

echo 0.1.11

Version bump only — no behavioural change.

Changes since v0.1.10.