Skip to content

Releases: C9up/ream

v0.2.19

Choose a tag to compare

@github-actions github-actions released this 19 Sep 09:12

List every package, and the static files the core serves

Three packages were missing from the table -- eclipse, parsec and
prism, the three most recent. They were already in the docs site, its
index, its navigation and the root README; only this table lagged.

The feature list also never mentioned static files, although the core
has always carried the middleware and now wires it through a provider.

Satisfy the lint the CI actually runs

A template literal in the containment test and an export sort in the
barrel. Both came from edits made AFTER I ran biome mid-way through the
work, over a narrower set of paths than biome check src/ tests/ --
so the last changes were never checked. Lint at the end, over the paths
CI uses.

Take back the version number that was burnt

0.2.19 was never published: npm runs 0.2.0 to 0.2.18 with no gap. It got
skipped because I could not move its tag and incremented instead, which
solved a git problem by spending a version number. Publishing 0.2.20
next would leave a hole in the registry that nothing explains.

The work that was in 0.2.20 is the same work; only the number changes.

Serve static files the way AdonisJS does

Nothing served public/. StaticMiddleware existed in the core and was
exported; no application mounted it, the CLI scaffold did not add it,
and the docs never mentioned it. app.publicPath() resolved a directory
nobody read.

What it did have diverged from upstream in ways that mattered:

  • An extension ALLOWLIST, under the name serve-static gives to a
    FALLBACK list. Porting an AdonisJS config across would silently
    restrict the server; and the list had no .avif, so the framework
    refused a format its own image package now writes.
  • No Last-Modified and no If-Modified-Since, though upstream defaults
    lastModified to true. A client that revalidates by date refetched
    everything.
  • No Range support, while the allowlist advertised .mp4, .webm, .mp3,
    .zip and .pdf. A browser cannot seek in a video without it.
  • A /static prefix, where upstream mounts at the URL root.

NAMED DEVIATION (NAPI): @adonisjs/static delegates to serve-static,
which writes to a Node ServerResponse. Ream's response crosses the NAPI
boundary as a complete object and there is none, so the behaviour is
reproduced rather than the code -- same option names, same defaults,
same headers. The prefix option is kept as an optional narrowing, defaulting to
upstream's behaviour of none.

Kept deliberately, being stronger than upstream: the file is opened
with O_NOFOLLOW and its metadata read from the descriptor, so the bytes
served are the ones that passed the containment checks, and a symlink
swapped in after the check fails the open instead.

Containment is now pinned by 16 tests covering encoded traversal,
symlinked files AND directories, sibling directories with a shared
prefix, malformed percent sequences (which made decodeURIComponent
throw, and would have been a 500 on a path anyone can send), and NUL.
Six of them compare against the real content of a system file rather
than only asserting fallthrough. Neutering either containment check
makes four of them fail, so they pin the guard rather than passing
vacuously.

Accept quasar 0.2 in the peer range

The session store's connection contract is unchanged in quasar 0.2.0, but
the range still said ^0.1.0, so every install reported an unmet peer
against the version actually on disk.

Folded into the unpublished 0.2.20.

Release 0.2.20

Add usingInker, so a package can find the template engine without the container

A package that contributes to templates — rosetta pushing its i18n globals —
needs to know whether an engine is installed. Asking the container meant
resolving a binding whose lifecycle has to have reached the right phase,
which is how that push ended up failing the whole application boot.

The flag is set by InkerProvider's constructor, so it is already true
before any provider boots. Same shape as upstream's usingEdgeJS.

Bring the Rust pins up to date and release 0.2.19

ream-http-napi was still pinned to blackhole v0.1.4, warden v0.1.4 and
sigil v0.1.4. The first of those cost a day: text/plain responses came back
entity-escaped (a b=c as a b=c), the fix had long since landed
in blackhole, and reading the local checkout said so while Cargo.lock
said otherwise. The lock is what builds.

blackhole v0.1.20, warden v0.2.1, sigil v0.1.15. Only sigil moved its API:
Argon2Options gained secret/variant/hash_length/salt_length.

Narrow the cookie deviation to the case that can happen, and test it

The note cited 0 and false alongside "". Neither can reach a signed
cookie: Response.cookie takes value: string. Claiming them made a sound
argument look like a stretch, so they are gone — the empty string is the whole
of it, and enough: a cleared preference or an emptied field is something
someone wrote, and || hands back the default for it.

And it had no test at the Request level, which is where it matters. Four now,
including a tampered cookie still falling back — an absence with a lie attached
is not a value. Flipping ?? to || fells the empty-string one.

Cite the upstream line the cookie fallback differs from

The deviation note asserted that upstream uses || without saying where, so it
could not be checked and an audit rightly called the claim unproven. It is
@adonisjs/http-server@9.3.0: return this.#cookieParser.unsign(key) || defaultValue.

