Repository navigation
Releases: C9up/chronos
Release list
v0.1.15
release: chronos 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
Ship the wasm the package says it ships
The published tarballs declare wasm/ in files and contain no wasm/ at
all -- not even the committed .d.ts stub. Every browser consumer got
the engine-missing error on the first call that touched the engine,
because src/native.ts imports the glue inside a try/catch: the import
fails quietly and only the call reports.
Cause is self-inflicted by the tool. wasm-pack treats its out-dir as a
package of its own and scaffolds one, including a .gitignore whose
whole content is ''. npm honours a .gitignore nested inside a
published directory even when files lists that directory, so the single
'' stripped everything. Measured with npm pack --dry-run: zero wasm/
entries with the file, three without it.
The gate could not see it because it asked the wrong question. It
checked the working directory -- where the artifacts genuinely are --
when the question is what ends up in the package. It now asks the
packer (npm pack --dry-run --json --ignore-scripts) and fails if a
required artifact is on disk but absent from the tarball, with the
nested-ignore cause named in the message.
build:wasm now removes wasm-pack's scaffolding before anything packs.
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.
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.
Run cargo with --locked in CI
Without it, cargo rewrites Cargo.lock in place when it has drifted from the
manifests — so CI resolves dependencies fresh and tests a graph nobody
committed, then the release is built from it. The workspace lock had drifted
by 382 lines before the same flag caught it locally.
Every package here commits a Cargo.lock, so --locked is meaningful: it fails
loudly instead of silently updating. Verified against the current lock before
the flag went in.
The Node side is deliberately left alone: these repositories ship no
pnpm-lock.yaml, so --frozen-lockfile has nothing to freeze against, and
resolving from the registry is what a consumer gets anyway.
Measure coverage once, not once per platform
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.jsonincludestests, but CI typechecked only
tsconfig.build.json— every type a test relied on went unchecked.vitest.config.tsdeclares coverage thresholds, but CI ran plain
vitest run, which does not read them.lintpointed atsrc/alone, so no test file was ever linted.
CI now runs pnpm typecheck, pnpm test:coverage and a lint that covers
tests/ as well.
Changes since v0.1.13.
v0.1.13
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.13
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.
Move the NAPI bindings to napi 3
The Rust needed no change; the toolchain did. napi-derive 3 writes one type-def
file per crate into NAPI_TYPE_DEF_TMP_FOLDER and panics outright when it sees
the old single-file TYPE_DEF_TMP_PATH — that variable is how it detects an
out-of-date toolchain, so the failure reads as "upgrade @napi-rs/cli" even
though the generator here is our own.
It also emits a function as a bare function name(...) where 2 emitted the
signature alone, so concatenating the name onto it produced
function xfunction x(...). The generator handles all three shapes now.
napi-build stays at 2 — there is no 3 on crates.io.
Verified by what the migration could break rather than by it compiling: the
generated src/native/generated.ts comes out byte-identical to the napi 2 one,
and the native binary is rebuilt and exercised by the JS suite.
Update the Rust dependencies within their ranges
Everything the existing semver ranges allow, so no manifest changes and no API
surface moves. fmt, strict clippy, tests and advisories all pass.
Clear strict clippy and the advisory list
Nothing in the root gate ran clippy or cargo-deny against a package workspace —
cargo test --all covered the ROOT workspace only, which is a handful of
crates. Five packages were failing strict clippy at the same time and nothing
said so.
Changes since v0.1.12.
v0.1.12
Turn on noUnusedLocals/noUnusedParameters
Release 0.1.12
Two ranges that only touch overlap when both bounds are closed
The boundary case used inclusiveStart || inclusiveEnd, so [a,b) and [b,c)
were reported as overlapping. They share exactly one instant — the second
range's start, which is also the first range's end — and it belongs to both
only when both bounds are closed. With the end open it is in the second alone,
and there is no instant in both.
An && is what that means. A range pair that genuinely shares a span is
unaffected.
Print the test run, not just its JSON
The workflow ran vitest with --reporter=json alone, so a failing job wrote
its report to a file and nothing to the log: an exit code, and not one word
about which test failed. Twice today that meant finding a failure by deduction
instead of reading it.
The default reporter runs alongside; the JSON one still feeds the smoke gate.
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.
Changes since v0.1.11.
v0.1.11
Stop tracking the relocated cargo target
A git add -A swept up a target symlink pointing into a local build
directory, so CI checked it out as a symlink and cargo could not create the
directory it needed: "failed to create directory .../target — Not a directory".
The napi build died before compiling anything.
target/ was already ignored, but that pattern matches a directory only.
Make diff answer a - b, signed and fractional
diff subtracted the wrong way round: a date two days ahead answered -2, the
opposite sign of every other library in this space, and the test that covered
it asserted only the magnitude with a comment saying the direction depended on
the engine — which is how it stayed unnoticed. It also returned an i64, so a
gap of five and a half months came back as five, the half silently gone.
Months and years are counted on the calendar rather than an averaged length:
whole steps first, then the remainder measured against the month it actually
falls in, so 15 days into February is 15/28 of a month and not 15/30. A month
reached by clamping counts as whole — January 31st plus one month is February
28th, so the gap between them is one month, where the truncating version said
zero.
Every value in the tests was taken from a reference implementation and matches
to the last digit. months_between had no caller left after the rewrite.
Write a README that documents the library, and name Interval's units
The README was a title, an install line and one example. It now covers
DateTime, Duration, Interval, recurrence and the Atlas adapter, and every
example in it was run against the built package before being written down —
which is how the two below turned up.
Interval.length keyed its unit table in the plural and answered 1 for anything
else, so length('day') returned 2592000000: milliseconds, presented as days.
The rest of chronos spells units in the singular, so the spelling most callers
would reach for was the one that silently lied. splitBy had the same fallback,
where an unrecognised unit becomes a one-millisecond step — 2.6 billion
sub-intervals over a month.
Both now resolve singular and plural, as Luxon does, and refuse a unit they do
not know. TypeScript already rejects one at the call site; the guard is for
JavaScript consumers, and the tests reach it the way those consumers do.
Refuse a calendar date that does not exist instead of sliding forward
DateTime.from('2026-02-30') answered 2026-03-02 while DateTime.from('2026-13-45')
threw. Both dates are equally impossible, and the inconsistency was the
dangerous half: a caller wrapping this in try/catch — the natural reading of
the lines that throw — had covered the loud case and left the one that writes
something false to the database.
The overflow comes from Date, and it is right for arithmetic: adding a month
to January 31st has to land somewhere, and chronos already clamps that to the
28th as Luxon does. It is wrong for reading input, where the same slide turns
a typo into a fact.
fromObject has always refused this exact input by checking that the date it
built is the date it was given. This is that check on the door that was
missing it, with a message naming the month's real length. Only the written
date is examined, never the instant it resolves to, so an offset that
legitimately moves the UTC day is untouched, and a real leap day still parses.
The leap-year table had a second copy in the calendar getter; both now read
from one helper.
Changes since v0.1.10.
v0.1.10
Release the work that landed after the last published version
The registry now carries the version this package.json was still on, so
everything committed since ships under the next one rather than
retroactively changing what a published version means.
Build the cross-platform matrix only when the version moves
The five-runner matrix exists to produce the prebuilt binaries a release
ships. It ran on every push to main, rebuilding artefacts nobody
downloads — five runners, every time, for a comment fix.
A version-gate job now compares the package version at HEAD^ with
the one at HEAD and the matrix runs only when they differ. Anything
that is not a push passes the gate unconditionally, so workflow_dispatch
— how a release is actually cut — is unaffected, and so is publish,
which still waits on the full matrix. A missing HEAD^ reads as a bump:
erring towards building is the safe direction.
The test signal deliberately does NOT move with it. quality and the
cargo/integration jobs were already independent of the matrix, but
vitest ran INSIDE it, so gating the matrix alone would have quietly
taken the TypeScript suite off every ordinary push. A ts-tests job now
runs it on ubuntu, building its own napi binary rather than waiting on a
gated artefact. On a push without a bump that leaves typecheck, lint,
cargo and vitest — one runner instead of five.
Derive the native TypeScript surface from the Rust
The hand-written interface describing the .node binary was a second
description of the same thing, and nothing on this side noticed when the
first one changed: a pub fn could gain a parameter, stop being async
or change its return with the declaration still claiming otherwise.
napi-derive can emit the declarations itself. Its type-def feature
writes one JSON line per #[napi] item while cargo compiles;
scripts/build-napi-types.mjs collects them and
scripts/generate-napi-types.mjs turns them into
src/native/generated.ts. build:napi regenerates it, and the
TypeScript side consumes it instead of restating it.
Three things the generation had to handle:
type-defAPPENDS to its output file, so a parallel cargo build
interleaves the writes and definitions go missing, silently, leaving
the generated file short. Crates are built one at a time, and a crate
that emits nothing fails the script rather than producing a partial
surface.- A Rust doc example holding a cron expression (
0 */5 * * *) closes
the generated comment early. Every*/is escaped except the one
that legitimately closes the block — escaping that one breaks the
file just as thoroughly. - The driver is Node rather than bash, because
build:napialso runs
on the Windows prebuild runner: a Git Bashmktemppath is not
something the native proc-macro can write to, and the type-def file
would come back empty with nothing to explain why.
The generated file is a .ts holding only ambient declarations rather
than a .d.ts, so tsc carries it into dist/native/ and the
reference from the emitted declarations still resolves for consumers.
Changes since v0.1.9.
v0.1.9
Use real private fields, not the TypeScript keyword
private is erased at compile time: at runtime the field is public,
enumerable, and shows up in Object.keys and JSON.stringify. # is enforced by
the engine. The difference is not cosmetic, and one test proved it — photon's
renderer test reached into a private ssrModule through an
as unknown as { ssrModule } cast, which the keyword never prevented. It now
goes through useSsrModule(), a real seam, and the cast is gone.
Constructor parameter properties are expanded into a field plus an assignment,
since # cannot be declared in a parameter list.
Three private CONSTRUCTORS stay as they are, annotated: that form has no native
equivalent, and it is the one place the keyword expresses something # cannot.
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.
chronos v0.1.8
chronos 0.1.8
Keep the milliseconds in toISO(), as Luxon does
chronos serialised the compact form, 2026-07-15T10:00:00Z, where Luxon's
toISO() — and therefore a serialized model under @adonisjs/lucid — returns
2026-07-15T10:00:00.000Z. Same instant, different literal, and a silent
difference in every JSON payload carrying a date. The compact form is Luxon's
opt-in toISO({ suppressMilliseconds: true }).
It was produced in TWO places that each justified themselves by matching the
other: normalizeIso() in TypeScript, and the Rust engine's canonical formatter,
which switched to SecondsFormat::Secs whenever the subsecond part was zero. So
an rrule expansion (which runs in Rust) disagreed with DateTime.toISO() on the
same instant — one value, two literals, across the NAPI boundary. Both now emit
milliseconds unconditionally.
format() is untouched: a caller-chosen token string like
YYYY-MM-DD[T]HH:mm:ss[Z] carries no milliseconds by construction, and the
canonical ISO form is toISO(), not a format string.
378b5fc
chronos: one boundary implementation for both the UTC and the zoned path
startOf/endOf had TWO implementations — Rust for UTC, a hand-written TypeScript
switch for zoned — and they had drifted. Adding startOf('second') to Rust left
the zoned switch silently returning the value unchanged for that unit, so the
same call answered differently depending on the user's zone.
- extract start_of_naive / next_unit_naive as the single core in Rust, kept side
by side so their unit lists cannot separate - expose start_of_in_zone / end_of_in_zone through NAPI and WASM; DateTime now
only picks the path. No calendar arithmetic left in TypeScript - month and year arithmetic preserve sub-second precision: both paths rebuilt
the instant from Y/M/D + h:m:s, so plus(1, 'month') silently rounded away the
milliseconds - add the missing startOf/endOf('second'), which Luxon has
Tests compare both paths unit by unit, so a unit added to one only now fails.
16d07d4
Changes since v0.1.7.