diff --git a/.changeset/run-main-front-door.md b/.changeset/run-main-front-door.md new file mode 100644 index 0000000..2bc58c3 --- /dev/null +++ b/.changeset/run-main-front-door.md @@ -0,0 +1,19 @@ +--- +"@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/CLAUDE.md b/CLAUDE.md index b9da194..dc4aaf9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -328,7 +328,11 @@ so a production bundle never pulls the fakes in. events as `{"cause":{}}`. A cause it cannot serialise at all (a circular object) falls back to `"[unserialisable]"` rather than throwing, since `safeSink` would swallow the throw and the event would be reported nowhere. -- **`runMain(app, exit?)`** — awaits `exited` and sets the exit code: +- **`runMain(module, options, exit?)`** — the front door: `start` composed + with the wait for `exited`, carrying the same phantom needs gate + (`RuntimeNeedsGate`, the shared alias all three gated surfaces use). Every + `main.ts` calls this one function; `start` is for callers that want the + `RunningApp` itself. It boots the module and sets the exit code: `0` clean, `1` a modeled startup `Err`, `2` drained with work abandoned **or exited with a non-empty `teardownErrors`**, `70` an uncaught exception/rejection, `70` a defect. Both `70`s are sysexits(3)'s diff --git a/README.md b/README.md index 2b88184..bbae8b9 100644 --- a/README.md +++ b/README.md @@ -89,14 +89,18 @@ const ticker: Runtime = { }, }; -await runMain(start(AppModule, { runtime: ticker })); +await runMain(AppModule, { runtime: ticker }); ``` -`start` returns immediately with a `RunningApp`; `runMain` awaits its -`exited` and turns the outcome into a process exit code. The runtime's declared +`runMain` is the front door: it boots the module, awaits the application's +exit and turns the outcome into a process exit code — one call, and the whole +of a `main.ts`. Underneath it is `start`, which returns immediately with a +`RunningApp` and decides nothing about the process: reach for it when the +handle itself is wanted (a test, an embedder, a dev runner booting two +applications). The runtime's declared `needs` are checked against the module's exports **at compile time** — booting `ticker` against a module that does not export `Greeter` is a type error at the -`start` call, not a boot-time crash. +call, not a boot-time crash. Every code sample on this page is compiled by [`packages/core/src/docs-examples.test-d.ts`](./packages/core/src/docs-examples.test-d.ts), diff --git a/examples/order-amqp-worker/src/main.ts b/examples/order-amqp-worker/src/main.ts index 36d6d97..2690702 100644 --- a/examples/order-amqp-worker/src/main.ts +++ b/examples/order-amqp-worker/src/main.ts @@ -1,4 +1,4 @@ -import { runMain, start } from "@btravstack/core"; +import { runMain } from "@btravstack/core"; import { P } from "unthrown"; import { orderAmqpRuntime } from "./amqp-runtime.js"; @@ -17,15 +17,13 @@ import { OrderAmqpModule } from "./module.js"; * source-only, and every spec drives `start` directly. */ const work = (env: Env): Promise => - runMain( - start(OrderAmqpModule, { - runtime: orderAmqpRuntime({ - urls: [env.AMQP_URL], - relay: { pollMs: env.OUTBOX_POLL_MS }, - }), - probes: { port: env.PROBE_PORT }, + runMain(OrderAmqpModule, { + runtime: orderAmqpRuntime({ + urls: [env.AMQP_URL], + relay: { pollMs: env.OUTBOX_POLL_MS }, }), - ); + probes: { port: env.PROBE_PORT }, + }); /** sysexits(3) `EX_CONFIG`: the deployment is wrong, not the code. */ const abort = (reason: string): void => { diff --git a/examples/order-api/src/main.ts b/examples/order-api/src/main.ts index 78406ae..1d62dc2 100644 --- a/examples/order-api/src/main.ts +++ b/examples/order-api/src/main.ts @@ -1,4 +1,4 @@ -import { runMain, start } from "@btravstack/core"; +import { runMain } from "@btravstack/core"; import { FindOrder, Logger, PlaceOrder } from "@btravstack/example-order-application"; import { httpRuntime } from "@btravstack/http"; import { P } from "unthrown"; @@ -20,16 +20,14 @@ import { OrderApiModule } from "./module.js"; * This file is the shape a real entry point takes. */ const serve = (env: Env): Promise => - runMain( - start(OrderApiModule, { - runtime: httpRuntime({ - port: env.PORT, - needs: [PlaceOrder, FindOrder, Logger], - handler: apiHandler, - }), - probes: { port: env.PROBE_PORT }, + runMain(OrderApiModule, { + runtime: httpRuntime({ + port: env.PORT, + needs: [PlaceOrder, FindOrder, Logger], + handler: apiHandler, }), - ); + probes: { port: env.PROBE_PORT }, + }); /** sysexits(3) `EX_CONFIG`: the deployment is wrong, not the code. */ const abort = (reason: string): void => { diff --git a/examples/order-temporal-worker/src/main.ts b/examples/order-temporal-worker/src/main.ts index c8737f0..8e95775 100644 --- a/examples/order-temporal-worker/src/main.ts +++ b/examples/order-temporal-worker/src/main.ts @@ -1,4 +1,4 @@ -import { runMain, start } from "@btravstack/core"; +import { runMain } from "@btravstack/core"; import { orderContract } from "@btravstack/example-order-temporal-contract"; import { workflowsPathFromURL } from "@temporal-contract/worker/worker"; import { NativeConnection } from "@temporalio/worker"; @@ -38,23 +38,22 @@ const work = (env: Env): AsyncResult => fromSafePromise(NativeConnection.connect({ address: env.TEMPORAL_ADDRESS })).flatMap( (connection) => fromSafePromise( - runMain( - start(OrderTemporalModule, { - runtime: temporalWorkerRuntime({ - contract: orderContract, - connection, - namespace: env.TEMPORAL_NAMESPACE, - workflows: { workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js") }, - }), - probes: { port: env.PROBE_PORT }, + runMain(OrderTemporalModule, { + runtime: temporalWorkerRuntime({ + contract: orderContract, + connection, + namespace: env.TEMPORAL_NAMESPACE, + workflows: { workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js") }, }), + probes: { port: env.PROBE_PORT }, + }) // `.finally`, not a `flatTap`: an open `NativeConnection` holds the // event loop, so a startup that ends in a defect is exactly the path // that must still close it. `runMain`'s bare `Promise` is the one // place a native combinator belongs — it is the documented boundary // where the Result world ends — and `close` never rejects, so the // exit code `runMain` just set survives. - ).finally(() => close(connection)), + .finally(() => close(connection)), ), ); diff --git a/packages/core/README.md b/packages/core/README.md index 379d83d..08e32d4 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -77,12 +77,15 @@ const ticker: Runtime = { }, }; -await runMain(start(AppModule, { runtime: ticker })); +await runMain(AppModule, { runtime: ticker }); ``` +`runMain` is the front door — boot the module, await the exit, set the process +exit code, one call. `start` is the same boot returning the `RunningApp` +instead of deciding the process's fate; it is what tests and embedders use. The runtime's declared `needs` are checked against the module's exports at compile time: booting `ticker` against a module that does not export `Greeter` -is a type error at the `start` call. +is a type error at the call. ## What you get diff --git a/packages/core/src/docs-examples.test-d.ts b/packages/core/src/docs-examples.test-d.ts index ab9d1c3..49709f5 100644 --- a/packages/core/src/docs-examples.test-d.ts +++ b/packages/core/src/docs-examples.test-d.ts @@ -79,7 +79,7 @@ const ticker: Runtime = { }, }; -await runMain(start(AppModule, { runtime: ticker })); +await runMain(AppModule, { runtime: ticker }); // --------------------------------------------------------------------------- // "The Runtime contract" — root README. Asserted equal to the shipped types diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 48372d0..8b6a92a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,12 @@ export { runMain } from "./run-main.js"; export { RuntimeStartFailed } from "./runtime.js"; export type { RunUnit, Runtime, RuntimeHost, Serving } from "./runtime.js"; export { start } from "./start.js"; -export type { ExitReport, RunningApp, StartOptions, TeardownError } from "./start.js"; +export type { + ExitReport, + RunningApp, + RuntimeNeedsGate, + StartOptions, + TeardownError, +} from "./start.js"; export { currentUnit } from "./units.js"; export type { UnitMeta, UnitRecord, UnitRegistry, UnitWork } from "./units.js"; diff --git a/packages/core/src/run-main.spec.ts b/packages/core/src/run-main.spec.ts index 0505d55..e95dfdd 100644 --- a/packages/core/src/run-main.spec.ts +++ b/packages/core/src/run-main.spec.ts @@ -1,9 +1,9 @@ import { Module, Port, Provider } from "@btravstack/di"; -import { ErrAsync, OkAsync } from "unthrown"; +import { Err, ErrAsync, OkAsync } from "unthrown"; import { describe, expect, it, vi } from "vitest"; import { createFakeClock } from "./fake-clock.js"; -import { runMain } from "./run-main.js"; +import { awaitExit, runMain } from "./run-main.js"; import { start, type ExitReport } from "./start.js"; import { testRuntime } from "./test-runtime.js"; @@ -14,9 +14,10 @@ const clean: ExitReport = { uptimeMs: 1, }; -// The kernel's own machinery is irrelevant here: `runMain` reads exactly one -// thing off a `RunningApp`, so a stub carrying only `exited` is the honest -// fixture. +// The kernel's own machinery is irrelevant to the code table: `awaitExit` +// reads exactly one thing off a `RunningApp`, so a stub carrying only +// `exited` is the honest fixture. The public `runMain` — which boots the +// kernel for real — is driven at the end of the suite. const appWith = (exited: unknown) => ({ exited, stop: () => {}, phase: () => "exited" }) as never; class Greeting extends Port("Greeting")<{ readonly text: string }> {} @@ -26,11 +27,26 @@ const AppModule = Module("App")({ exports: [Greeting], }); +const FailingModule = Module("Failing")({ + provides: [Provider(Greeting)({ make: () => Err("no-config" as const) })], + exports: [Greeting], +}); + +// `runMain` boots for real, so every call needs the harness options a spec +// always passes to `start` — fresh each time, since a `testRuntime` is +// stateful across starts. +const quiet = () => ({ + runtime: testRuntime(), + signals: false as const, + probes: false as const, + onEvent: () => {}, +}); + describe("runMain", () => { it("exits 0 on a clean report", async () => { const codes: number[] = []; - await runMain(appWith(OkAsync(clean)), (code) => codes.push(code)); + await awaitExit(appWith(OkAsync(clean)), (code) => codes.push(code)); expect(codes).toEqual([0]); }); @@ -38,7 +54,7 @@ describe("runMain", () => { it("exits 0 when the drain finished with nothing abandoned", async () => { const codes: number[] = []; - await runMain( + await awaitExit( appWith( OkAsync({ ...clean, @@ -54,7 +70,7 @@ describe("runMain", () => { it("exits 2 when work was abandoned", async () => { const codes: number[] = []; - await runMain( + await awaitExit( appWith( OkAsync({ ...clean, @@ -73,7 +89,7 @@ describe("runMain", () => { const codes: number[] = []; // WHEN the outcome is turned into an exit code - await runMain( + await awaitExit( appWith( OkAsync({ ...clean, @@ -96,7 +112,7 @@ describe("runMain", () => { // Installing an `uncaughtException` handler suppresses Node's own default // exit code of 1, so without this row a crashed process would report // success to its orchestrator. - await runMain(appWith(OkAsync({ ...clean, reason: "uncaught" })), (code) => codes.push(code)); + await awaitExit(appWith(OkAsync({ ...clean, reason: "uncaught" })), (code) => codes.push(code)); expect(codes).toEqual([70]); }); @@ -107,7 +123,7 @@ describe("runMain", () => { // The uncaught path skips the drain, so a report carrying both is not // reachable today — the precedence is asserted so it stays deliberate // rather than an accident of the order the conditions happen to be in. - await runMain( + await awaitExit( appWith( OkAsync({ ...clean, @@ -124,7 +140,7 @@ describe("runMain", () => { it("exits 1 on a startup failure", async () => { const codes: number[] = []; - await runMain(appWith(ErrAsync("no-config")), (code) => codes.push(code)); + await awaitExit(appWith(ErrAsync("no-config")), (code) => codes.push(code)); expect(codes).toEqual([1]); }); @@ -132,7 +148,7 @@ describe("runMain", () => { it("exits 70 on a defect", async () => { const codes: number[] = []; - await runMain( + await awaitExit( appWith( OkAsync(clean).map(() => { // oxlint-disable-next-line unthrown/no-throw -- a `Defect` has no public constructor by design, so a throw caught by a combinator's throw-to-defect net is the only way to hand `runMain` the defect this row asserts @@ -145,30 +161,53 @@ describe("runMain", () => { expect(codes).toEqual([70]); }); + // The rows above pin the code table through `awaitExit`; the rest drive the + // public `runMain`, which boots the kernel itself. A module whose provider + // fails is the cheapest deterministic outcome: `start`'s build stops there, + // the runtime never starts, and `exited` settles without a clock or a + // signal in sight. + it("boots the module it is given and maps its startup failure to 1", async () => { + // GIVEN a module whose only provider fails to construct + const codes: number[] = []; + + // WHEN the process is run through the front door + await runMain(FailingModule, quiet(), (code) => codes.push(code)); + + // THEN the modeled Err came back out as the startup exit code — proof the + // module and options actually reached `start` + expect(codes).toEqual([1]); + }); + it("sets process.exitCode when no exit callback is supplied", async () => { + // GIVEN the default exit sink const previous = process.exitCode; - await runMain(appWith(OkAsync(clean))); + // WHEN runMain is called without one + await runMain(FailingModule, quiet()); - expect(process.exitCode).toBe(0); + // THEN the code landed on process.exitCode itself + expect(process.exitCode).toBe(1); process.exitCode = previous; }); it("never calls process.exit", async () => { - const codes: number[] = []; + // GIVEN a spy that would catch the one call this package must never make const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); - await runMain(appWith(OkAsync(clean)), (code) => codes.push(code)); + // WHEN a whole boot-and-exit cycle runs + await runMain(FailingModule, quiet(), () => {}); + // THEN the fate was decided through the exit sink alone expect(exitSpy).not.toHaveBeenCalled(); exitSpy.mockRestore(); }); - // Every case above feeds `runMain` a hand-built report. This one drives a - // real application all the way to an abandoned-work drain, so the `2` is - // produced by the kernel rather than asserted against a fixture — the whole - // chain, from a unit left open at the deadline through `DrainReport` and - // `ExitReport` to the exit code. + // This one drives a real application all the way to an abandoned-work + // drain, so the `2` is produced by the kernel rather than asserted against + // a fixture — the whole chain, from a unit left open at the deadline + // through `DrainReport` and `ExitReport` to the exit code. It holds the + // `RunningApp` to drive the drain, which is exactly the case `start` + + // `awaitExit` exist for. it("yields 2 from a real application whose drain abandoned work", async () => { const codes: number[] = []; const clock = createFakeClock(); @@ -188,7 +227,7 @@ describe("runMain", () => { await clock.advance(5_000); await clock.advance(20_000); - await runMain(app, (code) => codes.push(code)); + await awaitExit(app, (code) => codes.push(code)); expect(await app.exited).toBeOkWith( expect.objectContaining({ drain: { inFlightAtStart: 1, completed: 0, abandoned: 1 } }), diff --git a/packages/core/src/run-main.ts b/packages/core/src/run-main.ts index 3552043..6b1fd8b 100644 --- a/packages/core/src/run-main.ts +++ b/packages/core/src/run-main.ts @@ -1,6 +1,13 @@ +import type { AnyPort, Module, Scope } from "@btravstack/di"; import { P } from "unthrown"; -import type { ExitReport, RunningApp } from "./start.js"; +import { + start, + type ExitReport, + type RunningApp, + type RuntimeNeedsGate, + type StartOptions, +} from "./start.js"; // sysexits(3)'s `EX_SOFTWARE`: an internal software error. A defect is exactly // that — a failure nobody modelled — so it gets its own code rather than @@ -38,8 +45,43 @@ const codeFor = (report: ExitReport): number => { }; /** - * Wait for an application to exit and turn its outcome into a process exit - * code — the one sanctioned place this package decides a process's fate. + * The exit-code half of `runMain`, on its own so the code table can be + * asserted against hand-built reports without booting a kernel. Exported for + * `run-main.spec.ts` only — not part of the public surface (`index.ts` does + * not re-export it). An embedder that will not use `runMain` folds + * `ExitReport` into a code itself; this is not the API for that, the README's + * embedding section is. + */ +export const awaitExit = async ( + // `RunningApp`, not `RunningApp`: only `exited` is read, and + // `Info` is covariant, so this accepts an app whose runtime publishes + // anything at all. + app: RunningApp, + exit: (code: number) => void, +): Promise => { + const result = await app.exited; + + exit( + result.match({ + ok: codeFor, + // `E` is the application's own error type, still unresolved here, so no + // arm list can prove exhaustiveness against it and the catch-all is the + // only arm that can terminate the match — the generic-`E` case the + // wildcard is kept for. Every modeled startup failure means the same + // thing to the operating system anyway: the process never came up. + // oxlint-disable-next-line unthrown/no-catch-all-pattern -- generic `E`: the catch-all is the only arm that can terminate a match over an unresolved type parameter + errCases: (matcher) => matcher.with(P._, () => 1), + defect: () => EX_SOFTWARE, + }), + ); +}; + +/** + * Boot a module and turn its outcome into a process exit code — the front + * door, and the one sanctioned place this package decides a process's fate. + * `start` composed with the wait for `exited`: use `start` instead when the + * `RunningApp` itself is wanted (a test, an embedder, a dev runner booting + * two applications — none of which may claim `process.exitCode`). * * `exit` is injectable and defaults to setting `process.exitCode`: `runMain` * never calls `process.exit()`, so pending output is flushed, an embedding @@ -61,35 +103,34 @@ const codeFor = (report: ExitReport): number => { * * @example * ```ts - * await runMain(start(AppModule, { runtime: httpRuntime })); + * await runMain(AppModule, { + * runtime: httpRuntime({ port: 3000, needs: [Greeting], handler }), + * }); * ``` */ // The one async surface in this package that returns a bare `Promise` // rather than an `AsyncResult`, deliberately: its whole job is to LEAVE the // Result world and become a process exit code. It is the boundary, and a // top-level `await runMain(...)` in an entry point is the intended shape. -export const runMain = async ( - // `RunningApp`, not `RunningApp`: `runMain` reads only - // `exited`, and `Info` is covariant, so this accepts an app whose runtime - // publishes anything at all. - app: RunningApp, +export const runMain = async ( + module: Module, + options: StartOptions, exit: (code: number) => void = (code) => { process.exitCode = code; }, + // The same phantom gate `start` carries, for the same reason: it makes the + // runtime's declared needs a compile-time check at *this* call site. + ...gate: RuntimeNeedsGate ): Promise => { - const result = await app.exited; + void gate; - exit( - result.match({ - ok: codeFor, - // `E` is the application's own error type, still unresolved here, so no - // arm list can prove exhaustiveness against it and the catch-all is the - // only arm that can terminate the match — the generic-`E` case the - // wildcard is kept for. Every modeled startup failure means the same - // thing to the operating system anyway: the process never came up. - // oxlint-disable-next-line unthrown/no-catch-all-pattern -- generic `E`: the catch-all is the only arm that can terminate a match over an unresolved type parameter - errCases: (matcher) => matcher.with(P._, () => 1), - defect: () => EX_SOFTWARE, - }), - ); + // The gate above proves the needs at the call site, but that proof is not + // visible inside a body where `X` and `Needs` are still unresolved type + // parameters — the same reason `withApp` discharges the tuple the same way. + const boot = start as ( + module: Module, + options: StartOptions, + ) => RunningApp; + + await awaitExit(boot(module, options), exit); }; diff --git a/packages/core/src/start.ts b/packages/core/src/start.ts index f431027..654fe84 100644 --- a/packages/core/src/start.ts +++ b/packages/core/src/start.ts @@ -67,25 +67,30 @@ export type RunningApp = { readonly runtimeInfo: () => AsyncResult; }; +/** + * The phantom rest tuple `start`, `runMain` and `withApp` all carry: empty — + * and invisible — when the module's exports cover the runtime's declared + * needs, a named error tuple otherwise, so an unmet need fails to typecheck at + * the call site. A trailing rest tuple rather than a conditional type on + * `module` or `options` is deliberate: a conditional on an inference-bearing + * parameter makes TypeScript defer that parameter's inference and can collapse + * `X` or `E` to `unknown`. Same shape, and the same reasoning, as di's own + * UNSATISFIED DEPENDENCIES gate on `Module.scoped`. + */ +export type RuntimeNeedsGate = [InstanceType] extends [X] + ? [] + : [error: "UNSATISFIED RUNTIME NEEDS", missing: Exclude, X>]; + // `Module`, not `Module`: `Needs` sits in covariant // position on `Module`, so this accepts a module with no needs at all *and* the // resourceful one whose `acquire`/`release` provider adds `Scope` — the single // need `Module.scoped` discharges by opening the scope itself. A module with a // genuine unmet dependency is rejected here, as di's own gate would reject it. // The `gate` rest parameter is a phantom: it never carries a runtime argument. -// It is what makes the runtime's declared needs a *compile-time* check — a -// runtime needing a port the module does not export makes the tuple non-empty, -// so the call fails to typecheck. A trailing rest tuple rather than a -// conditional type on `module` or `options` is deliberate: a conditional on an -// inference-bearing parameter makes TypeScript defer that parameter's -// inference and can collapse `X` or `E` to `unknown`. Same shape, and the same -// reasoning, as di's own UNSATISFIED DEPENDENCIES gate on `Module.scoped`. export const start = ( module: Module, options: StartOptions, - ...gate: [InstanceType] extends [X] - ? [] - : [error: "UNSATISFIED RUNTIME NEEDS", missing: Exclude, X>] + ...gate: RuntimeNeedsGate ): RunningApp => { void gate; const clock = options.clock ?? systemClock; diff --git a/packages/core/src/with-app.ts b/packages/core/src/with-app.ts index 36ce673..74bb1b5 100644 --- a/packages/core/src/with-app.ts +++ b/packages/core/src/with-app.ts @@ -1,6 +1,6 @@ import type { AnyPort, Module, Scope } from "@btravstack/di"; -import { start, type RunningApp, type StartOptions } from "./start.js"; +import { start, type RunningApp, type RuntimeNeedsGate, type StartOptions } from "./start.js"; /** * Start an application, hand it to `use`, and stop it again — whatever `use` @@ -40,9 +40,7 @@ export const withApp = async ( use: (app: RunningApp) => Promise, // The same phantom gate `start` carries, for the same reason: it makes the // runtime's declared needs a compile-time check at *this* call site. - ...gate: [InstanceType] extends [X] - ? [] - : [error: "UNSATISFIED RUNTIME NEEDS", missing: Exclude, X>] + ...gate: RuntimeNeedsGate ): Promise => { void gate;