v0.2.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, which is the very scenario eon's and atlas's restart work
depends on. The cached value AND any in-flight build are dropped, and a build
that finishes after a rebind no longer writes its result: awaiting the old
factory must not restore what the rebind replaced.
A failed call() leaked its class as the resolution parent. The restore
was not in a finally, so a parameter that failed with the caller catching the
error left the called class installed — and a contextual binding meant for one
handler applied to every later resolution in that chain. Visible only from
inside a resolution, which is where the first version of the test missed it.
fix(providers): mount routes and server middleware in start(), not boot()
Upstream documents that phase for exactly this, and the order is what makes it
matter: providers boot, then providers START, then the preloads run — and the
preloads are where an application writes its own routes and kernel. Mounted in
boot, a framework route landed ahead of every application route, so an
overlapping path was answered by the framework rather than by the app that meant
to override it.
graphql, rpc, openapi and static all did it. Their tests asserted the old
placement, so they now boot and start, which is the sequence a real host runs.
fix(dev): keep the headers describing the body, and stop the poller giving up
Three defects in the reload injection, all of them mine.
The representation headers described the previous body. content-length,
etag and the digests are computed before the script is appended, and were
returned unchanged: a content-length short by the length of the script truncates
it — the tag is cut off and the page never reloads — and an ETag from before
caches modified content under an unchanged validator. Length is recomputed;
the validators are dropped rather than invented, because one this layer makes up
is not the one the handler meant.
A single failed poll stopped the loop for good. if (!r.ok) return ended
the retry, so a page loaded during any blip never reloaded again for the life of
that tab — which looks exactly like the feature not working.
The endpoint was mounted in ready(), after the socket was already
accepting connections, so a page loaded in that window polled a route that did
not exist yet. It moves to the start phase, before the server listens.
feat(dev): reload the page when the server changes under it
Hot module replacement fixed the restart loop but not what the user actually
asked about first: the page still had to be reloaded by hand. A hot swap
replaces a module in the running process and a restart replaces the process --
both change what the server would render, and neither reached the browser.
The page polls a token that changes in exactly those two cases: a boot id, so a
restart is a change by construction, and a counter of hot swaps. Polling rather
than a socket is deliberate and named in the file -- a socket would still need a
reconnect loop and a boot identity to survive the restart half, which is most of
a poller with more moving parts.
Learning about a hot swap needed the other half of hot-hook's contract.
It reports BOTH outcomes through process.send, and onFullReloadAsked only
recovers the one it cannot swap; the swap itself was invisible to the process it
happened in. Since process.send is undefined under a Rust parent, the hot entry
now defines it -- not a trick, exactly the contract hot-hook expects, provided by
us instead of by Node.
NAMED DEVIATION: upstream's assembler does not refresh the browser at all, that
comes from its Vite integration. Ream has no such pipeline in the general case,
so this lives in the framework.
Found only in a real browser: the scaffold ships a nonce-based CSP, which
refused the injected tag and left the page never reloading, with nothing wrong
anywhere except one console line. The script now carries response.nonce.
feat(dev): hot module replacement, so an edit stops restarting the server
ream dev ran node's own --watch, which kills and reboots the process on
every change: the whole boot -- providers, connections, config -- replayed to
pick up one edited function. Upstream's dev server has an HMR mode for exactly
this, built on hot-hook, and ream had nothing.
@c9up/ream/hot is the entry the CLI loads before the application. It is a thin
shim over hot-hook, which does the work; the shim exists for one reason, named
in the file: upstream's dev server is itself a Node process, so hot-hook reports
a change it cannot swap by calling process.send. Ours is a Rust binary and a
process it spawns has no IPC channel, so that message goes nowhere and the
server would keep serving a stale module in silence. onFullReloadAsked turns
it into an exit code the CLI restarts on.
hot-hook is resolved from the project rather than from this package: it is a dev
dependency of the application, and under pnpm's strict layout a bare import here
looks inside @c9up/ream and finds nothing.
Measured on a scaffold: editing a file inside a boundary changed the response
from v1 to v2 with the SAME pid and no reboot; editing one outside restarted the
server, which came back on a new pid and kept answering.
Changes since v0.2.17.