Which settles it in an unexpected direction: the doc-comment on that very line
says the default is returned "when actual value is undefined" — what ?? does,
and what || does not. So this follows the contract upstream documents and
differs only from what it happens to execute, the same shape as rune's
accepted() returning a value its own declared type says it cannot.

Say one thing about the cipher, and name no framework version here

algorithm still described GCM as a divergence from upstream while the file
header explains it is upstream's own default now, with CBC surviving there as
the legacy driver. Two answers to the same question, twenty lines apart.

The router header claimed v6 compatibility; the target is declared once in the
guide now, so a per-file version number can only drift out of step with it.

Read the artifact where cargo actually wrote it

The NAPI copy script looked under <package>/target unconditionally. Cargo
writes elsewhere whenever CARGO_TARGET_DIR is set — a shared cache, a CI mount,
a read-only external directory — so the build produced the library and then
failed to find it, or silently copied a stale one from a previous run.

CARGO_TARGET_DIR is honoured now, resolved against the package root when it
is relative, as cargo resolves it. All fourteen scripts had the same line; an
audit reported it in ream-mcp alone.

Verified end to end, not by reading: a real cargo build redirected to a
temporary directory, the artifact copied out of it, and the package suite green
on that binary.

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

v0.2.18

Choose a tag to compare

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

Fix the tests typecheck, which is a separate program from the build one

CI runs tsc -p tsconfig.tests.json, a program tsc -p . does not cover, and
it had been failing since before this branch: two errors reaching for call
and resolve on a FactoryResolver, plus two I added reading requestBody
off an operation typed as an open record.

call belongs on FactoryResolver — both a Container and a per-request
ContainerResolver have it, and a factory that dispatches to a handler needs it.
Leaving it off the interface did not remove the capability, only the permission
to use it. resolve does NOT belong there: the per-request resolver has no
such method, so the test uses make, which is on both and which
Container.make delegates to anyway.

The two OpenAPI assertions now match on the operation object, the idiom the
rest of that file already uses.

Ask a route's validator to describe itself for the OpenAPI spec

route.validate('createUser') already names a validator, and a rune schema can
render its own JSON Schema. Nothing connected the two: the generator read a map
only registerSchema filled, so every validated route documented its body as a
bare { type: "object" } unless the app hand-wrote a second description of the
same payload and kept it in step. A client generated from that spec knew none
of the fields.

The provider now resolves each named validator once at start-up — generate()
is synchronous and the container is not — and asks it for its schema. What it
gets back is richer than the converter here could ever be: formats, optionality
and additionalProperties come out of the schema itself rather than from a
hand-rolled walk that understood six rule names.

Kept in a separate map from hand-registered definitions, which are rune FIELD
MAPS still needing conversion. One map for both would mean guessing which kind
a value is, and guessing wrong feeds a finished schema back through the
converter, which reads its type and properties keys as field names.

A validator that cannot be resolved, or cannot describe itself, is skipped: it
is already a hard error at request time, and a gap in the docs beats a boot
that fails over one. Four mutations, each felling its own test.

Add request.validateUsing and the per-request validation hooks

A validator is compiled when its module loads; the language to answer in and
where errors go are decided per request. Nothing joined the two, so an i18n
package had no way to reach validation at all: rosetta resolved a container
token named requestValidator that no package in this repo ever bound, and
its own tests were the only thing that ever bound it. The announced validation
i18n was green in test and dead at runtime.

RequestValidator is that seam. Two static hooks, assigned once at boot, pick
the messages provider and the error reporter for each request; validateUsing
fills them in unless the call site passed its own. It is bound as
requestValidator so a package can reach it through the container instead of
depending on ream.

params, headers and cookies stay nested under their own keys rather than
merged into the payload — flattening would let a route parameter land as a
top-level field and shadow, or be shadowed by, a body field of the same name.

Six mutations, each felling its own test; the flattening one fell nothing until
the test asserted the absence of the top-level key rather than the presence of
the nested one.

Name the validation contract rather than the project behind it

Comments and two test labels called rune's throwing validate() contract by
the name of the library its shape was taken from. Parity is shape, never
product identity, and what ream actually integrates with is rune.

Comment and test-label only; no identifier, no runtime string.

fix(container): recognise a published singleton whatever it holds

The publication check compared with ===, which fails two ways.

NaN is not equal to itself, so a waiter concluded the build had not been
published and built a second singleton — for a factory whose answer is a
number, which is not exotic.

And an absent cache entry reads back the same as one holding undefined, so
a request-scoped build that produced nothing looked published and was handed
to the next request: one factory call for two requests, each of which should
have run its own. That is the leak this check exists to prevent, reappearing
through the one value it could not see.

has() for presence, Object.is for identity, on both the explicit and the
auto-constructed paths.

