@btravstack/core 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -5,6 +5,7 @@ let node_timers_promises = require("node:timers/promises");
5
5
  let _btravstack_config = require("@btravstack/config");
6
6
  let node_http = require("node:http");
7
7
  let node_async_hooks = require("node:async_hooks");
8
+ let node_crypto = require("node:crypto");
8
9
  //#region src/instrumentation.ts
9
10
  /**
10
11
  * The set port a package contributes its instrumentation to.
@@ -428,14 +429,71 @@ const installUncaughtHandlers = (onUncaught) => {
428
429
  };
429
430
  };
430
431
  //#endregion
432
+ //#region src/unit-overrides.ts
433
+ /**
434
+ * Providers substituted inside a runtime's unit modules, keyed by the kind
435
+ * the runtime declares on `Runtime.units` — test-facing: `@btravstack/testing`'s
436
+ * `overridden(root, providers, { unit })` is the one contributor, and a
437
+ * production root never names it.
438
+ *
439
+ * Each provider must already carry di's override brand. The kernel applies
440
+ * them at boot, before the runtime starts: every kind named must be one the
441
+ * runtime binds, and every port must be one that kind's module provides, or
442
+ * the boot is a defect — the same drift gate a root-level override has.
443
+ */
444
+ var UnitOverrides = class extends _btravstack_di.Port.many("UnitOverrides") {};
445
+ /** Whether `module`'s tree carries a provider for `portId`. */
446
+ const provides = (module, portId) => module.provides.some((provider) => provider.port.portId === portId) || module.imports.some((imported) => provides(imported, portId));
447
+ /**
448
+ * Each overridden kind's module, mapped to the module the kernel forks in its
449
+ * place. Keyed by module IDENTITY, because `UnitHost.fork` is handed the
450
+ * module and never the kind — which is why a module bound under two kinds is
451
+ * refused rather than overridden under both.
452
+ */
453
+ const unitSubstitutes = (runtime, units, overrides) => {
454
+ const byKind = /* @__PURE__ */ new Map();
455
+ for (const member of overrides) for (const [kind, providers] of Object.entries(member)) byKind.set(kind, [...byKind.get(kind) ?? [], ...providers]);
456
+ const substitutes = /* @__PURE__ */ new Map();
457
+ for (const [kind, providers] of byKind) {
458
+ const module = units[kind];
459
+ if (module === void 0) throw new Error(`[core] unit override for kind "${kind}", which runtime "${runtime}" binds no module for`);
460
+ const shared = Object.keys(units).find((other) => other !== kind && units[other] === module);
461
+ if (shared !== void 0) throw new Error(`[core] unit override for kind "${kind}", whose module kind "${shared}" binds too — an override cannot reach one without the other`);
462
+ const seen = /* @__PURE__ */ new Set();
463
+ for (const provider of providers) {
464
+ if (seen.has(provider.port.portId)) throw new Error(`[core] two unit overrides registered for port ${JSON.stringify(provider.port.portId)} in kind "${kind}"`);
465
+ seen.add(provider.port.portId);
466
+ if (!provides(module, provider.port.portId)) throw new Error(`[core] unit override for port ${JSON.stringify(provider.port.portId)} in kind "${kind}" with nothing to override — its module no longer provides it`);
467
+ }
468
+ substitutes.set(module, (0, _btravstack_di.Module)("UnitOverridden")({
469
+ imports: [module],
470
+ provides: providers,
471
+ exports: [module]
472
+ }));
473
+ }
474
+ return substitutes;
475
+ };
476
+ //#endregion
431
477
  //#region src/units.ts
432
478
  const storage = new node_async_hooks.AsyncLocalStorage();
433
479
  const runWithUnit = (record, fn) => storage.run(record, fn);
434
480
  const currentUnit = () => storage.getStore();
