Repository navigation
Releases: C9up/station
Release list
v0.1.15
release: station 0.1.15
Require Node 24, and build the crates for production
Node 24, not because it is the current LTS — that is AdonisJS v7's own
stated reason and it is not one for us, since 22 still receives security
fixes until 2027, npm 11 is irrelevant under pnpm, and node:sqlite is
not what atlas uses. The reason is measurable and it is the framework's:
AsyncLocalStorage is on the request hot path, and Node 24 backs it with
AsyncContextFrame by default — 0.61 us per request instead of 1.55 us on
that exact pattern. Before 24 the same mechanism sat behind an
experimental flag, and a framework cannot base its performance on a flag
the application has to remember to pass. The reason travels with the
constraint, in a "//engines" key beside it.
Where there are crates: the default release profile leaves lto = false
and codegen-units = 16, so nothing inlines across crate boundaries —
and here the hot loop and the N-API binding that calls it are always two
different crates. Measured on atom, a scalar call through the binding
went from 18.85 ms to 13.59 ms for 50 000 operations. No panic = "abort": napi-rs catches panics and turns them into JavaScript
exceptions.
CI moves to Node 24 with them, since that is what the packages now ask
for.
Changes since v0.1.14.
v0.1.14
Declare the Node floor this package already has
24 of the cohort's packages declared engines.node >=22.0.0 and 8 did
not. Seven of the eight depend on @c9up/ream, which declares it, so
their real floor was already 22 -- just invisible to npm. helix is
standalone but its CI builds and tests on Node 22 only.
Undeclared, a Node 20 install succeeds without a word and the failure
arrives later as a runtime error far from its cause.
Changes since v0.1.13.
v0.1.13
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.
Widen the rune and warden ranges instead of replacing them
"Admit rune 0.2" replaced ^0.1.8 with ^0.2.0, which does not admit 0.2 — it
REQUIRES it and drops 0.1.x. rune 0.2.0 is not published, so the standalone CI,
which installs from the registry, could not resolve the dependency at all and
never reached a single test.
The warden devDependency had drifted the same way, to ^0.2.0 against a
registry at 0.1.27 — a second failure waiting behind the first, since pnpm
stopped at rune. Its own peer range already used the right form.
Both are ^0.1.x || ^0.2.x now, the shape the atlas and warden peer ranges
already use here. Station reaches rune through a lazy import over a structural
surface — rules.string/number/boolean/any, .optional/.nullable/.parse,
schema().validateResult() — and every one of those exists and behaves the
same in the published 0.1.15, which is what makes the wider range honest rather
than merely convenient.
Verified by reproducing the CI: a clean checkout, pnpm install resolving from
the registry, then typecheck, lint, build and 258 tests against rune 0.1.15 and
warden 0.1.27. The workspace still links the local 0.2.0, and passes too.
Correct a justification the coercion work made false, and drop a project name
The schema builder's doc-comment justified its explicit .parse() with "rune
does NOT coerce". rune coerces both booleans and numbers now, so the reason
given was wrong even though the code is still right: a checkbox posts the
values the COLUMN chooses, which is a wider set than a general-purpose
coercion should ever accept. The reason now says that.
Also drops the reference implementation's name from comments and one test
label — parity is shape, never product identity.
fix(deps): admit rune 0.2 — station never touches what changed in it
rune 0.2.0 no longer satisfied ^0.1.8, so pnpm stopped linking the
workspace copy and silently resolved station's rune to the published
0.1.15 instead. Station's tests were then proving nothing about the rune
it ships against.
Station consumes four constructors, three modifiers and
validateResult(). None of them moved in 0.2.0 — what moved is the
locale tables, the two normalisers, the emitted JSON Schema and the
default of mobile(), and station calls none of it. 258 tests green
against the local 0.2.0.
fix(deps): admit warden 0.2 — station never touches what changed in it
Warden 0.2.0 changed the OTP challenge store contract. Station resolves a
duck-typed AuthManager surface through the container and imports warden nowhere,
so it works with both lines, and the range now says so rather than pinning to
one.
The peer-ranges gate caught this, which is what it is for — but it read a
compound range as a failure, so the honest answer looked like the problem. It
now tries each alternative: a package that works with both lines should be able
to declare it instead of narrowing the manifest to please the check.
fix(provider): tell an absent peer from a broken one
isModuleNotFound read the error CODE and nothing else, so
ERR_MODULE_NOT_FOUND from anywhere inside an installed @c9up/atlas -- a
dependency of its own gone, a broken build -- was taken for atlas being absent.
Station then took the degraded-host path and disabled the admin, silently. The
peer was there; something in it was broken; and the one person who could fix it
was told nothing.
Node distinguishes the two in the message: a bare specifier that resolves to
nothing names the PACKAGE ("Cannot find package '@c9up/atlas'"), while a miss
inside an installed package names a path. isPackageMissing requires the first
and re-throws everything else, so a broken peer now surfaces as the error it is.
It also takes the specifier, so a missing rune can no longer read as a missing
atlas — the admin was disabled for a package it does not depend on.
Changes since v0.1.12.
v0.1.12
Name the CI workflow after the package
Every workflow already declared name: <pkg>-ci inside — twenty-eight of
twenty-nine — while the file was ci.yml almost everywhere and
<pkg>-napi-ci.yml in three places, where the -napi said nothing: half the
packages with a Rust engine did not carry it.
The file now matches the name it has always had, so one rule covers every
repository and the publish command no longer depends on remembering which
three were spelled differently.
GitHub keys run history by file path, so the runs recorded under the old name
stay reachable under it and this workflow starts a fresh history.
Cover the paths the mounter split introduced
The refactor moved the shell, the per-resource routes and the diagnostics
apart, and the suite reached none of the new seams: coverage fell from 85% to
82% and the gate would have caught it if I had run it with --coverage rather
than without.
Three tests, for the three behaviours the split created: the view-engine
failure held until a resource needs it, the shell mounted once however many
resources arrive, and ready() reporting the gaps when the surface is complete.
Sort the imports the vendored switch left out of order
Take nodeEnv from the vendored copy
The file was identical to the copies in the other packages, and they had
drifted apart only in their comments. It is generated from
scripts/vendor/nodeEnv.ts now and never edited here.
Release the resource registry when the application stops
shutdown() was empty, so a stopped application left a registry reachable
through services/main, still holding every resource that application had
declared. It clears its own now, and only its own.
Mount an admin resource where the application declares it
The provider read a snapshot of the resource registry in start() and returned
immediately when it was empty. Providers start before preloads run, and a
preload is where the documentation says to write station.register(...) — so an
application that followed the documentation registered its resources after the
only pass that would have mounted them, and got no admin at all with nothing
said about why.
Declaring a resource now mounts its routes, which is the shape upstream uses
for the same ordering problem: its provider registers no route, and the call
the application makes builds them. The admin shell goes up with the first
resource, so an application that registers none still requires no view engine
and mounts nothing — as before. The /admin index reads the registry when a
request arrives rather than capturing a list that would have named whatever
existed first.
The rune fail-closed check now names the resource at fault, and the
"permissions not seeded" / "no audit sink" warnings moved to ready(), which is
the first moment the whole surface is known.
Changes since v0.1.11.
v0.1.11
Keep the atlas range at ^0.3.x
atlas 0.4.0 was reconsidered as the patch it is: nothing in it breaks a
caller, and select(db.raw(...)) — the one behaviour that changed shape —
was a syntax error on Postgres, so nobody could depend on it.
Release 0.1.11
Accept atlas 0.4.0 alongside 0.3.x
A caret on a 0.x version is a ceiling, not a floor: ^0.3.0 excludes the
0.4.0 atlas is about to publish. Widened rather than moved, so this
resolves against the 0.3.12 on the registry today AND against 0.4.0 once
it lands — there is no window where CI, which installs from the registry,
resolves nothing.
Changes since v0.1.10.
v0.1.10
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.10 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, and declare the missing one
Three guard-rails were configured and never reached CI:
tsconfig.jsonincludestests, but CI typechecked only
tsconfig.build.json— every type a test relied on went unchecked.- coverage was measured and reported, with no thresholds at all, so
test:coveragecould not fail whatever the suite stopped covering. lintpointed atsrc/alone, so no test file was ever linted.
CI now runs pnpm typecheck, coverage, and a lint covering tests/.
The thresholds are a floor set just under what the suite covers today.
Refuse to mount an unauthenticated admin in production
With no working auth binding, every /admin/* route mounted with
authorizeAction answering true unconditionally: full CRUD on every
registered resource for anyone who can reach it. The only thing standing
between that and a production deployment was a console.warn.
It now refuses to start, unless station.requireAuth: false says the
deployment meant it — which is the same bet bay makes about a Redis
without LMOVE: losing a job, or opening an admin, is a choice a
deployment makes, not one a missing binding makes for it. Written down IS
the asking; defaulted is not, because defaulted means nobody decided.
NODE_ENV is read through the aliases people actually set: prod in a
Dockerfile answers "not production" verbatim, which would have taken the
guard back off. And the warning names production when the opt-out was
used — agreeing once in a config file is not the same as being reminded,
in the logs of an incident, that this is how the process was running.
Also drops a stale comment in the form view claiming CSRF enforcement was
still outstanding. It is not: all six write routes — create, update, the
_method router, destroy, login and logout — fail closed on
ctx.request.csrfProtected before any auth or DB work.
Reformat what the strictness pass reflowed
Two files-worth of blank lines and one long call the formatter wraps
differently now that a helper sits above them. CI resolves biome from a
caret range and installs a newer one than the lockfile pins.
Release 0.1.11
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.10
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.
Turn on noUnusedLocals/noUnusedParameters
Changes since v0.1.9.
v0.1.9
Release 0.1.9
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.
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.8.
station v0.1.8
station 0.1.8
Version bump only — no behavioural change.
Changes since v0.1.7.