@sincpro/client-framework 1.0.0 → 1.1.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.
Files changed (78) hide show
  1. package/AGENTS.md +53 -12
  2. package/MANIFESTO.md +56 -13
  3. package/README.md +329 -33
  4. package/dist/circuit_breaker/domain/circuit.d.ts +29 -0
  5. package/dist/circuit_breaker/domain/circuit.js +37 -0
  6. package/dist/circuit_breaker/entrypoint/circuit-breaker.d.ts +56 -0
  7. package/dist/circuit_breaker/entrypoint/circuit-breaker.js +94 -0
  8. package/dist/circuit_breaker/index.d.ts +9 -0
  9. package/dist/circuit_breaker/index.js +9 -0
  10. package/dist/client/adapters/json-rpc.d.ts +40 -0
  11. package/dist/client/adapters/json-rpc.js +97 -0
  12. package/dist/client/adapters/rest-operations.d.ts +19 -0
  13. package/dist/client/adapters/rest-operations.js +66 -0
  14. package/dist/client/domain/operation.d.ts +73 -0
  15. package/dist/client/domain/operation.js +19 -0
  16. package/dist/client/index.d.ts +5 -1
  17. package/dist/client/index.js +5 -1
  18. package/dist/client/services/guarded.d.ts +17 -0
  19. package/dist/client/services/guarded.js +19 -0
  20. package/dist/client/services/propagation.d.ts +20 -7
  21. package/dist/client/services/propagation.js +44 -15
  22. package/dist/codegen/entrypoint/cli.js +7 -7
  23. package/dist/codegen/services/client.d.ts +7 -8
  24. package/dist/codegen/services/client.js +50 -148
  25. package/dist/codegen/services/scaffold.d.ts +6 -3
  26. package/dist/codegen/services/scaffold.js +14 -13
  27. package/dist/common/failures.d.ts +83 -0
  28. package/dist/common/failures.js +212 -0
  29. package/dist/common/index.d.ts +1 -0
  30. package/dist/common/index.js +1 -0
  31. package/dist/context/domain/execution.d.ts +12 -0
  32. package/dist/core/domain/bus.d.ts +16 -7
  33. package/dist/core/domain/bus.js +14 -6
  34. package/dist/core/domain/dto.d.ts +32 -2
  35. package/dist/core/domain/dto.js +23 -0
  36. package/dist/core/domain/pipeline.d.ts +89 -0
  37. package/dist/core/domain/pipeline.js +1 -0
  38. package/dist/core/domain/services.d.ts +14 -4
  39. package/dist/core/domain/services.js +4 -1
  40. package/dist/core/entrypoint/bounded-context.d.ts +79 -10
  41. package/dist/core/entrypoint/bounded-context.js +207 -33
  42. package/dist/core/index.d.ts +4 -2
  43. package/dist/core/index.js +3 -2
  44. package/dist/core/infrastructure/listeners.d.ts +28 -0
  45. package/dist/core/infrastructure/listeners.js +34 -0
  46. package/dist/core/services/binding.d.ts +2 -2
  47. package/dist/core/services/binding.js +6 -3
  48. package/dist/core/services/execution.d.ts +37 -6
  49. package/dist/core/services/execution.js +86 -7
  50. package/dist/core/services/interceptors.d.ts +15 -0
  51. package/dist/core/services/interceptors.js +25 -0
  52. package/dist/core/services/listening.d.ts +17 -0
  53. package/dist/core/services/listening.js +25 -0
  54. package/dist/event_driven/adapters/memory-event-store.d.ts +10 -0
  55. package/dist/event_driven/adapters/memory-event-store.js +23 -0
  56. package/dist/event_driven/adapters/stored-queue.d.ts +67 -0
  57. package/dist/event_driven/adapters/stored-queue.js +192 -0
  58. package/dist/event_driven/adapters/sync-queue.d.ts +11 -0
  59. package/dist/event_driven/adapters/sync-queue.js +19 -0
  60. package/dist/event_driven/domain/envelope.d.ts +28 -0
  61. package/dist/event_driven/domain/envelope.js +19 -0
  62. package/dist/event_driven/domain/queue.d.ts +40 -0
  63. package/dist/event_driven/domain/queue.js +1 -0
  64. package/dist/event_driven/domain/store.d.ts +56 -0
  65. package/dist/event_driven/domain/store.js +1 -0
  66. package/dist/event_driven/entrypoint/publisher.d.ts +26 -0
  67. package/dist/event_driven/entrypoint/publisher.js +35 -0
  68. package/dist/event_driven/entrypoint/subscriber.d.ts +34 -0
  69. package/dist/event_driven/entrypoint/subscriber.js +51 -0
  70. package/dist/event_driven/index.d.ts +19 -0
  71. package/dist/event_driven/index.js +17 -0
  72. package/dist/index.d.ts +7 -2
  73. package/dist/index.js +7 -2
  74. package/dist/observability/domain/logging.d.ts +2 -0
  75. package/dist/observability/services/logging.js +1 -0
  76. package/dist/observability/services/outcome.d.ts +6 -6
  77. package/dist/observability/services/outcome.js +10 -13
  78. package/package.json +1 -1