435
- let counter = 0;
436
- const nextId = () => {
437
- counter += 1;
438
- return `u${counter}`;
481
+ const outcomes = /* @__PURE__ */ new WeakMap();
482
+ const settleUnit = (record, outcome) => {
483
+ outcomes.set(record, outcome);
484
+ };
485
+ /**
486
+ * How the current unit's work settled, for the code that runs AFTER it: a
487
+ * unit module's `onStop`, which tears down inside the record but is handed
488
+ * only its own service. `undefined` outside a unit and while the work is still
489
+ * running. Whether the unit was aborted is the record's own `signal`.
490
+ *
491
+ * It is the kernel's outcome, not the transport's: an HTTP unit that answered
492
+ * a `500` still settled `ok`, since the kernel never maps a status.
493
+ */
494
+ const unitOutcome = () => {
495
+ const unit = currentUnit();
496
+ return unit === void 0 ? void 0 : outcomes.get(unit);
439
497
  };
440
498
  const createUnitRegistry = () => {
441
499
  const open = /* @__PURE__ */ new Set();
@@ -451,7 +509,7 @@ const createUnitRegistry = () => {
451
509
  const controller = new AbortController();
452
510
  open.add(controller);
453
511
  const record = {
454
- unitId: nextId(),
512
+ unitId: (0, node_crypto.randomUUID)(),
455
513
  traceId: meta.traceId ?? meta.id,
456
514
  tenantId: meta.tenantId,
457
515
  signal: controller.signal
@@ -485,7 +543,7 @@ const createUnitRegistry = () => {
485
543
  * and whose side effects then have to be guarded one by one.
486
544
  */
487
545
  const withdrawn = () => (0, unthrown.fromSafePromise)(new Promise(() => {}));
488
- const providesEnv = (module) => module.provides.some((provider) => provider.port.portId === _btravstack_config.Env.portId) || module.imports.some(providesEnv);
546
+ const providesEnv = (module) => provides(module, _btravstack_config.Env.portId);
489
547
  /**
490
548
  * What the kernel reads from the environment for itself. Each field is pinned
491
549
  * by the matching `StartOptions` field — explicit beats environment beats
@@ -532,7 +590,13 @@ const readKernelConfig = (options, env) => {
532
590
  const start = (module, options = {}) => {
533
591
  const clock = options.clock ?? systemClock;
534
592
  const env = options.env ?? process.env;
535
- const emit = safeSink(options.onEvent ?? stderrSink);
593
+ const sink = safeSink(options.onEvent ?? stderrSink);
594
+ let exitedEmitted = false;
595
+ const emit = (event) => {
596
+ if (exitedEmitted) return;
597
+ sink(event);
598
+ if (event.type === "exited") exitedEmitted = true;
599
+ };
536
600
  let runtimeName = "";
537
601
  let servingInfo = void 0;
538
602
  let probeBoundPort = void 0;
@@ -584,6 +648,7 @@ const start = (module, options = {}) => {
584
648
  }
585
649
  });
586
650
  const disposeUncaught = options.signals === false ? () => {} : installUncaughtHandlers((cause) => {
651
+ crashed = true;
587
652
  emit({
588
653
  type: "uncaught",
589
654
  cause
@@ -594,6 +659,8 @@ const start = (module, options = {}) => {
594
659
  abandonBuild("uncaught");
595
660
  });
596
661
  let disposeProbes = () => {};
662
+ let crashed = false;
663
+ let startupFailing = false;
597
664
  const disposeAll = () => {
598
665
  disposeSignals();
599
666
  disposeUncaught();
@@ -601,7 +668,7 @@ const start = (module, options = {}) => {
601
668
  disposeProbes();
602
669
  };
603
670
  const reportOf = (reason, drain, abandonedAt) => ({
604
- reason,
671
+ reason: crashed ? "uncaught" : reason,
605
672
  drain,
606
673
  teardownErrors,
607
674
  uptimeMs: clock.now() - startedAt,
@@ -616,16 +683,24 @@ const start = (module, options = {}) => {
616
683
  tracker.advanceTo("stopping");
617
684
  disposeAll();
618
685
  };
686
+ const leaveFailed = (failure) => {
687
+ if (tracker.current() !== "stopping") emit({
688
+ type: "startFailed",
689
+ cause: failure.tag === "Err" ? failure.error : failure.cause
690
+ });
691
+ runtimePublished.resolve(void 0);
692
+ tracker.advanceTo("stopping");
693
+ };
619
694
  const stopping = Promise.withResolvers();
620
- const stopAbandoned = () => (0, unthrown.fromSafePromise)(stopping.promise).flatMap(({ reason, drain }) => clock.sleep(stopTimeoutMs, stopSettled.signal).flatMap(() => lifecycleSettled ? withdrawn() : (0, unthrown.OkAsync)(abandonStop(reason, drain))));
621
- const abandonStop = (reason, drain) => {
695
+ const stopAbandoned = () => (0, unthrown.fromSafePromise)(stopping.promise).flatMap((abandoned) => clock.sleep(stopTimeoutMs, stopSettled.signal).flatMap(() => lifecycleSettled ? withdrawn() : abandonStop(abandoned)));
696
+ const abandonStop = (abandoned) => {
622
697
  emit({
623
698
  type: "stoppedWaiting",
624
699
  phase: "stop",
625
700
  afterMs: stopSettled.signal.aborted ? void 0 : stopTimeoutMs
626
701
  });
627
702
  disposeAll();
628
- return reportOf(reason, drain, "stop");
703
+ return abandoned().map(({ reason, drain }) => reportOf(reason, drain, "stop"));
629
704
  };
630
705
  const abandoningBuild = Promise.withResolvers();
631
706
  const buildAbandoned = () => (0, unthrown.fromSafePromise)(abandoningBuild.promise).map((reason) => {
@@ -640,7 +715,7 @@ const start = (module, options = {}) => {
640
715
  });
641
716
  const abandonBuild = (reason) => {
642
717
  const phase = tracker.current();
643
- if (phase === "building" || phase === "starting") abandoningBuild.resolve(reason);
718
+ if (phase === "building" || phase === "starting" || reason === "uncaught" && startupFailing) abandoningBuild.resolve(reason);
644
719
  };
645
720
  emit({ type: "building" });
646
721
  const probesOptions = kernelConfig.map(({ probePort, probeHost }) => options.probes === false ? false : {
@@ -701,22 +776,23 @@ const start = (module, options = {}) => {
701
776
  if (reason !== "signal") registry.abortAll();
702
777
  return (reason === "signal" ? runDrain(serving) : (0, unthrown.OkAsync)(void 0)).flatMap((report) => {
703
778
  tracker.advanceTo("stopping");
704
- stopping.resolve({
779
+ stopping.resolve(() => (0, unthrown.OkAsync)({
705
780
  reason,
706
781
  drain: report
707
- });
708
- return serving.stop().map(() => {
709
- disposeAll();
710
- return reportOf(reason, report);
711
- });
782
+ }));
783
+ return serving.stop().map(() => ({
784
+ reason,
785
+ drain: report
786
+ }));
712
787
  });
713
788
  };
714
- const built = () => _btravstack_di.Module.scoped(root, (ctx) => {
789
+ const serve = (ctx) => {
715
790
  tracker.advanceTo("starting");
716
791
  const runtime = ctx.get(RuntimePort);
717
792
  runtimeName = runtime.name;
718
793
  healthChecks = ctx.get(HealthChecks);
719
794
  const runtimeCtx = ctx;
795
+ const substitutes = unitSubstitutes(runtime.name, runtime.units ?? {}, ctx.get(UnitOverrides));
720
796
  const run = (meta, work) => registry.run(meta, (signal) => {
721
797
  const settled = Promise.withResolvers();
722
798
  let closing;
@@ -725,12 +801,12 @@ const start = (module, options = {}) => {
725
801
  if (hasSettled) return (0, unthrown.fromSafePromise)(Promise.reject(/* @__PURE__ */ new Error("a unit forks after it has already settled")));
726
802
  if (closing !== void 0) return (0, unthrown.fromSafePromise)(Promise.reject(/* @__PURE__ */ new Error("a unit forks its scope once")));
727
803
  const ready = Promise.withResolvers();
728
- closing = _btravstack_di.Module.forkScope(ctx, module, (forked) => {
804
+ closing = _btravstack_di.Module.forkScope(ctx, substitutes.get(module) ?? module, (forked) => {
729
805
  ready.resolve(forked);
730
806
  return (0, unthrown.fromSafePromise)(settled.promise);
731
807
  }, {
732
808
  seed,
733
- onTeardownError: (port, cause) => emit({
809
+ onTeardownError: (port, cause) => sink({
734
810
  type: "teardownError",
735
811
  port,
736
812
  cause
@@ -741,13 +817,18 @@ const start = (module, options = {}) => {
741
817
  }).get();
742
818
  return (0, unthrown.fromSafePromise)(ready.promise);
743
819
  };
820
+ const record = currentUnit();
744
821
  const outcome = (async () => {
822
+ let settledAs = "error";
745
823
  try {
746
- return await work({
824
+ const result = await work({
747
825
  ctx: runtimeCtx,
748
826
  fork
749
827
  }, signal);
828
+ if (result.isOk()) settledAs = "ok";
829
+ return result;
750
830
  } finally {
831
+ if (record !== void 0) settleUnit(record, settledAs);
751
832
  hasSettled = true;
752
833
  settled.resolve();
753
834
  if (closing !== void 0) await closing;
@@ -768,23 +849,32 @@ const start = (module, options = {}) => {
768
849
  });
769
850
  return (0, unthrown.fromSafePromise)(shutdown.promise).flatMap((reason) => finish(serving, reason));
770
851
  });
771
- }, { onTeardownError: (port, cause) => {
772
- teardownErrors.push({
773
- port,
774
- cause
775
- });
776
- emit({
777
- type: "teardownError",
778
- port,
779
- cause
780
- });
781
- } }).tapFailure((failure) => {
782
- if (tracker.current() !== "stopping") emit({
783
- type: "startFailed",
784
- cause: failure.tag === "Err" ? failure.error : failure.cause
785
- });
786
- leaveStartup();
787
- }).tap(onLifecycleSettled).tapFailure(onLifecycleSettled);
852
+ };
853
+ const built = () => _btravstack_di.Module.scoped(root, (ctx) => (0, unthrown.OkAsync)(ctx).flatMap(serve).tapFailure((failure) => {
854
+ if (tracker.current() === "starting") startupFailing = true;
855
+ leaveFailed(failure);
856
+ stopping.resolve(() => failure.toAsync());
857
+ }), {
858
+ onConstructionFailure: (failure) => {
859
+ startupFailing = true;
860
+ leaveFailed(failure);
861
+ stopping.resolve(() => failure.toAsync());
862
+ },
863
+ onTeardownError: (port, cause) => {
864
+ teardownErrors.push({
865
+ port,
866
+ cause
867
+ });
868
+ emit({
869
+ type: "teardownError",
870
+ port,
871
+ cause
872
+ });
873
+ }
874
+ }).tapFailure((failure) => {
875
+ leaveFailed(failure);
876
+ disposeAll();
877
+ }).map(({ reason, drain }) => reportOf(reason, drain)).tap(disposeAll).tap(onLifecycleSettled).tapFailure(onLifecycleSettled);
788
878
  return {
789
879
  exited: probesStarted.flatMap(() => (0, unthrown.fromSafePromise)(Promise.race([
790
880
  built(),
@@ -961,6 +1051,7 @@ exports.RuntimePort = RuntimePort;
961
1051
  exports.RuntimeStartFailed = RuntimeStartFailed;
962
1052
  exports.SPAN_STATUS = SPAN_STATUS;
963
1053
  exports.Tracer = Tracer;
1054
+ exports.UnitOverrides = UnitOverrides;
964
1055
  exports.composeByPrefix = composeByPrefix;
965
1056
  exports.currentUnit = currentUnit;
966
1057
  exports.dispatchUnit = dispatchUnit;
@@ -976,4 +1067,5 @@ exports.start = start;
976
1067
  exports.stderrSink = stderrSink;
977
1068
  exports.systemClock = systemClock;
978
1069
  exports.traceIdOfTraceparent = traceIdOfTraceparent;
1070
+ exports.unitOutcome = unitOutcome;
979
1071
  exports.withUnitRecord = withUnitRecord;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AsyncResult, FailureView, Result } from "unthrown";
2
- import { AnyPort, Context, DependencyGate, Module, PortClass, PortInstance, Provider, Scope, SeedEntry, ServiceOf } from "@btravstack/di";
2
+ import { AnyPort, AnyProvider, Context, DependencyGate, Module, PortClass, PortInstance, Provider, Scope, SeedEntry, ServiceOf } from "@btravstack/di";
3
3
  import { Env, Environment } from "@btravstack/config";
4
4
  //#region src/instrumentation.d.ts
5
5
  /**
@@ -352,6 +352,18 @@ type UnitRecord = {
352
352
  readonly signal: AbortSignal;
353
353
  };
354
354
  export declare const currentUnit: () => UnitRecord | undefined;
355
+ /** How a unit's work settled: `ok`, or `error` for an `Err`, a `Defect` and a throw alike. */
356
+ type UnitOutcome = "ok" | "error";
357
+ /**
358
+ * How the current unit's work settled, for the code that runs AFTER it: a
359
+ * unit module's `onStop`, which tears down inside the record but is handed
360
+ * only its own service. `undefined` outside a unit and while the work is still
361
+ * running. Whether the unit was aborted is the record's own `signal`.
362
+ *
363
+ * It is the kernel's outcome, not the transport's: an HTTP unit that answered
364
+ * a `500` still settled `ok`, since the kernel never maps a status.
365
+ */
366
+ export declare const unitOutcome: () => UnitOutcome | undefined;
355
367
  /**
356
368
  * What a runtime says about one piece of work as it submits it. `kind` is the
357
369
  * category (`"http"`, `"tick"`, `"job"`); `id` identifies **this** unit.
@@ -363,7 +375,8 @@ export declare const currentUnit: () => UnitRecord | undefined;
363
375
  * silently defeats the ambient record. A route template is a `kind`.
364
376
  *
365
377
  * The kernel cannot check this, so uniqueness is the runtime's to guarantee.
366
- * What it does guarantee is {@link UnitRecord}'s `unitId`, minted per unit;
378
+ * What it does guarantee is {@link UnitRecord}'s `unitId`, a UUID minted per
379
+ * unit, so no two units share one across replicas either;
367
380
  * `traceId` is the CORRELATION id, which is why it is the one a runtime may
368
381
  * supply — it carries an id from outside the process.
369
382
  */
@@ -382,6 +395,64 @@ type UnitRegistry = {
382
395
  readonly awaitIdle: () => AsyncResult<void, never>;
383
396
  };
384
397
  //#endregion
398
+ //#region src/unit-record.d.ts
399
+ /**
400
+ * A module a runtime's `unit` option may bind, as the upper bound a starter
401
+ * constrains its own `Unit` type parameter to. `Module`'s `_exports` channel is
402
+ * contravariant, so `Exports = never` — never `unknown` — is what makes a REAL
403
+ * module's own (necessarily narrower) export type assignable to this bound:
404
+ * `(x: Concrete) => void` is assignable to `(x: never) => void`, not to
405
+ * `(x: unknown) => void`.
406
+ */
407
+ type AnyUnitModule = Module<never, never, unknown>;
408
+ /**
409
+ * The needs a bound unit module still owes, or `never` when none is bound.
410
+ * `Scope` is excluded, since nothing can ever provide it — the same exemption
411
+ * `NeedsGate` itself carries — and so is `Seeded`, the port the runtime's own
412
+ * fork seed discharges.
413
+ */
414
+ type UnitNeedsOf<Unit, Seeded = never> = Unit extends Module<never, never, infer N> ? Exclude<N, Scope | Seeded> : never;
415
+ /** What a bound unit module exports — the port instances a piece of that kind may read. */
416
+ type UnitExportsOf<M> = M extends Module<infer X, never, unknown> ? X : never;
417
+ /** A piece's declared `unit:` record, as the services it reads off `context.unit`. */
418
+ type UnitRecordOf<U extends Readonly<Record<string, AnyPort>>> = { readonly [N in keyof U]: ServiceOf<InstanceType<U[N]>>; };
419
+ /**
420
+ * A piece injecting a port the bound unit module does not export. `unknown`
421
+ * when every declared port is covered — including the empty case, so a worker
422
+ * whose pieces declare no `unit:` is gated on nothing.
423
+ */
424
+ type UnitGate<Unit, Declared> = [Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>] extends [never] ? unknown : {
425
+ readonly "UNIT DOES NOT PROVIDE — a piece injects a port the bound unit module does not export": Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>;
426
+ };
427
+ /**
428
+ * One `(helpers, input)` implementation with its piece's declared record put on
429
+ * `helpers.context.unit`, resolved out of the fork {@link dispatchUnit} opened.
430
+ * Applied once per piece as di constructs it, so a unit costs one record and
431
+ * one context object. Each worker maps it over its own entry shape.
432
+ */
433
+ export declare const withUnitRecord: (record: Readonly<Record<string, AnyPort>>, implementation: (helpers: never, input: never) => unknown) => (helpers: {
434
+ readonly context?: Readonly<Record<string | symbol, unknown>>;
435
+ }, input: never) => unknown;
436
+ /**
437
+ * A worker middleware's body: observe `operation`, open one kernel unit, fork
438
+ * `unit` — when one is bound — seeded with `seed`, and call `next` with the
439
+ * forked context where {@link withUnitRecord} finds it. With no `unit` bound,
440
+ * `next()` runs unchanged.
441
+ *
442
+ * Observed from whichever channel the unit settles on, a defect included: a
443
+ * defect is a failed unit too, and an errors count that omitted it would be the
444
+ * reassuring half.
445
+ */
446
+ export declare const dispatchUnit: <T, E>(dispatch: {
447
+ readonly host: RuntimeHost<never>;
448
+ readonly observers: readonly ((operation: Operation) => Settle)[];
449
+ readonly operation: Operation;
450
+ readonly meta: UnitMeta;
451
+ readonly unit: AnyUnitModule | undefined;
452
+ readonly seed: readonly [AnyPort, unknown];
453
+ readonly next: (overrides?: never) => AsyncResult<T, E>;
454
+ }) => AsyncResult<T, E>;
455
+ //#endregion
385
456
  //#region src/runtime.d.ts
386
457
  declare const RuntimeStartFailed_base: import("unthrown").TaggedErrorConstructor<"RuntimeStartFailed">;
387
458
  export declare class RuntimeStartFailed extends RuntimeStartFailed_base<{
@@ -491,6 +562,14 @@ type Runtime<Resolves extends AnyPort = never, Info = never> = {
491
562
  readonly name: string;
492
563
  readonly resolves: readonly Resolves[];
493
564
  readonly start: (host: RuntimeHost<Resolves>) => AsyncResult<Serving<Info>, RuntimeStartFailed>;
565
+ /**
566
+ * The unit modules this runtime forks, by kind — `{ message: … }`, or one
567
+ * per HTTP scheme. Optional, and read for one thing only: a test's
568
+ * `UnitOverrides` name a kind, `fork` is handed a module, and this is
569
+ * what maps one onto the other. A runtime that omits it refuses every unit
570
+ * override at boot rather than ignoring it.
571
+ */
572
+ readonly units?: Readonly<Record<string, AnyUnitModule | undefined>>;
494
573
  };
495
574
  /**
496
575
  * The port the kernel resolves its runtime from. A runtime is a **service the
@@ -834,63 +913,20 @@ export declare const start: <X, E, N>(module: Module<X, E, N> & StartGate<X, N>,
834
913
  */
835
914
  export declare const runMain: <X, E, N>(module: Module<X, E, N> & StartGate<X, N>, options?: StartOptions, exit?: (code: number) => void) => Promise<void>;
836
915
  //#endregion
837
- //#region src/unit-record.d.ts
838
- /**
839
- * A module a runtime's `unit` option may bind, as the upper bound a starter
840
- * constrains its own `Unit` type parameter to. `Module`'s `_exports` channel is
841
- * contravariant, so `Exports = never` — never `unknown` — is what makes a REAL
842
- * module's own (necessarily narrower) export type assignable to this bound:
843
- * `(x: Concrete) => void` is assignable to `(x: never) => void`, not to
844
- * `(x: unknown) => void`.
845
- */
846
- type AnyUnitModule = Module<never, never, unknown>;
847
- /**
848
- * The needs a bound unit module still owes, or `never` when none is bound.
849
- * `Scope` is excluded, since nothing can ever provide it — the same exemption
850
- * `NeedsGate` itself carries — and so is `Seeded`, the port the runtime's own
851
- * fork seed discharges.
852
- */
853
- type UnitNeedsOf<Unit, Seeded = never> = Unit extends Module<never, never, infer N> ? Exclude<N, Scope | Seeded> : never;
854
- /** What a bound unit module exports — the port instances a piece of that kind may read. */
855
- type UnitExportsOf<M> = M extends Module<infer X, never, unknown> ? X : never;
856
- /** A piece's declared `unit:` record, as the services it reads off `context.unit`. */
857
- type UnitRecordOf<U extends Readonly<Record<string, AnyPort>>> = { readonly [N in keyof U]: ServiceOf<InstanceType<U[N]>>; };
858
- /**
859
- * A piece injecting a port the bound unit module does not export. `unknown`
860
- * when every declared port is covered — including the empty case, so a worker
861
- * whose pieces declare no `unit:` is gated on nothing.
862
- */
863
- type UnitGate<Unit, Declared> = [Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>] extends [never] ? unknown : {
864
- readonly "UNIT DOES NOT PROVIDE — a piece injects a port the bound unit module does not export": Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>;
865
- };
916
+ //#region src/unit-overrides.d.ts
917
+ declare const UnitOverrides_base: import("@btravstack/di").ManyPortClass<"UnitOverrides">;
866
918
  /**
867
- * One `(helpers, input)` implementation with its piece's declared record put on
868
- * `helpers.context.unit`, resolved out of the fork {@link dispatchUnit} opened.
869
- * Applied once per piece as di constructs it, so a unit costs one record and
870
- * one context object. Each worker maps it over its own entry shape.
871
- */
872
- export declare const withUnitRecord: (record: Readonly<Record<string, AnyPort>>, implementation: (helpers: never, input: never) => unknown) => (helpers: {
873
- readonly context?: Readonly<Record<string | symbol, unknown>>;
874
- }, input: never) => unknown;
875
- /**
876
- * A worker middleware's body: observe `operation`, open one kernel unit, fork
877
- * `unit` — when one is bound — seeded with `seed`, and call `next` with the
878
- * forked context where {@link withUnitRecord} finds it. With no `unit` bound,
879
- * `next()` runs unchanged.
919
+ * Providers substituted inside a runtime's unit modules, keyed by the kind
920
+ * the runtime declares on `Runtime.units` — test-facing: `@btravstack/testing`'s
921
+ * `overridden(root, providers, { unit })` is the one contributor, and a
922
+ * production root never names it.
880
923
  *
881
- * Observed from whichever channel the unit settles on, a defect included: a
882
- * defect is a failed unit too, and an errors count that omitted it would be the
883
- * reassuring half.
924
+ * Each provider must already carry di's override brand. The kernel applies
925
+ * them at boot, before the runtime starts: every kind named must be one the
926
+ * runtime binds, and every port must be one that kind's module provides, or
927
+ * the boot is a defect — the same drift gate a root-level override has.
884
928
  */
885
- export declare const dispatchUnit: <T, E>(dispatch: {
886
- readonly host: RuntimeHost<never>;
887
- readonly observers: readonly ((operation: Operation) => Settle)[];
888
- readonly operation: Operation;
889
- readonly meta: UnitMeta;
890
- readonly unit: AnyUnitModule | undefined;
891
- readonly seed: readonly [AnyPort, unknown];
892
- readonly next: (overrides?: never) => AsyncResult<T, E>;
893
- }) => AsyncResult<T, E>;
929
+ export declare class UnitOverrides extends UnitOverrides_base<Readonly<Record<string, readonly AnyProvider[]>>> {}
894
930
  //#endregion
895
931
  //#region src/pieces.d.ts
896
932
  /**
@@ -942,4 +978,4 @@ export declare const noObserverMember: Provider<Observers, never, never> & {
942
978
  readonly port: typeof Observers;
943
979
  };
944
980
  //#endregion
945
- export type { AnyUnitModule, Attributes, Clock, ComponentHealth, Counter, DrainReport, EventSink, ExitReport, HealthCheck, HealthReport, Histogram, InstrumentationLoader, KernelEvent, Level, LoggerService, MeterService, Operation, Phase, Refuse, RunUnit, RunningApp, Runtime, RuntimeHost, RuntimeInfoOf, Serving, Settle, Settled, Span, SpanStatusCode, StartGate, StartOptions, TeardownError, TracerService, UnitExportsOf, UnitGate, UnitHost, UnitMeta, UnitNeedsOf, UnitRecord, UnitRecordOf, UnitRegistry, UnitWork };
981
+ export type { AnyUnitModule, Attributes, Clock, ComponentHealth, Counter, DrainReport, EventSink, ExitReport, HealthCheck, HealthReport, Histogram, InstrumentationLoader, KernelEvent, Level, LoggerService, MeterService, Operation, Phase, Refuse, RunUnit, RunningApp, Runtime, RuntimeHost, RuntimeInfoOf, Serving, Settle, Settled, Span, SpanStatusCode, StartGate, StartOptions, TeardownError, TracerService, UnitExportsOf, UnitGate, UnitHost, UnitMeta, UnitNeedsOf, UnitOutcome, UnitRecord, UnitRecordOf, UnitRegistry, UnitWork };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AnyPort, Context, DependencyGate, Module, PortClass, PortInstance, Provider, Scope, SeedEntry, ServiceOf } from "@btravstack/di";
1
+ import { AnyPort, AnyProvider, Context, DependencyGate, Module, PortClass, PortInstance, Provider, Scope, SeedEntry, ServiceOf } from "@btravstack/di";
2
2
  import { AsyncResult, FailureView, Result } from "unthrown";
3
3
  import { Env, Environment } from "@btravstack/config";
4
4
  //#region src/instrumentation.d.ts
@@ -352,6 +352,18 @@ type UnitRecord = {
352
352
  readonly signal: AbortSignal;
353
353
  };
354
354
  export declare const currentUnit: () => UnitRecord | undefined;
355
+ /** How a unit's work settled: `ok`, or `error` for an `Err`, a `Defect` and a throw alike. */
356
+ type UnitOutcome = "ok" | "error";
357
+ /**
358
+ * How the current unit's work settled, for the code that runs AFTER it: a
359
+ * unit module's `onStop`, which tears down inside the record but is handed
360
+ * only its own service. `undefined` outside a unit and while the work is still
361
+ * running. Whether the unit was aborted is the record's own `signal`.
362
+ *
363
+ * It is the kernel's outcome, not the transport's: an HTTP unit that answered
364
+ * a `500` still settled `ok`, since the kernel never maps a status.
365
+ */
366
+ export declare const unitOutcome: () => UnitOutcome | undefined;
355
367
  /**
356
368
  * What a runtime says about one piece of work as it submits it. `kind` is the
357
369
  * category (`"http"`, `"tick"`, `"job"`); `id` identifies **this** unit.
@@ -363,7 +375,8 @@ export declare const currentUnit: () => UnitRecord | undefined;
363
375
  * silently defeats the ambient record. A route template is a `kind`.
364
376
  *
365
377
  * The kernel cannot check this, so uniqueness is the runtime's to guarantee.
366
- * What it does guarantee is {@link UnitRecord}'s `unitId`, minted per unit;
378
+ * What it does guarantee is {@link UnitRecord}'s `unitId`, a UUID minted per
379
+ * unit, so no two units share one across replicas either;
367
380
  * `traceId` is the CORRELATION id, which is why it is the one a runtime may
368
381
  * supply — it carries an id from outside the process.
369
382
  */
@@ -382,6 +395,64 @@ type UnitRegistry = {
382
395
  readonly awaitIdle: () => AsyncResult<void, never>;
383
396
  };
384
397
  //#endregion
398
+ //#region src/unit-record.d.ts
399
+ /**
400
+ * A module a runtime's `unit` option may bind, as the upper bound a starter
401
+ * constrains its own `Unit` type parameter to. `Module`'s `_exports` channel is
402
+ * contravariant, so `Exports = never` — never `unknown` — is what makes a REAL
403
+ * module's own (necessarily narrower) export type assignable to this bound:
404
+ * `(x: Concrete) => void` is assignable to `(x: never) => void`, not to
405
+ * `(x: unknown) => void`.
406
+ */
407
+ type AnyUnitModule = Module<never, never, unknown>;
408
+ /**
409
+ * The needs a bound unit module still owes, or `never` when none is bound.
410
+ * `Scope` is excluded, since nothing can ever provide it — the same exemption
411
+ * `NeedsGate` itself carries — and so is `Seeded`, the port the runtime's own
412
+ * fork seed discharges.
413
+ */
414
+ type UnitNeedsOf<Unit, Seeded = never> = Unit extends Module<never, never, infer N> ? Exclude<N, Scope | Seeded> : never;
415
+ /** What a bound unit module exports — the port instances a piece of that kind may read. */
416
+ type UnitExportsOf<M> = M extends Module<infer X, never, unknown> ? X : never;
417
+ /** A piece's declared `unit:` record, as the services it reads off `context.unit`. */
418
+ type UnitRecordOf<U extends Readonly<Record<string, AnyPort>>> = { readonly [N in keyof U]: ServiceOf<InstanceType<U[N]>>; };
419
+ /**
420
+ * A piece injecting a port the bound unit module does not export. `unknown`
421
+ * when every declared port is covered — including the empty case, so a worker
422
+ * whose pieces declare no `unit:` is gated on nothing.
423
+ */
424
+ type UnitGate<Unit, Declared> = [Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>] extends [never] ? unknown : {
425
+ readonly "UNIT DOES NOT PROVIDE — a piece injects a port the bound unit module does not export": Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>;
426
+ };
427
+ /**
428
+ * One `(helpers, input)` implementation with its piece's declared record put on
429
+ * `helpers.context.unit`, resolved out of the fork {@link dispatchUnit} opened.
430
+ * Applied once per piece as di constructs it, so a unit costs one record and
431
+ * one context object. Each worker maps it over its own entry shape.
432
+ */
433
+ export declare const withUnitRecord: (record: Readonly<Record<string, AnyPort>>, implementation: (helpers: never, input: never) => unknown) => (helpers: {
434
+ readonly context?: Readonly<Record<string | symbol, unknown>>;
435
+ }, input: never) => unknown;
436
+ /**
437
+ * A worker middleware's body: observe `operation`, open one kernel unit, fork
438
+ * `unit` — when one is bound — seeded with `seed`, and call `next` with the
439
+ * forked context where {@link withUnitRecord} finds it. With no `unit` bound,
440
+ * `next()` runs unchanged.
441
+ *
442
+ * Observed from whichever channel the unit settles on, a defect included: a
443
+ * defect is a failed unit too, and an errors count that omitted it would be the
444
+ * reassuring half.
445
+ */
446
+ export declare const dispatchUnit: <T, E>(dispatch: {
447
+ readonly host: RuntimeHost<never>;
448
+ readonly observers: readonly ((operation: Operation) => Settle)[];
449
+ readonly operation: Operation;
450
+ readonly meta: UnitMeta;
451
+ readonly unit: AnyUnitModule | undefined;
452
+ readonly seed: readonly [AnyPort, unknown];
453
+ readonly next: (overrides?: never) => AsyncResult<T, E>;
454
+ }) => AsyncResult<T, E>;
455
+ //#endregion
385
456
  //#region src/runtime.d.ts
386
457
  declare const RuntimeStartFailed_base: import("unthrown").TaggedErrorConstructor<"RuntimeStartFailed">;
387
458
  export declare class RuntimeStartFailed extends RuntimeStartFailed_base<{
@@ -491,6 +562,14 @@ type Runtime<Resolves extends AnyPort = never, Info = never> = {
491
562
  readonly name: string;
492
563
  readonly resolves: readonly Resolves[];
493
564
  readonly start: (host: RuntimeHost<Resolves>) => AsyncResult<Serving<Info>, RuntimeStartFailed>;
565
+ /**
566
+ * The unit modules this runtime forks, by kind — `{ message: … }`, or one
567
+ * per HTTP scheme. Optional, and read for one thing only: a test's
568
+ * `UnitOverrides` name a kind, `fork` is handed a module, and this is
569
+ * what maps one onto the other. A runtime that omits it refuses every unit
570
+ * override at boot rather than ignoring it.
571
+ */
572
+ readonly units?: Readonly<Record<string, AnyUnitModule | undefined>>;
494
573
  };
495
574
  /**
496
575
  * The port the kernel resolves its runtime from. A runtime is a **service the
@@ -834,63 +913,20 @@ export declare const start: <X, E, N>(module: Module<X, E, N> & StartGate<X, N>,
834
913
  */
835
914
  export declare const runMain: <X, E, N>(module: Module<X, E, N> & StartGate<X, N>, options?: StartOptions, exit?: (code: number) => void) => Promise<void>;
836
915
  //#endregion
837
- //#region src/unit-record.d.ts
838
- /**
839
- * A module a runtime's `unit` option may bind, as the upper bound a starter
840
- * constrains its own `Unit` type parameter to. `Module`'s `_exports` channel is
841
- * contravariant, so `Exports = never` — never `unknown` — is what makes a REAL
842
- * module's own (necessarily narrower) export type assignable to this bound:
843
- * `(x: Concrete) => void` is assignable to `(x: never) => void`, not to
844
- * `(x: unknown) => void`.
845
- */
846
- type AnyUnitModule = Module<never, never, unknown>;
847
- /**
848
- * The needs a bound unit module still owes, or `never` when none is bound.
849
- * `Scope` is excluded, since nothing can ever provide it — the same exemption
850
- * `NeedsGate` itself carries — and so is `Seeded`, the port the runtime's own
851
- * fork seed discharges.
852
- */
853
- type UnitNeedsOf<Unit, Seeded = never> = Unit extends Module<never, never, infer N> ? Exclude<N, Scope | Seeded> : never;
854
- /** What a bound unit module exports — the port instances a piece of that kind may read. */
855
- type UnitExportsOf<M> = M extends Module<infer X, never, unknown> ? X : never;
856
- /** A piece's declared `unit:` record, as the services it reads off `context.unit`. */
857
- type UnitRecordOf<U extends Readonly<Record<string, AnyPort>>> = { readonly [N in keyof U]: ServiceOf<InstanceType<U[N]>>; };
858
- /**
859
- * A piece injecting a port the bound unit module does not export. `unknown`
860
- * when every declared port is covered — including the empty case, so a worker
861
- * whose pieces declare no `unit:` is gated on nothing.
862
- */
863
- type UnitGate<Unit, Declared> = [Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>] extends [never] ? unknown : {
864
- readonly "UNIT DOES NOT PROVIDE — a piece injects a port the bound unit module does not export": Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>;
865
- };
916
+ //#region src/unit-overrides.d.ts
917
+ declare const UnitOverrides_base: import("@btravstack/di").ManyPortClass<"UnitOverrides">;
866
918
  /**
867
- * One `(helpers, input)` implementation with its piece's declared record put on
868
- * `helpers.context.unit`, resolved out of the fork {@link dispatchUnit} opened.
869
- * Applied once per piece as di constructs it, so a unit costs one record and
870
- * one context object. Each worker maps it over its own entry shape.
871
- */
872
- export declare const withUnitRecord: (record: Readonly<Record<string, AnyPort>>, implementation: (helpers: never, input: never) => unknown) => (helpers: {
873
- readonly context?: Readonly<Record<string | symbol, unknown>>;
874
- }, input: never) => unknown;
875
- /**
876
- * A worker middleware's body: observe `operation`, open one kernel unit, fork
877
- * `unit` — when one is bound — seeded with `seed`, and call `next` with the
878
- * forked context where {@link withUnitRecord} finds it. With no `unit` bound,
879
- * `next()` runs unchanged.
919
+ * Providers substituted inside a runtime's unit modules, keyed by the kind
920
+ * the runtime declares on `Runtime.units` — test-facing: `@btravstack/testing`'s
921
+ * `overridden(root, providers, { unit })` is the one contributor, and a
922
+ * production root never names it.
880
923
  *
881
- * Observed from whichever channel the unit settles on, a defect included: a
882
- * defect is a failed unit too, and an errors count that omitted it would be the
883
- * reassuring half.
924
+ * Each provider must already carry di's override brand. The kernel applies
925
+ * them at boot, before the runtime starts: every kind named must be one the
926
+ * runtime binds, and every port must be one that kind's module provides, or
927
+ * the boot is a defect — the same drift gate a root-level override has.
884
928
  */
885
- export declare const dispatchUnit: <T, E>(dispatch: {
886
- readonly host: RuntimeHost<never>;
887
- readonly observers: readonly ((operation: Operation) => Settle)[];
888
- readonly operation: Operation;
889
- readonly meta: UnitMeta;
890
- readonly unit: AnyUnitModule | undefined;
891
- readonly seed: readonly [AnyPort, unknown];
892
- readonly next: (overrides?: never) => AsyncResult<T, E>;
893
- }) => AsyncResult<T, E>;
929
+ export declare class UnitOverrides extends UnitOverrides_base<Readonly<Record<string, readonly AnyProvider[]>>> {}
894
930
  //#endregion
895
931
  //#region src/pieces.d.ts
896
932
  /**
@@ -942,4 +978,4 @@ export declare const noObserverMember: Provider<Observers, never, never> & {
942
978
  readonly port: typeof Observers;
943
979
  };
944
980
  //#endregion
945
- export type { AnyUnitModule, Attributes, Clock, ComponentHealth, Counter, DrainReport, EventSink, ExitReport, HealthCheck, HealthReport, Histogram, InstrumentationLoader, KernelEvent, Level, LoggerService, MeterService, Operation, Phase, Refuse, RunUnit, RunningApp, Runtime, RuntimeHost, RuntimeInfoOf, Serving, Settle, Settled, Span, SpanStatusCode, StartGate, StartOptions, TeardownError, TracerService, UnitExportsOf, UnitGate, UnitHost, UnitMeta, UnitNeedsOf, UnitRecord, UnitRecordOf, UnitRegistry, UnitWork };
981
+ export type { AnyUnitModule, Attributes, Clock, ComponentHealth, Counter, DrainReport, EventSink, ExitReport, HealthCheck, HealthReport, Histogram, InstrumentationLoader, KernelEvent, Level, LoggerService, MeterService, Operation, Phase, Refuse, RunUnit, RunningApp, Runtime, RuntimeHost, RuntimeInfoOf, Serving, Settle, Settled, Span, SpanStatusCode, StartGate, StartOptions, TeardownError, TracerService, UnitExportsOf, UnitGate, UnitHost, UnitMeta, UnitNeedsOf, UnitOutcome, UnitRecord, UnitRecordOf, UnitRegistry, UnitWork };
package/dist/index.mjs CHANGED
@@ -4,6 +4,7 @@ import { setTimeout as setTimeout$1 } from "node:timers/promises";
4
4
  import { Config, ConfigInvalid, Env } from "@btravstack/config";
5
5
  import { createServer } from "node:http";
6
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
+ import { randomUUID } from "node:crypto";
7
8
  //#region src/instrumentation.ts
8
9
  /**
9
10
  * The set port a package contributes its instrumentation to.
@@ -427,14 +428,71 @@ const installUncaughtHandlers = (onUncaught) => {
427
428
  };
428
429
  };
429
430
  //#endregion
431
+ //#region src/unit-overrides.ts
432
+ /**
433
+ * Providers substituted inside a runtime's unit modules, keyed by the kind
434
+ * the runtime declares on `Runtime.units` — test-facing: `@btravstack/testing`'s
435
+ * `overridden(root, providers, { unit })` is the one contributor, and a
436
+ * production root never names it.
437
+ *
438
+ * Each provider must already carry di's override brand. The kernel applies
439
+ * them at boot, before the runtime starts: every kind named must be one the
440
+ * runtime binds, and every port must be one that kind's module provides, or
441
+ * the boot is a defect — the same drift gate a root-level override has.
442
+ */
443
+ var UnitOverrides = class extends Port.many("UnitOverrides") {};
444
+ /** Whether `module`'s tree carries a provider for `portId`. */
445
+ const provides = (module, portId) => module.provides.some((provider) => provider.port.portId === portId) || module.imports.some((imported) => provides(imported, portId));
446
+ /**
447
+ * Each overridden kind's module, mapped to the module the kernel forks in its
448
+ * place. Keyed by module IDENTITY, because `UnitHost.fork` is handed the
449
+ * module and never the kind — which is why a module bound under two kinds is
450
+ * refused rather than overridden under both.
451
+ */
452
+ const unitSubstitutes = (runtime, units, overrides) => {
453
+ const byKind = /* @__PURE__ */ new Map();
454
+ for (const member of overrides) for (const [kind, providers] of Object.entries(member)) byKind.set(kind, [...byKind.get(kind) ?? [], ...providers]);
455
+ const substitutes = /* @__PURE__ */ new Map();
456
+ for (const [kind, providers] of byKind) {
457
+ const module = units[kind];
458
+ if (module === void 0) throw new Error(`[core] unit override for kind "${kind}", which runtime "${runtime}" binds no module for`);
459
+ const shared = Object.keys(units).find((other) => other !== kind && units[other] === module);
460
+ if (shared !== void 0) throw new Error(`[core] unit override for kind "${kind}", whose module kind "${shared}" binds too — an override cannot reach one without the other`);
461
+ const seen = /* @__PURE__ */ new Set();
462
+ for (const provider of providers) {
463
+ if (seen.has(provider.port.portId)) throw new Error(`[core] two unit overrides registered for port ${JSON.stringify(provider.port.portId)} in kind "${kind}"`);
464
+ seen.add(provider.port.portId);
465
+ if (!provides(module, provider.port.portId)) throw new Error(`[core] unit override for port ${JSON.stringify(provider.port.portId)} in kind "${kind}" with nothing to override — its module no longer provides it`);
466
+ }
467
+ substitutes.set(module, Module("UnitOverridden")({
468
+ imports: [module],
469
+ provides: providers,
470
+ exports: [module]
471
+ }));
472
+ }
473
+ return substitutes;
474
+ };
475
+ //#endregion
430
476
  //#region src/units.ts
431
477
  const storage = new AsyncLocalStorage();
432
478
  const runWithUnit = (record, fn) => storage.run(record, fn);
433
479
  const currentUnit = () => storage.getStore();
434
- let counter = 0;
435
- const nextId = () => {
436
- counter += 1;
437
- return `u${counter}`;
480
+ const outcomes = /* @__PURE__ */ new WeakMap();
481
+ const settleUnit = (record, outcome) => {
482
+ outcomes.set(record, outcome);
483
+ };
484
+ /**
485
+ * How the current unit's work settled, for the code that runs AFTER it: a
486
+ * unit module's `onStop`, which tears down inside the record but is handed
487
+ * only its own service. `undefined` outside a unit and while the work is still
488
+ * running. Whether the unit was aborted is the record's own `signal`.
489
+ *
490
+ * It is the kernel's outcome, not the transport's: an HTTP unit that answered
491
+ * a `500` still settled `ok`, since the kernel never maps a status.
492
+ */
493
+ const unitOutcome = () => {
494
+ const unit = currentUnit();
495
+ return unit === void 0 ? void 0 : outcomes.get(unit);
438
496
  };
439
497
  const createUnitRegistry = () => {
440
498
  const open = /* @__PURE__ */ new Set();
@@ -450,7 +508,7 @@ const createUnitRegistry = () => {
450
508
  const controller = new AbortController();
451
509
  open.add(controller);
452
510
  const record = {
453
- unitId: nextId(),
511
+ unitId: randomUUID(),
454
512
  traceId: meta.traceId ?? meta.id,
455
513
  tenantId: meta.tenantId,
456
514
  signal: controller.signal
@@ -484,7 +542,7 @@ const createUnitRegistry = () => {
484
542
  * and whose side effects then have to be guarded one by one.
485
543
  */
486
544
  const withdrawn = () => fromSafePromise(new Promise(() => {}));
487
- const providesEnv = (module) => module.provides.some((provider) => provider.port.portId === Env.portId) || module.imports.some(providesEnv);
545
+ const providesEnv = (module) => provides(module, Env.portId);
488
546
  /**
489
547
  * What the kernel reads from the environment for itself. Each field is pinned
490
548
  * by the matching `StartOptions` field — explicit beats environment beats
@@ -531,7 +589,13 @@ const readKernelConfig = (options, env) => {
531
589
  const start = (module, options = {}) => {
532
590
  const clock = options.clock ?? systemClock;
533
591
  const env = options.env ?? process.env;
534
- const emit = safeSink(options.onEvent ?? stderrSink);
592
+ const sink = safeSink(options.onEvent ?? stderrSink);
593
+ let exitedEmitted = false;
594
+ const emit = (event) => {
595
+ if (exitedEmitted) return;
596
+ sink(event);
597
+ if (event.type === "exited") exitedEmitted = true;
598
+ };
535
599
  let runtimeName = "";
536
600
  let servingInfo = void 0;
537
601
  let probeBoundPort = void 0;
@@ -583,6 +647,7 @@ const start = (module, options = {}) => {
583
647
  }
584
648
  });
585
649
  const disposeUncaught = options.signals === false ? () => {} : installUncaughtHandlers((cause) => {
650
+ crashed = true;
586
651
  emit({
587
652
  type: "uncaught",
588
653
  cause
@@ -593,6 +658,8 @@ const start = (module, options = {}) => {
593
658
  abandonBuild("uncaught");
594
659
  });
595
660
  let disposeProbes = () => {};
661
+ let crashed = false;
662
+ let startupFailing = false;
596
663
  const disposeAll = () => {
597
664
  disposeSignals();
598
665
  disposeUncaught();
@@ -600,7 +667,7 @@ const start = (module, options = {}) => {
600
667
  disposeProbes();
601
668
  };
602
669
  const reportOf = (reason, drain, abandonedAt) => ({
603
- reason,
670
+ reason: crashed ? "uncaught" : reason,
604
671
  drain,
605
672
  teardownErrors,
606
673
  uptimeMs: clock.now() - startedAt,
@@ -615,16 +682,24 @@ const start = (module, options = {}) => {
615
682
  tracker.advanceTo("stopping");
616
683
  disposeAll();
617
684
  };
685
+ const leaveFailed = (failure) => {
686
+ if (tracker.current() !== "stopping") emit({
687
+ type: "startFailed",
688
+ cause: failure.tag === "Err" ? failure.error : failure.cause
689
+ });
690
+ runtimePublished.resolve(void 0);
691
+ tracker.advanceTo("stopping");
692
+ };
618
693
  const stopping = Promise.withResolvers();
619
- const stopAbandoned = () => fromSafePromise(stopping.promise).flatMap(({ reason, drain }) => clock.sleep(stopTimeoutMs, stopSettled.signal).flatMap(() => lifecycleSettled ? withdrawn() : OkAsync(abandonStop(reason, drain))));
620
- const abandonStop = (reason, drain) => {
694
+ const stopAbandoned = () => fromSafePromise(stopping.promise).flatMap((abandoned) => clock.sleep(stopTimeoutMs, stopSettled.signal).flatMap(() => lifecycleSettled ? withdrawn() : abandonStop(abandoned)));
695
+ const abandonStop = (abandoned) => {
621
696
  emit({
622
697
  type: "stoppedWaiting",
623
698
  phase: "stop",
624
699
  afterMs: stopSettled.signal.aborted ? void 0 : stopTimeoutMs
625
700
  });
626
701
  disposeAll();
627
- return reportOf(reason, drain, "stop");
702
+ return abandoned().map(({ reason, drain }) => reportOf(reason, drain, "stop"));
628
703
  };
629
704
  const abandoningBuild = Promise.withResolvers();
630
705
  const buildAbandoned = () => fromSafePromise(abandoningBuild.promise).map((reason) => {
@@ -639,7 +714,7 @@ const start = (module, options = {}) => {
639
714
  });
640
715
  const abandonBuild = (reason) => {
641
716
  const phase = tracker.current();
642
- if (phase === "building" || phase === "starting") abandoningBuild.resolve(reason);
717
+ if (phase === "building" || phase === "starting" || reason === "uncaught" && startupFailing) abandoningBuild.resolve(reason);
643
718
  };
644
719
  emit({ type: "building" });
645
720
  const probesOptions = kernelConfig.map(({ probePort, probeHost }) => options.probes === false ? false : {
@@ -700,22 +775,23 @@ const start = (module, options = {}) => {
700
775
  if (reason !== "signal") registry.abortAll();
701
776
  return (reason === "signal" ? runDrain(serving) : OkAsync(void 0)).flatMap((report) => {
702
777
  tracker.advanceTo("stopping");
703
- stopping.resolve({
778
+ stopping.resolve(() => OkAsync({
704
779
  reason,
705
780
  drain: report
706
- });
707
- return serving.stop().map(() => {
708
- disposeAll();
709
- return reportOf(reason, report);
710
- });
781
+ }));
782
+ return serving.stop().map(() => ({
783
+ reason,
784
+ drain: report
785
+ }));
711
786
  });
712
787
  };
713
- const built = () => Module.scoped(root, (ctx) => {
788
+ const serve = (ctx) => {
714
789
  tracker.advanceTo("starting");
715
790
  const runtime = ctx.get(RuntimePort);
716
791
  runtimeName = runtime.name;
717
792
  healthChecks = ctx.get(HealthChecks);
718
793
  const runtimeCtx = ctx;
794
+ const substitutes = unitSubstitutes(runtime.name, runtime.units ?? {}, ctx.get(UnitOverrides));
719
795
  const run = (meta, work) => registry.run(meta, (signal) => {
720
796
  const settled = Promise.withResolvers();
721
797
  let closing;
@@ -724,12 +800,12 @@ const start = (module, options = {}) => {
724
800
  if (hasSettled) return fromSafePromise(Promise.reject(/* @__PURE__ */ new Error("a unit forks after it has already settled")));
725
801
  if (closing !== void 0) return fromSafePromise(Promise.reject(/* @__PURE__ */ new Error("a unit forks its scope once")));
726
802
  const ready = Promise.withResolvers();
727
- closing = Module.forkScope(ctx, module, (forked) => {
803
+ closing = Module.forkScope(ctx, substitutes.get(module) ?? module, (forked) => {
728
804
  ready.resolve(forked);
729
805
  return fromSafePromise(settled.promise);
730
806
  }, {
731
807
  seed,
732
- onTeardownError: (port, cause) => emit({
808
+ onTeardownError: (port, cause) => sink({
733
809
  type: "teardownError",
734
810
  port,
735
811
  cause
@@ -740,13 +816,18 @@ const start = (module, options = {}) => {
740
816
  }).get();
741
817
  return fromSafePromise(ready.promise);
742
818
  };
819
+ const record = currentUnit();
743
820
  const outcome = (async () => {
821
+ let settledAs = "error";
744
822
  try {
745
- return await work({
823
+ const result = await work({
746
824
  ctx: runtimeCtx,
747
825
  fork
748
826
  }, signal);
827
+ if (result.isOk()) settledAs = "ok";
828
+ return result;
749
829
  } finally {
830
+ if (record !== void 0) settleUnit(record, settledAs);
750
831
  hasSettled = true;
751
832
  settled.resolve();
752
833
  if (closing !== void 0) await closing;
@@ -767,23 +848,32 @@ const start = (module, options = {}) => {
767
848
  });
768
849
  return fromSafePromise(shutdown.promise).flatMap((reason) => finish(serving, reason));
769
850
  });
770
- }, { onTeardownError: (port, cause) => {
771
- teardownErrors.push({
772
- port,
773
- cause
774
- });
775
- emit({
776
- type: "teardownError",
777
- port,
778
- cause
779
- });
780
- } }).tapFailure((failure) => {
781
- if (tracker.current() !== "stopping") emit({
782
- type: "startFailed",
783
- cause: failure.tag === "Err" ? failure.error : failure.cause
784
- });
785
- leaveStartup();
786
- }).tap(onLifecycleSettled).tapFailure(onLifecycleSettled);
851
+ };
852
+ const built = () => Module.scoped(root, (ctx) => OkAsync(ctx).flatMap(serve).tapFailure((failure) => {
853
+ if (tracker.current() === "starting") startupFailing = true;
854
+ leaveFailed(failure);
855
+ stopping.resolve(() => failure.toAsync());
856
+ }), {
857
+ onConstructionFailure: (failure) => {
858
+ startupFailing = true;
859
+ leaveFailed(failure);
860
+ stopping.resolve(() => failure.toAsync());
861
+ },
862
+ onTeardownError: (port, cause) => {
863
+ teardownErrors.push({
864
+ port,
865
+ cause
866
+ });
867
+ emit({
868
+ type: "teardownError",
869
+ port,
870
+ cause
871
+ });
872
+ }
873
+ }).tapFailure((failure) => {
874
+ leaveFailed(failure);
875
+ disposeAll();
876
+ }).map(({ reason, drain }) => reportOf(reason, drain)).tap(disposeAll).tap(onLifecycleSettled).tapFailure(onLifecycleSettled);
787
877
  return {
788
878
  exited: probesStarted.flatMap(() => fromSafePromise(Promise.race([
789
879
  built(),
@@ -949,4 +1039,4 @@ const noObserverMember = Provider.member(Observers)({
949
1039
  value: noObserver
950
1040
  });
951
1041
  //#endregion
952
- export { HealthCheckFailed, HealthChecks, Instrumentations, LEVELS, Logger, Meter, Observers, RuntimePort, RuntimeStartFailed, SPAN_STATUS, Tracer, composeByPrefix, currentUnit, dispatchUnit, mintPiece, noObserver, noObserverMember, observe, observed, releasedBy, runHealthChecks, runMain, start, stderrSink, systemClock, traceIdOfTraceparent, withUnitRecord };
1042
+ export { HealthCheckFailed, HealthChecks, Instrumentations, LEVELS, Logger, Meter, Observers, RuntimePort, RuntimeStartFailed, SPAN_STATUS, Tracer, UnitOverrides, composeByPrefix, currentUnit, dispatchUnit, mintPiece, noObserver, noObserverMember, observe, observed, releasedBy, runHealthChecks, runMain, start, stderrSink, systemClock, traceIdOfTraceparent, unitOutcome, withUnitRecord };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/core",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "The application kernel: boot a di module into a running process, and stop it without losing work",
5
5
  "keywords": [
6
6
  "application",
@@ -45,20 +45,23 @@
45
45
  "./package.json": "./package.json"
46
46
  },
47
47
  "devDependencies": {
48
- "@btravstack/config": "0.17.0",
49
- "@btravstack/di": "0.17.0",
48
+ "@btravstack/config": "0.19.0",
49
+ "@btravstack/di": "0.19.0",
50
50
  "@btravstack/tsconfig": "0.4.0",
51
+ "@stryker-mutator/core": "10.0.0",
52
+ "@stryker-mutator/vitest-runner": "10.0.0",
51
53
  "@types/node": "26.6.4",
52
54
  "@unthrown/vitest": "5.12.0",
53
55
  "@vitest/coverage-v8": "5.0.3",
54
56
  "tsdown": "0.23.0",
55
57
  "typescript": "7.0.2",
58
+ "typescript-consumer": "npm:typescript@5.9.3",
56
59
  "unthrown": "5.12.0",
57
60
  "vitest": "5.0.3"
58
61
  },
59
62
  "peerDependencies": {
60
- "@btravstack/config": "^0.17.0",
61
- "@btravstack/di": "^0.17.0",
63
+ "@btravstack/config": "^0.19.0",
64
+ "@btravstack/di": "^0.19.0",
62
65
  "unthrown": "^5.0.0"
63
66
  },
64
67
  "engines": {
@@ -68,7 +71,8 @@
68
71
  "build": "tsdown src/index.ts --format cjs,esm --dts --clean --tsconfig tsconfig.build.json",
69
72
  "dev": "tsdown src/index.ts --format cjs,esm --dts --watch --tsconfig tsconfig.build.json",
70
73
  "generate": "node ../../docs/scripts/extract-doc-samples.ts --group core --out src/generated/doc-samples",
74
+ "mutate": "stryker run",
71
75
  "test": "vitest run --coverage",
72
- "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
76
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json && node ./node_modules/typescript-consumer/bin/tsc --noEmit -p tsconfig.test-d.json"
73
77
  }
74
78
  }