v0.2.0
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.