Two mutations: dropping has() fails the undefined test, and === in place
of Object.is fails the NaN one.

fix(container): a joined build is only shared if it was published

The explicit-binding path joined #pendingSingletons and returned whatever
that build produced. A factory that reads a request-scoped value builds THAT
request's instance, and #cacheIfAppWide deliberately refuses to cache it —
so the resolver waiting behind was handed the first request's HttpContext,
identity, tenant or enriched logger.

Reproduced with two resolvers: {"fromA":"request-a","fromB":"request-a"}.

The auto-constructed path already re-checked; this is the same guard on the
other half. A resolution whose join turns out not to have been published
builds its own instead.

Restoring the blind join fails exactly the new two-resolver test.

fix: import the preloads together, as upstream does

PreloadsManager.import() is a filter on the environment followed by one
Promise.all; this walked them one at a time and called the difference a
deviation. It is not a NAPI constraint and not a safety win — it is just a
divergence, so it goes.

The consequence is worth stating: evaluation order between two preload
files is now whichever finishes loading first, so an application cannot use
the array's order to decide which of two overlapping routes answers. Work
that must be ordered belongs inside ONE preload, where the order is the
file's own.

fix(container): an auto-constructed singleton follows the same pipeline

@Service({ scope: 'singleton' }) never goes through an explicit binding,
so resolve() fell through to auto-construction — which had its own,
weaker rules. The instance was cached on the way out of construction and
the resolving hooks ran afterwards, so a hook that threw left a half-built
object in the cache: the caller saw the error, and the next resolution was
handed the same object with no hook and nothing to say a step had been
skipped. That is the failure the explicit path was fixed for, on the path
most applications actually use.

It also never joined #pendingSingletons, so two resolutions in flight at
once each built their own — two singletons, and whichever finished last was
the one everybody got.

Construction no longer publishes anything; #resolveAutoConstructed owns
the order, the same one the explicit bindings use: pending promise, build,
hooks, then the cache.

The join re-checks that the build it waited on was actually published
application-wide. @Service() defaults to singleton, and a build that
consumed a request-scoped value is deliberately NOT cached — joining it
blindly handed one request's instance to another, which is the leak
createResolver() exists to prevent.

Two mutations, each falling on its own test: caching before the hooks fails
the poisoned-instance test, and dropping the join fails the concurrent one.

docs: say which phase actually mounts, and why preloads are not raced

Two header comments still described boot() as the phase that mounts the
static and OpenAPI middleware; both moved to start() and neither comment
followed. A comment that describes a lifecycle the code no longer has is
worse than none — it reads as settled.

The preload loop keeps importing one file at a time, and now says so as a
deviation rather than leaving it to look like an oversight. Upstream uses
Promise.all, so whichever module finishes loading first evaluates first;
here a preload is where an application registers its routes, routing is
first-match, and an array in a configuration file reads as an ordered list.

fix(container): publish a singleton only once its hooks have succeeded

The instance was cached BEFORE the resolving hooks ran, so a hook that threw
left it in the cache: the caller saw the error, and the NEXT resolution was
handed the same half-built object — no hook, no error, nothing to say a step had
been skipped. A hook that opens a connection, validates a config or wraps a
security decorator failing is exactly when the instance must not be reachable.

It also contradicted the semantics these hooks copy, where they modify the value
before it is returned — which only holds while the value is not already shared.

The pending promise still guards against a second build, so concurrent callers
share one instance and run the hooks once. The binding is re-checked after the
hooks: a rebind can land while they run, and the cache it dropped must not be
refilled by the build it replaced.

fix(dev): drop the modern digest headers too, and stop the comments lying

Content-Digest and Repr-Digest (RFC 9530) replaced Digest. Left
describing the body from before the reload script was appended, they are the
same defect one header along: a client that checks them rejects a response that
is perfectly fine.

And the header comments on graphql and rpc still announced a mount in boot(),
which stopped being true when the routes moved to start().

fix(container): drop the cached instance on rebind, and restore the parent in a finally

A rebind changed nothing. Resolution reads the cache before the binding, so
a token re-registered with a new factory kept answering with the old instance —
silently. That is what a provider does when it boots a second time in one
process, so the container went on handing out the connection the previous
shutdown had closed, wh...

Read more

v0.2.17

Choose a tag to compare

@github-actions github-actions released this 06 Sep 14:52

Isolate the scheduler-autoload cases without rebuilding the module graph

The negative cases needed a registry that had never seen the fixture, and the
first draft got one with vi.resetModules() per case. That invalidates the
worker's whole module cache, so every later dynamic import came back cold —
and a 5-second test timeout became a coin flip, for this file and for its
neighbours. One run in three was red, on tests that passed alone.