@@ -1,30 +1,46 @@
1
1
  import type { ContextValues } from "../../context/domain/store.js";
2
2
  import { type Context } from "../../context/entrypoint/context.js";
3
3
  import { type CommandBus } from "../domain/bus.js";
4
- import type { Answer, AnyCommand, Command, CommandClass, ResponseClass } from "../domain/dto.js";
4
+ import { type Answer, type AnyCommand, Command, type CommandClass, DomainEvent, type EventClass, type Executable, type ResponseClass } from "../domain/dto.js";
5
5
  import type { ErrorClass, ErrorHandler } from "../domain/errors.js";
6
+ import type { Cause, ExecuteOptions, ExecutionCompleted, ExecutionFailed, ExecutionStarted, Extension, Interceptor, Listener } from "../domain/pipeline.js";
7
+ import { type ExecutableClass } from "../services/listening.js";
6
8
  import type { LayerLogger } from "../../observability/domain/logging.js";
7
- interface Executor<C extends AnyCommand, R> {
8
- execute(command: C): R | Promise<R>;
9
+ interface Executor<C extends Executable, R> {
10
+ execute(executable: C): R | Promise<R>;
9
11
  }
10
- type ServiceClass<C extends AnyCommand, R> = {
12
+ type ServiceClass<C extends Executable, R> = {
11
13
  new (): Executor<C, R>;
12
14
  readonly serviceName: string;
13
15
  };
16
+ /** What a service is registered for: a Command's class or a domain event's. */
17
+ type DtoClass = ExecutableClass;
18
+ /** The service a registration takes the place of, as the backend's `replaces=`. */
19
+ export type RegistrationOptions = {
20
+ replaces?: abstract new (...args: never[]) => object;
21
+ };
22
+ /** What `feature(...)` and `appService(...)` answer: the function that registers the service. */
23
+ export type Registrar<T extends Executable> = <S extends ServiceClass<NoInfer<T>, Answer<NoInfer<T>>>>(Service: S, decorator?: ClassDecoratorContext<S>) => S;
14
24
  /**
15
- * A bounded context: the bus of its Commands, as sincpro_framework's `UseFramework`.
25
+ * A bounded context: the bus of its Commands and domain events, as sincpro_framework's
26
+ * `UseFramework`.
16
27
  *
17
28
  * const project = new BoundedContext<ProjectDependencies>("project");
18
29
  * project.addDependency("client", clientOf(transport));
19
30
  * project.feature(CommandCancelIssue)(CancelIssue); // or @project.feature(...)
31
+ * project.feature(IssueClosed)(NotifyWatchers); // a subscriber of an event
32
+ * project.feature(CommandCancelIssue, { replaces: CancelIssue })(CancelAndArchive);
20
33
  * project.addFeatureErrorHandler(toEmptyWhenMissing);
21
- * project.expectErrors(IssueClosed); // refused, never reported
34
+ * project.expectErrors(IssueLocked); // refused, never reported
22
35
  * await project.execute(new CommandCancelIssue("x")); // ResponseCancelIssue
36
+ * await project.execute(new IssueClosed("x")); // what its subscriber returns
23
37
  * project.logger.info("Catalog preloaded");
24
38
  *
25
39
  * Context: registrations, dependencies, handlers and expected errors are snapshotted per
26
40
  * generation; a call keeps the generation it started with, and what is added later answers from
27
- * the next call — as the backend's `build_root_bus`.
41
+ * the next call — as the backend's `build_root_bus`. A domain event is executed as a Command is:
42
+ * one subscriber per bounded context, through the same pipeline; carrying it to every bounded
43
+ * context that hears it (a queue, an outbox) is the platform's.
28
44
  */
29
45
  export declare class BoundedContext<D extends object = Record<never, never>, C extends object = ContextValues> {
30
46
  readonly name: string;
@@ -36,6 +52,8 @@ export declare class BoundedContext<D extends object = Record<never, never>, C e
36
52
  private readonly dependencyValues;
37
53
  private readonly handlers;
38
54
  private readonly expected;
55
+ private readonly interceptors;
56
+ private readonly listeners;
39
57
  private revision;
40
58
  private builtRevision;
41
59
  private built;
@@ -44,8 +62,43 @@ export declare class BoundedContext<D extends object = Record<never, never>, C e
44
62
  addDependency<K extends keyof D & string>(name: K, dependency: D[K], options?: {
45
63
  replaces: D[K];
46
64
  }): this;
47
- feature<T extends AnyCommand>(command: CommandClass<T>): <S extends ServiceClass<NoInfer<T>, Answer<NoInfer<T>>>>(Service: S, decorator?: ClassDecoratorContext<S> | undefined) => S;
48
- appService<T extends AnyCommand>(command: CommandClass<T>): <S extends ServiceClass<NoInfer<T>, Answer<NoInfer<T>>>>(Service: S, decorator?: ClassDecoratorContext<S> | undefined) => S;
65
+ /**
66
+ * Registers the Feature that executes a Command — or that subscribes to a domain event. With
67
+ * `replaces`, it takes the place of the service registered now for it.
68
+ */
69
+ feature<T extends AnyCommand>(command: CommandClass<T>, options?: RegistrationOptions): Registrar<T>;
70
+ feature<E extends DomainEvent>(event: EventClass<E>, options?: RegistrationOptions): Registrar<E>;
71
+ /** Registers the ApplicationService of a Command or a domain event; `replaces` as `feature`. */
72
+ appService<T extends AnyCommand>(command: CommandClass<T>, options?: RegistrationOptions): Registrar<T>;
73
+ appService<E extends DomainEvent>(event: EventClass<E>, options?: RegistrationOptions): Registrar<E>;
74
+ /** The service registered now for a Command or an event class — what a `replaces` names. */
75
+ handlerOf(dto: DtoClass): (abstract new (...args: never[]) => object) | undefined;
76
+ /**
77
+ * The event class registered here under a wire name, or `undefined` when nothing here hears it
78
+ * — what a platform's queue rebuilds a stored event as, as the backend's `event_type(name)`.
79
+ */
80
+ eventTypeOf(name: string): EventClass<DomainEvent> | undefined;
81
+ /**
82
+ * Runs `interceptor` around every execution of these classes — of every one when none is
83
+ * named —, outermost first in the order registered. It may look, veto (throw), adjust what it
84
+ * passes on (of the same class) or answer by itself. Registered after a call, it wraps from
85
+ * the next one.
86
+ */
87
+ interceptor<T extends Executable = Executable>(...dtos: (abstract new (...args: never[]) => T)[]): <F extends Interceptor<T>>(interceptor: F) => F;
88
+ /** Stops `interceptor` wherever it was registered; from the next call. */
89
+ withoutInterceptor(interceptor: (...args: never[]) => unknown): this;
90
+ /** Hears every run of these classes (every one when none is named) begin. Answers how to stop. */
91
+ onStart(listener: Listener<ExecutionStarted>, ...dtos: DtoClass[]): () => void;
92
+ /** Hears every run of these classes whose service answered — not an answer a handler gave. */
93
+ onCompletion(listener: Listener<ExecutionCompleted>, ...dtos: DtoClass[]): () => void;
94
+ /** Hears every failure that escapes a call — after every handler —, once, where it failed. */
95
+ onFailure(listener: Listener<ExecutionFailed>, ...dtos: DtoClass[]): () => void;
96
+ /**
97
+ * Wires a component into this bounded context — its dependencies, services, interceptors,
98
+ * listeners, handlers — in one place: a module of an application, a plugin. `fresh()` copies
99
+ * all it registered.
100
+ */
101
+ extend(extension: Extension<this>): this;
49
102
  /** Answers a failure that reaches `execute` (or a scope's): after every layer's handlers. */
50
103
  addGlobalErrorHandler(handler: ErrorHandler): this;
51
104
  /** Answers a Feature's failure, wherever it was executed from. */
@@ -57,12 +110,28 @@ export declare class BoundedContext<D extends object = Record<never, never>, C e
57
110
  * its span is no error and nothing is reported. They are still thrown — or handled.
58
111
  */
59
112
  expectErrors(...types: ErrorClass[]): this;
60
- execute<R>(command: Command<R>, _returnType?: ResponseClass<NoInfer<R>>): Promise<R>;
113
+ /**
114
+ * Executes a Command — its Response, inferred —, or a domain event — its subscriber's answer.
115
+ * `{ signal }` cancels the run (it ends `aborted`); a delivered event's envelope continues its
116
+ * flow (`execute(event, envelope)`).
117
+ */
118
+ execute<R>(command: Command<R>, returnType?: ResponseClass<NoInfer<R>>): Promise<R>;
119
+ execute<R>(command: Command<R>, options: ExecuteOptions): Promise<R>;
120
+ execute(event: DomainEvent, options?: ExecuteOptions | Cause): Promise<unknown>;
61
121
  scoped(context: Partial<C>): CommandBus;
62
122
  buildRootBus(): void;
63
123
  fresh(): BoundedContext<D, C>;
124
+ /** What escaped a call, told once, from where it first failed. */
125
+ private escaped;
64
126
  private addErrorHandler;
65
127
  private registrar;
128
+ /**
129
+ * 1. The service is of the layer it is registered in, for a Command or a domain event class.
130
+ * 2. The class has no service yet — or, with `replaces`, the one it has is that one.
131
+ * 3. Its `serviceName` is given and no other service has it; an event's `eventName` is given
132
+ * and no other event class here has it — the name means one event.
133
+ * Final: registered, said on its spans when it replaced another; the next call answers with it.
134
+ */
66
135
  private registerService;
67
136
  private generation;
68
137
  private buildGeneration;
@@ -1,9 +1,13 @@
1
1
  import { contextAt, } from "../../context/entrypoint/context.js";
2
2
  import { processContext, processStore, } from "../../context/infrastructure/process.js";
3
- import { executeTyped, FeatureBus, } from "../domain/bus.js";
3
+ import { FeatureBus, optionsOf, } from "../domain/bus.js";
4
+ import { Command, DomainEvent, } from "../domain/dto.js";
4
5
  import { ApplicationService, Feature, RESERVED_MEMBERS, } from "../domain/services.js";
6
+ import { processHearing } from "../infrastructure/listeners.js";
5
7
  import { handled } from "../services/error-handlers.js";
6
- import { executeService } from "../services/execution.js";
8
+ import { executeService, failedAt } from "../services/execution.js";
9
+ import { announceTo, concerns, listenIn, } from "../services/listening.js";
10
+ import { observabilityNow } from "../../observability/infrastructure/registry.js";
7
11
  import { boundedContextLogger, logHandled, } from "../../observability/services/logging.js";
8
12
  function isExecutor(value) {
9
13
  return (typeof value === "object" &&
@@ -11,20 +15,33 @@ function isExecutor(value) {
11
15
  "execute" in value &&
12
16
  typeof value.execute === "function");
13
17
  }
18
+ function isEventClass(dto) {
19
+ return dto.prototype instanceof DomainEvent;
20
+ }
21
+ function nameOf(Service) {
22
+ const named = Service;
23
+ return String(named?.serviceName ?? named?.name ?? Service);
24
+ }
14
25
  /**
15
- * A bounded context: the bus of its Commands, as sincpro_framework's `UseFramework`.
26
+ * A bounded context: the bus of its Commands and domain events, as sincpro_framework's
27
+ * `UseFramework`.
16
28
  *
17
29
  * const project = new BoundedContext<ProjectDependencies>("project");
18
30
  * project.addDependency("client", clientOf(transport));
19
31
  * project.feature(CommandCancelIssue)(CancelIssue); // or @project.feature(...)
32
+ * project.feature(IssueClosed)(NotifyWatchers); // a subscriber of an event
33
+ * project.feature(CommandCancelIssue, { replaces: CancelIssue })(CancelAndArchive);
20
34
  * project.addFeatureErrorHandler(toEmptyWhenMissing);
21
- * project.expectErrors(IssueClosed); // refused, never reported
35
+ * project.expectErrors(IssueLocked); // refused, never reported
22
36
  * await project.execute(new CommandCancelIssue("x")); // ResponseCancelIssue
37
+ * await project.execute(new IssueClosed("x")); // what its subscriber returns
23
38
  * project.logger.info("Catalog preloaded");
24
39
  *
25
40
  * Context: registrations, dependencies, handlers and expected errors are snapshotted per
26
41
  * generation; a call keeps the generation it started with, and what is added later answers from
27
- * the next call — as the backend's `build_root_bus`.
42
+ * the next call — as the backend's `build_root_bus`. A domain event is executed as a Command is:
43
+ * one subscriber per bounded context, through the same pipeline; carrying it to every bounded
44
+ * context that hears it (a queue, an outbox) is the platform's.
28
45
  */
29
46
  export class BoundedContext {
30
47
  name;
@@ -40,6 +57,12 @@ export class BoundedContext {
40
57
  application_service: [],
41
58
  };
42
59
  expected = [];
60
+ interceptors = [];
61
+ listeners = {
62
+ started: new Set(),
63
+ completed: new Set(),
64
+ failed: new Set(),
65
+ };
43
66
  revision = 0;
44
67
  builtRevision = -1;
45
68
  built;
@@ -89,11 +112,70 @@ export class BoundedContext {
89
112
  this.revision++;
90
113
  return this;
91
114
  }
92
- feature(command) {
93
- return this.registrar(command, "feature");
115
+ feature(dto, options) {
116
+ return this.registrar(dto, "feature", options);
117
+ }
118
+ appService(dto, options) {
119
+ return this.registrar(dto, "application_service", options);
120
+ }
121
+ /** The service registered now for a Command or an event class — what a `replaces` names. */
122
+ handlerOf(dto) {
123
+ return this.registrations.get(dto)?.Service;
124
+ }
125
+ /**
126
+ * The event class registered here under a wire name, or `undefined` when nothing here hears it
127
+ * — what a platform's queue rebuilds a stored event as, as the backend's `event_type(name)`.
128
+ */
129
+ eventTypeOf(name) {
130
+ for (const dto of this.registrations.keys()) {
131
+ if (isEventClass(dto) && dto.eventName === name)
132
+ return dto;
133
+ }
134
+ return undefined;
135
+ }
136
+ /**
137
+ * Runs `interceptor` around every execution of these classes — of every one when none is
138
+ * named —, outermost first in the order registered. It may look, veto (throw), adjust what it
139
+ * passes on (of the same class) or answer by itself. Registered after a call, it wraps from
140
+ * the next one.
141
+ */
142
+ interceptor(...dtos) {
143
+ return (interceptor) => {
144
+ if (typeof interceptor !== "function")
145
+ throw new TypeError("An interceptor is a function");
146
+ // Registered for these classes only, it is called only with them.
147
+ this.interceptors.push({ what: interceptor, dtos });
148
+ this.revision++;
149
+ return interceptor;
150
+ };
151
+ }
152
+ /** Stops `interceptor` wherever it was registered; from the next call. */
153
+ withoutInterceptor(interceptor) {
154
+ const kept = this.interceptors.filter((one) => one.what !== interceptor);
155
+ this.interceptors.splice(0, this.interceptors.length, ...kept);
156
+ this.revision++;
157
+ return this;
158
+ }
159
+ /** Hears every run of these classes (every one when none is named) begin. Answers how to stop. */
160
+ onStart(listener, ...dtos) {
161
+ return listenIn(this.listeners.started, listener, dtos);
162
+ }
163
+ /** Hears every run of these classes whose service answered — not an answer a handler gave. */
164
+ onCompletion(listener, ...dtos) {
165
+ return listenIn(this.listeners.completed, listener, dtos);
166
+ }
167
+ /** Hears every failure that escapes a call — after every handler —, once, where it failed. */
168
+ onFailure(listener, ...dtos) {
169
+ return listenIn(this.listeners.failed, listener, dtos);
94
170
  }
95
- appService(command) {
96
- return this.registrar(command, "application_service");
171
+ /**
172
+ * Wires a component into this bounded context — its dependencies, services, interceptors,
173
+ * listeners, handlers — in one place: a module of an application, a plugin. `fresh()` copies
174
+ * all it registered.
175
+ */
176
+ extend(extension) {
177
+ extension(this);
178
+ return this;
97
179
  }
98
180
  /** Answers a failure that reaches `execute` (or a scope's): after every layer's handlers. */
99
181
  addGlobalErrorHandler(handler) {
@@ -121,20 +203,21 @@ export class BoundedContext {
121
203
  this.revision++;
122
204
  return this;
123
205
  }
124
- execute(command, _returnType) {
125
- return executeTyped((one) => this.generation().root(one, {}), command);
206
+ execute(executable, second) {
207
+ // As with Python's return_type, the answer is not validated at runtime.
208
+ return this.generation().root(executable, {}, optionsOf(second));
126
209
  }
127
210
  scoped(context) {
128
211
  const scope = { ...context };
129
- return new FeatureBus((command) => this.generation().root(command, scope));
212
+ return new FeatureBus((executable, options) => this.generation().root(executable, scope, options));
130
213
  }
131
214
  buildRootBus() {
132
215
  this.generation();
133
216
  }
134
217
  fresh() {
135
218
  const fresh = new BoundedContext(this.name);
136
- for (const [command, registration] of this.registrations) {
137
- fresh.registrations.set(command, registration);
219
+ for (const [dto, registration] of this.registrations) {
220
+ fresh.registrations.set(dto, registration);
138
221
  }
139
222
  for (const [name, dependency] of this.dependencyValues) {
140
223
  fresh.dependencyValues.set(name, dependency);
@@ -143,9 +226,35 @@ export class BoundedContext {
143
226
  fresh.handlers[where].push(...this.handlers[where]);
144
227
  }
145
228
  fresh.expected.push(...this.expected);
229
+ fresh.interceptors.push(...this.interceptors);
230
+ for (const moment of ["started", "completed", "failed"]) {
231
+ for (const one of this.listeners[moment])
232
+ fresh.listeners[moment].add(one);
233
+ }
146
234
  fresh.revision = this.revision;
147
235
  return fresh;
148
236
  }
237
+ /** What escaped a call, told once, from where it first failed. */
238
+ escaped(executable, error, expected) {
239
+ const at = failedAt(error);
240
+ let outcome = "failed";
241
+ try {
242
+ outcome =
243
+ at?.outcome ?? (expected(error) ? "refused" : observabilityNow().outcomeOf(error));
244
+ }
245
+ catch {
246
+ // An outcome that cannot be told is a failure.
247
+ }
248
+ const heard = {
249
+ executable: at?.executable ?? executable,
250
+ execution: at?.execution,
251
+ layer: at?.layer,
252
+ error,
253
+ outcome,
254
+ };
255
+ announceTo(this.listeners.failed, heard);
256
+ announceTo(processHearing().failed, heard);
257
+ }
149
258
  addErrorHandler(where, handler) {
150
259
  if (typeof handler !== "function")
151
260
  throw new TypeError("An error handler is a function");
@@ -153,9 +262,9 @@ export class BoundedContext {
153
262
  this.revision++;
154
263
  return this;
155
264
  }
156
- registrar(command, layer) {
265
+ registrar(dto, layer, options = {}) {
157
266
  return (Service, decorator) => {
158
- const register = () => this.registerService(command, Service, layer);
267
+ const register = () => this.registerService(dto, Service, layer, options.replaces);
159
268
  // Standard decorators run before static fields have initialized.
160
269
  if (decorator)
161
270
  decorator.addInitializer(register);
@@ -164,23 +273,54 @@ export class BoundedContext {
164
273
  return Service;
165
274
  };
166
275
  }
167
- registerService(command, Service, layer) {
276
+ /**
277
+ * 1. The service is of the layer it is registered in, for a Command or a domain event class.
278
+ * 2. The class has no service yet — or, with `replaces`, the one it has is that one.
279
+ * 3. Its `serviceName` is given and no other service has it; an event's `eventName` is given
280
+ * and no other event class here has it — the name means one event.
281
+ * Final: registered, said on its spans when it replaced another; the next call answers with it.
282
+ */
283
+ registerService(dto, Service, layer, replaces) {
168
284
  const Base = layer === "feature" ? Feature : ApplicationService;
169
285
  if (!(Service.prototype instanceof Base) ||
170
286
  (layer === "feature" && Service.prototype instanceof ApplicationService)) {
171
287
  throw new TypeError(`The service is not an ${layer}`);
172
288
  }
173
- if (this.registrations.has(command)) {
289
+ const event = isEventClass(dto);
290
+ if (!event && !(dto.prototype instanceof Command)) {
291
+ throw new TypeError("A service is registered for a Command or a DomainEvent class");
292
+ }
293
+ const current = this.registrations.get(dto);
294
+ if (replaces !== undefined && current?.Service !== replaces) {
295
+ throw new Error(`Replacement does not match the registration: ${nameOf(replaces)}`);
296
+ }
297
+ if (replaces === undefined && current) {
174
298
  throw new Error("Command already registered: one executor across both layers");
175
299
  }
176
300
  const name = Service.serviceName;
177
301
  if (typeof name !== "string" || name.trim() === "") {
178
302
  throw new Error("A service needs a nonempty registered name");
179
303
  }
180
- if ([...this.registrations.values()].some((registration) => registration.name === name)) {
181
- throw new Error(`Service name already registered: ${name}`);
304
+ for (const [other, registration] of this.registrations) {
305
+ if (registration !== current && registration.name === name) {
306
+ throw new Error(`Service name already registered: ${name}`);
307
+ }
308
+ if (event &&
309
+ other !== dto &&
310
+ isEventClass(other) &&
311
+ other.eventName === dto.eventName) {
312
+ throw new Error(`Event name already registered: ${dto.eventName}`);
313
+ }
314
+ }
315
+ if (event && (typeof dto.eventName !== "string" || dto.eventName.trim() === "")) {
316
+ throw new Error("A domain event needs a nonempty eventName");
182
317
  }
183
- this.registrations.set(command, { name, layer, Service });
318
+ this.registrations.set(dto, {
319
+ name,
320
+ layer,
321
+ Service,
322
+ ...(current ? { replaces: current.name } : {}),
323
+ });
184
324
  this.revision++;
185
325
  }
186
326
  generation() {
@@ -202,6 +342,18 @@ export class BoundedContext {
202
342
  };
203
343
  const expectedTypes = [...this.expected];
204
344
  const expected = (error) => expectedTypes.some((type) => error instanceof type);
345
+ const interceptors = [...this.interceptors];
346
+ const everyContext = processHearing();
347
+ const announce = {
348
+ started: (heard) => {
349
+ announceTo(this.listeners.started, heard);
350
+ announceTo(everyContext.started, heard);
351
+ },
352
+ completed: (heard) => {
353
+ announceTo(this.listeners.completed, heard);
354
+ announceTo(everyContext.completed, heard);
355
+ },
356
+ };
205
357
  const instances = new Map();
206
358
  for (const registration of registrations.values()) {
207
359
  const instance = new registration.Service();
@@ -210,9 +362,9 @@ export class BoundedContext {
210
362
  }
211
363
  instances.set(registration, instance);
212
364
  }
213
- const dispatch = async (command, parent, featureOnly = false, scope = {}) => {
214
- // Exact constructor identity: a subclass of a registered Command is another Command.
215
- const target = registrations.get(Object.getPrototypeOf(command)?.constructor);
365
+ const dispatch = async (executable, parent, featureOnly = false, scope = {}, options = {}) => {
366
+ // Exact constructor identity: a subclass of a registered class is another one.
367
+ const target = registrations.get(Object.getPrototypeOf(executable)?.constructor);
216
368
  if (!target)
217
369
  throw new Error("No executor registered for this command class");
218
370
  if (featureOnly && target.layer !== "feature") {
@@ -222,29 +374,51 @@ export class BoundedContext {
222
374
  if (!service) {
223
375
  throw new Error(`Registered service is unavailable: ${target.name}`);
224
376
  }
225
- return executeService(service, command, {
377
+ const attributes = {
378
+ ...(executable instanceof DomainEvent ? { "sincpro.event": executable.name } : {}),
379
+ ...(target.replaces ? { "sincpro.replaces": target.replaces } : {}),
380
+ };
381
+ return executeService(service, executable, {
226
382
  boundedContext: this.name,
227
383
  name: target.name,
228
384
  layer: target.layer,
385
+ attributes,
229
386
  dependencies,
230
387
  context: () => ({ ...processContext.values, ...this.context.values, ...scope }),
231
388
  ...(parent ? { parent } : {}),
232
- featureBus: (execution) => new FeatureBus((child) => dispatch(child, execution, true)),
389
+ // Only an event comes from outside with a cause; a child continues its parent.
390
+ ...(options.cause && executable instanceof DomainEvent && !parent
391
+ ? { cause: options.cause }
392
+ : {}),
393
+ ...(options.signal ? { signal: options.signal } : {}),
394
+ featureBus: (execution) => new FeatureBus((child, options) => dispatch(child, execution, true, {}, options)),
233
395
  expected,
234
396
  handlers: handlers[target.layer],
397
+ interceptors: interceptors
398
+ .filter((one) => concerns(one.dtos, executable))
399
+ .map((one) => one.what),
400
+ announce,
235
401
  });
236
402
  };
237
403
  return {
238
- root: async (command, scope) => {
404
+ root: async (executable, scope, options) => {
239
405
  try {
240
- return await dispatch(command, undefined, false, scope);
406
+ return await dispatch(executable, undefined, false, scope, options);
241
407
  }
242
408
  catch (error) {
243
- if (handlers.global.length === 0)
244
- throw error;
245
- const answer = await handled(handlers.global, error);
246
- logHandled("bounded_context", { boundedContext: this.name });
247
- return answer;
409
+ let escaping = error;
410
+ if (handlers.global.length > 0) {
411
+ try {
412
+ const answer = await handled(handlers.global, error);
413
+ logHandled("bounded_context", { boundedContext: this.name });
414
+ return answer;
415
+ }
416
+ catch (thrown) {
417
+ escaping = thrown;
418
+ }
419
+ }
420
+ this.escaped(executable, escaping, expected);
421
+ throw escaping;
248
422
  }
249
423
  },
250
424
  };
@@ -9,9 +9,11 @@
9
9
  * infrastructure/ `configureProcess`, `configureLogger`
10
10
  */
11
11
  export { type CommandBus, FeatureBus } from "./domain/bus.js";
12
- export { Command, DataTransferObject, Response, } from "./domain/dto.js";
12
+ export { Command, DataTransferObject, DomainEvent, type EventClass, Response, } from "./domain/dto.js";
13
13
  export type { ErrorClass, ErrorHandler } from "./domain/errors.js";
14
+ export type { CallNext, Cause, ExecuteOptions, ExecutionCompleted, ExecutionFailed, ExecutionStarted, Extension, InterceptedRun, Interceptor, Listener, PipelineLayer, } from "./domain/pipeline.js";
14
15
  export { ApplicationService, Feature } from "./domain/services.js";
15
- export { BoundedContext } from "./entrypoint/bounded-context.js";
16
+ export { BoundedContext, type Registrar, type RegistrationOptions, } from "./entrypoint/bounded-context.js";
16
17
  export { screensTransport } from "./entrypoint/screens.js";
18
+ export { processListeners } from "./infrastructure/listeners.js";
17
19
  export { configureLogger, configureProcess, type LoggerConfiguration, type ProcessOptions, } from "./infrastructure/process.js";
@@ -9,8 +9,9 @@
9
9
  * infrastructure/ `configureProcess`, `configureLogger`
10
10
  */
11
11
  export { FeatureBus } from "./domain/bus.js";
12
- export { Command, DataTransferObject, Response, } from "./domain/dto.js";
12
+ export { Command, DataTransferObject, DomainEvent, Response, } from "./domain/dto.js";
13
13
  export { ApplicationService, Feature } from "./domain/services.js";
14
- export { BoundedContext } from "./entrypoint/bounded-context.js";
14
+ export { BoundedContext, } from "./entrypoint/bounded-context.js";
15
15
  export { screensTransport } from "./entrypoint/screens.js";
16
+ export { processListeners } from "./infrastructure/listeners.js";
16
17
  export { configureLogger, configureProcess, } from "./infrastructure/process.js";
@@ -0,0 +1,28 @@
1
+ import type { ExecutionCompleted, ExecutionFailed, ExecutionStarted, Listener } from "../domain/pipeline.js";
2
+ import { type ExecutableClass, type Hearing } from "../services/listening.js";
3
+ /**
4
+ * Who hears every bounded context of this process — the backend's process listeners: an
5
+ * activity indicator, a toast, an audit that must not be wired into each context.
6
+ *
7
+ * const stop = processListeners.onFailure((failed) => toast(failed.execution?.useCase));
8
+ *
9
+ * Context: a bounded context's own listeners (`project.onStart`) hear it alone; these hear all of
10
+ * them, after its own. They live as long as the process — `configureProcess` does not clear
11
+ * them —, so whoever adds one keeps the function that stops it.
12
+ */
13
+ declare const hearing: {
14
+ started: Set<Hearing<Listener<ExecutionStarted>>>;
15
+ completed: Set<Hearing<Listener<ExecutionCompleted>>>;
16
+ failed: Set<Hearing<Listener<ExecutionFailed>>>;
17
+ };
18
+ /** @internal — what the bounded contexts announce to, besides their own listeners. */
19
+ export declare function processHearing(): typeof hearing;
20
+ export declare const processListeners: {
21
+ /** Hears every run of every bounded context begin. Answers how to stop. */
22
+ onStart(listener: Listener<ExecutionStarted>, ...dtos: ExecutableClass[]): () => void;
23
+ /** Hears every service that answered, in every bounded context. */
24
+ onCompletion(listener: Listener<ExecutionCompleted>, ...dtos: ExecutableClass[]): () => void;
25
+ /** Hears every failure that escapes a call, once, where it failed. */
26
+ onFailure(listener: Listener<ExecutionFailed>, ...dtos: ExecutableClass[]): () => void;
27
+ };
28
+ export {};
@@ -0,0 +1,34 @@
1
+ import { listenIn, } from "../services/listening.js";
2
+ /**
3
+ * Who hears every bounded context of this process — the backend's process listeners: an
4
+ * activity indicator, a toast, an audit that must not be wired into each context.
5
+ *
6
+ * const stop = processListeners.onFailure((failed) => toast(failed.execution?.useCase));
7
+ *
8
+ * Context: a bounded context's own listeners (`project.onStart`) hear it alone; these hear all of
9
+ * them, after its own. They live as long as the process — `configureProcess` does not clear
10
+ * them —, so whoever adds one keeps the function that stops it.
11
+ */
12
+ const hearing = {
13
+ started: new Set(),
14
+ completed: new Set(),
15
+ failed: new Set(),
16
+ };
17
+ /** @internal — what the bounded contexts announce to, besides their own listeners. */
18
+ export function processHearing() {
19
+ return hearing;
20
+ }
21
+ export const processListeners = {
22
+ /** Hears every run of every bounded context begin. Answers how to stop. */
23
+ onStart(listener, ...dtos) {
24
+ return listenIn(hearing.started, listener, dtos);
25
+ },
26
+ /** Hears every service that answered, in every bounded context. */
27
+ onCompletion(listener, ...dtos) {
28
+ return listenIn(hearing.completed, listener, dtos);
29
+ },
30
+ /** Hears every failure that escapes a call, once, where it failed. */
31
+ onFailure(listener, ...dtos) {
32
+ return listenIn(hearing.failed, listener, dtos);
33
+ },
34
+ };
@@ -1,6 +1,6 @@
1
1
  import { type Execution } from "../../context/domain/execution.js";
2
2
  /**
3
- * A dependency as the execution should see it: bound when it can be (`BIND`, or a `Transport`),
4
- * as it is otherwise.
3
+ * A dependency as the execution should see it: bound when it can be (`BIND`, a `Transport`, an
4
+ * `OperationTransport`), as it is otherwise.
5
5
  */
6
6
  export declare function bindDependency<T>(value: T, execution: Execution): T;
@@ -1,13 +1,16 @@
1
- import { bindTransport, isTransport, } from "../../client/services/propagation.js";
1
+ import { isOperationTransport } from "../../client/domain/operation.js";
2
+ import { bindOperations, bindTransport, isTransport, } from "../../client/services/propagation.js";
2
3
  import { BIND, isBindable, } from "../../context/domain/execution.js";
3
4
  /**
4
- * A dependency as the execution should see it: bound when it can be (`BIND`, or a `Transport`),
5
- * as it is otherwise.
5
+ * A dependency as the execution should see it: bound when it can be (`BIND`, a `Transport`, an
6
+ * `OperationTransport`), as it is otherwise.
6
7
  */
7
8
  export function bindDependency(value, execution) {
8
9
  if (isBindable(value))
9
10
  return value[BIND](execution);
10
11
  if (isTransport(value))
11
12
  return bindTransport(value, execution);
13
+ if (isOperationTransport(value))
14
+ return bindOperations(value, execution);
12
15
  return value;
13
16
  }