@btravstack/core 0.16.0 → 0.18.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
@@ -1,9 +1,11 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _btravstack_di = require("@btravstack/di");
3
3
  let unthrown = require("unthrown");
4
+ let node_timers_promises = require("node:timers/promises");
4
5
  let _btravstack_config = require("@btravstack/config");
5
6
  let node_http = require("node:http");
6
7
  let node_async_hooks = require("node:async_hooks");
8
+ let node_crypto = require("node:crypto");
7
9
  //#region src/instrumentation.ts
8
10
  /**
9
11
  * The set port a package contributes its instrumentation to.
@@ -69,10 +71,11 @@ const answeringWithin = (health, ms) => {
69
71
  * rather than a recovery.** Neither recovery above can see one: there is no
70
72
  * error and no defect, only silence, so `/healthz` held a socket open per hit
71
73
  * while kubelet timed out against a report that named nothing. Each check now
72
- * races {@link HealthOptions.timeoutMs}, and the component that hung is named
74
+ * races its own `timeoutMs` (default {@link DEFAULT_HEALTH_TIMEOUT_MS}), and
75
+ * the component that hung is named
73
76
  * in the report like any other unhealthy one.
74
77
  */
75
- const runHealthChecks = (checks, options = {}) => (0, unthrown.allAsync)(checks.map((health) => answeringWithin(health, health.timeoutMs ?? options.timeoutMs ?? 800).map(() => ({
78
+ const runHealthChecks = (checks) => (0, unthrown.allAsync)(checks.map((health) => answeringWithin(health, health.timeoutMs ?? 800).map(() => ({
76
79
  name: health.name,
77
80
  status: "healthy"
78
81
  })).recoverErrCases((matcher) => matcher.with(unthrown.P.tag("HealthCheckFailed"), (error) => ({
@@ -153,22 +156,10 @@ const observed = (observers, operation, call, settled = {}) => {
153
156
  //#region src/clock.ts
154
157
  const systemClock = {
155
158
  now: () => Date.now(),
156
- sleep: (ms, signal) => (0, unthrown.fromSafePromise)(new Promise((resolve) => {
157
- if (signal?.aborted === true) {
158
- resolve();
159
- return;
160
- }
161
- const timer = setTimeout(() => {
162
- signal?.removeEventListener("abort", onAbort);
163
- resolve();
164
- }, ms);
165
- timer.unref?.();
166
- function onAbort() {
167
- clearTimeout(timer);
168
- resolve();
169
- }
170
- signal?.addEventListener("abort", onAbort, { once: true });
171
- }))
159
+ sleep: (ms, signal) => (0, unthrown.fromSafePromise)((0, node_timers_promises.setTimeout)(ms, void 0, {
160
+ signal,
161
+ ref: false
162
+ }).catch(() => {}))
172
163
  };
173
164
  //#endregion
174
165
  //#region src/events.ts
@@ -442,10 +433,22 @@ const installUncaughtHandlers = (onUncaught) => {
442
433
  const storage = new node_async_hooks.AsyncLocalStorage();
443
434
  const runWithUnit = (record, fn) => storage.run(record, fn);
444
435
  const currentUnit = () => storage.getStore();
445
- let counter = 0;
446
- const nextId = () => {
447
- counter += 1;
448
- return `u${counter}`;
436
+ const outcomes = /* @__PURE__ */ new WeakMap();
437
+ const settleUnit = (record, outcome) => {
438
+ outcomes.set(record, outcome);
439
+ };
440
+ /**
441
+ * How the current unit's work settled, for the code that runs AFTER it: a
442
+ * unit module's `onStop`, which tears down inside the record but is handed
443
+ * only its own service. `undefined` outside a unit and while the work is still
444
+ * running. Whether the unit was aborted is the record's own `signal`.
445
+ *
446
+ * It is the kernel's outcome, not the transport's: an HTTP unit that answered
447
+ * a `500` still settled `ok`, since the kernel never maps a status.
448
+ */
449
+ const unitOutcome = () => {
450
+ const unit = currentUnit();
451
+ return unit === void 0 ? void 0 : outcomes.get(unit);
449
452
  };
450
453
  const createUnitRegistry = () => {
451
454
  const open = /* @__PURE__ */ new Set();
@@ -461,7 +464,7 @@ const createUnitRegistry = () => {
461
464
  const controller = new AbortController();
462
465
  open.add(controller);
463
466
  const record = {
464
- unitId: nextId(),
467
+ unitId: (0, node_crypto.randomUUID)(),
465
468
  traceId: meta.traceId ?? meta.id,
466
469
  tenantId: meta.tenantId,
467
470
  signal: controller.signal
@@ -621,6 +624,11 @@ const start = (module, options = {}) => {
621
624
  const health = () => runHealthChecks(healthChecks);
622
625
  const probeBound = Promise.withResolvers();
623
626
  const runtimePublished = Promise.withResolvers();
627
+ const leaveStartup = () => {
628
+ runtimePublished.resolve(void 0);
629
+ tracker.advanceTo("stopping");
630
+ disposeAll();
631
+ };
624
632
  const stopping = Promise.withResolvers();
625
633
  const stopAbandoned = () => (0, unthrown.fromSafePromise)(stopping.promise).flatMap(({ reason, drain }) => clock.sleep(stopTimeoutMs, stopSettled.signal).flatMap(() => lifecycleSettled ? withdrawn() : (0, unthrown.OkAsync)(abandonStop(reason, drain))));
626
634
  const abandonStop = (reason, drain) => {
@@ -640,9 +648,7 @@ const start = (module, options = {}) => {
640
648
  afterMs: void 0
641
649
  });
642
650
  registry.abortAll();
643
- runtimePublished.resolve(void 0);
644
- tracker.advanceTo("stopping");
645
- disposeAll();
651
+ leaveStartup();
646
652
  return reportOf(reason, void 0, "build");
647
653
  });
648
654
  const abandonBuild = (reason) => {
@@ -673,11 +679,7 @@ const start = (module, options = {}) => {
673
679
  cause: failure.tag === "Err" ? failure.error : failure.cause
674
680
  });
675
681
  probeBound.resolve(void 0);
676
- runtimePublished.resolve(void 0);
677
- tracker.advanceTo("stopping");
678
- disposeSignals();
679
- disposeUncaught();
680
- tracker.advanceTo("exited");
682
+ leaveStartup();
681
683
  });
682
684
  const root = (0, _btravstack_di.Module)("Kernel")({
683
685
  imports: providesEnv(module) ? [module] : [module, (0, _btravstack_di.Module)("Environment")({
@@ -752,13 +754,18 @@ const start = (module, options = {}) => {
752
754
  }).get();
753
755
  return (0, unthrown.fromSafePromise)(ready.promise);
754
756
  };
757
+ const record = currentUnit();
755
758
  const outcome = (async () => {
759
+ let settledAs = "error";
756
760
  try {
757
- return await work({
761
+ const result = await work({
758
762
  ctx: runtimeCtx,
759
763
  fork
760
764
  }, signal);
765
+ if (result.isOk()) settledAs = "ok";
766
+ return result;
761
767
  } finally {
768
+ if (record !== void 0) settleUnit(record, settledAs);
762
769
  hasSettled = true;
763
770
  settled.resolve();
764
771
  if (closing !== void 0) await closing;
@@ -794,9 +801,7 @@ const start = (module, options = {}) => {
794
801
  type: "startFailed",
795
802
  cause: failure.tag === "Err" ? failure.error : failure.cause
796
803
  });
797
- runtimePublished.resolve(void 0);
798
- tracker.advanceTo("stopping");
799
- disposeAll();
804
+ leaveStartup();
800
805
  }).tap(onLifecycleSettled).tapFailure(onLifecycleSettled);
801
806
  return {
802
807
  exited: probesStarted.flatMap(() => (0, unthrown.fromSafePromise)(Promise.race([
@@ -872,6 +877,97 @@ const runMain = async (module, options = {}, exit = (code) => {
872
877
  await awaitExit(start(module, options), exit);
873
878
  };
874
879
  //#endregion
880
+ //#region src/unit-record.ts
881
+ /**
882
+ * Where {@link dispatchUnit} leaves the forked context for {@link withUnitRecord}
883
+ * to read. A symbol rather than a name: the library merges it into the context
884
+ * every piece sees, and a string key would appear on the record a piece written
885
+ * against the `{ inject, sync }` arm destructures.
886
+ */
887
+ const UNIT_SCOPE = Symbol("@btravstack/core/unit-scope");
888
+ /**
889
+ * The declared record, as a getter per name resolved on read out of the fork.
890
+ * Neither writable nor configurable — a piece reads what the fork holds, and
891
+ * cannot reshape the record under the next unit. With no unit module bound
892
+ * there is nothing to resolve from, so the record is empty whatever was
893
+ * declared.
894
+ */
895
+ const unitRecordOf = (forked, record) => {
896
+ const unit = {};
897
+ if (forked === void 0) return unit;
898
+ for (const [name, port] of Object.entries(record)) Object.defineProperty(unit, name, {
899
+ enumerable: true,
900
+ get: () => forked.get(port)
901
+ });
902
+ return unit;
903
+ };
904
+ /**
905
+ * One `(helpers, input)` implementation with its piece's declared record put on
906
+ * `helpers.context.unit`, resolved out of the fork {@link dispatchUnit} opened.
907
+ * Applied once per piece as di constructs it, so a unit costs one record and
908
+ * one context object. Each worker maps it over its own entry shape.
909
+ */
910
+ const withUnitRecord = (record, implementation) => (helpers, input) => {
911
+ const { [UNIT_SCOPE]: forked, ...rest } = helpers.context ?? {};
912
+ return implementation({
913
+ ...helpers,
914
+ context: {
915
+ ...rest,
916
+ unit: unitRecordOf(forked, record)
917
+ }
918
+ }, input);
919
+ };
920
+ /**
921
+ * A worker middleware's body: observe `operation`, open one kernel unit, fork
922
+ * `unit` — when one is bound — seeded with `seed`, and call `next` with the
923
+ * forked context where {@link withUnitRecord} finds it. With no `unit` bound,
924
+ * `next()` runs unchanged.
925
+ *
926
+ * Observed from whichever channel the unit settles on, a defect included: a
927
+ * defect is a failed unit too, and an errors count that omitted it would be the
928
+ * reassuring half.
929
+ */
930
+ const dispatchUnit = (dispatch) => observed(dispatch.observers, dispatch.operation, () => dispatch.host.run(dispatch.meta, (scope) => dispatch.unit === void 0 ? dispatch.next() : scope.fork(dispatch.unit, [dispatch.seed]).flatMap((forked) => dispatch.next({ context: { [UNIT_SCOPE]: forked } }))));
931
+ //#endregion
932
+ //#region src/pieces.ts
933
+ /**
934
+ * The runtime half of a starter's piece factory — `AmqpHandler(contract, key)`,
935
+ * `TemporalWorkflowActivities(contract, key)`: a provider on `port` whose
936
+ * service is what `sync` answers, wrapped once by `withUnit` so it reads the
937
+ * record the piece declared. The starter keeps the types; this keeps the body.
938
+ */
939
+ const mintPiece = (port, withUnit) => (options) => (0, _btravstack_di.Provider)(port)({
940
+ inject: options.inject,
941
+ sync: (services) => withUnit(options.unit ?? {}, options.sync(services))
942
+ });
943
+ /**
944
+ * The runtime half of a starter's composing provider — `AmqpHandlers(contract)`,
945
+ * `TemporalActivities(contract)` — and its two arms. An array of pieces is
946
+ * composed into one record keyed by each piece's port id less `prefix`, so the
947
+ * services record IS the composed record; a `{ inject, unit?, sync }` record
948
+ * has `withUnit` applied to every entry `sync` answers.
949
+ */
950
+ const composeByPrefix = (port, prefix, withUnit) => {
951
+ const whole = mintPiece(port, (record, entries) => Object.fromEntries(Object.entries(entries).map(([key, entry]) => [key, withUnit(record, entry)])));
952
+ return (first) => Array.isArray(first) ? (0, _btravstack_di.Provider)(port)({
953
+ inject: Object.fromEntries(first.map((piece) => [piece.port.portId.slice(prefix.length), piece.port])),
954
+ sync: (services) => services
955
+ }) : whole(first);
956
+ };
957
+ //#endregion
958
+ //#region src/starter.ts
959
+ /**
960
+ * The no-op member every module reading {@link Observers} provides, so the set
961
+ * it reads is never the empty dependency di refuses: a graph composing no
962
+ * observability still starts. One provider, shared — di de-duplicates a graph's
963
+ * providers by reference, so however many modules contribute it, an operation
964
+ * costs one inert call.
965
+ */
966
+ const noObserverMember = _btravstack_di.Provider.member(Observers)({
967
+ inject: {},
968
+ value: noObserver
969
+ });
970
+ //#endregion
875
971
  exports.HealthCheckFailed = HealthCheckFailed;
876
972
  exports.HealthChecks = HealthChecks;
877
973
  exports.Instrumentations = Instrumentations;
@@ -883,8 +979,12 @@ exports.RuntimePort = RuntimePort;
883
979
  exports.RuntimeStartFailed = RuntimeStartFailed;
884
980
  exports.SPAN_STATUS = SPAN_STATUS;
885
981
  exports.Tracer = Tracer;
982
+ exports.composeByPrefix = composeByPrefix;
886
983
  exports.currentUnit = currentUnit;
984
+ exports.dispatchUnit = dispatchUnit;
985
+ exports.mintPiece = mintPiece;
887
986
  exports.noObserver = noObserver;
987
+ exports.noObserverMember = noObserverMember;
888
988
  exports.observe = observe;
889
989
  exports.observed = observed;
890
990
  exports.releasedBy = releasedBy;
@@ -894,3 +994,5 @@ exports.start = start;
894
994
  exports.stderrSink = stderrSink;
895
995
  exports.systemClock = systemClock;
896
996
  exports.traceIdOfTraceparent = traceIdOfTraceparent;
997
+ exports.unitOutcome = unitOutcome;
998
+ 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, Scope, SeedEntry, ServiceOf } from "@btravstack/di";
2
+ import { AnyPort, 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
  /**
@@ -8,10 +8,10 @@ import { Env, Environment } from "@btravstack/config";
8
8
  * **Async, and answers `undefined` rather than failing, because the package
9
9
  * supplying the instrumentation is an OPTIONAL peer.** A starter cannot know at
10
10
  * composition time whether the consumer installed it, so it contributes the
11
- * ATTEMPT and the collector drops what did not load — which is what lets
12
- * `@btravstack/prisma` declare engine tracing without making every consumer
13
- * install `@prisma/instrumentation`. A contributor that wants to say why its
14
- * load answered nothing logs that itself; it is the one that knows.
11
+ * ATTEMPT and the collector drops what did not load — which is what lets a
12
+ * starter declare an instrumentation without making every consumer install the
13
+ * package that supplies it. A contributor that wants to say why its load
14
+ * answered nothing logs that itself; it is the one that knows.
15
15
  *
16
16
  * The instrumentation is `unknown` on purpose. Naming OpenTelemetry's
17
17
  * `Instrumentation` would put the vendor in the package every other one peers
@@ -48,7 +48,8 @@ type HealthCheck = {
48
48
  readonly name: string;
49
49
  readonly check: () => AsyncResult<void, HealthCheckFailed>;
50
50
  /**
51
- * How long this component gets to answer, overriding the fold's own default.
51
+ * How long this component gets to answer, overriding
52
+ * {@link DEFAULT_HEALTH_TIMEOUT_MS}.
52
53
  *
53
54
  * It is on the CONTRIBUTION because only the contributor knows: a `SELECT 1`
54
55
  * over a warm pool and an SMTP relay's greeting are two orders of magnitude
@@ -78,10 +79,6 @@ type HealthReport = {
78
79
  readonly status: "healthy" | "unhealthy";
79
80
  readonly components: readonly ComponentHealth[];
80
81
  };
81
- type HealthOptions = {
82
- /** Per check, not for the fold: the checks run concurrently. Default {@link DEFAULT_HEALTH_TIMEOUT_MS}. */
83
- readonly timeoutMs?: number;
84
- };
85
82
  /**
86
83
  * Runs every check and folds the results into one report — the whole app is
87
84
  * unhealthy if any single component is.
@@ -99,10 +96,11 @@ type HealthOptions = {
99
96
  * rather than a recovery.** Neither recovery above can see one: there is no
100
97
  * error and no defect, only silence, so `/healthz` held a socket open per hit
101
98
  * while kubelet timed out against a report that named nothing. Each check now
102
- * races {@link HealthOptions.timeoutMs}, and the component that hung is named
99
+ * races its own `timeoutMs` (default {@link DEFAULT_HEALTH_TIMEOUT_MS}), and
100
+ * the component that hung is named
103
101
  * in the report like any other unhealthy one.
104
102
  */
105
- export declare const runHealthChecks: (checks: readonly HealthCheck[], options?: HealthOptions) => AsyncResult<HealthReport, never>;
103
+ export declare const runHealthChecks: (checks: readonly HealthCheck[]) => AsyncResult<HealthReport, never>;
106
104
  //#endregion
107
105
  //#region src/observability.d.ts
108
106
  /**
@@ -133,7 +131,6 @@ type Attributes = Readonly<Record<string, string | number | boolean | undefined>
133
131
  * failure with nothing else to say.
134
132
  */
135
133
  type LoggerService = {
136
- readonly log: (level: Level, message: string, attributes?: Attributes, cause?: unknown) => void;
137
134
  readonly trace: (message: string, attributes?: Attributes, cause?: unknown) => void;
138
135
  readonly debug: (message: string, attributes?: Attributes, cause?: unknown) => void;
139
136
  readonly info: (message: string, attributes?: Attributes, cause?: unknown) => void;
@@ -257,15 +254,12 @@ type Operation = {
257
254
  /**
258
255
  * Whether a tracing observer should open a span (default `true`).
259
256
  *
260
- * `false` is for a component whose spans already come from somewhere better —
261
- * a vendor tracer reaching below what the contributor can see, where a second
262
- * span would cost one more per operation for strictly less information.
263
- * Counting and timing still happen.
264
- *
265
- * **No starter here passes `false` today.** `@btravstack/prisma` did, while
266
- * Prisma 7's engine-level `@prisma/instrumentation` was the better source;
267
- * Prisma 8 has no engine and ships no tracer, so its middleware is the only
268
- * span there is and it opens one.
257
+ * `false` is for an operation a span would say nothing about — one whose
258
+ * spans already come from somewhere better, or one with no duration at all.
259
+ * Counting and timing still happen. `@btravstack/amqp-worker`'s broker
260
+ * diagnostics pass it, being zero-length events; `@btravstack/prisma` no
261
+ * longer does, since Prisma 8 ships no engine tracer and its middleware is
262
+ * the only span there is.
269
263
  *
270
264
  * Whether a span is worth opening is the CONTRIBUTOR's knowledge, which is
271
265
  * why it rides the operation rather than being configured on the observer.
@@ -358,6 +352,18 @@ type UnitRecord = {
358
352
  readonly signal: AbortSignal;
359
353
  };
360
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;
361
367
  /**
362
368
  * What a runtime says about one piece of work as it submits it. `kind` is the
363
369
  * category (`"http"`, `"tick"`, `"job"`); `id` identifies **this** unit.
@@ -369,7 +375,8 @@ export declare const currentUnit: () => UnitRecord | undefined;
369
375
  * silently defeats the ambient record. A route template is a `kind`.
370
376
  *
371
377
  * The kernel cannot check this, so uniqueness is the runtime's to guarantee.
372
- * 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;
373
380
  * `traceId` is the CORRELATION id, which is why it is the one a runtime may
374
381
  * supply — it carries an id from outside the process.
375
382
  */
@@ -840,4 +847,112 @@ export declare const start: <X, E, N>(module: Module<X, E, N> & StartGate<X, N>,
840
847
  */
841
848
  export declare const runMain: <X, E, N>(module: Module<X, E, N> & StartGate<X, N>, options?: StartOptions, exit?: (code: number) => void) => Promise<void>;
842
849
  //#endregion
843
- export type { Attributes, Clock, ComponentHealth, Counter, DrainReport, EventSink, ExitReport, HealthCheck, HealthReport, Histogram, InstrumentationLoader, KernelEvent, Level, LoggerService, MeterService, Operation, Phase, RunUnit, RunningApp, Runtime, RuntimeHost, RuntimeInfoOf, Serving, Settle, Settled, Span, SpanStatusCode, StartGate, StartOptions, TeardownError, TracerService, UnitHost, UnitMeta, UnitRecord, UnitRegistry, UnitWork };
850
+ //#region src/unit-record.d.ts
851
+ /**
852
+ * A module a runtime's `unit` option may bind, as the upper bound a starter
853
+ * constrains its own `Unit` type parameter to. `Module`'s `_exports` channel is
854
+ * contravariant, so `Exports = never` — never `unknown` — is what makes a REAL
855
+ * module's own (necessarily narrower) export type assignable to this bound:
856
+ * `(x: Concrete) => void` is assignable to `(x: never) => void`, not to
857
+ * `(x: unknown) => void`.
858
+ */
859
+ type AnyUnitModule = Module<never, never, unknown>;
860
+ /**
861
+ * The needs a bound unit module still owes, or `never` when none is bound.
862
+ * `Scope` is excluded, since nothing can ever provide it — the same exemption
863
+ * `NeedsGate` itself carries — and so is `Seeded`, the port the runtime's own
864
+ * fork seed discharges.
865
+ */
866
+ type UnitNeedsOf<Unit, Seeded = never> = Unit extends Module<never, never, infer N> ? Exclude<N, Scope | Seeded> : never;
867
+ /** What a bound unit module exports — the port instances a piece of that kind may read. */
868
+ type UnitExportsOf<M> = M extends Module<infer X, never, unknown> ? X : never;
869
+ /** A piece's declared `unit:` record, as the services it reads off `context.unit`. */
870
+ type UnitRecordOf<U extends Readonly<Record<string, AnyPort>>> = { readonly [N in keyof U]: ServiceOf<InstanceType<U[N]>>; };
871
+ /**
872
+ * A piece injecting a port the bound unit module does not export. `unknown`
873
+ * when every declared port is covered — including the empty case, so a worker
874
+ * whose pieces declare no `unit:` is gated on nothing.
875
+ */
876
+ type UnitGate<Unit, Declared> = [Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>] extends [never] ? unknown : {
877
+ readonly "UNIT DOES NOT PROVIDE — a piece injects a port the bound unit module does not export": Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>;
878
+ };
879
+ /**
880
+ * One `(helpers, input)` implementation with its piece's declared record put on
881
+ * `helpers.context.unit`, resolved out of the fork {@link dispatchUnit} opened.
882
+ * Applied once per piece as di constructs it, so a unit costs one record and
883
+ * one context object. Each worker maps it over its own entry shape.
884
+ */
885
+ export declare const withUnitRecord: (record: Readonly<Record<string, AnyPort>>, implementation: (helpers: never, input: never) => unknown) => (helpers: {
886
+ readonly context?: Readonly<Record<string | symbol, unknown>>;
887
+ }, input: never) => unknown;
888
+ /**
889
+ * A worker middleware's body: observe `operation`, open one kernel unit, fork
890
+ * `unit` — when one is bound — seeded with `seed`, and call `next` with the
891
+ * forked context where {@link withUnitRecord} finds it. With no `unit` bound,
892
+ * `next()` runs unchanged.
893
+ *
894
+ * Observed from whichever channel the unit settles on, a defect included: a
895
+ * defect is a failed unit too, and an errors count that omitted it would be the
896
+ * reassuring half.
897
+ */
898
+ export declare const dispatchUnit: <T, E>(dispatch: {
899
+ readonly host: RuntimeHost<never>;
900
+ readonly observers: readonly ((operation: Operation) => Settle)[];
901
+ readonly operation: Operation;
902
+ readonly meta: UnitMeta;
903
+ readonly unit: AnyUnitModule | undefined;
904
+ readonly seed: readonly [AnyPort, unknown];
905
+ readonly next: (overrides?: never) => AsyncResult<T, E>;
906
+ }) => AsyncResult<T, E>;
907
+ //#endregion
908
+ //#region src/pieces.d.ts
909
+ /**
910
+ * A refused array: as long as the array the caller wrote, its head the caller's
911
+ * own elements — which match — and its LAST element the marker paired with what
912
+ * is wrong.
913
+ *
914
+ * TypeScript compares two equal-length tuples element by element, so the extra
915
+ * diagnostic it reports lands on the trailing element and carries both the
916
+ * sentence and the missing key. A fixed two-element tuple named the key only
917
+ * when the array happened to be two elements long; every other arity was a
918
+ * length mismatch, and the developer diffed the contract against the array by
919
+ * hand.
920
+ */
921
+ type Refuse<T extends readonly unknown[], Marker extends string, Detail> = T extends readonly [...infer Head, unknown] ? readonly [...Head, readonly [Marker, Detail]] : readonly [readonly [Marker, Detail]];
922
+ /** How a starter puts a piece's declared `unit:` record on one of its entries. */
923
+ type EntryWrapper = (record: Readonly<Record<string, AnyPort>>, entry: unknown) => unknown;
924
+ /** A piece's `{ inject, unit?, sync }` options, with the types each starter states erased. */
925
+ type PieceOptions = {
926
+ readonly inject: Readonly<Record<string, AnyPort>>;
927
+ readonly unit?: Readonly<Record<string, AnyPort>>;
928
+ readonly sync: (services: never) => unknown;
929
+ };
930
+ /**
931
+ * The runtime half of a starter's piece factory — `AmqpHandler(contract, key)`,
932
+ * `TemporalWorkflowActivities(contract, key)`: a provider on `port` whose
933
+ * service is what `sync` answers, wrapped once by `withUnit` so it reads the
934
+ * record the piece declared. The starter keeps the types; this keeps the body.
935
+ */
936
+ export declare const mintPiece: (port: AnyPort, withUnit: EntryWrapper) => (options: PieceOptions) => unknown;
937
+ /**
938
+ * The runtime half of a starter's composing provider — `AmqpHandlers(contract)`,
939
+ * `TemporalActivities(contract)` — and its two arms. An array of pieces is
940
+ * composed into one record keyed by each piece's port id less `prefix`, so the
941
+ * services record IS the composed record; a `{ inject, unit?, sync }` record
942
+ * has `withUnit` applied to every entry `sync` answers.
943
+ */
944
+ export declare const composeByPrefix: (port: AnyPort, prefix: string, withUnit: EntryWrapper) => ((first: unknown) => unknown);
945
+ //#endregion
946
+ //#region src/starter.d.ts
947
+ /**
948
+ * The no-op member every module reading {@link Observers} provides, so the set
949
+ * it reads is never the empty dependency di refuses: a graph composing no
950
+ * observability still starts. One provider, shared — di de-duplicates a graph's
951
+ * providers by reference, so however many modules contribute it, an operation
952
+ * costs one inert call.
953
+ */
954
+ export declare const noObserverMember: Provider<Observers, never, never> & {
955
+ readonly port: typeof Observers;
956
+ };
957
+ //#endregion
958
+ 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, Scope, SeedEntry, ServiceOf } from "@btravstack/di";
1
+ import { AnyPort, 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
@@ -8,10 +8,10 @@ import { Env, Environment } from "@btravstack/config";
8
8
  * **Async, and answers `undefined` rather than failing, because the package
9
9
  * supplying the instrumentation is an OPTIONAL peer.** A starter cannot know at
10
10
  * composition time whether the consumer installed it, so it contributes the
11
- * ATTEMPT and the collector drops what did not load — which is what lets
12
- * `@btravstack/prisma` declare engine tracing without making every consumer
13
- * install `@prisma/instrumentation`. A contributor that wants to say why its
14
- * load answered nothing logs that itself; it is the one that knows.
11
+ * ATTEMPT and the collector drops what did not load — which is what lets a
12
+ * starter declare an instrumentation without making every consumer install the
13
+ * package that supplies it. A contributor that wants to say why its load
14
+ * answered nothing logs that itself; it is the one that knows.
15
15
  *
16
16
  * The instrumentation is `unknown` on purpose. Naming OpenTelemetry's
17
17
  * `Instrumentation` would put the vendor in the package every other one peers
@@ -48,7 +48,8 @@ type HealthCheck = {
48
48
  readonly name: string;
49
49
  readonly check: () => AsyncResult<void, HealthCheckFailed>;
50
50
  /**
51
- * How long this component gets to answer, overriding the fold's own default.
51
+ * How long this component gets to answer, overriding
52
+ * {@link DEFAULT_HEALTH_TIMEOUT_MS}.
52
53
  *
53
54
  * It is on the CONTRIBUTION because only the contributor knows: a `SELECT 1`
54
55
  * over a warm pool and an SMTP relay's greeting are two orders of magnitude
@@ -78,10 +79,6 @@ type HealthReport = {
78
79
  readonly status: "healthy" | "unhealthy";
79
80
  readonly components: readonly ComponentHealth[];
80
81
  };
81
- type HealthOptions = {
82
- /** Per check, not for the fold: the checks run concurrently. Default {@link DEFAULT_HEALTH_TIMEOUT_MS}. */
83
- readonly timeoutMs?: number;
84
- };
85
82
  /**
86
83
  * Runs every check and folds the results into one report — the whole app is
87
84
  * unhealthy if any single component is.
@@ -99,10 +96,11 @@ type HealthOptions = {
99
96
  * rather than a recovery.** Neither recovery above can see one: there is no
100
97
  * error and no defect, only silence, so `/healthz` held a socket open per hit
101
98
  * while kubelet timed out against a report that named nothing. Each check now
102
- * races {@link HealthOptions.timeoutMs}, and the component that hung is named
99
+ * races its own `timeoutMs` (default {@link DEFAULT_HEALTH_TIMEOUT_MS}), and
100
+ * the component that hung is named
103
101
  * in the report like any other unhealthy one.
104
102
  */
105
- export declare const runHealthChecks: (checks: readonly HealthCheck[], options?: HealthOptions) => AsyncResult<HealthReport, never>;
103
+ export declare const runHealthChecks: (checks: readonly HealthCheck[]) => AsyncResult<HealthReport, never>;
106
104
  //#endregion
107
105
  //#region src/observability.d.ts
108
106
  /**
@@ -133,7 +131,6 @@ type Attributes = Readonly<Record<string, string | number | boolean | undefined>
133
131
  * failure with nothing else to say.
134
132
  */
135
133
  type LoggerService = {
136
- readonly log: (level: Level, message: string, attributes?: Attributes, cause?: unknown) => void;
137
134
  readonly trace: (message: string, attributes?: Attributes, cause?: unknown) => void;
138
135
  readonly debug: (message: string, attributes?: Attributes, cause?: unknown) => void;
139
136
  readonly info: (message: string, attributes?: Attributes, cause?: unknown) => void;
@@ -257,15 +254,12 @@ type Operation = {
257
254
  /**
258
255
  * Whether a tracing observer should open a span (default `true`).
259
256
  *
260
- * `false` is for a component whose spans already come from somewhere better —
261
- * a vendor tracer reaching below what the contributor can see, where a second
262
- * span would cost one more per operation for strictly less information.
263
- * Counting and timing still happen.
264
- *
265
- * **No starter here passes `false` today.** `@btravstack/prisma` did, while
266
- * Prisma 7's engine-level `@prisma/instrumentation` was the better source;
267
- * Prisma 8 has no engine and ships no tracer, so its middleware is the only
268
- * span there is and it opens one.
257
+ * `false` is for an operation a span would say nothing about — one whose
258
+ * spans already come from somewhere better, or one with no duration at all.
259
+ * Counting and timing still happen. `@btravstack/amqp-worker`'s broker
260
+ * diagnostics pass it, being zero-length events; `@btravstack/prisma` no
261
+ * longer does, since Prisma 8 ships no engine tracer and its middleware is
262
+ * the only span there is.
269
263
  *
270
264
  * Whether a span is worth opening is the CONTRIBUTOR's knowledge, which is
271
265
  * why it rides the operation rather than being configured on the observer.
@@ -358,6 +352,18 @@ type UnitRecord = {
358
352
  readonly signal: AbortSignal;
359
353
  };
360
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;
361
367
  /**
362
368
  * What a runtime says about one piece of work as it submits it. `kind` is the
363
369
  * category (`"http"`, `"tick"`, `"job"`); `id` identifies **this** unit.
@@ -369,7 +375,8 @@ export declare const currentUnit: () => UnitRecord | undefined;
369
375
  * silently defeats the ambient record. A route template is a `kind`.
370
376
  *
371
377
  * The kernel cannot check this, so uniqueness is the runtime's to guarantee.
372
- * 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;
373
380
  * `traceId` is the CORRELATION id, which is why it is the one a runtime may
374
381
  * supply — it carries an id from outside the process.
375
382
  */
@@ -840,4 +847,112 @@ export declare const start: <X, E, N>(module: Module<X, E, N> & StartGate<X, N>,
840
847
  */
841
848
  export declare const runMain: <X, E, N>(module: Module<X, E, N> & StartGate<X, N>, options?: StartOptions, exit?: (code: number) => void) => Promise<void>;
842
849
  //#endregion
843
- export type { Attributes, Clock, ComponentHealth, Counter, DrainReport, EventSink, ExitReport, HealthCheck, HealthReport, Histogram, InstrumentationLoader, KernelEvent, Level, LoggerService, MeterService, Operation, Phase, RunUnit, RunningApp, Runtime, RuntimeHost, RuntimeInfoOf, Serving, Settle, Settled, Span, SpanStatusCode, StartGate, StartOptions, TeardownError, TracerService, UnitHost, UnitMeta, UnitRecord, UnitRegistry, UnitWork };
850
+ //#region src/unit-record.d.ts
851
+ /**
852
+ * A module a runtime's `unit` option may bind, as the upper bound a starter
853
+ * constrains its own `Unit` type parameter to. `Module`'s `_exports` channel is
854
+ * contravariant, so `Exports = never` — never `unknown` — is what makes a REAL
855
+ * module's own (necessarily narrower) export type assignable to this bound:
856
+ * `(x: Concrete) => void` is assignable to `(x: never) => void`, not to
857
+ * `(x: unknown) => void`.
858
+ */
859
+ type AnyUnitModule = Module<never, never, unknown>;
860
+ /**
861
+ * The needs a bound unit module still owes, or `never` when none is bound.
862
+ * `Scope` is excluded, since nothing can ever provide it — the same exemption
863
+ * `NeedsGate` itself carries — and so is `Seeded`, the port the runtime's own
864
+ * fork seed discharges.
865
+ */
866
+ type UnitNeedsOf<Unit, Seeded = never> = Unit extends Module<never, never, infer N> ? Exclude<N, Scope | Seeded> : never;
867
+ /** What a bound unit module exports — the port instances a piece of that kind may read. */
868
+ type UnitExportsOf<M> = M extends Module<infer X, never, unknown> ? X : never;
869
+ /** A piece's declared `unit:` record, as the services it reads off `context.unit`. */
870
+ type UnitRecordOf<U extends Readonly<Record<string, AnyPort>>> = { readonly [N in keyof U]: ServiceOf<InstanceType<U[N]>>; };
871
+ /**
872
+ * A piece injecting a port the bound unit module does not export. `unknown`
873
+ * when every declared port is covered — including the empty case, so a worker
874
+ * whose pieces declare no `unit:` is gated on nothing.
875
+ */
876
+ type UnitGate<Unit, Declared> = [Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>] extends [never] ? unknown : {
877
+ readonly "UNIT DOES NOT PROVIDE — a piece injects a port the bound unit module does not export": Exclude<NonNullable<Declared>, UnitExportsOf<Unit>>;
878
+ };
879
+ /**
880
+ * One `(helpers, input)` implementation with its piece's declared record put on
881
+ * `helpers.context.unit`, resolved out of the fork {@link dispatchUnit} opened.
882
+ * Applied once per piece as di constructs it, so a unit costs one record and
883
+ * one context object. Each worker maps it over its own entry shape.
884
+ */
885
+ export declare const withUnitRecord: (record: Readonly<Record<string, AnyPort>>, implementation: (helpers: never, input: never) => unknown) => (helpers: {
886
+ readonly context?: Readonly<Record<string | symbol, unknown>>;
887
+ }, input: never) => unknown;
888
+ /**
889
+ * A worker middleware's body: observe `operation`, open one kernel unit, fork
890
+ * `unit` — when one is bound — seeded with `seed`, and call `next` with the
891
+ * forked context where {@link withUnitRecord} finds it. With no `unit` bound,
892
+ * `next()` runs unchanged.
893
+ *
894
+ * Observed from whichever channel the unit settles on, a defect included: a
895
+ * defect is a failed unit too, and an errors count that omitted it would be the
896
+ * reassuring half.
897
+ */
898
+ export declare const dispatchUnit: <T, E>(dispatch: {
899
+ readonly host: RuntimeHost<never>;
900
+ readonly observers: readonly ((operation: Operation) => Settle)[];
901
+ readonly operation: Operation;
902
+ readonly meta: UnitMeta;
903
+ readonly unit: AnyUnitModule | undefined;
904
+ readonly seed: readonly [AnyPort, unknown];
905
+ readonly next: (overrides?: never) => AsyncResult<T, E>;
906
+ }) => AsyncResult<T, E>;
907
+ //#endregion
908
+ //#region src/pieces.d.ts
909
+ /**
910
+ * A refused array: as long as the array the caller wrote, its head the caller's
911
+ * own elements — which match — and its LAST element the marker paired with what
912
+ * is wrong.
913
+ *
914
+ * TypeScript compares two equal-length tuples element by element, so the extra
915
+ * diagnostic it reports lands on the trailing element and carries both the
916
+ * sentence and the missing key. A fixed two-element tuple named the key only
917
+ * when the array happened to be two elements long; every other arity was a
918
+ * length mismatch, and the developer diffed the contract against the array by
919
+ * hand.
920
+ */
921
+ type Refuse<T extends readonly unknown[], Marker extends string, Detail> = T extends readonly [...infer Head, unknown] ? readonly [...Head, readonly [Marker, Detail]] : readonly [readonly [Marker, Detail]];
922
+ /** How a starter puts a piece's declared `unit:` record on one of its entries. */
923
+ type EntryWrapper = (record: Readonly<Record<string, AnyPort>>, entry: unknown) => unknown;
924
+ /** A piece's `{ inject, unit?, sync }` options, with the types each starter states erased. */
925
+ type PieceOptions = {
926
+ readonly inject: Readonly<Record<string, AnyPort>>;
927
+ readonly unit?: Readonly<Record<string, AnyPort>>;
928
+ readonly sync: (services: never) => unknown;
929
+ };
930
+ /**
931
+ * The runtime half of a starter's piece factory — `AmqpHandler(contract, key)`,
932
+ * `TemporalWorkflowActivities(contract, key)`: a provider on `port` whose
933
+ * service is what `sync` answers, wrapped once by `withUnit` so it reads the
934
+ * record the piece declared. The starter keeps the types; this keeps the body.
935
+ */
936
+ export declare const mintPiece: (port: AnyPort, withUnit: EntryWrapper) => (options: PieceOptions) => unknown;
937
+ /**
938
+ * The runtime half of a starter's composing provider — `AmqpHandlers(contract)`,
939
+ * `TemporalActivities(contract)` — and its two arms. An array of pieces is
940
+ * composed into one record keyed by each piece's port id less `prefix`, so the
941
+ * services record IS the composed record; a `{ inject, unit?, sync }` record
942
+ * has `withUnit` applied to every entry `sync` answers.
943
+ */
944
+ export declare const composeByPrefix: (port: AnyPort, prefix: string, withUnit: EntryWrapper) => ((first: unknown) => unknown);
945
+ //#endregion
946
+ //#region src/starter.d.ts
947
+ /**
948
+ * The no-op member every module reading {@link Observers} provides, so the set
949
+ * it reads is never the empty dependency di refuses: a graph composing no
950
+ * observability still starts. One provider, shared — di de-duplicates a graph's
951
+ * providers by reference, so however many modules contribute it, an operation
952
+ * costs one inert call.
953
+ */
954
+ export declare const noObserverMember: Provider<Observers, never, never> & {
955
+ readonly port: typeof Observers;
956
+ };
957
+ //#endregion
958
+ 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
@@ -1,8 +1,10 @@
1
1
  import { Module, Port, Provider } from "@btravstack/di";
2
2
  import { Err, Ok, OkAsync, P, TaggedError, allAsync, fromSafePromise } from "unthrown";
3
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
3
4
  import { Config, ConfigInvalid, Env } from "@btravstack/config";
4
5
  import { createServer } from "node:http";
5
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
+ import { randomUUID } from "node:crypto";
6
8
  //#region src/instrumentation.ts
7
9
  /**
8
10
  * The set port a package contributes its instrumentation to.
@@ -68,10 +70,11 @@ const answeringWithin = (health, ms) => {
68
70
  * rather than a recovery.** Neither recovery above can see one: there is no
69
71
  * error and no defect, only silence, so `/healthz` held a socket open per hit
70
72
  * while kubelet timed out against a report that named nothing. Each check now
71
- * races {@link HealthOptions.timeoutMs}, and the component that hung is named
73
+ * races its own `timeoutMs` (default {@link DEFAULT_HEALTH_TIMEOUT_MS}), and
74
+ * the component that hung is named
72
75
  * in the report like any other unhealthy one.
73
76
  */
74
- const runHealthChecks = (checks, options = {}) => allAsync(checks.map((health) => answeringWithin(health, health.timeoutMs ?? options.timeoutMs ?? 800).map(() => ({
77
+ const runHealthChecks = (checks) => allAsync(checks.map((health) => answeringWithin(health, health.timeoutMs ?? 800).map(() => ({
75
78
  name: health.name,
76
79
  status: "healthy"
77
80
  })).recoverErrCases((matcher) => matcher.with(P.tag("HealthCheckFailed"), (error) => ({
@@ -152,22 +155,10 @@ const observed = (observers, operation, call, settled = {}) => {
152
155
  //#region src/clock.ts
153
156
  const systemClock = {
154
157
  now: () => Date.now(),
155
- sleep: (ms, signal) => fromSafePromise(new Promise((resolve) => {
156
- if (signal?.aborted === true) {
157
- resolve();
158
- return;
159
- }
160
- const timer = setTimeout(() => {
161
- signal?.removeEventListener("abort", onAbort);
162
- resolve();
163
- }, ms);
164
- timer.unref?.();
165
- function onAbort() {
166
- clearTimeout(timer);
167
- resolve();
168
- }
169
- signal?.addEventListener("abort", onAbort, { once: true });
170
- }))
158
+ sleep: (ms, signal) => fromSafePromise(setTimeout$1(ms, void 0, {
159
+ signal,
160
+ ref: false
161
+ }).catch(() => {}))
171
162
  };
172
163
  //#endregion
173
164
  //#region src/events.ts
@@ -441,10 +432,22 @@ const installUncaughtHandlers = (onUncaught) => {
441
432
  const storage = new AsyncLocalStorage();
442
433
  const runWithUnit = (record, fn) => storage.run(record, fn);
443
434
  const currentUnit = () => storage.getStore();
444
- let counter = 0;
445
- const nextId = () => {
446
- counter += 1;
447
- return `u${counter}`;
435
+ const outcomes = /* @__PURE__ */ new WeakMap();
436
+ const settleUnit = (record, outcome) => {
437
+ outcomes.set(record, outcome);
438
+ };
439
+ /**
440
+ * How the current unit's work settled, for the code that runs AFTER it: a
441
+ * unit module's `onStop`, which tears down inside the record but is handed
442
+ * only its own service. `undefined` outside a unit and while the work is still
443
+ * running. Whether the unit was aborted is the record's own `signal`.
444
+ *
445
+ * It is the kernel's outcome, not the transport's: an HTTP unit that answered
446
+ * a `500` still settled `ok`, since the kernel never maps a status.
447
+ */
448
+ const unitOutcome = () => {
449
+ const unit = currentUnit();
450
+ return unit === void 0 ? void 0 : outcomes.get(unit);
448
451
  };
449
452
  const createUnitRegistry = () => {
450
453
  const open = /* @__PURE__ */ new Set();
@@ -460,7 +463,7 @@ const createUnitRegistry = () => {
460
463
  const controller = new AbortController();
461
464
  open.add(controller);
462
465
  const record = {
463
- unitId: nextId(),
466
+ unitId: randomUUID(),
464
467
  traceId: meta.traceId ?? meta.id,
465
468
  tenantId: meta.tenantId,
466
469
  signal: controller.signal
@@ -620,6 +623,11 @@ const start = (module, options = {}) => {
620
623
  const health = () => runHealthChecks(healthChecks);
621
624
  const probeBound = Promise.withResolvers();
622
625
  const runtimePublished = Promise.withResolvers();
626
+ const leaveStartup = () => {
627
+ runtimePublished.resolve(void 0);
628
+ tracker.advanceTo("stopping");
629
+ disposeAll();
630
+ };
623
631
  const stopping = Promise.withResolvers();
624
632
  const stopAbandoned = () => fromSafePromise(stopping.promise).flatMap(({ reason, drain }) => clock.sleep(stopTimeoutMs, stopSettled.signal).flatMap(() => lifecycleSettled ? withdrawn() : OkAsync(abandonStop(reason, drain))));
625
633
  const abandonStop = (reason, drain) => {
@@ -639,9 +647,7 @@ const start = (module, options = {}) => {
639
647
  afterMs: void 0
640
648
  });
641
649
  registry.abortAll();
642
- runtimePublished.resolve(void 0);
643
- tracker.advanceTo("stopping");
644
- disposeAll();
650
+ leaveStartup();
645
651
  return reportOf(reason, void 0, "build");
646
652
  });
647
653
  const abandonBuild = (reason) => {
@@ -672,11 +678,7 @@ const start = (module, options = {}) => {
672
678
  cause: failure.tag === "Err" ? failure.error : failure.cause
673
679
  });
674
680
  probeBound.resolve(void 0);
675
- runtimePublished.resolve(void 0);
676
- tracker.advanceTo("stopping");
677
- disposeSignals();
678
- disposeUncaught();
679
- tracker.advanceTo("exited");
681
+ leaveStartup();
680
682
  });
681
683
  const root = Module("Kernel")({
682
684
  imports: providesEnv(module) ? [module] : [module, Module("Environment")({
@@ -751,13 +753,18 @@ const start = (module, options = {}) => {
751
753
  }).get();
752
754
  return fromSafePromise(ready.promise);
753
755
  };
756
+ const record = currentUnit();
754
757
  const outcome = (async () => {
758
+ let settledAs = "error";
755
759
  try {
756
- return await work({
760
+ const result = await work({
757
761
  ctx: runtimeCtx,
758
762
  fork
759
763
  }, signal);
764
+ if (result.isOk()) settledAs = "ok";
765
+ return result;
760
766
  } finally {
767
+ if (record !== void 0) settleUnit(record, settledAs);
761
768
  hasSettled = true;
762
769
  settled.resolve();
763
770
  if (closing !== void 0) await closing;
@@ -793,9 +800,7 @@ const start = (module, options = {}) => {
793
800
  type: "startFailed",
794
801
  cause: failure.tag === "Err" ? failure.error : failure.cause
795
802
  });
796
- runtimePublished.resolve(void 0);
797
- tracker.advanceTo("stopping");
798
- disposeAll();
803
+ leaveStartup();
799
804
  }).tap(onLifecycleSettled).tapFailure(onLifecycleSettled);
800
805
  return {
801
806
  exited: probesStarted.flatMap(() => fromSafePromise(Promise.race([
@@ -871,4 +876,95 @@ const runMain = async (module, options = {}, exit = (code) => {
871
876
  await awaitExit(start(module, options), exit);
872
877
  };
873
878
  //#endregion
874
- export { HealthCheckFailed, HealthChecks, Instrumentations, LEVELS, Logger, Meter, Observers, RuntimePort, RuntimeStartFailed, SPAN_STATUS, Tracer, currentUnit, noObserver, observe, observed, releasedBy, runHealthChecks, runMain, start, stderrSink, systemClock, traceIdOfTraceparent };
879
+ //#region src/unit-record.ts
880
+ /**
881
+ * Where {@link dispatchUnit} leaves the forked context for {@link withUnitRecord}
882
+ * to read. A symbol rather than a name: the library merges it into the context
883
+ * every piece sees, and a string key would appear on the record a piece written
884
+ * against the `{ inject, sync }` arm destructures.
885
+ */
886
+ const UNIT_SCOPE = Symbol("@btravstack/core/unit-scope");
887
+ /**
888
+ * The declared record, as a getter per name resolved on read out of the fork.
889
+ * Neither writable nor configurable — a piece reads what the fork holds, and
890
+ * cannot reshape the record under the next unit. With no unit module bound
891
+ * there is nothing to resolve from, so the record is empty whatever was
892
+ * declared.
893
+ */
894
+ const unitRecordOf = (forked, record) => {
895
+ const unit = {};
896
+ if (forked === void 0) return unit;
897
+ for (const [name, port] of Object.entries(record)) Object.defineProperty(unit, name, {
898
+ enumerable: true,
899
+ get: () => forked.get(port)
900
+ });
901
+ return unit;
902
+ };
903
+ /**
904
+ * One `(helpers, input)` implementation with its piece's declared record put on
905
+ * `helpers.context.unit`, resolved out of the fork {@link dispatchUnit} opened.
906
+ * Applied once per piece as di constructs it, so a unit costs one record and
907
+ * one context object. Each worker maps it over its own entry shape.
908
+ */
909
+ const withUnitRecord = (record, implementation) => (helpers, input) => {
910
+ const { [UNIT_SCOPE]: forked, ...rest } = helpers.context ?? {};
911
+ return implementation({
912
+ ...helpers,
913
+ context: {
914
+ ...rest,
915
+ unit: unitRecordOf(forked, record)
916
+ }
917
+ }, input);
918
+ };
919
+ /**
920
+ * A worker middleware's body: observe `operation`, open one kernel unit, fork
921
+ * `unit` — when one is bound — seeded with `seed`, and call `next` with the
922
+ * forked context where {@link withUnitRecord} finds it. With no `unit` bound,
923
+ * `next()` runs unchanged.
924
+ *
925
+ * Observed from whichever channel the unit settles on, a defect included: a
926
+ * defect is a failed unit too, and an errors count that omitted it would be the
927
+ * reassuring half.
928
+ */
929
+ const dispatchUnit = (dispatch) => observed(dispatch.observers, dispatch.operation, () => dispatch.host.run(dispatch.meta, (scope) => dispatch.unit === void 0 ? dispatch.next() : scope.fork(dispatch.unit, [dispatch.seed]).flatMap((forked) => dispatch.next({ context: { [UNIT_SCOPE]: forked } }))));
930
+ //#endregion
931
+ //#region src/pieces.ts
932
+ /**
933
+ * The runtime half of a starter's piece factory — `AmqpHandler(contract, key)`,
934
+ * `TemporalWorkflowActivities(contract, key)`: a provider on `port` whose
935
+ * service is what `sync` answers, wrapped once by `withUnit` so it reads the
936
+ * record the piece declared. The starter keeps the types; this keeps the body.
937
+ */
938
+ const mintPiece = (port, withUnit) => (options) => Provider(port)({
939
+ inject: options.inject,
940
+ sync: (services) => withUnit(options.unit ?? {}, options.sync(services))
941
+ });
942
+ /**
943
+ * The runtime half of a starter's composing provider — `AmqpHandlers(contract)`,
944
+ * `TemporalActivities(contract)` — and its two arms. An array of pieces is
945
+ * composed into one record keyed by each piece's port id less `prefix`, so the
946
+ * services record IS the composed record; a `{ inject, unit?, sync }` record
947
+ * has `withUnit` applied to every entry `sync` answers.
948
+ */
949
+ const composeByPrefix = (port, prefix, withUnit) => {
950
+ const whole = mintPiece(port, (record, entries) => Object.fromEntries(Object.entries(entries).map(([key, entry]) => [key, withUnit(record, entry)])));
951
+ return (first) => Array.isArray(first) ? Provider(port)({
952
+ inject: Object.fromEntries(first.map((piece) => [piece.port.portId.slice(prefix.length), piece.port])),
953
+ sync: (services) => services
954
+ }) : whole(first);
955
+ };
956
+ //#endregion
957
+ //#region src/starter.ts
958
+ /**
959
+ * The no-op member every module reading {@link Observers} provides, so the set
960
+ * it reads is never the empty dependency di refuses: a graph composing no
961
+ * observability still starts. One provider, shared — di de-duplicates a graph's
962
+ * providers by reference, so however many modules contribute it, an operation
963
+ * costs one inert call.
964
+ */
965
+ const noObserverMember = Provider.member(Observers)({
966
+ inject: {},
967
+ value: noObserver
968
+ });
969
+ //#endregion
970
+ 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, unitOutcome, withUnitRecord };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/core",
3
- "version": "0.16.0",
3
+ "version": "0.18.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,21 @@
45
45
  "./package.json": "./package.json"
46
46
  },
47
47
  "devDependencies": {
48
- "@btravstack/config": "0.16.0",
49
- "@btravstack/di": "0.16.0",
48
+ "@btravstack/config": "0.18.0",
49
+ "@btravstack/di": "0.18.0",
50
50
  "@btravstack/tsconfig": "0.4.0",
51
- "@types/node": "26.4.1",
52
- "@unthrown/vitest": "5.8.0",
53
- "@vitest/coverage-v8": "4.1.11",
51
+ "@types/node": "26.6.4",
52
+ "@unthrown/vitest": "5.12.0",
53
+ "@vitest/coverage-v8": "5.0.3",
54
54
  "tsdown": "0.23.0",
55
55
  "typescript": "7.0.2",
56
- "unthrown": "5.8.0",
57
- "vitest": "4.1.11"
56
+ "typescript-consumer": "npm:typescript@5.9.3",
57
+ "unthrown": "5.12.0",
58
+ "vitest": "5.0.3"
58
59
  },
59
60
  "peerDependencies": {
60
- "@btravstack/config": "^0.16.0",
61
- "@btravstack/di": "^0.16.0",
61
+ "@btravstack/config": "^0.18.0",
62
+ "@btravstack/di": "^0.18.0",
62
63
  "unthrown": "^5.0.0"
63
64
  },
64
65
  "engines": {
@@ -69,6 +70,6 @@
69
70
  "dev": "tsdown src/index.ts --format cjs,esm --dts --watch --tsconfig tsconfig.build.json",
70
71
  "generate": "node ../../docs/scripts/extract-doc-samples.ts --group core --out src/generated/doc-samples",
71
72
  "test": "vitest run --coverage",
72
- "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
73
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json && node ./node_modules/typescript-consumer/bin/tsc --noEmit -p tsconfig.test-d.json"
73
74
  }
74
75
  }