Each case now points at its own fixture, and asks whether ITS task is present
rather than whether the registry is empty. No reset, no ordering dependence,
and the assertion is the more precise one anyway: what is under test is
whether the autoload imported that file. Four consecutive full runs green.

Release 0.2.17

0.2.16 went to npm at 08:26 UTC, and three commits landed after it under the
same version: the schedule:list diagnostic, app.rcFile, and terminate() taking
the HTTP server down with it. None of them is in what was published.

The v0.2.16 tag is back on 4eb8bf3, the commit that WAS published, and this
version carries the rest.

Assert the scheduler fired, not how many times the clock allowed

The window is 65 seconds because the grammar is five fields: once a minute is
as often as a task can run, so that is the shortest span guaranteed to cross a
boundary. A run starting at :56 crosses two and fires twice — the scheduler
working — and asserting exactly one failed on the clock rather than on the
behaviour.

Take the HTTP server down with the application

terminate() shut the providers down and called process.exit(0). The socket,
the error boundary and the service locators belong to the Ignitor, so none of
them was released — and the exit could win the race against a drain another
path had already begun, which is exactly what the signal wiring in this file's
own example set up.

It follows upstream now: terminating hooks, run in reverse, then the providers,
and no exit. Whoever opens the socket registers the close, so the Ignitor does;
graceful shutdown terminates the application rather than stopping the Ignitor,
so there is one authority however the shutdown was triggered. terminate() and
shutdown() are both re-entrant, because two signals arriving together must not
close a pool twice.

Say why no scheduled task was found

