From 0673623111b530b1e8c154a77ef3d64c4fe56fe1 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 16 Aug 2026 19:08:46 +0200 Subject: [PATCH 1/2] chore!: put all eight packages on one lockstep version at 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An application installs a kernel and two or three starters together, so 'which @btravstack/http goes with @btravstack/core@0.4.1' is a question nobody should have to answer. A changesets `fixed` group makes every release bump all eight, whether or not they changed — Spring Boot's model. @btravstack/di is the only one with a published history (0.1.0, from its standalone repo). The unified line starts at 0.2.0: above that, and 0.x because the API still moves. di's peer range was a hardcoded ^0.1.0 while every other in-repo peer used workspace:^. That pin would have gone stale silently on the first bump; it is workspace:^ now, which pnpm rewrites to a real range at publish, so consumers still install their own copy. The 19 pending changesets are consumed into the CHANGELOGs rather than squashed — each was written when its change was fresh. Recorded in CLAUDE.md, including the constraint that bit here: changesets majors any package whose peer dependency takes a minor, so from 0.2.0 a patch gives 0.2.1 but a minor gives 1.0.0. Neither documented escape hatch suppresses it, and the internal peers cannot become ordinary dependencies. This release overrode the computed 1.0.0 by hand. --- .changeset/amqp-starter.md | 74 ------- .changeset/amqp.md | 24 --- .changeset/config-and-starters.md | 40 ---- .changeset/config.json | 14 +- .changeset/di-declared-pieces.md | 26 --- .changeset/drain-and-shutdown-fixes.md | 52 ----- .changeset/drop-version-const.md | 11 - .changeset/http-handler-port.md | 60 ------ .changeset/http-unit-defects.md | 11 - .changeset/http.md | 16 -- .changeset/initial-kernel.md | 45 ----- .changeset/observability.md | 37 ---- .changeset/ponytail-audit.md | 21 -- .changeset/run-main-front-door.md | 19 -- .changeset/runtime-port.md | 24 --- .changeset/temporal-starter.md | 53 ----- .changeset/temporal.md | 19 -- .changeset/testing-package.md | 24 --- .changeset/unit-module.md | 36 ---- .changeset/unit-signal.md | 37 ---- CLAUDE.md | 45 ++++- packages/amqp/CHANGELOG.md | 146 ++++++++++++++ packages/amqp/package.json | 4 +- packages/config/CHANGELOG.md | 46 +++++ packages/config/package.json | 4 +- packages/core/CHANGELOG.md | 266 +++++++++++++++++++++++++ packages/core/package.json | 4 +- packages/di/CHANGELOG.md | 46 +++++ packages/di/package.json | 2 +- packages/http/CHANGELOG.md | 134 +++++++++++++ packages/http/package.json | 4 +- packages/observability/CHANGELOG.md | 56 ++++++ packages/observability/package.json | 4 +- packages/temporal/CHANGELOG.md | 120 +++++++++++ packages/temporal/package.json | 4 +- packages/testing/CHANGELOG.md | 152 ++++++++++++++ packages/testing/package.json | 4 +- 37 files changed, 1036 insertions(+), 648 deletions(-) delete mode 100644 .changeset/amqp-starter.md delete mode 100644 .changeset/amqp.md delete mode 100644 .changeset/config-and-starters.md delete mode 100644 .changeset/di-declared-pieces.md delete mode 100644 .changeset/drain-and-shutdown-fixes.md delete mode 100644 .changeset/drop-version-const.md delete mode 100644 .changeset/http-handler-port.md delete mode 100644 .changeset/http-unit-defects.md delete mode 100644 .changeset/http.md delete mode 100644 .changeset/initial-kernel.md delete mode 100644 .changeset/observability.md delete mode 100644 .changeset/ponytail-audit.md delete mode 100644 .changeset/run-main-front-door.md delete mode 100644 .changeset/runtime-port.md delete mode 100644 .changeset/temporal-starter.md delete mode 100644 .changeset/temporal.md delete mode 100644 .changeset/testing-package.md delete mode 100644 .changeset/unit-module.md delete mode 100644 .changeset/unit-signal.md create mode 100644 packages/amqp/CHANGELOG.md create mode 100644 packages/config/CHANGELOG.md create mode 100644 packages/core/CHANGELOG.md create mode 100644 packages/http/CHANGELOG.md create mode 100644 packages/observability/CHANGELOG.md create mode 100644 packages/temporal/CHANGELOG.md create mode 100644 packages/testing/CHANGELOG.md diff --git a/.changeset/amqp-starter.md b/.changeset/amqp-starter.md deleted file mode 100644 index 17e2f01..0000000 --- a/.changeset/amqp-starter.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -"@btravstack/amqp": minor ---- - -**Breaking.** `@btravstack/amqp` becomes a starter, the same shape as -`@btravstack/http`'s `http()`. `amqp({ contract, url?, ... })` -returns a module providing the runtime on the new **`AmqpRuntime`** port -(`RuntimePort>` — the runtime has no needs) and the -broker on **`AmqpConfig`** (`{ url }`, bound from `AMQP_URL`, default -`amqp://127.0.0.1:5672`, unless `url` is pinned — then the module reads -nothing from the environment; its declared `Env` need and `ConfigInvalid` -stay). - -The handlers are provided on the **starter's own handlers port** — one id, -`Port("AmqpHandlers")`, framework-owned like `AmqpConfig`, since a consumer -serves one handlers record as it boots one runtime; typed per contract at the -type level, so a provider built for one contract cannot be handed to a module -declaring another — whose service is the handlers record the contract wants -with no injected context (`WorkerInferHandlers`), checked -against `contract` at the `AmqpHandlers(contract)(…)` call. The starter -**needs** that port, so a composition that imports `amqp({ contract })` -without providing handlers is refused at `start`. Its provider declares what -the handlers need and closes over it — there is no `context.ctx` any more, -and no `needs`. - -```ts -const orderHandlers = AmqpHandlers(orderContract)([Logger], { - sync: (logger) => ({ orderChanged: (message) => … }), -}); - -const Worker = Module("Worker")({ - imports: [AppModule, amqp({ contract: orderContract })], - provides: [orderHandlers], - exports: [AmqpRuntime], -}); -``` - -Gone: `amqpRuntime`, `messageUnits`, `MessageMiddleware`, `MessageUnitContext`, -and `AmqpOptions.needs` / `handlers(host)` / `middleware(host)` / `urls`. The -unit-per-delivery middleware is installed by the starter and injects nothing; -`currentUnit()` still carries the trace id (the publisher's `messageId`). -`@btravstack/config` joins the peer dependencies. - -**`AmqpModule(name)({ contract, handlers, url?, connectionOptions?, defaultConsumerOptions?, connectTimeoutMs?, imports?, provides?, exports? })`** -is the way an application declares an AMQP deployment: `Module(name)({...})` -plus the contract and the handlers **provider**. It imports the starter, -provides the handlers, exports `AmqpRuntime`, and hands the augmented -imports/provides/exports to di's own `Module(name)({...})`, whose return type -is the sugar's — sugar over the same primitives, nothing new for the kernel or -the gates. `handlers` is a plain `Provider` on the starter's handlers port -for `contract` — what `AmqpHandlers` returns. -`amqp({ contract })` stays exported as the primitive it delegates to. - -```ts -const Worker = AmqpModule("Worker")({ - contract: orderContract, - handlers: orderHandlers, - imports: [AppModule], -}); -``` - -**`AmqpHandlers(contract)`** is di's own `Provider(port)` on the starter's -handlers port typed for the contract, so the class line and the name go: the -next call is exactly `Provider(port)(deps, arm)`, checked against the -contract's record — a bare function per consumer, nothing to wrap it in — -and the provider carries the port typed (`orderHandlers.port`, di's -`PortClassOf<"AmqpHandlers", WorkerInferHandlers>`) for a hand-declared -provider or a type test. - -```ts -const orderHandlers = AmqpHandlers(orderContract)([Logger], { - sync: (logger) => ({ orderChanged: (message) => … }), -}); -``` diff --git a/.changeset/amqp.md b/.changeset/amqp.md deleted file mode 100644 index 958651b..0000000 --- a/.changeset/amqp.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@btravstack/amqp": minor ---- - -The AMQP consumer runtime for `@btravstack/core`. - -`amqpRuntime({ urls, contract, handlers, needs })` runs an `amqp-contract` -worker under the kernel's lifecycle: one unit per delivery, and a drain where -the kernel's `drainTimeoutMs` is the only deadline — the library is told to wait -forever and the kernel's signal is raced against it, so there is no second -timeout to keep in sync. - -Add `messageUnits(host)` to the worker's middleware and every delivery becomes a -kernel unit with the application context injected. `@amqp-contract/worker` and -`@opentelemetry/api` are peer dependencies — install them alongside this -package; `@amqp-contract/contract` stays a devDependency only, used to type -this package's own tests and never appearing in the published type surface, -because the middleware type is declared structurally rather than imported. - -`Result` → ack / retry / DLQ is a three-way split, not a single mapping: a -modeled `RetryableError` / `NonRetryableError` is routed by `amqp-contract`'s -own dispatch against the queue's retry policy, and a `Defect` is a third -channel — dead-lettered on its first attempt, never retried, unless the -handler recovers it into a `RetryableError` itself. diff --git a/.changeset/config-and-starters.md b/.changeset/config-and-starters.md deleted file mode 100644 index 284c0a9..0000000 --- a/.changeset/config-and-starters.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@btravstack/config": minor -"@btravstack/core": minor -"@btravstack/http": minor ---- - -**Configuration, the twelve-factor way, in its own package.** `@btravstack/config` -exports `Env` — the environment as a port, which `@btravstack/core` provides to -every graph `start` boots (`process.env`, or `StartOptions.env` for a test) — -and `Config`: -`Config.string/integer/port(variable, { default?, min?, max? })` fields, -`Config.object({...})` composing them into a Standard Schema over the -environment (any other Standard Schema, a `zod` object over the raw variables -for instance, is accepted too), and `Config.provider(Port)(schema)` binding a -port from `Env` — a modeled `ConfigInvalid` naming every offending variable -when the environment is wrong, which `runMain` maps to sysexits(3)'s -`EX_CONFIG` (78) rather than the generic startup `1`. The kernel binds its own -`PROBE_PORT` the same way (default `9000`; `probes` still overrides), and a -startup failure of any kind is now reported as a `startFailed` kernel event -before `stopping`, so a bad environment is named on stderr instead of exiting -silently. An empty or blank variable is an error, never an absent one; `PORT=0` -stays expressible. - -`@btravstack/http` becomes a starter: `http()` provides -`HttpRuntime` and `HttpConfig`, bound from `PORT` (default `3000`) and `HOST` -(default `0.0.0.0`) unless pinned (`http({ port: 0 })` for a test — -explicit beats environment beats default, per field, through -`Config.pinned(value, field)`; a pinned field reads nothing from the -environment, and the module's declared `Env` need and `ConfigInvalid` stay -whatever is pinned). `RuntimeNeedsGate` is renamed `StartGate`, since it now -also states `NO RUNTIME`. - -`Config.provider("Name")(schema)` — the name form — mints the port (its -service is the schema's output) and returns the provider carrying it typed -(`provider.port`), the shape for a slice that is one application's own; the -class form `Config.provider(Port)(schema)` stays for a slice that is public -API another package names. Config is the one sugar that takes a name — several -config slices per application is normal, and the name is what `ConfigInvalid` -prints; the starters' `HttpRouter` / `TemporalActivities` / `AmqpHandlers` -provide the starter's own fixed port and take none. diff --git a/.changeset/config.json b/.changeset/config.json index 12b7ce1..9365261 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -5,5 +5,17 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "fixed": [ + [ + "@btravstack/di", + "@btravstack/config", + "@btravstack/core", + "@btravstack/testing", + "@btravstack/observability", + "@btravstack/http", + "@btravstack/temporal", + "@btravstack/amqp" + ] + ] } diff --git a/.changeset/di-declared-pieces.md b/.changeset/di-declared-pieces.md deleted file mode 100644 index 6fc2555..0000000 --- a/.changeset/di-declared-pieces.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@btravstack/di": patch ---- - -`AnyModule`, `AnyProvider` and `Exportable` — the constraints -`Module(name)({ imports, provides, exports })` puts on its three tuples — are -exported as types, so a package offering a shaped module (a starter's -`HttpModule(name)({ router, imports, provides, exports })` sugar, which appends -its own import and export to what the application wrote) can constrain its -tuples the same way and hand them to `Module(name)({...})` itself, whose -return type is then the sugar's, spelled once. `PortClassOf` -(`{ portId: Id; new (): PortInstance }`) is exported as the one -nameable type of a port class declared inside a helper — what -`Config.provider(name)(schema)`, `HttpRouter`, `TemporalActivities` and -`AmqpHandlers` return as `provider.port`, and what a starter spells its own -fixed port through. - -`Provider(port)(deps, arm)` now returns `Provider & { readonly port: -typeof port }` — the provider carries the port class it was declared for, -typed, so a helper that returns a provider on a port it owns (a starter's -`HttpRouter(contract)(deps, arm)`, `Config.provider(name)(schema)`) hands back -one value and `provider.port` is what a dependent lists in its deps. Purely -additive. `PortInstance` is exported as a type for the same reason: a provider -over a port declared inside a helper needs a nameable declared type when a -consumer exports it (naming the instance type forges nothing — the brand keys -stay private). diff --git a/.changeset/drain-and-shutdown-fixes.md b/.changeset/drain-and-shutdown-fixes.md deleted file mode 100644 index b85a542..0000000 --- a/.changeset/drain-and-shutdown-fixes.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@btravstack/core": minor ---- - -Seven shutdown-path fixes found by a full review of the kernel. Five change -observable behaviour. - -- **The drain waits for a unit that opens while the runtime is still stopping - accepting.** `UnitRegistry.awaitIdle()` answers about the registry at the - instant it is _called_, and beat 3 was calling it in the same tick as - `Serving.drain(signal)`. A unit opened while `drain` was still resolving was - therefore never awaited — it was aborted at the deadline and reported - `abandoned` with the whole `drainTimeoutMs` unspent. It is now sequenced - behind `drain`. The window is wide for any runtime whose `drain` is a real - wait, such as an HTTP server closing out keep-alive connections. - -- **`stop()` and the uncaught path now abort in-flight units.** Both skip the - drain, and neither signalled the work it was leaving behind. That contradicted - the reason `"uncaught"` skips the drain at all — that in-flight work may be - completing against corrupted state — and let a unit holding a ref'd socket - keep the event loop alive after the exit report. - -- **`runMain` exits `2` when `ExitReport.teardownErrors` is non-empty.** - Previously a shutdown whose finalisers all failed still exited `0`, reporting - success to an orchestrator for a shutdown that may have lost data. `2` already - meant "we stopped, but not cleanly"; a failed finaliser now earns it as much - as abandoned work does. - -- **The pre-drain delay is charged from when the shutdown was requested.** A - signal arriving mid-build is buffered until the runtime is serving, so the - full `preDrainDelayMs` was paid _again_ afterwards. Both together can exceed - `terminationGracePeriodSeconds` and turn a graceful exit into a SIGKILL. - -- **An out-of-range or non-integer probe port is a modeled - `Err(RuntimeStartFailed)`.** `server.listen` validates the port synchronously - and _throws_ `ERR_SOCKET_BAD_PORT` rather than emitting `'error'`, so it - escaped as a `Defect` — bypassing the declared error channel and exiting `70` - where a startup failure exits `1`. - -- **`stderrSink` renders an `Error` cause instead of `{}`.** `JSON.stringify` - skips non-enumerable properties, so `Error.message` and `stack` never - serialised — leaving `{"type":"uncaught","cause":{}}` as the default crash - report. A cause it cannot serialise at all now falls back to - `"[unserialisable]"` rather than throwing, which `safeSink` would swallow, - losing the event entirely. - -- **The probe server keeps an `'error'` listener for its whole life.** The - bind-failure listener is now replaced rather than merely removed: a - post-listen `'error'` (an accept failure such as `EMFILE`) had no listener, - and an unhandled `'error'` throws — which the kernel's own `uncaughtException` - handler turned into a whole-application teardown over a fault in its health - endpoint. diff --git a/.changeset/drop-version-const.md b/.changeset/drop-version-const.md deleted file mode 100644 index 152168c..0000000 --- a/.changeset/drop-version-const.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@btravstack/core": minor ---- - -Remove the `VERSION` export. - -It was a hand-maintained copy of `package.json`'s `version`, read by nothing but -a test asserting the literal it was written as — so it could only ever go stale -or fail its own tautology. Neither `@btravstack/http` nor -`@btravstack/temporal` ever shipped one. A consumer that needs the version -should read it from the package manifest. diff --git a/.changeset/http-handler-port.md b/.changeset/http-handler-port.md deleted file mode 100644 index 8e97c09..0000000 --- a/.changeset/http-handler-port.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@btravstack/http": minor ---- - -**Breaking.** `@btravstack/http` is the HTTP starter, and there is one way HTTP -is answered: **oRPC, over its own node adapter**. `http()` mounts the -application's router under `prefix` (default `/rpc`) and provides the runtime -on **`HttpRuntime`** (declared over core's `RuntimePort`, `Runtime` — no `needs`), which the composition root imports and exports so -`start` finds it. The router is not an option: it is a **provider on the -starter's own router port** — one id, `Port("HttpRouter")`, framework-owned -like `HttpConfig`, since a process serves one router as it boots one runtime — -whose service is a context-free oRPC router built from the use cases its -procedures call. The starter **needs** that port through di, so a composition -that imports it without providing a router is refused at `start`, at compile -time; two router providers in one graph are di's duplicate-provider defect at -build. - -```ts -const orderRouter = HttpRouter(orderContract)([PlaceOrder, FindOrder], { - sync: (place, find) => ({ orders: { place: …, find: … } }), -}); - -const OrderApi = Module("OrderApi")({ - imports: [ApplicationModule, PersistenceModule, http()], - provides: [orderRouter], - exports: [HttpRuntime], -}); -``` - -`@btravstack/orpc` is folded into this package and no longer exists. `needs`, -`handler` and `router` are gone from `HttpOptions`; `httpRuntime` is no longer -exported; the node listener port `HttpHandler` is internal — an application -provides a router, never a handler, and a handler built per request by the -`StartOptions.unit` module is gone with it. An unmatched path is declined -unwritten by oRPC and answered by the runtime's own `404`, and a defect inside -a procedure is oRPC's own `INTERNAL_SERVER_ERROR`; `Result` → HTTP status -stays the router's `.result()` triage. `@orpc/server`, `@orpc/contract` and -`@unthrown/orpc` are peer dependencies — not `hono` or `@hono/node-server`, -which routed one pattern to oRPC's fetch adapter and are gone. - -**`HttpModule(name)({ router, prefix?, port?, hostname?, imports?, provides?, exports? })`** -is the way an application declares an HTTP deployment: `Module(name)({...})` -plus the router **provider**. It imports the starter, provides the router, -exports `HttpRuntime`, and hands the augmented imports/provides/exports to -di's own `Module(name)({...})`, whose return type is the sugar's — sugar over -the same primitives, nothing new for the kernel or the gates. `router` is a -plain `Provider` on the starter's router port, which is what `HttpRouter` -returns. `http()` stays exported as the primitive it delegates to. - -`HttpRouter(contract)(deps, { sync })` — contract-first: `sync` returns a -record shaped like the contract whose leaves are plain `Result`-returning -functions (the `.result()` handler `@unthrown/orpc` gives an implementer), -typed by the contract at the call; `implement`, `os.…`, `.result(...)` and -`os.router(...)` are done for you. It is di's own `Provider(port)` on the -starter's router port — no name to give, no class line — returning the -provider with the port typed (`orderRouter.port`, di's -`PortClassOf<"HttpRouter", Router<…>>`) for a hand-declared provider or a -type test; `HttpModule({ router: orderRouter })` takes it from there. -`@orpc/contract` and `@unthrown/orpc` join the peers. diff --git a/.changeset/http-unit-defects.md b/.changeset/http-unit-defects.md deleted file mode 100644 index 122d3f7..0000000 --- a/.changeset/http-unit-defects.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@btravstack/http": patch ---- - -Two consequences of the kernel's new `StartOptions.unit`. A unit whose work -begins after its response has already closed — a client that hung up during a -slow per-request build — now settles at once instead of waiting for a `'close'` -event that already fired, which held the unit open for the process lifetime. -And a defect that never reaches the handler's promise — a synchronous throw, or -a unit provider that failed to build — now answers `500` when no headers are -out, rather than only resetting the connection. diff --git a/.changeset/http.md b/.changeset/http.md deleted file mode 100644 index 0d1fe12..0000000 --- a/.changeset/http.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@btravstack/http": minor ---- - -The HTTP runtime for `@btravstack/core`. - -`httpRuntime({ port, needs, handler })` owns an HTTP server's lifecycle and -nothing else: it binds (publishing the real port on `Serving.info`, so -`port: 0` is usable), opens one kernel unit per request, drains by genuinely -refusing new work, and stops by destroying what is left. - -Its guarantee is that every request produces exactly one completed response, -and the unit stays open until that response is on the wire — which makes the -kernel's least-checkable contract structural rather than documented. Routing, -middleware and `Result` → HTTP status are deliberately not included: bring an -oRPC router (see the starter entry below). diff --git a/.changeset/initial-kernel.md b/.changeset/initial-kernel.md deleted file mode 100644 index b6efaa7..0000000 --- a/.changeset/initial-kernel.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@btravstack/core": minor -"@btravstack/testing": minor ---- - -The application kernel: `start` boots a `@btravstack/di` module into a running -process with one runtime, drains in-flight work on SIGTERM, and closes the -application scope on every path. - -- `start(module, options)` returns a `RunningApp` — `exited` - (`AsyncResult`, the module's own error - type passed through unwrapped), `stop`, `requestDrain`, `phase`, `ready`, - `probePort` and `runtimeInfo`. It never throws and never calls - `process.exit`. The runtime's - declared `needs` are checked against the module's exports at compile time. -- The `Runtime` / `RuntimeHost` / `RunUnit` / `Serving` contract, with unit - tracking owned by the kernel: `Serving.drain(signal)` returns - `AsyncResult` and the kernel does the accounting into a - `DrainReport`. -- A channel for what a runtime **is**: `Serving.info` publishes arbitrary - structured info about a serving runtime, and `RunningApp.runtimeInfo()` reads - it back as an `AsyncResult` that settles when the - runtime starts serving — so a runtime binding an ephemeral `port: 0` tells the - caller which port it got instead of inventing an `onListening` hook. The shape - is the runtime's own (a queue runtime has no port), and `Info` defaults to - `never`, so publishing is optional with no extra ceremony. -- A three-beat drain — readiness false, `preDrainDelayMs` before the runtime - stops accepting, then `drainTimeoutMs` for in-flight work — plus liveness and - readiness probes served from the lifecycle state machine rather than a - transport. -- `runMain`, which turns an outcome into a process exit code (`0` / `1` / `2` / - `70`) by setting `process.exitCode`. -- `currentUnit()` over an `AsyncLocalStorage` record carrying - `{ unitId, traceId, tenantId, deadline, signal }` — data, never capabilities. -- A `@btravstack/testing` package with `testRuntime`, - `createFakeClock` and `withApp`. -- **Every async API returns an `AsyncResult`, never a bare `Promise`** — the - infallible ones included, where `AsyncResult` spells "async, and - cannot fail". `probePort()`, `Clock.sleep`, `FakeClock.advance`, - `UnitRegistry.awaitIdle`, `TestRuntime.untilStarted` and `ProbeServer.close` - all carry `E = never`. Three surfaces are deliberately outside it: `runMain` - (the boundary out of the Result world, into a process exit code), `UnitWork`'s - `Promise>` arm (it accepts a caller's `async` handler) and - `withApp`/`use` (a thrown assertion inside a test body must reach the test - runner, which an `AsyncResult` — which never rejects — would swallow). diff --git a/.changeset/observability.md b/.changeset/observability.md deleted file mode 100644 index fea1aa9..0000000 --- a/.changeset/observability.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@btravstack/observability": minor ---- - -**`@btravstack/observability`** — observability for the kernel, starting with -logging. - -`Logger` is a di port over a deliberately strict interface, and every -difference from NestJS's logger is a defect it does not have: a port rather -than a class you `new` (no static instance, no `useLogger` reaching past DI), -`with(attributes)` returning a new logger rather than `setContext` mutating the -one every caller shares, a flat record of scalars rather than `any` varargs, a -dedicated `cause` channel (an `Error`'s `message` and `stack` are -non-enumerable, so `JSON.stringify` alone drops exactly the part worth -keeping), six fixed levels, and a guarantee that a log call cannot throw — a -broken sink is swallowed rather than becoming an outage. - -- **Correlation is not the caller's job.** `createLogger` reads - `currentUnit()` **per call**, so every line written inside a unit carries its - `traceId`, `unitId` and `tenantId` — one application-scope logger, correct - for every request, with nothing threaded through the call stack. -- **`observability({ sink?, level? })`** provides `Logger` and `LoggerConfig`, - bound from `LOG_LEVEL` (default `info`) and validated once: a level outside - the six is a `ConfigInvalid` naming the variable, exit `78` under `runMain`, - rather than a silent fallback. -- **`jsonSink`** is the default — one JSON object per line on stdout, no - runtime dependency — with the unit's ids as top-level fields a log backend - indexes. **`pinoSink`** lives behind the `@btravstack/observability/pino` - subpath, with `pino` as an optional peer; the level filter stays this - package's, so there is one filter in the process. -- **`kernelEvents(logger)`** turns the kernel's nine lifecycle events into log - lines in that same stream, keeping each event's fields as attributes — pass - it as `StartOptions.onEvent`. - -Traces and metrics are not here yet; the package is named for the whole because -logs, traces and metrics share a correlation id, a resource, a config slice and -a flush-on-shutdown lifecycle. diff --git a/.changeset/ponytail-audit.md b/.changeset/ponytail-audit.md deleted file mode 100644 index 9081d66..0000000 --- a/.changeset/ponytail-audit.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@btravstack/di": minor -"@btravstack/testing": minor ---- - -Remove `Port.many` and `Provider.member` from `@btravstack/di`, and `withApp` -from `@btravstack/testing`. - -Set ports had no consumer: not one of the eight packages or ten example -workspaces declared one. The exemption they needed had rippled into the -container's levelling pass, which kept two count maps and a provider-identity -`Set` so a set port's later members were not dropped once the first landed; -readiness is now one membership test. Gone with them: the `MANY` brand, -`ManyPortClass`, `MemberOf`, and the "registered as both a set port and an -ordinary port" wiring defect. - -`withApp` was the callback harness that predated `bootFixture`, which does the -same job — start, stop on every exit path, rethrow a shutdown `Defect` — inside -the `test.extend` protocol the Test conventions mandate. Every example and -every starter already used `bootFixture`; only the kernel's own four invariant -specs still called `withApp`, and they now take the `boot` fixture. diff --git a/.changeset/run-main-front-door.md b/.changeset/run-main-front-door.md deleted file mode 100644 index 2bc58c3..0000000 --- a/.changeset/run-main-front-door.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@btravstack/core": minor ---- - -**Breaking:** `runMain` now takes the module and options directly — -`runMain(AppModule, { runtime })` — booting `start` itself and carrying the -same compile-time needs gate. The old app-taking form is gone: a whole -`main.ts` is one call, and `start` remains the API for callers that want the -`RunningApp` itself (tests, embedders, a dev runner booting two applications — -none of which may claim `process.exitCode`). - -The nesting it replaces — `runMain(start(module, options))` — made `start` -look complete on its own, and using it alone in an entry point is the -documented footgun: the kernel's uncaught handlers suppress Node's default -exit 1, so a crash exited `0`. The front door is now the one-call shape the -docs lead with. - -Also exports `RuntimeNeedsGate`, the phantom rest-tuple gate `start`, -`runMain` and `withApp` all carry, previously inlined at each site. diff --git a/.changeset/runtime-port.md b/.changeset/runtime-port.md deleted file mode 100644 index 03a6195..0000000 --- a/.changeset/runtime-port.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@btravstack/core": minor -"@btravstack/testing": minor ---- - -**Breaking.** The runtime is a service the module provides, not an option. -`StartOptions.runtime` is gone: `start(module, options?)`, `runMain(module, -options?, exit?)` and `withApp(module, options, use)` build the module, -resolve its runtime through the kernel's new **`RuntimePort`** — `Port("Runtime")`, -exported generic so a runtime package (or an application) declares its own -concrete port over it, `class HttpRuntime extends -RuntimePort> {}` — and drive what they find. The kernel is DI -initialisation and lifecycle, nothing else; every runtime port shares one id, -which is how a graph holds exactly one. - -The phantom gate grows a third arm: `NO RUNTIME` when the module exports no -runtime port, alongside `UNSATISFIED RUNTIME NEEDS` and `UNSATISFIED UNIT -NEEDS`. `Needs` and `Info` are read off the module (`RuntimeInfoOf` is exported), so -`RunningApp>` types `runtimeInfo()` from the composition -alone. - -`@btravstack/testing`: `testRuntime()` carries `.module`, a module -providing itself on the exported `TestRuntimePort` — import it next to the -module under test and export the port. diff --git a/.changeset/temporal-starter.md b/.changeset/temporal-starter.md deleted file mode 100644 index 063147e..0000000 --- a/.changeset/temporal-starter.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@btravstack/temporal": minor ---- - -**`@btravstack/temporal` becomes a starter, and everything is a provider.** -`temporal({ contract, workflows, address?, namespace?, gracePeriod?, -forceAfter? })` is a module providing `TemporalRuntime` (a `Runtime` on the package's own port over `RuntimePort`), `TemporalConfig` -(`{ address, namespace }`, bound from `TEMPORAL_ADDRESS` / `TEMPORAL_NAMESPACE` -unless pinned — explicit beats environment beats default, per field, through -`Config.pinned`; a pinned field reads nothing from the environment, and the -declared `Env` need and `ConfigInvalid` stay whatever is pinned) and -`TemporalConnection` (the `NativeConnection` -as a resource of the graph, opened with the scope and closed on every exit -path; a service that will not answer is the modeled `TemporalUnreachable`). -Import it next to the application, export `TemporalRuntime`, and provide the -activities on the starter's own activities port. - -**Breaking.** `temporalRuntime`, `activityUnits`, `ActivityMiddleware`, -`ActivityUnitContext` and `TemporalOptions.needs` / `connection` / `taskQueue` -/ `activities(host)` are gone. The activities are now provided on the -**starter's own port** — one id, `Port("TemporalActivities")`, framework-owned -like `TemporalConfig`, since a worker serves one activities record as it -polls one task queue; typed per contract at the type level, so a provider -built for one contract cannot be handed to a module declaring another — its -service the implementations record `declareActivitiesHandler` takes for -`contract`, with no injected context, built by a provider closing over the -application's own services. That port is a need of the starter's module: the -runtime resolves nothing from a `ctx`, `needs` is `never`, and a composition -root that provides no activities is rejected by `start` for still owing the -port. The starter calls `declareActivitiesHandler` itself, inside its error -qualifier, with its unit middleware in place; the middleware injects nothing. -`@temporal-contract/worker`, `@temporal-contract/contract` and -`@btravstack/config` join the peer dependencies. - -**`TemporalModule(name)({...})` is the way an application writes its worker -root.** `TemporalModule(name)({ contract, activities, workflows, address?, -namespace?, gracePeriod?, forceAfter?, imports?, provides?, exports? })` is -`Module(name)({...})` for a Temporal worker: `activities` is the **provider** -on the starter's activities port for `contract` (what `TemporalActivities` -returns; one built for another contract is refused), and the sugar imports the starter, -provides the activities and exports `TemporalRuntime` — handing the augmented -lists to di's own `Module(name)({...})`, whose return type is the sugar's, so -`start`'s gate and di's see nothing new. `temporal()` stays exported as the primitive -it delegates to. `TemporalModuleOptions` is exported for the type. - -**`TemporalActivities(contract)` is the activities provider builder.** The -one call fixes the contract and hands back di's own `Provider(port)` on the -starter's activities port typed for it — `(deps, arm)` with the usual typing, -returning a provider that carries the port typed as `provider.port` (di's -`PortClassOf<"TemporalActivities", ActivitiesOf>`). There is no name to -give and no hand-declared `class extends Port(name)<…>` line. A hand-written -`Provider(port)` over the same port still works. diff --git a/.changeset/temporal.md b/.changeset/temporal.md deleted file mode 100644 index 635e7bf..0000000 --- a/.changeset/temporal.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@btravstack/temporal": minor ---- - -The Temporal worker runtime for `@btravstack/core`. - -`temporalRuntime({ connection, taskQueue, workflows, activities, needs })` runs a -Temporal worker under the kernel's lifecycle: one unit per activity attempt, and -a drain that releases the kernel at its **own** deadline rather than Temporal's -`shutdownForceTime` — `@temporalio/worker` exposes no public forced shutdown, so -stopping the wait is the only escalation available, and the worker keeps winding -down underneath until the process exits. - -It integrates through `temporal-contract`: add `activityUnits(host)` to -`declareActivitiesHandler`'s middleware and every activity attempt becomes a -kernel unit with the application context injected. `temporal-contract` is not a -peer dependency — the middleware type is structural — and `Result` → activity -failure is deliberately not mapped here, because `declareActivitiesHandler` -already does it. diff --git a/.changeset/testing-package.md b/.changeset/testing-package.md deleted file mode 100644 index 5c1cfae..0000000 --- a/.changeset/testing-package.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@btravstack/testing": minor -"@btravstack/core": minor ---- - -**`@btravstack/testing`** — the test harness is a package of its own, the -way `@nestjs/testing` is, and `@btravstack/core/testing` is gone (breaking, -unreleased). It ships what the kernel's entry point did — `testRuntime()` / -`TestRuntimePort`, `createFakeClock()`, `withApp()` — plus two things the -example suites had been hand-rolling in every `test-fixtures.ts`: - -- **`bootFixture(defaults?)`** — a `test.extend` fixture handing the test a - `boot(module, options?)` with a test's defaults baked in (`signals: false` - always, `probes: false` unless a call asks for a port, `preDrainDelayMs: 0`, - a silent `onEvent`), every application it started stopped when the test - ends. Teardown mirrors `withApp`: a `Defect` on `exited` fails the test, a - modeled `Err` passes through. -- **`tapped(module, [Port, …])`** — read services out of a booted application - (`start` hands the context to the runtime alone). Returns `{ module, -services() }`; the gate refuses a port `module` does not export, and - `services()` is loud before the graph is built. - -The kernel's own specs, the three starters' and the three deployment -examples' fixtures now use it; core keeps no test double of its own. diff --git a/.changeset/unit-module.md b/.changeset/unit-module.md deleted file mode 100644 index ce9fbc5..0000000 --- a/.changeset/unit-module.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@btravstack/core": minor -"@btravstack/testing": minor ---- - -`StartOptions.unit` — a module the kernel forks around **every unit**. Its -providers are constructed as a unit opens, reading anything the application -context carries, and torn down as it closes — while the unit's ambient record -is still open, so a teardown log line carries the request's own trace id. Unit -work receives the forked `Context`, which makes a per-request scope -transparent: a handler routes, and no application code calls -`Module.forkScope`. - -`start`'s compile-time gate also covers the fork's own direction: the unit -module's needs must be met by the application module's exports (or `Scope`, -or `Env`). A runtime's `needs` are checked against the application module's -exports alone — a unit-only port is rejected at the call site, since -`RuntimeHost.ctx` never carries it (see below). The unit -module's error channel is pinned to `never` — a construction failure at unit -scope has no modeled channel to land in, so it rides the unit's defect path, -which every runtime already answers. - -A unit finaliser that fails is emitted as a `teardownError` event and kept off -`ExitReport.teardownErrors`, which is the application scope's. - -Two things a runtime author should know. `RuntimeHost.ctx` remains the -application context: a unit-provided port exists only while a unit is open, -which is why the gate refuses a runtime that names one. And with a unit module the unit's -work runs only once the fork is built — after an `await` when a provider is -async — so a runtime subscribing to an event from inside its work must check -whether it already fired. Without the option, unit work receives the -application context exactly as before, synchronously. This closes the "Per-unit -ports" deferral: `RunUnit` was typed for this fork from the start. - -`@btravstack/testing`'s `SubmittedUnit.signal` is now available -synchronously after `submit()` whether or not a unit module is in play. diff --git a/.changeset/unit-signal.md b/.changeset/unit-signal.md deleted file mode 100644 index b46a91f..0000000 --- a/.changeset/unit-signal.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@btravstack/core": minor -"@btravstack/temporal": patch -"@btravstack/amqp": patch ---- - -**`UnitRecord` gains `signal: AbortSignal`** — the ambient record is five -fields now, not four. It is the **very** controller the unit's work callback is -handed, not a copy: one abort, two ways to reach it, fired at the drain -deadline or at once on a path that skips the drain. - -The gap it closes: a middleware-shaped runtime opens its unit around a call it -does not own the arguments of. `@btravstack/temporal`'s `activityUnits` and -`@btravstack/amqp`'s `messageUnits` both hand the kernel a work callback that -_is_ the library's `next()`, so an activity or a handler had no parameter to -receive the signal through and the kernel's `drainTimeoutMs` was unobservable -from inside the work. Injecting a context the transport's contract does not -type was the alternative, and it is exactly the hidden-dependency shape `di` -exists to prevent, so the signal travels on the record instead — data about -this unit, like `deadline`, with nothing to substitute in a test. -`@btravstack/http` is unchanged: it still passes the same signal as its -handler's third parameter. - -What each transport does with it is the transport's own business, and both -examples are worked: - -- **`examples/order-amqp-worker`** answers a `RetryableError` when - `currentUnit()?.signal.aborted`, leaving the delivery un-acked so the broker - hands it to the next worker. This transport has no cancellation of its own — - a redelivery is recovery, not cancellation. -- **`examples/order-temporal-worker`**'s `ShippingService.arrange` fails as a - **defect**, which the platform retries on another worker. The contract's - `ShippingUnavailable` is a permanent no and would be the wrong error for "we - ran out of time". Temporal's `Context.current().cancellationSignal` is a - different clock — workflow-side cancellation, and worker shutdown after - `shutdownGraceTime` — so the two are honoured together rather than one - standing in for the other. diff --git a/CLAUDE.md b/CLAUDE.md index 6bcbbd4..d5f7768 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,41 @@ pnpm build # tsdown dual CJS/ESM + d.ts Commits follow Conventional Commits (commitlint via a lefthook `commit-msg` hook). User-facing changes need a changeset. +## Versioning: all eight packages move as one + +The eight published packages share **one version number**, enforced by a +`fixed` group in `.changeset/config.json`. A release bumps every one of them, +whether or not it changed — Spring Boot's model, and the reason is the same: +an application installs a kernel and two or three starters together, and +"which version of `@btravstack/http` goes with `@btravstack/core@0.4.1`" is a +question nobody should have to answer. + +`@btravstack/di` is the only one with a published history (`0.1.0`, from its +standalone repository, before the merge). The unified line therefore starts at +**0.2.0**: above di's published version, and 0.x because the API still moves — +this repo removed `Port.many` and `withApp` in a single afternoon. + +**A minor bump lands on 1.0.0, and that is changesets, not a decision.** Every +package here peer-depends on `@btravstack/di` and most on `@btravstack/config` +and `@btravstack/core`, and changesets majors any package whose _peer_ +dependency is bumped by a minor or major. From 0.x a major is `1.0.0`. +Measured on changesets 2.31.1: + +| From 0.2.0 | Result | +| ------------------- | -------------------------------------- | +| a `patch` changeset | `0.2.1` — the whole group, as intended | +| a `minor` changeset | `1.0.0` — the whole group | + +Neither documented escape hatch suppresses it +(`onlyUpdatePeerDependentsWhenOutOfRange`, `updateInternalDependents`: both +tried, neither changes the result), and the internal peers cannot become +ordinary dependencies — the dual-copy hazard is what they exist to prevent. +So the options at the first feature release are to accept `1.0.0`, or to +override the computed version by hand as the `0.2.0` release did (run +`changeset version`, then rewrite the eight `package.json` versions and the +eight `CHANGELOG.md` headings). Decide it deliberately; do not let a routine +`pnpm run version` decide it. + ## Thesis (do not drift from these) 1. **One process, one runtime.** The kernel knows several runtime _kinds_; a @@ -484,9 +519,13 @@ Persistence, observability()], exports: [Logger] })`** is the whole peers on both of those plus `@btravstack/core` itself, for the same reason. `node:` builtins only otherwise. Do not add a dependency — `Config` is hand-rolled Standard Schema for exactly this reason. `@btravstack/di` - living in this workspace does **not** change that: it is linked with - `workspace:*` in `devDependencies` and stays `^0.1.0` in `peerDependencies`, - so a consumer still installs one copy of it themselves. `di` itself peers on + living in this workspace does **not** change that: it is `workspace:*` in + `devDependencies` and `workspace:^` in `peerDependencies` — the same + protocol as every other in-repo peer, which pnpm rewrites to a real `^` + range at publish, so a consumer still installs one copy themselves. It was + a hardcoded `^0.1.0` until the versions went lockstep; a literal range in a + peer field is a pin that goes stale silently the first time the dependency + is bumped. `di` itself peers on `unthrown` and depends on nothing; `config` peers on `di` and `unthrown`; `core` peers on all three; `testing` peers on all four (and not on `vitest` — `bootFixture` is a plain function in vitest's fixture shape); diff --git a/packages/amqp/CHANGELOG.md b/packages/amqp/CHANGELOG.md new file mode 100644 index 0000000..e1d9e09 --- /dev/null +++ b/packages/amqp/CHANGELOG.md @@ -0,0 +1,146 @@ +# @btravstack/amqp + +## 0.2.0 + +### Minor Changes + +- f9d48ec: **Breaking.** `@btravstack/amqp` becomes a starter, the same shape as + `@btravstack/http`'s `http()`. `amqp({ contract, url?, ... })` + returns a module providing the runtime on the new **`AmqpRuntime`** port + (`RuntimePort>` — the runtime has no needs) and the + broker on **`AmqpConfig`** (`{ url }`, bound from `AMQP_URL`, default + `amqp://127.0.0.1:5672`, unless `url` is pinned — then the module reads + nothing from the environment; its declared `Env` need and `ConfigInvalid` + stay). + + The handlers are provided on the **starter's own handlers port** — one id, + `Port("AmqpHandlers")`, framework-owned like `AmqpConfig`, since a consumer + serves one handlers record as it boots one runtime; typed per contract at the + type level, so a provider built for one contract cannot be handed to a module + declaring another — whose service is the handlers record the contract wants + with no injected context (`WorkerInferHandlers`), checked + against `contract` at the `AmqpHandlers(contract)(…)` call. The starter + **needs** that port, so a composition that imports `amqp({ contract })` + without providing handlers is refused at `start`. Its provider declares what + the handlers need and closes over it — there is no `context.ctx` any more, + and no `needs`. + + ```ts + const orderHandlers = AmqpHandlers(orderContract)([Logger], { + sync: (logger) => ({ orderChanged: (message) => … }), + }); + + const Worker = Module("Worker")({ + imports: [AppModule, amqp({ contract: orderContract })], + provides: [orderHandlers], + exports: [AmqpRuntime], + }); + ``` + + Gone: `amqpRuntime`, `messageUnits`, `MessageMiddleware`, `MessageUnitContext`, + and `AmqpOptions.needs` / `handlers(host)` / `middleware(host)` / `urls`. The + unit-per-delivery middleware is installed by the starter and injects nothing; + `currentUnit()` still carries the trace id (the publisher's `messageId`). + `@btravstack/config` joins the peer dependencies. + + **`AmqpModule(name)({ contract, handlers, url?, connectionOptions?, defaultConsumerOptions?, connectTimeoutMs?, imports?, provides?, exports? })`** + is the way an application declares an AMQP deployment: `Module(name)({...})` + plus the contract and the handlers **provider**. It imports the starter, + provides the handlers, exports `AmqpRuntime`, and hands the augmented + imports/provides/exports to di's own `Module(name)({...})`, whose return type + is the sugar's — sugar over the same primitives, nothing new for the kernel or + the gates. `handlers` is a plain `Provider` on the starter's handlers port + for `contract` — what `AmqpHandlers` returns. + `amqp({ contract })` stays exported as the primitive it delegates to. + + ```ts + const Worker = AmqpModule("Worker")({ + contract: orderContract, + handlers: orderHandlers, + imports: [AppModule], + }); + ``` + + **`AmqpHandlers(contract)`** is di's own `Provider(port)` on the starter's + handlers port typed for the contract, so the class line and the name go: the + next call is exactly `Provider(port)(deps, arm)`, checked against the + contract's record — a bare function per consumer, nothing to wrap it in — + and the provider carries the port typed (`orderHandlers.port`, di's + `PortClassOf<"AmqpHandlers", WorkerInferHandlers>`) for a hand-declared + provider or a type test. + + ```ts + const orderHandlers = AmqpHandlers(orderContract)([Logger], { + sync: (logger) => ({ orderChanged: (message) => … }), + }); + ``` + +- 2f1974e: The AMQP consumer runtime for `@btravstack/core`. + + `amqpRuntime({ urls, contract, handlers, needs })` runs an `amqp-contract` + worker under the kernel's lifecycle: one unit per delivery, and a drain where + the kernel's `drainTimeoutMs` is the only deadline — the library is told to wait + forever and the kernel's signal is raced against it, so there is no second + timeout to keep in sync. + + Add `messageUnits(host)` to the worker's middleware and every delivery becomes a + kernel unit with the application context injected. `@amqp-contract/worker` and + `@opentelemetry/api` are peer dependencies — install them alongside this + package; `@amqp-contract/contract` stays a devDependency only, used to type + this package's own tests and never appearing in the published type surface, + because the middleware type is declared structurally rather than imported. + + `Result` → ack / retry / DLQ is a three-way split, not a single mapping: a + modeled `RetryableError` / `NonRetryableError` is routed by `amqp-contract`'s + own dispatch against the queue's retry policy, and a `Defect` is a third + channel — dead-lettered on its first attempt, never retried, unless the + handler recovers it into a `RetryableError` itself. + +### Patch Changes + +- 068399d: **`UnitRecord` gains `signal: AbortSignal`** — the ambient record is five + fields now, not four. It is the **very** controller the unit's work callback is + handed, not a copy: one abort, two ways to reach it, fired at the drain + deadline or at once on a path that skips the drain. + + The gap it closes: a middleware-shaped runtime opens its unit around a call it + does not own the arguments of. `@btravstack/temporal`'s `activityUnits` and + `@btravstack/amqp`'s `messageUnits` both hand the kernel a work callback that + _is_ the library's `next()`, so an activity or a handler had no parameter to + receive the signal through and the kernel's `drainTimeoutMs` was unobservable + from inside the work. Injecting a context the transport's contract does not + type was the alternative, and it is exactly the hidden-dependency shape `di` + exists to prevent, so the signal travels on the record instead — data about + this unit, like `deadline`, with nothing to substitute in a test. + `@btravstack/http` is unchanged: it still passes the same signal as its + handler's third parameter. + + What each transport does with it is the transport's own business, and both + examples are worked: + + - **`examples/order-amqp-worker`** answers a `RetryableError` when + `currentUnit()?.signal.aborted`, leaving the delivery un-acked so the broker + hands it to the next worker. This transport has no cancellation of its own — + a redelivery is recovery, not cancellation. + - **`examples/order-temporal-worker`**'s `ShippingService.arrange` fails as a + **defect**, which the platform retries on another worker. The contract's + `ShippingUnavailable` is a permanent no and would be the wrong error for "we + ran out of time". Temporal's `Context.current().cancellationSignal` is a + different clock — workflow-side cancellation, and worker shutdown after + `shutdownGraceTime` — so the two are honoured together rather than one + standing in for the other. + +- Updated dependencies [f133934] +- Updated dependencies [9ca73c5] +- Updated dependencies [ba815e4] +- Updated dependencies [38d7cd5] +- Updated dependencies [4fa693c] +- Updated dependencies [b56501f] +- Updated dependencies [e616e23] +- Updated dependencies [5a271c0] +- Updated dependencies [72b8fbd] +- Updated dependencies [e950473] +- Updated dependencies [068399d] + - @btravstack/config@1.0.0 + - @btravstack/core@1.0.0 + - @btravstack/di@1.0.0 diff --git a/packages/amqp/package.json b/packages/amqp/package.json index c406659..f30f1ba 100644 --- a/packages/amqp/package.json +++ b/packages/amqp/package.json @@ -1,6 +1,6 @@ { "name": "@btravstack/amqp", - "version": "0.0.0", + "version": "0.2.0", "description": "The AMQP starter for @btravstack/core: the handlers as a port, one unit per delivery, and a drain the kernel's deadline releases", "keywords": [ "amqp", @@ -73,7 +73,7 @@ "@amqp-contract/worker": "^3.0.0-beta.6", "@btravstack/config": "workspace:^", "@btravstack/core": "workspace:^", - "@btravstack/di": "^0.1.0", + "@btravstack/di": "workspace:^", "@opentelemetry/api": "^1", "unthrown": "^5.0.0" }, diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md new file mode 100644 index 0000000..e775b55 --- /dev/null +++ b/packages/config/CHANGELOG.md @@ -0,0 +1,46 @@ +# @btravstack/config + +## 0.2.0 + +### Minor Changes + +- f133934: **Configuration, the twelve-factor way, in its own package.** `@btravstack/config` + exports `Env` — the environment as a port, which `@btravstack/core` provides to + every graph `start` boots (`process.env`, or `StartOptions.env` for a test) — + and `Config`: + `Config.string/integer/port(variable, { default?, min?, max? })` fields, + `Config.object({...})` composing them into a Standard Schema over the + environment (any other Standard Schema, a `zod` object over the raw variables + for instance, is accepted too), and `Config.provider(Port)(schema)` binding a + port from `Env` — a modeled `ConfigInvalid` naming every offending variable + when the environment is wrong, which `runMain` maps to sysexits(3)'s + `EX_CONFIG` (78) rather than the generic startup `1`. The kernel binds its own + `PROBE_PORT` the same way (default `9000`; `probes` still overrides), and a + startup failure of any kind is now reported as a `startFailed` kernel event + before `stopping`, so a bad environment is named on stderr instead of exiting + silently. An empty or blank variable is an error, never an absent one; `PORT=0` + stays expressible. + + `@btravstack/http` becomes a starter: `http()` provides + `HttpRuntime` and `HttpConfig`, bound from `PORT` (default `3000`) and `HOST` + (default `0.0.0.0`) unless pinned (`http({ port: 0 })` for a test — + explicit beats environment beats default, per field, through + `Config.pinned(value, field)`; a pinned field reads nothing from the + environment, and the module's declared `Env` need and `ConfigInvalid` stay + whatever is pinned). `RuntimeNeedsGate` is renamed `StartGate`, since it now + also states `NO RUNTIME`. + + `Config.provider("Name")(schema)` — the name form — mints the port (its + service is the schema's output) and returns the provider carrying it typed + (`provider.port`), the shape for a slice that is one application's own; the + class form `Config.provider(Port)(schema)` stays for a slice that is public + API another package names. Config is the one sugar that takes a name — several + config slices per application is normal, and the name is what `ConfigInvalid` + prints; the starters' `HttpRouter` / `TemporalActivities` / `AmqpHandlers` + provide the starter's own fixed port and take none. + +### Patch Changes + +- Updated dependencies [9ca73c5] +- Updated dependencies [b56501f] + - @btravstack/di@1.0.0 diff --git a/packages/config/package.json b/packages/config/package.json index efa7fe1..c6c9547 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@btravstack/config", - "version": "0.0.0", + "version": "0.2.0", "description": "Configuration the twelve-factor way for @btravstack/di: typed values bound from the environment, validated once, injected like any other service", "keywords": [ "12-factor", @@ -61,7 +61,7 @@ "vitest": "catalog:" }, "peerDependencies": { - "@btravstack/di": "^0.1.0", + "@btravstack/di": "workspace:^", "unthrown": "^5.0.0" }, "engines": { diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000..353439f --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,266 @@ +# @btravstack/core + +## 0.2.0 + +### Minor Changes + +- f133934: **Configuration, the twelve-factor way, in its own package.** `@btravstack/config` + exports `Env` — the environment as a port, which `@btravstack/core` provides to + every graph `start` boots (`process.env`, or `StartOptions.env` for a test) — + and `Config`: + `Config.string/integer/port(variable, { default?, min?, max? })` fields, + `Config.object({...})` composing them into a Standard Schema over the + environment (any other Standard Schema, a `zod` object over the raw variables + for instance, is accepted too), and `Config.provider(Port)(schema)` binding a + port from `Env` — a modeled `ConfigInvalid` naming every offending variable + when the environment is wrong, which `runMain` maps to sysexits(3)'s + `EX_CONFIG` (78) rather than the generic startup `1`. The kernel binds its own + `PROBE_PORT` the same way (default `9000`; `probes` still overrides), and a + startup failure of any kind is now reported as a `startFailed` kernel event + before `stopping`, so a bad environment is named on stderr instead of exiting + silently. An empty or blank variable is an error, never an absent one; `PORT=0` + stays expressible. + + `@btravstack/http` becomes a starter: `http()` provides + `HttpRuntime` and `HttpConfig`, bound from `PORT` (default `3000`) and `HOST` + (default `0.0.0.0`) unless pinned (`http({ port: 0 })` for a test — + explicit beats environment beats default, per field, through + `Config.pinned(value, field)`; a pinned field reads nothing from the + environment, and the module's declared `Env` need and `ConfigInvalid` stay + whatever is pinned). `RuntimeNeedsGate` is renamed `StartGate`, since it now + also states `NO RUNTIME`. + + `Config.provider("Name")(schema)` — the name form — mints the port (its + service is the schema's output) and returns the provider carrying it typed + (`provider.port`), the shape for a slice that is one application's own; the + class form `Config.provider(Port)(schema)` stays for a slice that is public + API another package names. Config is the one sugar that takes a name — several + config slices per application is normal, and the name is what `ConfigInvalid` + prints; the starters' `HttpRouter` / `TemporalActivities` / `AmqpHandlers` + provide the starter's own fixed port and take none. + +- ba815e4: Seven shutdown-path fixes found by a full review of the kernel. Five change + observable behaviour. + + - **The drain waits for a unit that opens while the runtime is still stopping + accepting.** `UnitRegistry.awaitIdle()` answers about the registry at the + instant it is _called_, and beat 3 was calling it in the same tick as + `Serving.drain(signal)`. A unit opened while `drain` was still resolving was + therefore never awaited — it was aborted at the deadline and reported + `abandoned` with the whole `drainTimeoutMs` unspent. It is now sequenced + behind `drain`. The window is wide for any runtime whose `drain` is a real + wait, such as an HTTP server closing out keep-alive connections. + + - **`stop()` and the uncaught path now abort in-flight units.** Both skip the + drain, and neither signalled the work it was leaving behind. That contradicted + the reason `"uncaught"` skips the drain at all — that in-flight work may be + completing against corrupted state — and let a unit holding a ref'd socket + keep the event loop alive after the exit report. + + - **`runMain` exits `2` when `ExitReport.teardownErrors` is non-empty.** + Previously a shutdown whose finalisers all failed still exited `0`, reporting + success to an orchestrator for a shutdown that may have lost data. `2` already + meant "we stopped, but not cleanly"; a failed finaliser now earns it as much + as abandoned work does. + + - **The pre-drain delay is charged from when the shutdown was requested.** A + signal arriving mid-build is buffered until the runtime is serving, so the + full `preDrainDelayMs` was paid _again_ afterwards. Both together can exceed + `terminationGracePeriodSeconds` and turn a graceful exit into a SIGKILL. + + - **An out-of-range or non-integer probe port is a modeled + `Err(RuntimeStartFailed)`.** `server.listen` validates the port synchronously + and _throws_ `ERR_SOCKET_BAD_PORT` rather than emitting `'error'`, so it + escaped as a `Defect` — bypassing the declared error channel and exiting `70` + where a startup failure exits `1`. + + - **`stderrSink` renders an `Error` cause instead of `{}`.** `JSON.stringify` + skips non-enumerable properties, so `Error.message` and `stack` never + serialised — leaving `{"type":"uncaught","cause":{}}` as the default crash + report. A cause it cannot serialise at all now falls back to + `"[unserialisable]"` rather than throwing, which `safeSink` would swallow, + losing the event entirely. + + - **The probe server keeps an `'error'` listener for its whole life.** The + bind-failure listener is now replaced rather than merely removed: a + post-listen `'error'` (an accept failure such as `EMFILE`) had no listener, + and an unhandled `'error'` throws — which the kernel's own `uncaughtException` + handler turned into a whole-application teardown over a fault in its health + endpoint. + +- 38d7cd5: Remove the `VERSION` export. + + It was a hand-maintained copy of `package.json`'s `version`, read by nothing but + a test asserting the literal it was written as — so it could only ever go stale + or fail its own tautology. Neither `@btravstack/http` nor + `@btravstack/temporal` ever shipped one. A consumer that needs the version + should read it from the package manifest. + +- 4fa693c: The application kernel: `start` boots a `@btravstack/di` module into a running + process with one runtime, drains in-flight work on SIGTERM, and closes the + application scope on every path. + + - `start(module, options)` returns a `RunningApp` — `exited` + (`AsyncResult`, the module's own error + type passed through unwrapped), `stop`, `requestDrain`, `phase`, `ready`, + `probePort` and `runtimeInfo`. It never throws and never calls + `process.exit`. The runtime's + declared `needs` are checked against the module's exports at compile time. + - The `Runtime` / `RuntimeHost` / `RunUnit` / `Serving` contract, with unit + tracking owned by the kernel: `Serving.drain(signal)` returns + `AsyncResult` and the kernel does the accounting into a + `DrainReport`. + - A channel for what a runtime **is**: `Serving.info` publishes arbitrary + structured info about a serving runtime, and `RunningApp.runtimeInfo()` reads + it back as an `AsyncResult` that settles when the + runtime starts serving — so a runtime binding an ephemeral `port: 0` tells the + caller which port it got instead of inventing an `onListening` hook. The shape + is the runtime's own (a queue runtime has no port), and `Info` defaults to + `never`, so publishing is optional with no extra ceremony. + - A three-beat drain — readiness false, `preDrainDelayMs` before the runtime + stops accepting, then `drainTimeoutMs` for in-flight work — plus liveness and + readiness probes served from the lifecycle state machine rather than a + transport. + - `runMain`, which turns an outcome into a process exit code (`0` / `1` / `2` / + `70`) by setting `process.exitCode`. + - `currentUnit()` over an `AsyncLocalStorage` record carrying + `{ unitId, traceId, tenantId, deadline, signal }` — data, never capabilities. + - A `@btravstack/testing` package with `testRuntime`, + `createFakeClock` and `withApp`. + - **Every async API returns an `AsyncResult`, never a bare `Promise`** — the + infallible ones included, where `AsyncResult` spells "async, and + cannot fail". `probePort()`, `Clock.sleep`, `FakeClock.advance`, + `UnitRegistry.awaitIdle`, `TestRuntime.untilStarted` and `ProbeServer.close` + all carry `E = never`. Three surfaces are deliberately outside it: `runMain` + (the boundary out of the Result world, into a process exit code), `UnitWork`'s + `Promise>` arm (it accepts a caller's `async` handler) and + `withApp`/`use` (a thrown assertion inside a test body must reach the test + runner, which an `AsyncResult` — which never rejects — would swallow). + +- e616e23: **Breaking:** `runMain` now takes the module and options directly — + `runMain(AppModule, { runtime })` — booting `start` itself and carrying the + same compile-time needs gate. The old app-taking form is gone: a whole + `main.ts` is one call, and `start` remains the API for callers that want the + `RunningApp` itself (tests, embedders, a dev runner booting two applications — + none of which may claim `process.exitCode`). + + The nesting it replaces — `runMain(start(module, options))` — made `start` + look complete on its own, and using it alone in an entry point is the + documented footgun: the kernel's uncaught handlers suppress Node's default + exit 1, so a crash exited `0`. The front door is now the one-call shape the + docs lead with. + + Also exports `RuntimeNeedsGate`, the phantom rest-tuple gate `start`, + `runMain` and `withApp` all carry, previously inlined at each site. + +- 5a271c0: **Breaking.** The runtime is a service the module provides, not an option. + `StartOptions.runtime` is gone: `start(module, options?)`, `runMain(module, +options?, exit?)` and `withApp(module, options, use)` build the module, + resolve its runtime through the kernel's new **`RuntimePort`** — `Port("Runtime")`, + exported generic so a runtime package (or an application) declares its own + concrete port over it, `class HttpRuntime extends +RuntimePort> {}` — and drive what they find. The kernel is DI + initialisation and lifecycle, nothing else; every runtime port shares one id, + which is how a graph holds exactly one. + + The phantom gate grows a third arm: `NO RUNTIME` when the module exports no + runtime port, alongside `UNSATISFIED RUNTIME NEEDS` and `UNSATISFIED UNIT +NEEDS`. `Needs` and `Info` are read off the module (`RuntimeInfoOf` is exported), so + `RunningApp>` types `runtimeInfo()` from the composition + alone. + + `@btravstack/testing`: `testRuntime()` carries `.module`, a module + providing itself on the exported `TestRuntimePort` — import it next to the + module under test and export the port. + +- 72b8fbd: **`@btravstack/testing`** — the test harness is a package of its own, the + way `@nestjs/testing` is, and `@btravstack/core/testing` is gone (breaking, + unreleased). It ships what the kernel's entry point did — `testRuntime()` / + `TestRuntimePort`, `createFakeClock()`, `withApp()` — plus two things the + example suites had been hand-rolling in every `test-fixtures.ts`: + + - **`bootFixture(defaults?)`** — a `test.extend` fixture handing the test a + `boot(module, options?)` with a test's defaults baked in (`signals: false` + always, `probes: false` unless a call asks for a port, `preDrainDelayMs: 0`, + a silent `onEvent`), every application it started stopped when the test + ends. Teardown mirrors `withApp`: a `Defect` on `exited` fails the test, a + modeled `Err` passes through. + - **`tapped(module, [Port, …])`** — read services out of a booted application + (`start` hands the context to the runtime alone). Returns `{ module, +services() }`; the gate refuses a port `module` does not export, and + `services()` is loud before the graph is built. + + The kernel's own specs, the three starters' and the three deployment + examples' fixtures now use it; core keeps no test double of its own. + +- e950473: `StartOptions.unit` — a module the kernel forks around **every unit**. Its + providers are constructed as a unit opens, reading anything the application + context carries, and torn down as it closes — while the unit's ambient record + is still open, so a teardown log line carries the request's own trace id. Unit + work receives the forked `Context`, which makes a per-request scope + transparent: a handler routes, and no application code calls + `Module.forkScope`. + + `start`'s compile-time gate also covers the fork's own direction: the unit + module's needs must be met by the application module's exports (or `Scope`, + or `Env`). A runtime's `needs` are checked against the application module's + exports alone — a unit-only port is rejected at the call site, since + `RuntimeHost.ctx` never carries it (see below). The unit + module's error channel is pinned to `never` — a construction failure at unit + scope has no modeled channel to land in, so it rides the unit's defect path, + which every runtime already answers. + + A unit finaliser that fails is emitted as a `teardownError` event and kept off + `ExitReport.teardownErrors`, which is the application scope's. + + Two things a runtime author should know. `RuntimeHost.ctx` remains the + application context: a unit-provided port exists only while a unit is open, + which is why the gate refuses a runtime that names one. And with a unit module the unit's + work runs only once the fork is built — after an `await` when a provider is + async — so a runtime subscribing to an event from inside its work must check + whether it already fired. Without the option, unit work receives the + application context exactly as before, synchronously. This closes the "Per-unit + ports" deferral: `RunUnit` was typed for this fork from the start. + + `@btravstack/testing`'s `SubmittedUnit.signal` is now available + synchronously after `submit()` whether or not a unit module is in play. + +- 068399d: **`UnitRecord` gains `signal: AbortSignal`** — the ambient record is five + fields now, not four. It is the **very** controller the unit's work callback is + handed, not a copy: one abort, two ways to reach it, fired at the drain + deadline or at once on a path that skips the drain. + + The gap it closes: a middleware-shaped runtime opens its unit around a call it + does not own the arguments of. `@btravstack/temporal`'s `activityUnits` and + `@btravstack/amqp`'s `messageUnits` both hand the kernel a work callback that + _is_ the library's `next()`, so an activity or a handler had no parameter to + receive the signal through and the kernel's `drainTimeoutMs` was unobservable + from inside the work. Injecting a context the transport's contract does not + type was the alternative, and it is exactly the hidden-dependency shape `di` + exists to prevent, so the signal travels on the record instead — data about + this unit, like `deadline`, with nothing to substitute in a test. + `@btravstack/http` is unchanged: it still passes the same signal as its + handler's third parameter. + + What each transport does with it is the transport's own business, and both + examples are worked: + + - **`examples/order-amqp-worker`** answers a `RetryableError` when + `currentUnit()?.signal.aborted`, leaving the delivery un-acked so the broker + hands it to the next worker. This transport has no cancellation of its own — + a redelivery is recovery, not cancellation. + - **`examples/order-temporal-worker`**'s `ShippingService.arrange` fails as a + **defect**, which the platform retries on another worker. The contract's + `ShippingUnavailable` is a permanent no and would be the wrong error for "we + ran out of time". Temporal's `Context.current().cancellationSignal` is a + different clock — workflow-side cancellation, and worker shutdown after + `shutdownGraceTime` — so the two are honoured together rather than one + standing in for the other. + +### Patch Changes + +- Updated dependencies [f133934] +- Updated dependencies [9ca73c5] +- Updated dependencies [b56501f] + - @btravstack/config@1.0.0 + - @btravstack/di@1.0.0 diff --git a/packages/core/package.json b/packages/core/package.json index b472740..ae9030d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@btravstack/core", - "version": "0.1.0", + "version": "0.2.0", "description": "The application kernel: boot a di module into a running process, and stop it without losing work", "keywords": [ "application", @@ -65,7 +65,7 @@ }, "peerDependencies": { "@btravstack/config": "workspace:^", - "@btravstack/di": "^0.1.0", + "@btravstack/di": "workspace:^", "unthrown": "^5.0.0" }, "engines": { diff --git a/packages/di/CHANGELOG.md b/packages/di/CHANGELOG.md index b7469ea..1552f3b 100644 --- a/packages/di/CHANGELOG.md +++ b/packages/di/CHANGELOG.md @@ -1,5 +1,51 @@ # @btravstack/di +## 0.2.0 + +### Minor Changes + +- b56501f: Remove `Port.many` and `Provider.member` from `@btravstack/di`, and `withApp` + from `@btravstack/testing`. + + Set ports had no consumer: not one of the eight packages or ten example + workspaces declared one. The exemption they needed had rippled into the + container's levelling pass, which kept two count maps and a provider-identity + `Set` so a set port's later members were not dropped once the first landed; + readiness is now one membership test. Gone with them: the `MANY` brand, + `ManyPortClass`, `MemberOf`, and the "registered as both a set port and an + ordinary port" wiring defect. + + `withApp` was the callback harness that predated `bootFixture`, which does the + same job — start, stop on every exit path, rethrow a shutdown `Defect` — inside + the `test.extend` protocol the Test conventions mandate. Every example and + every starter already used `bootFixture`; only the kernel's own four invariant + specs still called `withApp`, and they now take the `boot` fixture. + +### Patch Changes + +- 9ca73c5: `AnyModule`, `AnyProvider` and `Exportable` — the constraints + `Module(name)({ imports, provides, exports })` puts on its three tuples — are + exported as types, so a package offering a shaped module (a starter's + `HttpModule(name)({ router, imports, provides, exports })` sugar, which appends + its own import and export to what the application wrote) can constrain its + tuples the same way and hand them to `Module(name)({...})` itself, whose + return type is then the sugar's, spelled once. `PortClassOf` + (`{ portId: Id; new (): PortInstance }`) is exported as the one + nameable type of a port class declared inside a helper — what + `Config.provider(name)(schema)`, `HttpRouter`, `TemporalActivities` and + `AmqpHandlers` return as `provider.port`, and what a starter spells its own + fixed port through. + + `Provider(port)(deps, arm)` now returns `Provider & { readonly port: +typeof port }` — the provider carries the port class it was declared for, + typed, so a helper that returns a provider on a port it owns (a starter's + `HttpRouter(contract)(deps, arm)`, `Config.provider(name)(schema)`) hands back + one value and `provider.port` is what a dependent lists in its deps. Purely + additive. `PortInstance` is exported as a type for the same reason: a provider + over a port declared inside a helper needs a nameable declared type when a + consumer exports it (naming the instance type forges nothing — the brand keys + stay private). + ## 0.1.0 Initial release. diff --git a/packages/di/package.json b/packages/di/package.json index cc690fb..2c31d97 100644 --- a/packages/di/package.json +++ b/packages/di/package.json @@ -1,6 +1,6 @@ { "name": "@btravstack/di", - "version": "0.1.0", + "version": "0.2.0", "description": "A module-based dependency-injection container for TypeScript: ports as the vocabulary an application defines, providers bound at one edge, and modules that declare their imports and exports", "keywords": [ "container", diff --git a/packages/http/CHANGELOG.md b/packages/http/CHANGELOG.md new file mode 100644 index 0000000..c09e236 --- /dev/null +++ b/packages/http/CHANGELOG.md @@ -0,0 +1,134 @@ +# @btravstack/http + +## 0.2.0 + +### Minor Changes + +- f133934: **Configuration, the twelve-factor way, in its own package.** `@btravstack/config` + exports `Env` — the environment as a port, which `@btravstack/core` provides to + every graph `start` boots (`process.env`, or `StartOptions.env` for a test) — + and `Config`: + `Config.string/integer/port(variable, { default?, min?, max? })` fields, + `Config.object({...})` composing them into a Standard Schema over the + environment (any other Standard Schema, a `zod` object over the raw variables + for instance, is accepted too), and `Config.provider(Port)(schema)` binding a + port from `Env` — a modeled `ConfigInvalid` naming every offending variable + when the environment is wrong, which `runMain` maps to sysexits(3)'s + `EX_CONFIG` (78) rather than the generic startup `1`. The kernel binds its own + `PROBE_PORT` the same way (default `9000`; `probes` still overrides), and a + startup failure of any kind is now reported as a `startFailed` kernel event + before `stopping`, so a bad environment is named on stderr instead of exiting + silently. An empty or blank variable is an error, never an absent one; `PORT=0` + stays expressible. + + `@btravstack/http` becomes a starter: `http()` provides + `HttpRuntime` and `HttpConfig`, bound from `PORT` (default `3000`) and `HOST` + (default `0.0.0.0`) unless pinned (`http({ port: 0 })` for a test — + explicit beats environment beats default, per field, through + `Config.pinned(value, field)`; a pinned field reads nothing from the + environment, and the module's declared `Env` need and `ConfigInvalid` stay + whatever is pinned). `RuntimeNeedsGate` is renamed `StartGate`, since it now + also states `NO RUNTIME`. + + `Config.provider("Name")(schema)` — the name form — mints the port (its + service is the schema's output) and returns the provider carrying it typed + (`provider.port`), the shape for a slice that is one application's own; the + class form `Config.provider(Port)(schema)` stays for a slice that is public + API another package names. Config is the one sugar that takes a name — several + config slices per application is normal, and the name is what `ConfigInvalid` + prints; the starters' `HttpRouter` / `TemporalActivities` / `AmqpHandlers` + provide the starter's own fixed port and take none. + +- ee6c612: **Breaking.** `@btravstack/http` is the HTTP starter, and there is one way HTTP + is answered: **oRPC, over its own node adapter**. `http()` mounts the + application's router under `prefix` (default `/rpc`) and provides the runtime + on **`HttpRuntime`** (declared over core's `RuntimePort`, `Runtime` — no `needs`), which the composition root imports and exports so + `start` finds it. The router is not an option: it is a **provider on the + starter's own router port** — one id, `Port("HttpRouter")`, framework-owned + like `HttpConfig`, since a process serves one router as it boots one runtime — + whose service is a context-free oRPC router built from the use cases its + procedures call. The starter **needs** that port through di, so a composition + that imports it without providing a router is refused at `start`, at compile + time; two router providers in one graph are di's duplicate-provider defect at + build. + + ```ts + const orderRouter = HttpRouter(orderContract)([PlaceOrder, FindOrder], { + sync: (place, find) => ({ orders: { place: …, find: … } }), + }); + + const OrderApi = Module("OrderApi")({ + imports: [ApplicationModule, PersistenceModule, http()], + provides: [orderRouter], + exports: [HttpRuntime], + }); + ``` + + `@btravstack/orpc` is folded into this package and no longer exists. `needs`, + `handler` and `router` are gone from `HttpOptions`; `httpRuntime` is no longer + exported; the node listener port `HttpHandler` is internal — an application + provides a router, never a handler, and a handler built per request by the + `StartOptions.unit` module is gone with it. An unmatched path is declined + unwritten by oRPC and answered by the runtime's own `404`, and a defect inside + a procedure is oRPC's own `INTERNAL_SERVER_ERROR`; `Result` → HTTP status + stays the router's `.result()` triage. `@orpc/server`, `@orpc/contract` and + `@unthrown/orpc` are peer dependencies — not `hono` or `@hono/node-server`, + which routed one pattern to oRPC's fetch adapter and are gone. + + **`HttpModule(name)({ router, prefix?, port?, hostname?, imports?, provides?, exports? })`** + is the way an application declares an HTTP deployment: `Module(name)({...})` + plus the router **provider**. It imports the starter, provides the router, + exports `HttpRuntime`, and hands the augmented imports/provides/exports to + di's own `Module(name)({...})`, whose return type is the sugar's — sugar over + the same primitives, nothing new for the kernel or the gates. `router` is a + plain `Provider` on the starter's router port, which is what `HttpRouter` + returns. `http()` stays exported as the primitive it delegates to. + + `HttpRouter(contract)(deps, { sync })` — contract-first: `sync` returns a + record shaped like the contract whose leaves are plain `Result`-returning + functions (the `.result()` handler `@unthrown/orpc` gives an implementer), + typed by the contract at the call; `implement`, `os.…`, `.result(...)` and + `os.router(...)` are done for you. It is di's own `Provider(port)` on the + starter's router port — no name to give, no class line — returning the + provider with the port typed (`orderRouter.port`, di's + `PortClassOf<"HttpRouter", Router<…>>`) for a hand-declared provider or a + type test; `HttpModule({ router: orderRouter })` takes it from there. + `@orpc/contract` and `@unthrown/orpc` join the peers. + +- 2f1974e: The HTTP runtime for `@btravstack/core`. + + `httpRuntime({ port, needs, handler })` owns an HTTP server's lifecycle and + nothing else: it binds (publishing the real port on `Serving.info`, so + `port: 0` is usable), opens one kernel unit per request, drains by genuinely + refusing new work, and stops by destroying what is left. + + Its guarantee is that every request produces exactly one completed response, + and the unit stays open until that response is on the wire — which makes the + kernel's least-checkable contract structural rather than documented. Routing, + middleware and `Result` → HTTP status are deliberately not included: bring an + oRPC router (see the starter entry below). + +### Patch Changes + +- d3564a9: Two consequences of the kernel's new `StartOptions.unit`. A unit whose work + begins after its response has already closed — a client that hung up during a + slow per-request build — now settles at once instead of waiting for a `'close'` + event that already fired, which held the unit open for the process lifetime. + And a defect that never reaches the handler's promise — a synchronous throw, or + a unit provider that failed to build — now answers `500` when no headers are + out, rather than only resetting the connection. +- Updated dependencies [f133934] +- Updated dependencies [9ca73c5] +- Updated dependencies [ba815e4] +- Updated dependencies [38d7cd5] +- Updated dependencies [4fa693c] +- Updated dependencies [b56501f] +- Updated dependencies [e616e23] +- Updated dependencies [5a271c0] +- Updated dependencies [72b8fbd] +- Updated dependencies [e950473] +- Updated dependencies [068399d] + - @btravstack/config@1.0.0 + - @btravstack/core@1.0.0 + - @btravstack/di@1.0.0 diff --git a/packages/http/package.json b/packages/http/package.json index 108f7fc..bebf355 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -1,6 +1,6 @@ { "name": "@btravstack/http", - "version": "0.0.0", + "version": "0.2.0", "description": "The HTTP starter for @btravstack/core: oRPC over node:http, one unit per request, and a drain that actually stops accepting", "keywords": [ "graceful-shutdown", @@ -71,7 +71,7 @@ "peerDependencies": { "@btravstack/config": "workspace:^", "@btravstack/core": "workspace:^", - "@btravstack/di": "^0.1.0", + "@btravstack/di": "workspace:^", "@orpc/contract": "^2.0.0-beta", "@orpc/server": "^2.0.0-beta", "@unthrown/orpc": "^0.1.0", diff --git a/packages/observability/CHANGELOG.md b/packages/observability/CHANGELOG.md new file mode 100644 index 0000000..23942dc --- /dev/null +++ b/packages/observability/CHANGELOG.md @@ -0,0 +1,56 @@ +# @btravstack/observability + +## 0.2.0 + +### Minor Changes + +- 18e8943: **`@btravstack/observability`** — observability for the kernel, starting with + logging. + + `Logger` is a di port over a deliberately strict interface, and every + difference from NestJS's logger is a defect it does not have: a port rather + than a class you `new` (no static instance, no `useLogger` reaching past DI), + `with(attributes)` returning a new logger rather than `setContext` mutating the + one every caller shares, a flat record of scalars rather than `any` varargs, a + dedicated `cause` channel (an `Error`'s `message` and `stack` are + non-enumerable, so `JSON.stringify` alone drops exactly the part worth + keeping), six fixed levels, and a guarantee that a log call cannot throw — a + broken sink is swallowed rather than becoming an outage. + + - **Correlation is not the caller's job.** `createLogger` reads + `currentUnit()` **per call**, so every line written inside a unit carries its + `traceId`, `unitId` and `tenantId` — one application-scope logger, correct + for every request, with nothing threaded through the call stack. + - **`observability({ sink?, level? })`** provides `Logger` and `LoggerConfig`, + bound from `LOG_LEVEL` (default `info`) and validated once: a level outside + the six is a `ConfigInvalid` naming the variable, exit `78` under `runMain`, + rather than a silent fallback. + - **`jsonSink`** is the default — one JSON object per line on stdout, no + runtime dependency — with the unit's ids as top-level fields a log backend + indexes. **`pinoSink`** lives behind the `@btravstack/observability/pino` + subpath, with `pino` as an optional peer; the level filter stays this + package's, so there is one filter in the process. + - **`kernelEvents(logger)`** turns the kernel's nine lifecycle events into log + lines in that same stream, keeping each event's fields as attributes — pass + it as `StartOptions.onEvent`. + + Traces and metrics are not here yet; the package is named for the whole because + logs, traces and metrics share a correlation id, a resource, a config slice and + a flush-on-shutdown lifecycle. + +### Patch Changes + +- Updated dependencies [f133934] +- Updated dependencies [9ca73c5] +- Updated dependencies [ba815e4] +- Updated dependencies [38d7cd5] +- Updated dependencies [4fa693c] +- Updated dependencies [b56501f] +- Updated dependencies [e616e23] +- Updated dependencies [5a271c0] +- Updated dependencies [72b8fbd] +- Updated dependencies [e950473] +- Updated dependencies [068399d] + - @btravstack/config@1.0.0 + - @btravstack/core@1.0.0 + - @btravstack/di@1.0.0 diff --git a/packages/observability/package.json b/packages/observability/package.json index 2271754..2b82bd3 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@btravstack/observability", - "version": "0.0.0", + "version": "0.2.0", "description": "Observability for @btravstack/core: a strict Logger port correlated with the kernel's units, a dependency-free JSON sink, and the lifecycle events as log lines", "keywords": [ "dependency-injection", @@ -78,7 +78,7 @@ "peerDependencies": { "@btravstack/config": "workspace:^", "@btravstack/core": "workspace:^", - "@btravstack/di": "^0.1.0", + "@btravstack/di": "workspace:^", "pino": "^10", "unthrown": "^5.0.0" }, diff --git a/packages/temporal/CHANGELOG.md b/packages/temporal/CHANGELOG.md new file mode 100644 index 0000000..40495f4 --- /dev/null +++ b/packages/temporal/CHANGELOG.md @@ -0,0 +1,120 @@ +# @btravstack/temporal + +## 0.2.0 + +### Minor Changes + +- f9d48ec: **`@btravstack/temporal` becomes a starter, and everything is a provider.** + `temporal({ contract, workflows, address?, namespace?, gracePeriod?, +forceAfter? })` is a module providing `TemporalRuntime` (a `Runtime` on the package's own port over `RuntimePort`), `TemporalConfig` + (`{ address, namespace }`, bound from `TEMPORAL_ADDRESS` / `TEMPORAL_NAMESPACE` + unless pinned — explicit beats environment beats default, per field, through + `Config.pinned`; a pinned field reads nothing from the environment, and the + declared `Env` need and `ConfigInvalid` stay whatever is pinned) and + `TemporalConnection` (the `NativeConnection` + as a resource of the graph, opened with the scope and closed on every exit + path; a service that will not answer is the modeled `TemporalUnreachable`). + Import it next to the application, export `TemporalRuntime`, and provide the + activities on the starter's own activities port. + + **Breaking.** `temporalRuntime`, `activityUnits`, `ActivityMiddleware`, + `ActivityUnitContext` and `TemporalOptions.needs` / `connection` / `taskQueue` + / `activities(host)` are gone. The activities are now provided on the + **starter's own port** — one id, `Port("TemporalActivities")`, framework-owned + like `TemporalConfig`, since a worker serves one activities record as it + polls one task queue; typed per contract at the type level, so a provider + built for one contract cannot be handed to a module declaring another — its + service the implementations record `declareActivitiesHandler` takes for + `contract`, with no injected context, built by a provider closing over the + application's own services. That port is a need of the starter's module: the + runtime resolves nothing from a `ctx`, `needs` is `never`, and a composition + root that provides no activities is rejected by `start` for still owing the + port. The starter calls `declareActivitiesHandler` itself, inside its error + qualifier, with its unit middleware in place; the middleware injects nothing. + `@temporal-contract/worker`, `@temporal-contract/contract` and + `@btravstack/config` join the peer dependencies. + + **`TemporalModule(name)({...})` is the way an application writes its worker + root.** `TemporalModule(name)({ contract, activities, workflows, address?, +namespace?, gracePeriod?, forceAfter?, imports?, provides?, exports? })` is + `Module(name)({...})` for a Temporal worker: `activities` is the **provider** + on the starter's activities port for `contract` (what `TemporalActivities` + returns; one built for another contract is refused), and the sugar imports the starter, + provides the activities and exports `TemporalRuntime` — handing the augmented + lists to di's own `Module(name)({...})`, whose return type is the sugar's, so + `start`'s gate and di's see nothing new. `temporal()` stays exported as the primitive + it delegates to. `TemporalModuleOptions` is exported for the type. + + **`TemporalActivities(contract)` is the activities provider builder.** The + one call fixes the contract and hands back di's own `Provider(port)` on the + starter's activities port typed for it — `(deps, arm)` with the usual typing, + returning a provider that carries the port typed as `provider.port` (di's + `PortClassOf<"TemporalActivities", ActivitiesOf>`). There is no name to + give and no hand-declared `class extends Port(name)<…>` line. A hand-written + `Provider(port)` over the same port still works. + +- 2f1974e: The Temporal worker runtime for `@btravstack/core`. + + `temporalRuntime({ connection, taskQueue, workflows, activities, needs })` runs a + Temporal worker under the kernel's lifecycle: one unit per activity attempt, and + a drain that releases the kernel at its **own** deadline rather than Temporal's + `shutdownForceTime` — `@temporalio/worker` exposes no public forced shutdown, so + stopping the wait is the only escalation available, and the worker keeps winding + down underneath until the process exits. + + It integrates through `temporal-contract`: add `activityUnits(host)` to + `declareActivitiesHandler`'s middleware and every activity attempt becomes a + kernel unit with the application context injected. `temporal-contract` is not a + peer dependency — the middleware type is structural — and `Result` → activity + failure is deliberately not mapped here, because `declareActivitiesHandler` + already does it. + +### Patch Changes + +- 068399d: **`UnitRecord` gains `signal: AbortSignal`** — the ambient record is five + fields now, not four. It is the **very** controller the unit's work callback is + handed, not a copy: one abort, two ways to reach it, fired at the drain + deadline or at once on a path that skips the drain. + + The gap it closes: a middleware-shaped runtime opens its unit around a call it + does not own the arguments of. `@btravstack/temporal`'s `activityUnits` and + `@btravstack/amqp`'s `messageUnits` both hand the kernel a work callback that + _is_ the library's `next()`, so an activity or a handler had no parameter to + receive the signal through and the kernel's `drainTimeoutMs` was unobservable + from inside the work. Injecting a context the transport's contract does not + type was the alternative, and it is exactly the hidden-dependency shape `di` + exists to prevent, so the signal travels on the record instead — data about + this unit, like `deadline`, with nothing to substitute in a test. + `@btravstack/http` is unchanged: it still passes the same signal as its + handler's third parameter. + + What each transport does with it is the transport's own business, and both + examples are worked: + + - **`examples/order-amqp-worker`** answers a `RetryableError` when + `currentUnit()?.signal.aborted`, leaving the delivery un-acked so the broker + hands it to the next worker. This transport has no cancellation of its own — + a redelivery is recovery, not cancellation. + - **`examples/order-temporal-worker`**'s `ShippingService.arrange` fails as a + **defect**, which the platform retries on another worker. The contract's + `ShippingUnavailable` is a permanent no and would be the wrong error for "we + ran out of time". Temporal's `Context.current().cancellationSignal` is a + different clock — workflow-side cancellation, and worker shutdown after + `shutdownGraceTime` — so the two are honoured together rather than one + standing in for the other. + +- Updated dependencies [f133934] +- Updated dependencies [9ca73c5] +- Updated dependencies [ba815e4] +- Updated dependencies [38d7cd5] +- Updated dependencies [4fa693c] +- Updated dependencies [b56501f] +- Updated dependencies [e616e23] +- Updated dependencies [5a271c0] +- Updated dependencies [72b8fbd] +- Updated dependencies [e950473] +- Updated dependencies [068399d] + - @btravstack/config@1.0.0 + - @btravstack/core@1.0.0 + - @btravstack/di@1.0.0 diff --git a/packages/temporal/package.json b/packages/temporal/package.json index f5d971c..4f25dc3 100644 --- a/packages/temporal/package.json +++ b/packages/temporal/package.json @@ -1,6 +1,6 @@ { "name": "@btravstack/temporal", - "version": "0.0.0", + "version": "0.2.0", "description": "The Temporal worker runtime for @btravstack/core: one unit per activity attempt, and a drain that honours the kernel's deadline", "keywords": [ "graceful-shutdown", @@ -75,7 +75,7 @@ "peerDependencies": { "@btravstack/config": "workspace:^", "@btravstack/core": "workspace:^", - "@btravstack/di": "^0.1.0", + "@btravstack/di": "workspace:^", "@temporal-contract/contract": "^8.0.0-beta", "@temporal-contract/worker": "^8.0.0-beta", "@temporalio/activity": "^1.22.0", diff --git a/packages/testing/CHANGELOG.md b/packages/testing/CHANGELOG.md new file mode 100644 index 0000000..e893ec1 --- /dev/null +++ b/packages/testing/CHANGELOG.md @@ -0,0 +1,152 @@ +# @btravstack/testing + +## 0.2.0 + +### Minor Changes + +- 4fa693c: The application kernel: `start` boots a `@btravstack/di` module into a running + process with one runtime, drains in-flight work on SIGTERM, and closes the + application scope on every path. + + - `start(module, options)` returns a `RunningApp` — `exited` + (`AsyncResult`, the module's own error + type passed through unwrapped), `stop`, `requestDrain`, `phase`, `ready`, + `probePort` and `runtimeInfo`. It never throws and never calls + `process.exit`. The runtime's + declared `needs` are checked against the module's exports at compile time. + - The `Runtime` / `RuntimeHost` / `RunUnit` / `Serving` contract, with unit + tracking owned by the kernel: `Serving.drain(signal)` returns + `AsyncResult` and the kernel does the accounting into a + `DrainReport`. + - A channel for what a runtime **is**: `Serving.info` publishes arbitrary + structured info about a serving runtime, and `RunningApp.runtimeInfo()` reads + it back as an `AsyncResult` that settles when the + runtime starts serving — so a runtime binding an ephemeral `port: 0` tells the + caller which port it got instead of inventing an `onListening` hook. The shape + is the runtime's own (a queue runtime has no port), and `Info` defaults to + `never`, so publishing is optional with no extra ceremony. + - A three-beat drain — readiness false, `preDrainDelayMs` before the runtime + stops accepting, then `drainTimeoutMs` for in-flight work — plus liveness and + readiness probes served from the lifecycle state machine rather than a + transport. + - `runMain`, which turns an outcome into a process exit code (`0` / `1` / `2` / + `70`) by setting `process.exitCode`. + - `currentUnit()` over an `AsyncLocalStorage` record carrying + `{ unitId, traceId, tenantId, deadline, signal }` — data, never capabilities. + - A `@btravstack/testing` package with `testRuntime`, + `createFakeClock` and `withApp`. + - **Every async API returns an `AsyncResult`, never a bare `Promise`** — the + infallible ones included, where `AsyncResult` spells "async, and + cannot fail". `probePort()`, `Clock.sleep`, `FakeClock.advance`, + `UnitRegistry.awaitIdle`, `TestRuntime.untilStarted` and `ProbeServer.close` + all carry `E = never`. Three surfaces are deliberately outside it: `runMain` + (the boundary out of the Result world, into a process exit code), `UnitWork`'s + `Promise>` arm (it accepts a caller's `async` handler) and + `withApp`/`use` (a thrown assertion inside a test body must reach the test + runner, which an `AsyncResult` — which never rejects — would swallow). + +- b56501f: Remove `Port.many` and `Provider.member` from `@btravstack/di`, and `withApp` + from `@btravstack/testing`. + + Set ports had no consumer: not one of the eight packages or ten example + workspaces declared one. The exemption they needed had rippled into the + container's levelling pass, which kept two count maps and a provider-identity + `Set` so a set port's later members were not dropped once the first landed; + readiness is now one membership test. Gone with them: the `MANY` brand, + `ManyPortClass`, `MemberOf`, and the "registered as both a set port and an + ordinary port" wiring defect. + + `withApp` was the callback harness that predated `bootFixture`, which does the + same job — start, stop on every exit path, rethrow a shutdown `Defect` — inside + the `test.extend` protocol the Test conventions mandate. Every example and + every starter already used `bootFixture`; only the kernel's own four invariant + specs still called `withApp`, and they now take the `boot` fixture. + +- 5a271c0: **Breaking.** The runtime is a service the module provides, not an option. + `StartOptions.runtime` is gone: `start(module, options?)`, `runMain(module, +options?, exit?)` and `withApp(module, options, use)` build the module, + resolve its runtime through the kernel's new **`RuntimePort`** — `Port("Runtime")`, + exported generic so a runtime package (or an application) declares its own + concrete port over it, `class HttpRuntime extends +RuntimePort> {}` — and drive what they find. The kernel is DI + initialisation and lifecycle, nothing else; every runtime port shares one id, + which is how a graph holds exactly one. + + The phantom gate grows a third arm: `NO RUNTIME` when the module exports no + runtime port, alongside `UNSATISFIED RUNTIME NEEDS` and `UNSATISFIED UNIT +NEEDS`. `Needs` and `Info` are read off the module (`RuntimeInfoOf` is exported), so + `RunningApp>` types `runtimeInfo()` from the composition + alone. + + `@btravstack/testing`: `testRuntime()` carries `.module`, a module + providing itself on the exported `TestRuntimePort` — import it next to the + module under test and export the port. + +- 72b8fbd: **`@btravstack/testing`** — the test harness is a package of its own, the + way `@nestjs/testing` is, and `@btravstack/core/testing` is gone (breaking, + unreleased). It ships what the kernel's entry point did — `testRuntime()` / + `TestRuntimePort`, `createFakeClock()`, `withApp()` — plus two things the + example suites had been hand-rolling in every `test-fixtures.ts`: + + - **`bootFixture(defaults?)`** — a `test.extend` fixture handing the test a + `boot(module, options?)` with a test's defaults baked in (`signals: false` + always, `probes: false` unless a call asks for a port, `preDrainDelayMs: 0`, + a silent `onEvent`), every application it started stopped when the test + ends. Teardown mirrors `withApp`: a `Defect` on `exited` fails the test, a + modeled `Err` passes through. + - **`tapped(module, [Port, …])`** — read services out of a booted application + (`start` hands the context to the runtime alone). Returns `{ module, +services() }`; the gate refuses a port `module` does not export, and + `services()` is loud before the graph is built. + + The kernel's own specs, the three starters' and the three deployment + examples' fixtures now use it; core keeps no test double of its own. + +- e950473: `StartOptions.unit` — a module the kernel forks around **every unit**. Its + providers are constructed as a unit opens, reading anything the application + context carries, and torn down as it closes — while the unit's ambient record + is still open, so a teardown log line carries the request's own trace id. Unit + work receives the forked `Context`, which makes a per-request scope + transparent: a handler routes, and no application code calls + `Module.forkScope`. + + `start`'s compile-time gate also covers the fork's own direction: the unit + module's needs must be met by the application module's exports (or `Scope`, + or `Env`). A runtime's `needs` are checked against the application module's + exports alone — a unit-only port is rejected at the call site, since + `RuntimeHost.ctx` never carries it (see below). The unit + module's error channel is pinned to `never` — a construction failure at unit + scope has no modeled channel to land in, so it rides the unit's defect path, + which every runtime already answers. + + A unit finaliser that fails is emitted as a `teardownError` event and kept off + `ExitReport.teardownErrors`, which is the application scope's. + + Two things a runtime author should know. `RuntimeHost.ctx` remains the + application context: a unit-provided port exists only while a unit is open, + which is why the gate refuses a runtime that names one. And with a unit module the unit's + work runs only once the fork is built — after an `await` when a provider is + async — so a runtime subscribing to an event from inside its work must check + whether it already fired. Without the option, unit work receives the + application context exactly as before, synchronously. This closes the "Per-unit + ports" deferral: `RunUnit` was typed for this fork from the start. + + `@btravstack/testing`'s `SubmittedUnit.signal` is now available + synchronously after `submit()` whether or not a unit module is in play. + +### Patch Changes + +- Updated dependencies [f133934] +- Updated dependencies [9ca73c5] +- Updated dependencies [ba815e4] +- Updated dependencies [38d7cd5] +- Updated dependencies [4fa693c] +- Updated dependencies [b56501f] +- Updated dependencies [e616e23] +- Updated dependencies [5a271c0] +- Updated dependencies [72b8fbd] +- Updated dependencies [e950473] +- Updated dependencies [068399d] + - @btravstack/config@1.0.0 + - @btravstack/core@1.0.0 + - @btravstack/di@1.0.0 diff --git a/packages/testing/package.json b/packages/testing/package.json index fef3a13..9f3c8de 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -1,6 +1,6 @@ { "name": "@btravstack/testing", - "version": "0.0.0", + "version": "0.2.0", "description": "Test the applications @btravstack/core boots: an in-memory runtime, a fake clock, a boot fixture that stops what it started, and a tap into a running graph", "keywords": [ "dependency-injection", @@ -65,7 +65,7 @@ "peerDependencies": { "@btravstack/config": "workspace:^", "@btravstack/core": "workspace:^", - "@btravstack/di": "^0.1.0", + "@btravstack/di": "workspace:^", "unthrown": "^5.0.0" }, "engines": { From 933c78d762fc986ee645948fd4bb8432a48d51c6 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 16 Aug 2026 19:16:55 +0200 Subject: [PATCH 2/2] fix: correct the Updated dependencies blocks the version override missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewriting the computed 1.0.0 down to 0.2.0 caught the eight CHANGELOG headings and the eight package.json versions, but not the 'Updated dependencies' lists inside seven of those changelogs — which changesets also writes with the computed version. Left alone they would have published @btravstack/di@1.0.0 and friends as the dependency line of a 0.2.0 release. CLAUDE.md now names that third place explicitly, and disambiguates the two escape hatches: both are under ___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH and neither is the updateInternalDependencies already in our config. --- CLAUDE.md | 23 +++++++++++++++-------- packages/amqp/CHANGELOG.md | 6 +++--- packages/config/CHANGELOG.md | 2 +- packages/core/CHANGELOG.md | 4 ++-- packages/http/CHANGELOG.md | 6 +++--- packages/observability/CHANGELOG.md | 6 +++--- packages/temporal/CHANGELOG.md | 6 +++--- packages/testing/CHANGELOG.md | 6 +++--- 8 files changed, 33 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d5f7768..546212f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,15 +87,22 @@ Measured on changesets 2.31.1: | a `patch` changeset | `0.2.1` — the whole group, as intended | | a `minor` changeset | `1.0.0` — the whole group | -Neither documented escape hatch suppresses it -(`onlyUpdatePeerDependentsWhenOutOfRange`, `updateInternalDependents`: both -tried, neither changes the result), and the internal peers cannot become -ordinary dependencies — the dual-copy hazard is what they exist to prevent. +Neither escape hatch suppresses it. Both live under +`___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH`, **not** in the ordinary +config, and neither is the `updateInternalDependencies` this repo's +`.changeset/config.json` already sets — the names are close enough to mislead, +so: `onlyUpdatePeerDependentsWhenOutOfRange: true` and +`updateInternalDependents: "out-of-range"`, both read by +`@changesets/assemble-release-plan`, both tried here, neither changing the +result. The internal peers cannot become ordinary dependencies either — the +dual-copy hazard is what they exist to prevent. + So the options at the first feature release are to accept `1.0.0`, or to -override the computed version by hand as the `0.2.0` release did (run -`changeset version`, then rewrite the eight `package.json` versions and the -eight `CHANGELOG.md` headings). Decide it deliberately; do not let a routine -`pnpm run version` decide it. +override the computed version by hand as the `0.2.0` release did: run +`changeset version`, then rewrite the eight `package.json` versions, the eight +`CHANGELOG.md` headings **and the `Updated dependencies` blocks inside those +changelogs**, which carry the computed version too and are easy to miss. +Decide it deliberately; do not let a routine `pnpm run version` decide it. ## Thesis (do not drift from these) diff --git a/packages/amqp/CHANGELOG.md b/packages/amqp/CHANGELOG.md index e1d9e09..e02528e 100644 --- a/packages/amqp/CHANGELOG.md +++ b/packages/amqp/CHANGELOG.md @@ -141,6 +141,6 @@ - Updated dependencies [72b8fbd] - Updated dependencies [e950473] - Updated dependencies [068399d] - - @btravstack/config@1.0.0 - - @btravstack/core@1.0.0 - - @btravstack/di@1.0.0 + - @btravstack/config@0.2.0 + - @btravstack/core@0.2.0 + - @btravstack/di@0.2.0 diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md index e775b55..f9207c3 100644 --- a/packages/config/CHANGELOG.md +++ b/packages/config/CHANGELOG.md @@ -43,4 +43,4 @@ - Updated dependencies [9ca73c5] - Updated dependencies [b56501f] - - @btravstack/di@1.0.0 + - @btravstack/di@0.2.0 diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 353439f..1143c58 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -262,5 +262,5 @@ services() }`; the gate refuses a port `module` does not export, and - Updated dependencies [f133934] - Updated dependencies [9ca73c5] - Updated dependencies [b56501f] - - @btravstack/config@1.0.0 - - @btravstack/di@1.0.0 + - @btravstack/config@0.2.0 + - @btravstack/di@0.2.0 diff --git a/packages/http/CHANGELOG.md b/packages/http/CHANGELOG.md index c09e236..f7b3857 100644 --- a/packages/http/CHANGELOG.md +++ b/packages/http/CHANGELOG.md @@ -129,6 +129,6 @@ HttpInfo>` — no `needs`), which the composition root imports and exports so - Updated dependencies [72b8fbd] - Updated dependencies [e950473] - Updated dependencies [068399d] - - @btravstack/config@1.0.0 - - @btravstack/core@1.0.0 - - @btravstack/di@1.0.0 + - @btravstack/config@0.2.0 + - @btravstack/core@0.2.0 + - @btravstack/di@0.2.0 diff --git a/packages/observability/CHANGELOG.md b/packages/observability/CHANGELOG.md index 23942dc..cb86f40 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -51,6 +51,6 @@ - Updated dependencies [72b8fbd] - Updated dependencies [e950473] - Updated dependencies [068399d] - - @btravstack/config@1.0.0 - - @btravstack/core@1.0.0 - - @btravstack/di@1.0.0 + - @btravstack/config@0.2.0 + - @btravstack/core@0.2.0 + - @btravstack/di@0.2.0 diff --git a/packages/temporal/CHANGELOG.md b/packages/temporal/CHANGELOG.md index 40495f4..688ea9b 100644 --- a/packages/temporal/CHANGELOG.md +++ b/packages/temporal/CHANGELOG.md @@ -115,6 +115,6 @@ namespace?, gracePeriod?, forceAfter?, imports?, provides?, exports? })` is - Updated dependencies [72b8fbd] - Updated dependencies [e950473] - Updated dependencies [068399d] - - @btravstack/config@1.0.0 - - @btravstack/core@1.0.0 - - @btravstack/di@1.0.0 + - @btravstack/config@0.2.0 + - @btravstack/core@0.2.0 + - @btravstack/di@0.2.0 diff --git a/packages/testing/CHANGELOG.md b/packages/testing/CHANGELOG.md index e893ec1..c682a57 100644 --- a/packages/testing/CHANGELOG.md +++ b/packages/testing/CHANGELOG.md @@ -147,6 +147,6 @@ services() }`; the gate refuses a port `module` does not export, and - Updated dependencies [72b8fbd] - Updated dependencies [e950473] - Updated dependencies [068399d] - - @btravstack/config@1.0.0 - - @btravstack/core@1.0.0 - - @btravstack/di@1.0.0 + - @btravstack/config@0.2.0 + - @btravstack/core@0.2.0 + - @btravstack/di@0.2.0