Discovery reads the IoC service registry, so a class nothing imported is not
there to find. A @schedule in app/modules/** therefore depends on the module
auto-loader having imported the file, and that has two conditions the
folder-structure guide does not lead anyone to expect: reamrc.modules.path
must be set at all, and modules.autoload must name the file — it defaults to
routes and events, so billing/scheduler.ts is skipped. Either way the symptom
was the same unexplained line.

schedule:list now names whichever condition is unmet, reading it from
app.rcFile — which the application now keeps whole, as upstream does, instead
of only the directories key.

The scheduling doc claimed app/modules/** was loaded unconditionally. Both
locales now state the two conditions and point at a preload, which is explicit
and does not depend on where the file sits.


Changes since v0.2.16.

v0.2.16

Choose a tag to compare

@github-actions github-actions released this 06 Sep 08:22

Typecheck the tests the way CI does

Two test files I added yesterday did not compile under tsconfig.tests.json:
one imported HttpKernelRequest/HttpKernelResponse from the barrel, which does
not re-export them, and the other handed BodyParserMiddleware a hand-rolled
object instead of an HttpContext. tsc --noEmit -p . compiles src alone, so
neither showed up locally; pnpm typecheck:tests is the gate, and it is red
on main, which is what blocks the 0.2.16 publish.

Discover scheduled tasks after the modules that declare them

app/modules/** is auto-loaded at the END of the start phase, after every
provider's start(). The scheduler discovered in start(), so a @service
carrying @schedule in a module — where one naturally lives — was read for
before it existed. The task never fired, and nothing said so. Discovery and
the ticker move to ready(), which runs after the autoload; the earlier passes
stay because they are idempotent and cost nothing.

A test process can now serve HTTP while declaring itself as 'test'. It could
not before: a bootstrap had to call httpServer() to get a server, and that set
the environment to 'web', so a provider entry's environment: ['web'] — the
declarative way to keep a scheduler out of a test run — excluded nothing and
the ticker fired mid-suite.

Read PORT and middleware config where the app can see them

PORT was read at Ignitor construction, but #start/env — the import that
loads .env — only runs in booting(). A PORT living in .env therefore did not
exist yet: the server bound 3000 while the banner, built later from the same
variable, announced 3007. It is now read where HOST already was, when the
socket binds, and refused by name when it is not a port.

BodyParserMiddleware and SessionMiddleware take their settings from
config/bodyparser.ts and config/session.ts when the container builds them,
which is what router.use([() => import('@c9up/ream/session_middleware')])
does. Neither could be registered that way before: there was no path from a
config file to the instance, and an optional constructor parameter still
counts toward Function.length, so the container refused to construct either
one.

Run clippy as a gate, not a note

cargo fmt --check was gating the formatting while nothing gated the
lints that catch a real defect — the one place the compiler stays silent
and clippy does not. Nine of the ten crates in this cohort had the same
hole; only one ran it.

No version change: this gates what is already there, and every crate
passes it today.

Release 0.2.16

Stop the scheduler from being a reason the process lives

Scheduler.register() builds a NAPI ThreadsafeFunction per task, and a
ThreadsafeFunction holds Node's event loop referenced for as long as it
lives. stop() cancels the tick loop, but the callbacks live on in the
task registry — so a process that had merely REGISTERED a task never
exited. Every console command booting an app that carries a @Schedule
ran its work to completion and then hung until a timeout or the operator
killed it, which is exactly what makes such a command unusable from cron.

The control experiment: start/stop with no task registered exits at once;
with one task it is still alive minutes later. And nothing in JS could
point at it — process.getActiveResourcesInfo() reports an empty list,
because the reference is held below what Node can report.

The task callbacks are weak now. That is the honest shape: a scheduler is
a side concern, not a reason for a process to exist. A server is held open
by its listener, and a long-running scheduler process by the command that
runs it, the way queue:work holds its own — no shipped command relied on
the old behaviour, schedule:run and schedule:list both return.

create_threadsafe_fn is untouched and stays strong for the HTTP listener
and the event bus, where the reference IS the reason to keep running.

Three tests spawn real processes and check they end, because a reference
invisible to JS cannot be asserted from inside the process holding it.


Changes since v0.2.15.

v0.2.14

Choose a tag to compare

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

Fold the exported VERSION with the package version

Collapsing 0.2.17 back onto 0.2.14 moved package.json and left src/index.ts
exporting the version that no longer exists. The export-map test exists for
exactly this and caught it.

Release 0.2.14

0.2.14 was never published — npm stops at the version before it — so the work
that followed folds into it rather than incrementing past it. The tags for
0.2.15,0.2.16,0.2.17 existed with no npm release and no GitHub release behind them.

Turn on noUnusedLocals/noUnusedParameters, and say what is checked

tsconfig named tests in include AND in exclude, and exclude wins, so
nothing under tests/ was ever typechecked while the config claimed otherwise.
include now says src.

Behind that exclusion are 220 type errors across 39 files, and — as in helix —
test-side declare module augmentations that leak into src when the two share
one program. Checking them needs a separate tsconfig and its own pass.

The StaticMiddleware test added earlier today is fixed here rather than left
for that pass: its fake StreamBackend was missing two members and its
RawRequest/RouteInfo literals did not match the real shapes.

Key the container on the token, stream static files, generate the MIME table

Container: Fold's stores are Map<string | symbol | AbstractConstructor, …>, so
a token is its own key. Deriving a string key instead cost what identity buys:
Symbol.for('cache') became "Symbol(cache)" and collided with that plain string,
a unique Symbol() had to be refused outright, and two @service classes sharing
a name shared a singleton cache entry.

StaticMiddleware: readFileSync on the event loop, with .mp4/.zip/.pdf among the
allowed extensions — one large asset was every other request's latency, and
each request allocated the file again. Streamed through response.stream(), and
opened with O_NOFOLLOW so the last component cannot be swapped for a symlink
between the realpath check and the read. Its ETag is now quoted and weak
(RFC 9110 §8.8.3) and If-None-Match is parsed as a list compared weakly (§13.1.2).

mime.ts: the hand-written table knew the extensions someone thought of, so
.yaml, .sql, .opus and .xhtml fell through and went out as the content type
verbatim. Generated from mime-db instead, with a test proving all 1239
extensions resolve as mime-types resolves them. The +json charset rule was
invented: mime-db gives fhir+json a charset and ld+json none.

MultipartFile.move: overwrite:false checked existence and then wrote, so two
concurrent moves both succeeded and the second replaced the first. O_EXCL now
decides. Upstream's message is kept word for word.

SseStream: a keepalive write that rejected was an unhandled rejection on a
detached timer — one dead socket ending every other client's stream.

Release 0.2.17

Branch on the second signal instead of returning after exit

A return after process.exit is dead in production and only mattered
because the suite replaces process.exit so it does not kill the runner — code
shaped by its test, with a line that reads as a mistake to anyone who has not
read the comment explaining why it is not one.

The two paths are mutually exclusive, so they are a branch. Nothing falls
through under a stubbed exit either, and there is no dead line to explain.

Release 0.2.16

Compute entity tags and content types here

Two dependencies for work the ecosystem already did. etag is fifteen lines
of hashing, and @c9up/archive has kept its own MIME table all along — so the
framework was pulling a package to do what a sibling already does by hand.

Both formats are reproduced exactly, and that is the whole constraint. An ETag
is a cache key: a different shape invalidates every cached response in flight
the day it ships. contentType keeps its charset rules, keeps a charset the
caller set, and still answers false for what it cannot resolve so the
response falls back to the raw input rather than writing content-type: false.

Checked both against the packages they replace before removing them — byte for
byte on the ETags, and identical on thirty-seven content types, the one
difference being ico, where the IANA spelling won.

Release 0.2.15

Key a class token by the class, not by its name

token.name made two different classes called Service one binding — the
second registration silently replaced the first — and made the class Service
indistinguishable from the string "Service". AdonisJS keys on the
constructor, so a class, a string and a symbol are three tokens even when they
read alike.

A weak map assigns each class a key that carries its name, for error messages
and for inspect, and a counter that makes two same-named classes distinct.
The map is shared across containers, because per-container numbering would give
one class two keys in a parent and its child, and the child would never find
what the parent bound.

Checked the ecosystem first for the migration this could force — a binding
registered by class and resolved by the string of its name. There is none, and
the twelve packages that consume the container all pass unchanged.

Keep the escalation return, which a test relies on

process.exit does not return in production, so the line after it is dead
there — but the suite replaces process.exit so it does not kill the runner,
and without the return a second signal would fall through and start a second
shutdown.

Keep MultipartFile.type as upstream has it, and narrow beside it

Closing type to a union was a deviation the rule does not allow: nothing
about NAPI forced it — the filtering happens in TypeScript, after #mimeParts —
and the protection can be had without touching the contract. It also broke a
public type in a patch, so valid AdonisJS code stopped compiling and
x-foo/bar came back as undefined where upstream hands back the segment.

type is string | undefined again, raw segment and all. registeredType is
the narrowed member: against a closed union === 'image/png' is a compile
error instead of a comparison that always fails. The two disagree only where
the type is outside the registered set, and nowhere else.

Container introspection followed the wrong order. resolve() follows an alias
before it looks at a binding, but the listing kept the binding and skipped the
alias — so a token with both was reported as transient while resolving it
handed back the alias target. Inspect showed a definition nobody gets. The
alias wins now, names what it points at, and says which binding it shadows.

Name the MultipartFile.type deviation

AdonisJS types it type?: string. Closing it to the registered top-level set
is deliberate — a string is what let file.type === 'image/png' compile — and
it costs one thing worth stating rather than leaving for a reader to discover:
a malformed top-level type reports undefined here where upstream hands back the
raw segment. mime still carries it.

Release 0.2.14

List what the container holds, and close MultipartFile.type

inspect --help promised a section for services and rendered none, because
nothing could enumerate them — has() answers about one token you already know
the name of, which is no help when the question is what is in there. The
container lists its bindings now, with the kind beside each. Not every
@Inject()-decorated class: one is discovered when it is resolved and nothing
registers it before that, so no complete list of those exists. The help says
bindings, which is what it shows.

MultipartFile.type is a closed union of the top-level types IANA registers, so
file.type === 'image/png' is a compile error instead of a comparison that
always fails — the exact trap its own comment used to warn about, now refused
by the compiler. The runtime agrees with the type rather than asserting past
it: a top-level type outside the set reports undefined, and mime still
carries whatever was read, so nothing is lost.


Changes since v0.2.13.

v0.2.13

Choose a tag to compare

@github-actions github-actions released this 31 Aug 18:47

Expose the registered providers so inspect can name them

ream inspect read app.providers, a property that had never been added, so
it reported 0 providers on an application running twelve — while the routes
beside them counted correctly, which made the number look believable. Nothing
was broken at runtime; the tool opened to check that a provider is wired was
the one thing that could not see them.

A count alone does not answer that question, so the getter returns the list, in
registration order, as a fresh array each call.

Release 0.2.13

Keep the E_ prefix to error codes only

The rename swept up three kinds of name that are not codes.

PIPELINE_STAGES is the list of pipeline stage names, exported and imported by
that name — E_PIPELINE_STAGES broke a public import for nothing.

ATLAS_STRICT, ATLAS_TEST_PG_URL and ATLAS_TEST_MYSQL_URL are environment
variables. Renamed, the integration suites read a variable nothing sets, so
they would have skipped silently forever instead of running against a real
Postgres or MySQL — coverage lost with no failing test to show for it. Three
test-local variables in ream went the same way and are restored too.

A photon comment named two codes in their pre-namespace spelling.


Changes since v0.2.12.

v0.2.12

Choose a tag to compare

@github-actions github-actions released this 31 Aug 15:58

Format the Rust crates, and gate it so they stay formatted

Twelve of the thirteen crates had drifted — 949 differences in all, atlas
alone 345, and build.rs files that had never been through the formatter.
None of their workflows checked, so nothing ever said so; the drift only
surfaced when it took a publish job down.

cargo fmt applied throughout, and a cargo fmt --check step added to each
workflow so this cannot happen again. Formatting only: the Rust tests pass
unchanged in every crate.

One spot in atlas needed a real edit rather than the formatter: cargo fmt
rewrote a return Err(format!(…)) arm back to the inline form on every run
while --check kept asking for the block form, so the file could never
converge. The message is bound to a name, which fits the line budget and
settles it.

Release 0.2.12

Namespace every error code as E__

The convention was announced and not kept: 115 framework codes across ten
packages carried no E_ prefix, so an application filtering on the documented
rule handled some failures and missed others.

Blanket-prefixing them was the wrong fix, and trying it proved why — it made
E_FORBIDDEN mean three different things across ream, relay and warden, which
is worse than the inconsistency it replaced. The namespace after E_ is what
tells them apart.

Where a package already prefixed inside its error constructor — atlas, rune,
warden, and ream's module classes — the constructor now emits E__ and the
call sites stay bare, so the rule lives in one place per package instead of at
every throw. Each of those constructors passes through a code that already
starts with E_, which is how the upstream identifiers keep their exact
spelling: E_UNAUTHORIZED_ACCESS, E_INVALID_CREDENTIALS and E_VALIDATION_ERROR
are the ones a consumer branches on, and two packages naming the same upstream
failure legitimately share one.

Ignore a relocated cargo target

target/ matches a directory only. When the build output is moved elsewhere
and a symlink named target is left in its place, that pattern does not catch
it — it shows up untracked, and a stray git add -A commits a path that only
resolves on one machine.


Changes since v0.2.11.

v0.2.11

Choose a tag to compare

@github-actions github-actions released this 31 Aug 13:40

Move the exported VERSION with the release

package.json went to 0.2.11 and the VERSION constant did not, which is the
mismatch its own test exists to catch — a health endpoint or a bug report would
have named the wrong release. ream is the only package that exports one;
checked the other 28.

Release 0.2.11

List every package in the ecosystem table, alphabetically

nebula was the one missing: 28 packages exist, 27 were listed, and the table
published on npm had the same gap. Sorted by name so the next addition has an
obvious place and a reader can find a package without scanning.

rover's line said "SMTP, log, pluggable" and it ships eight transports —
SMTP, SES, Mailgun, SendGrid, Brevo, Resend, SparkPost and log. Checked the
other entries against the source too: transit really does ship the SAML, LDAP
and OIDC drivers it claims, and ream still has no WebSocket upgrade point, so
relay's caveat stands.


Changes since v0.2.10.

v0.2.10

Choose a tag to compare

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

Give the response ceiling a code instead of a message to grep

The ceiling threw a plain Error with [E_RESPONSE_TOO_LARGE] spelled into its
message, so an application deciding what to do with it had to match on a
string. It is a ReamError now, with the code where every other framework code
lives, the byte count and the ceiling in context, and the way out in the hint.

ReamError's own doc comment offered ATLAS_QUERY_ERROR and CONTAINER_NOT_FOUND
as examples of a code, neither of which carries the E_ prefix every real one
does — an example is where a convention is learned.

Apply the response ceiling to every path that builds a body

maxResponseBytes was enforced in sendBuffer() alone, so json(), send() and
jsonp() walked past it — and those are the doors a response actually comes
through. The failure the ceiling exists to name (a process that grows until it
dies, with nothing said about why) came back unchanged.

Every textual assignment goes through one checked setter, measured in UTF-8
bytes rather than characters: accented or CJK text costs one and a half to
three times its length on the wire.

Make serialize() the dump upstream reports, and name the safe view

serialize() withheld the body and the cookies and redacted the credential
headers, which is right for a log line but is not what upstream returns. It
now carries the whole request — body, cookies, every header verbatim — under
the shape upstream documents: id, url, query, body, params, headers, method,
protocol, cookies, hostname, ip, subdomains.

The log-safe view keeps its behaviour under serializeSafe(), which upstream
has no counterpart for. Both are pinned side by side: the same secret shows
up in one and not in the other.

Load APP_KEY before wiring the services that read it

The signer was built in the constructor, from process.env, while .env was
only read at the top of start(). A scaffolded app keeps its key in .env and
nowhere else, so no signer was ever registered and every signed cookie and
signed URL was refused.

Also brings the lifecycle in line: providers start before the preload files
that reach for them, and the application is marked ready once the server
listens rather than at the end of boot, so a health check cannot green a
process with no socket.

NODE_ENV is normalised in one place and read through it everywhere. Nine
sites compared the raw value, including the Secure flag on the session
cookie and the switch behind development error pages; NODE_ENV=prod read as
'not production' at every one of them. inDev is now an exact match, so an
unconfigured or staging machine no longer starts the reload watcher.

cookie() signs the cookie's NAME along with its value and refuses to write
without a key instead of silently sending it plain; request.cookie() answers
nothing rather than handing back an unverified value. serialize() redacts
the headers that carry a credential. Domain routes match a host
case-insensitively. warmUp() assembles an application without running it,
and repl is an environment of its own.

Discover a scheduled task declared in a module

A @Service() carrying @Schedule in app/modules/ — the natural place —
was never registered, and nothing said so. The registry was read when
providers boot; app/modules/** is auto-loaded during the start phase, after
that. The application started normally and the task simply never fired: no
error, no warning, nothing in the log.

The registry is now walked at start as well, once the modules are in place,
and discovery is idempotent per task name so the two passes cannot register
anything twice. A service declared earlier — in a provider, in a preload — is
in the registry by then too, so nothing is lost by looking again.

Reported by a consumer who watched a minute-cron produce zero fires in
seventy seconds.

Refuse an upload that claims a type its bytes cannot carry

An allowlist that accepts a claimed type is not an allowlist. When magic-byte
detection finds nothing, both halves of what validate() checked came from the
client: the extension off the filename, the mime off the header. A shell script
uploaded as avatar.png with Content-Type: image/png therefore passed
extnames: ['png'].

Upstream does the same — computeFileTypeFromName(clientName, headers) — so
this is a deliberate deviation, and a narrow one. The strictness comes from the
allowlist itself rather than from a new flag: when every allowed extension is
one the detector would have recognised, finding nothing PROVES the file is not
among them, and it is refused. When the list includes a format that carries no
signature — csv, txt — finding nothing is the normal case and says nothing,
so nothing changes.

That closes the hole without breaking a single legitimate upload, and needs no
decision from the caller. typeSource says which of the two answers mime and
extname are giving, for an application that wants to decide for itself.

Mention LDAP in the package list

Mention SAML in the package list

Mention OpenID Connect in the package list

List Transit among the packages of the universe

Ship the scheduler lock a second replica needs

The scheduler locks nothing by default and shipped only a memory backend, so
a horizontally-scaled application ran every task on every instance: the daily
invoice run went out N times, the reminder email arrived N times, and nothing
in the logs said so. The header even directed Redis backends to user-land.

locks.redis({ connection: 'main' }) fills it, over a @c9up/quasar
connection resolved by name at the first fire — ream imports nothing, so
quasar stays optional — or over any client answering set and eval.

The lease is taken with SET NX PX (one atomic round trip, so every instance
gets the same answer) and released through a compare-and-delete script. A
plain DEL is the classic way to break this: a task that outlives its TTL loses
the lock, another instance acquires the name and starts running, and the first
then deletes THAT lease on its way out.

config/scheduler.ts is now read by ScheduleProvider, which had no config
path at all — lock takes a backend or a factory, because the file is loaded
before the connection it names exists.

Read the session store key the way it is written elsewhere

Both store and driver were accepted, but the type presented driver
first and called store the foreign spelling. It is the other way round:
store is the key a session config uses, and driver is ream's older name
for it. The docs already read that way; only the type did not.

No behaviour change — store already won at resolution.

Also moves the exported VERSION to 0.2.10 with the manifest. It had stayed
at 0.2.9, which the export-map test catches — anything reading the constant
to report a version would have named the release before this one.


Changes since v0.2.9.

v0.2.9

Choose a tag to compare

@github-actions github-actions released this 29 Aug 07:23

Ship migrate, migrate:rollback and migrate:status as commands

Same move as the schedule pair: they were JavaScript inside a Rust string
literal, booting an application to drive the migrations registry. Nothing
native about them either.

The registry stays the ream particularity it was — a command that names no
store, so an app holding a relational store and a time-series one migrates both
in one pass — and --only still picks one. Sequential, because two stores on one
server contend on locks and interleaved output cannot be attributed.

Ship schedule:list and schedule:run as commands, not as Rust

Both existed only inside the ream binary, as JavaScript in a Rust string
literal that booted an application and drove the scheduler through it. Nothing
about them is native: they read the scheduler out of the container and print.

They are command classes now, behind @c9up/ream/commands — the shape AdonisJS
uses for @adonisjs/core/commands. The Ignitor registers that loader itself,
before discovery and before reamrc.commands, so an application keeps the
commands it had and can still override either by name.

The distinct exit codes survive the move (1 failed, 2 unknown task or missing
provider, 3 already running), and are now covered by tests the embedded script
could not have.

Let a package ship one command loader, as AdonisJS does

reamrc.commands accepted one shape: a module default-exporting a single
command class. A package with six of them — atlas has exactly six —
needed six rc entries, and ream list imported every class to read a
name off it.

AdonisJS accepts a LOADER: getMetaData() answers the list, and
getCommand() imports a class only when it is about to run. @adonisjs/lucid
ships one, backed by a generated commands.json. ream already had the
interface — CommandLoader, same two methods — and the kernel already
loads lazily through it; only this channel would not take one.

That gap has a cost, and it has already been paid. nebula and nova wired
their commands into the ream CLI binary instead: clap variants, a Rust
module each, shelling back to Node. Every package with a command then
needs a release of a binary that is not even on crates.io. atlas is
worse — its six commands exist, are exported, and no application declares
them, so the only working path is a Rust copy that drives the migration
runner through JavaScript embedded in string literals.

The docs now say it in one line, in both languages: this is the ONLY way
a package adds a command, and the CLI already dispatches any name it does
not own to the console kernel. The single-class form still works, for a
package that has one.


Changes since v0.2.8.