@ingenioz-it/caits 0.6.0 → 0.8.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 (57) hide show
  1. package/bin/caits.js +20 -7
  2. package/bin/init.js +16 -1
  3. package/package.json +8 -5
  4. package/quality/architecture/adapters/scss.mjs +2 -2
  5. package/quality/architecture/facts.mjs +39 -11
  6. package/quality/architecture/project.mjs +8 -6
  7. package/quality/dev.mjs +6 -3
  8. package/quality/mutation-summary.mjs +30 -20
  9. package/quality/mutation.mjs +170 -14
  10. package/src/adapter.js +94 -0
  11. package/src/di/Container.ts +27 -0
  12. package/src/di/RequestContext.ts +8 -2
  13. package/src/di/backend.ts +12 -0
  14. package/src/di/createBackend.ts +197 -25
  15. package/src/di/declareDependencies.js +25 -5
  16. package/src/di/discover.ts +19 -9
  17. package/src/di/discoverFrontends.ts +4 -8
  18. package/src/di/discovered.ts +2 -2
  19. package/src/di/discoveredFrontends.ts +2 -2
  20. package/src/messaging/BackendEventBus.ts +2 -3
  21. package/src/messaging/ContextEvents.ts +11 -0
  22. package/src/messaging/ContextMap.ts +3 -4
  23. package/src/messaging/Controller.ts +36 -75
  24. package/src/messaging/EventSubscriber.ts +1 -1
  25. package/src/messaging/InProcessMessageBus.ts +8 -9
  26. package/src/messaging/MessageBus.ts +4 -2
  27. package/src/messaging/Request.ts +116 -0
  28. package/src/messaging/RequestEvents.ts +8 -0
  29. package/src/messaging/StreamController.ts +261 -0
  30. package/src/messaging/TimerController.ts +25 -0
  31. package/src/messaging/sse.ts +23 -0
  32. package/src/server/entry.ts +37 -0
  33. package/src/server/kernel.ts +19 -0
  34. package/src/server/packages.d.ts +12 -0
  35. package/src/server/serve.js +121 -0
  36. package/src/state/createStore.ts +1 -2
  37. package/src/testing/backend/application.ts +1 -1
  38. package/src/testing/backend/applicationScenario.ts +6 -7
  39. package/src/testing/backend/applications.ts +4 -1
  40. package/src/testing/backend/context-map.ts +1 -1
  41. package/src/testing/backend/integration.ts +1 -1
  42. package/src/testing/backend/presentationApplication.ts +23 -3
  43. package/src/testing/backend/presentationScenario.ts +84 -7
  44. package/src/testing/frontend/applicationScenario.ts +2 -3
  45. package/src/testing/frontend/applications.ts +3 -3
  46. package/src/testing/frontend/frontends.ts +3 -4
  47. package/src/testing/shared/FakeMessageBus.ts +39 -5
  48. package/src/virtual-modules.d.ts +0 -2
  49. package/src/vite.js +2 -2
  50. package/src/vite.types.d.ts +7 -0
  51. package/template/caits/architecture.mjs +162 -113
  52. package/template/caits/imports.mjs +2 -2
  53. package/template/caits/stryker.mjs +11 -6
  54. package/template/caits/vite.ts +15 -5
  55. package/template/project/AGENTS +1 -1
  56. package/quality/mutation-suite.mjs +0 -82
  57. package/src/messaging/Input.ts +0 -4
package/src/adapter.js ADDED
@@ -0,0 +1,94 @@
1
+ // The adapter of @marko/run for a CAITS app: quality/caits/vite.ts gives it to the plugin of @marko/run. JavaScript, not TypeScript: Node
2
+ // loads it with the configuration of Vite, and strips no types in node_modules, where a project installs the kernel.
3
+ // - `npm run build` builds the server of `npm start` from server/entry.ts: it boots the backend, and stops it on SIGTERM or SIGINT.
4
+ // - `caits dev` serves the app with the dev server of @marko/run, in a server of its own (server/serve.js): it boots the backend through
5
+ // Vite, boots it again when a module that the backend imports changes, and stops it when it closes.
6
+ import { fileURLToPath } from "node:url";
7
+ import baseAdapter, { createDevServer } from "@marko/run/adapter";
8
+ import { serve } from "./server/serve.js";
9
+
10
+ /** @import { Adapter } from "@marko/run/vite" */
11
+ /** @import { ModuleNode, ViteDevServer } from "vite" */
12
+
13
+ const ENTRY = fileURLToPath(new URL("./server/entry.ts", import.meta.url));
14
+ const KERNEL = fileURLToPath(new URL("./server/kernel.ts", import.meta.url));
15
+
16
+ /**
17
+ * The adapter of @marko/run that serves a CAITS app: that of Node, whose server boots the backend and stops it.
18
+ * @returns {Adapter}
19
+ */
20
+ export function adapter() {
21
+ return {
22
+ ...baseAdapter(),
23
+ name: "caits",
24
+ getEntryFile: () => ENTRY,
25
+ startDev: async ({ config, options }) => {
26
+ const vite = await createDevServer(config);
27
+ /** @type {Awaited<ReturnType<typeof serve>>} */
28
+ let served;
29
+ try {
30
+ served = await serve({
31
+ handle: vite.middlewares,
32
+ port: options.port ?? 3000,
33
+ boot: async () => /** @type {typeof import("./server/kernel.js")} */ (await vite.ssrLoadModule(KERNEL)).boot()
34
+ });
35
+ } catch (error) {
36
+ // A first boot that fails, or a port in use: the server of Vite and its watcher would keep the command alive, and nothing would
37
+ // answer. The command ends, with the error.
38
+ await vite.close();
39
+ throw error;
40
+ }
41
+ vite.watcher.on("change", file => {
42
+ if (!imports(vite, KERNEL, file)) return;
43
+ forget(vite, file);
44
+ void served.reboot();
45
+ });
46
+ // The words of the server of @marko/run: who waits for the server reads them.
47
+ console.log(`\n Server listening at\n http://localhost:${served.port}\n`);
48
+ return {
49
+ port: served.port,
50
+ close: async () => {
51
+ await served.close();
52
+ await vite.close();
53
+ }
54
+ };
55
+ }
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Whether a module that Vite loaded imports a file, itself or through the modules that it imports.
61
+ * @param {ViteDevServer} vite
62
+ * @param {string} id
63
+ * @param {string} file
64
+ */
65
+ function imports(vite, id, file) {
66
+ /** @type {Set<ModuleNode>} */
67
+ const seen = new Set();
68
+ /** @type {(module: ModuleNode | undefined) => boolean} */
69
+ const visit = module => {
70
+ if (!module || seen.has(module)) return false;
71
+ seen.add(module);
72
+ return module.file === file || [...module.importedModules].some(visit);
73
+ };
74
+ return visit(vite.moduleGraph.getModuleById(id));
75
+ }
76
+
77
+ /**
78
+ * Makes Vite forget a file that changed, and the modules that import it: the backend that boots again loads their new code. Vite would
79
+ * forget them itself, but after the watchers of the project see the change.
80
+ * @param {ViteDevServer} vite
81
+ * @param {string} file
82
+ */
83
+ function forget(vite, file) {
84
+ /** @type {Set<ModuleNode>} */
85
+ const seen = new Set();
86
+ /** @type {(module: ModuleNode) => void} */
87
+ const visit = module => {
88
+ if (seen.has(module)) return;
89
+ seen.add(module);
90
+ vite.moduleGraph.invalidateModule(module);
91
+ for (const importer of module.importers) visit(importer);
92
+ };
93
+ for (const module of vite.moduleGraph.getModulesByFile(file) ?? []) visit(module);
94
+ }
@@ -40,6 +40,13 @@ export function bind<T>(key: Key<T>) {
40
40
  }
41
41
 
42
42
  const describe = (route: readonly Key[]) => route.map(key => key.name).join(" → ");
43
+
44
+ /** Releases an instance that holds a resource: with Symbol.asyncDispose, else Symbol.dispose. Another instance holds none. */
45
+ async function release(instance: unknown): Promise<void> {
46
+ const { [Symbol.asyncDispose]: asyncDispose, [Symbol.dispose]: dispose } = Object(instance) as Partial<AsyncDisposable & Disposable>;
47
+ if (typeof asyncDispose === "function") await asyncDispose.call(instance);
48
+ else if (typeof dispose === "function") dispose.call(instance);
49
+ }
43
50
  const isKey = (value: unknown): value is Key => value instanceof Token || typeof value === "function";
44
51
 
45
52
  /**
@@ -90,6 +97,26 @@ export class Container {
90
97
  return value;
91
98
  }
92
99
 
100
+ /**
101
+ * Releases what this container built, the last built first, so a dependency outlives what needs it: each instance with
102
+ * Symbol.asyncDispose or Symbol.dispose. A value that a binding gives belongs to whoever made it, and what the parent built to the
103
+ * parent. Every instance is released, even when another fails: the errors come together, after. What it released, it builds again if
104
+ * something needs it.
105
+ */
106
+ async dispose(): Promise<void> {
107
+ const instances = [...this.built.values()].reverse();
108
+ this.built.clear();
109
+ const errors: unknown[] = [];
110
+ for (const instance of instances) {
111
+ try {
112
+ await release(instance);
113
+ } catch (error) {
114
+ errors.push(error);
115
+ }
116
+ }
117
+ if (errors.length > 0) throw new AggregateError(errors, `${errors.length} instance${errors.length === 1 ? "" : "s"} failed to release`);
118
+ }
119
+
93
120
  private resolve(key: Key, path: readonly Key[], make: boolean): unknown {
94
121
  const binding = this.bindings.get(key);
95
122
  if (!binding && this.parent?.has(key)) return this.parent.resolve(key, path, make);
@@ -1,6 +1,12 @@
1
1
  import { Token } from "../Token.js";
2
2
 
3
- /** What the backend knows of the request that a handler or a controller serves. */
4
- export type RequestContext = { readonly correlationId: string };
3
+ /**
4
+ * What the backend knows of the request that a handler or a controller serves: its correlation id, and its signal, which aborts when its
5
+ * client leaves. A handler gives the signal to the ports whose work the client waits for: a call to a model, to a service, to a database.
6
+ * A request without a client sees the backend stop: the signal of a turn of a timer aborts as soon as the server stops, that of a reaction
7
+ * to an event once the grace of the server ends. A DTO that the backend sends without a signal, as a test does, gets one that does not
8
+ * abort.
9
+ */
10
+ export type RequestContext = { readonly correlationId: string; readonly signal: AbortSignal };
5
11
 
6
12
  export const RequestContext = new Token<RequestContext>();
package/src/di/backend.ts CHANGED
@@ -8,6 +8,18 @@ export function startBackend(options: BackendOptions = {}): Backend {
8
8
  return current = createBackend(discovered, options);
9
9
  }
10
10
 
11
+ /**
12
+ * Stops the backend that serves the requests, if one started, then forgets it: its feeds, its timers, the turns that run, the requests
13
+ * that the server still serves (the promise that it gives), the reactions, until the grace ends (the signal), then what its containers
14
+ * built. The next use starts a new one. A request that comes
15
+ * while it stops gets it: a new backend would outlive the server, since nothing would stop it.
16
+ */
17
+ export async function stopBackend(requestsDone?: Promise<unknown>, graceOver?: AbortSignal): Promise<void> {
18
+ const stopping = current;
19
+ await stopping?.stop(requestsDone, graceOver);
20
+ if (current === stopping) current = undefined;
21
+ }
22
+
11
23
  /** The backend that serves the requests, started with the production bindings on first use. */
12
24
  export function backend(): Backend {
13
25
  return current ?? startBackend();
@@ -1,12 +1,15 @@
1
1
  import type { DomainEvent } from "../DomainEvent.js";
2
2
  import type { Token } from "../Token.js";
3
3
  import { BackendEventBus, type Published } from "../messaging/BackendEventBus.js";
4
+ import { ContextEvents } from "../messaging/ContextEvents.js";
4
5
  import { ContextMap, type Envelope, type Translation } from "../messaging/ContextMap.js";
5
6
  import { EventPublisher } from "../messaging/EventPublisher.js";
6
- import { InProcessMessageBus } from "../messaging/InProcessMessageBus.js";
7
- import type { Input } from "../messaging/Input.js";
7
+ import { InProcessMessageBus, UnknownInputError } from "../messaging/InProcessMessageBus.js";
8
+ import type { EventSubscriber } from "../messaging/EventSubscriber.js";
8
9
  import { MessageBus } from "../messaging/MessageBus.js";
9
10
  import { ReportError, reportToConsole } from "../messaging/ReportError.js";
11
+ import { RequestEvents } from "../messaging/RequestEvents.js";
12
+ import { TimerController } from "../messaging/TimerController.js";
10
13
  import { bind, Container, type Binding, type Concrete } from "./Container.js";
11
14
  import type { BackendModules, Class, UseCase } from "./discover.js";
12
15
  import { RequestContext } from "./RequestContext.js";
@@ -16,20 +19,69 @@ export type BackendOptions = {
16
19
  replace?: Partial<Record<string, readonly Binding[]>>;
17
20
  reportError?: ReportError;
18
21
  nextEventId?: () => string;
22
+ /** The clock of the timer controllers: setInterval when not given. */
23
+ schedule?: Schedule;
19
24
  };
20
25
 
21
- /** A context, and the events of shared that an event of its handlers gives. */
22
- type Context = { readonly container: Container; readonly useCases: readonly UseCase[]; readonly shares: (event: DomainEvent) => DomainEvent[] };
26
+ /** Runs a turn every `every` milliseconds, until the function that it gives is called. */
27
+ export type Schedule = (every: number, turn: () => void) => () => void;
23
28
 
24
- const SYSTEM: RequestContext = { correlationId: "system" };
29
+ /** The longest delay of setInterval, about 24 days: Node runs a timer with a longer one every millisecond. */
30
+ const MAX_EVERY = 2 ** 31 - 1;
31
+
32
+ /** How long the turns and the reactions that still run get, once the grace ends and their signal aborted, in milliseconds. */
33
+ const LAST_WAIT_MS = 1000;
34
+
35
+ /** Waits until the work ends, or the signal aborts. */
36
+ const untilEndOrAbort = (work: Promise<unknown>, signal: AbortSignal) => new Promise<void>(resolve => {
37
+ signal.addEventListener("abort", () => { resolve(); });
38
+ if (signal.aborted) resolve();
39
+ void work.then(() => { resolve(); });
40
+ });
41
+
42
+
43
+ const everyInterval: Schedule = (every, turn) => {
44
+ const timer = setInterval(turn, every);
45
+ return () => { clearInterval(timer); };
46
+ };
47
+
48
+ const isTimer = (type: Class): type is Concrete<TimerController<object>> => type.prototype instanceof TimerController;
49
+
50
+ type Listener = (event: DomainEvent) => void;
51
+
52
+ /**
53
+ * A context, its timer controllers, the events of shared that an event of its handlers gives, and who listens to the events of its
54
+ * handlers.
55
+ */
56
+ type Context = {
57
+ readonly container: Container;
58
+ readonly useCases: readonly UseCase[];
59
+ readonly timers: readonly Concrete<TimerController<object>>[];
60
+ readonly shares: (event: DomainEvent) => DomainEvent[];
61
+ readonly listeners: Set<Listener>;
62
+ };
63
+
64
+ /** What subscribes to the events that come to these listeners. */
65
+ const subscriberOf = (listeners: Set<Listener>): EventSubscriber<DomainEvent> => ({
66
+ subscribe: listener => {
67
+ listeners.add(listener);
68
+ return () => { listeners.delete(listener); };
69
+ }
70
+ });
71
+
72
+ /** Where a request of the backend comes from: its correlation id. */
73
+ type Correlation = Pick<RequestContext, "correlationId">;
74
+
75
+ const SYSTEM: Correlation = { correlationId: "system" };
25
76
 
26
77
  /** The context whose events are those of shared: its handlers publish them on the shared bus. */
27
78
  const SHARED = "shared";
28
79
 
29
80
  /**
30
81
  * Builds every context in a container of its own, child of the container of shared: the dependencies of a context are private to it,
31
- * those of shared are built once for all. Each request gets a child of its context, with its correlation id, the publisher of its events
32
- * and its message bus: its handlers and its controllers are built there. The backend event bus is the shared bus: the context map of a
82
+ * those of shared are built once for all. Each request gets a child of its context, with its correlation id, its signal, the publisher of
83
+ * its events and its message bus: its handlers and its controllers are built there. Its signal aborts when the signal that goes with its
84
+ * DTO aborts: a controller gives the signal of its request, so its handler sees its client leave. The backend event bus is the shared bus: the context map of a
33
85
  * context shares there the events of shared that the events of its handlers give, and the context map of each context turns the events
34
86
  * of shared that it hears into inputs of the context. The handlers of the context shared publish its events there.
35
87
  */
@@ -38,35 +90,76 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
38
90
  const shared = new Container([bind(ReportError).toValue(reportError), ...modules.shared, ...(options.replace?.shared ?? [])]);
39
91
  const contexts = new Map<string, Context>();
40
92
  const owners = new Map<unknown, Context>();
41
- const listeners = new Set<(event: DomainEvent) => void>();
93
+ const listeners = new Set<Listener>();
94
+ /** Aborts when the backend stops: the events of the contexts end. */
95
+ const stopping = new AbortController();
96
+ /** The requests without a client see the backend stop: the turns of the timers at once, the reactions to the events once the grace ends. */
97
+ const turnsEnd = new AbortController();
98
+ const reactionsEnd = new AbortController();
42
99
 
43
- const ownerOf = (owned: Class): Context => {
100
+ /** The context of an input DTO, or of a controller: the error of an input is that of the message bus, which names the fix. */
101
+ const ownerOf = (owned: Class, refusal: () => Error): Context => {
44
102
  const context = owners.get(owned);
45
- if (!context) throw new Error(`No context owns ${owned.name}`);
103
+ if (!context) throw refusal();
46
104
  return context;
47
105
  };
106
+ const ownerOfInput = (input: object): Context => ownerOf(input.constructor as Class, () => new UnknownInputError(input.constructor.name));
48
107
 
49
- /** The container of a request, and its message bus: each input DTO gets a new handler. */
50
- const requestOf = ({ container, useCases, shares }: Context, request: RequestContext) => {
108
+ /**
109
+ * The container of a request, and its message bus: each input DTO gets a new handler. A request without a client follows `ends`: its
110
+ * signal aborts when the backend stops it.
111
+ */
112
+ const requestOf = (context: Context, { correlationId }: Correlation, ends?: AbortSignal) => {
113
+ const { container, useCases } = context;
51
114
  const bus = new InProcessMessageBus();
115
+ const left = new AbortController();
116
+ const request: RequestContext = { correlationId, signal: ends === undefined ? left.signal : AbortSignal.any([left.signal, ends]) };
117
+ /** The signal of the request follows the one that goes with a DTO: it aborts with it, or at once if it came aborted. */
118
+ const follow = (signal: AbortSignal | undefined) => {
119
+ if (signal?.aborted) left.abort(signal.reason);
120
+ else signal?.addEventListener("abort", () => { left.abort(signal.reason); });
121
+ };
122
+ // Who listens to the events that the handlers of this request publish: a controller that streams them.
123
+ const heard = new Set<Listener>();
52
124
  const scope = container.child([
53
125
  bind(RequestContext).toValue(request),
54
- bind(EventPublisher).toValue({ publish: (event: DomainEvent) => { tell(shares, event, request); } }),
55
- ...(container.has(MessageBus) ? [] : [bind(MessageBus).toValue(bus)])
126
+ bind(EventPublisher).toValue({ publish: (event: DomainEvent) => {
127
+ for (const listener of [...heard]) listener(event);
128
+ tell(context, event, request);
129
+ } }),
130
+ ...(container.has(MessageBus) ? [] : [bind(MessageBus).toValue(bus)]),
131
+ ...(container.has(RequestEvents) ? [] : [bind(RequestEvents).toValue(subscriberOf(heard))])
56
132
  ]);
57
- for (const { input, handler } of useCases) bus.subscribe(input as never, received => scope.create(handler as Concrete<{ execute(input: unknown): unknown }>).execute(received) as never);
133
+ for (const { input, handler } of useCases) {
134
+ bus.subscribe(input as never, (received, { signal }) => {
135
+ follow(signal);
136
+ return scope.create(handler as Concrete<{ execute(input: unknown): unknown }>).execute(received);
137
+ });
138
+ }
58
139
  return { scope, bus };
59
140
  };
60
141
 
61
- const send = async <Output>(input: Input<Output>, request: RequestContext = SYSTEM): Promise<Output> =>
62
- requestOf(ownerOf(input.constructor as Class), request).bus.send(input);
142
+ /** Sends an input DTO in a request of its own: with a correlation id, "system" by default, and the signal of its client, if it has one. */
143
+ const send = async (input: object, { correlationId = SYSTEM.correlationId, signal }: Partial<Correlation> & { readonly signal?: AbortSignal } = {}): Promise<unknown> =>
144
+ requestOf(ownerOfInput(input), { correlationId }).bus.send(input, { signal });
145
+
146
+ /** The reactions to the events that run, and the name of their input: the stop reports those that outlive it. */
147
+ const reactions = new Map<Promise<unknown>, string>();
148
+ /** Sends the input that an event gives, in a request of its own, which ends when the backend stops the reactions. */
149
+ const react = (input: object, correlationId: string): Promise<unknown> => {
150
+ const reaction = (async () => requestOf(ownerOfInput(input), { correlationId }, reactionsEnd.signal).bus.send(input))();
151
+ reactions.set(reaction, input.constructor.name);
152
+ const forget = () => { reactions.delete(reaction); };
153
+ void reaction.then(forget, forget);
154
+ return reaction;
155
+ };
63
156
 
64
157
  /**
65
158
  * An event that a handler publishes, in its context: the events of shared that the lines of its map that share give go to the shared
66
159
  * bus, in the request of the handler. A line that fails is reported: the handler never waits for the other contexts, nor fails for them.
67
160
  */
68
- const tell = (shares: Context["shares"], event: DomainEvent, request: RequestContext) => {
69
- for (const listener of [...listeners]) listener(event);
161
+ const tell = ({ shares, listeners: contextListeners }: Context, event: DomainEvent, request: Correlation) => {
162
+ for (const listener of [...listeners, ...contextListeners]) listener(event);
70
163
  let told: DomainEvent[];
71
164
  try {
72
165
  told = shares(event);
@@ -83,14 +176,14 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
83
176
  event: line.event,
84
177
  inputs: (event: never, envelope: Envelope) => {
85
178
  const inputs = line.inputs(event, envelope);
86
- const foreign = inputs.find(input => ownerOf(input.constructor as Class) !== contexts.get(name));
179
+ const foreign = inputs.find(input => ownerOfInput(input) !== contexts.get(name));
87
180
  if (foreign) throw new Error(`the context map of ${name} gives ${foreign.constructor.name}, an input of another context: a context map gives the inputs of its own context`);
88
181
  return inputs;
89
182
  }
90
183
  }]));
91
184
  const eventBus = new BackendEventBus({
92
185
  map: new ContextMap(lines),
93
- send: (input, envelope) => send(input, { correlationId: envelope.correlationId }),
186
+ send: (input, envelope) => react(input, envelope.correlationId),
94
187
  nextEventId: options.nextEventId ?? (() => crypto.randomUUID()),
95
188
  reportError
96
189
  });
@@ -98,7 +191,8 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
98
191
  for (const [name, { bindings, useCases, controllers, map }] of Object.entries(modules.contexts)) {
99
192
  const contextMap = new ContextMap(map);
100
193
  const shares = name === SHARED ? (event: DomainEvent) => [event] : (event: DomainEvent) => contextMap.eventsFor(event);
101
- const context = { container: shared.child([...bindings, ...(options.replace?.[name] ?? [])]), useCases, shares };
194
+ const context = { container: shared.child([...bindings, ...(options.replace?.[name] ?? [])]), useCases, timers: controllers.filter(isTimer), shares, listeners: new Set<Listener>() };
195
+ if (!context.container.has(ContextEvents)) context.container.use(ContextEvents, { ...subscriberOf(context.listeners), signal: stopping.signal });
102
196
  for (const { input } of useCases) owners.set(input, context);
103
197
  for (const controller of controllers) owners.set(controller, context);
104
198
  contexts.set(name, context);
@@ -114,14 +208,92 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
114
208
  return found;
115
209
  };
116
210
 
211
+ const schedule = options.schedule ?? everyInterval;
212
+ const cancels: (() => void)[] = [];
213
+ /** The turns that run, and the name of their timer controller. */
214
+ const turns = new Map<Promise<void>, string>();
215
+
216
+ /** The rhythm of a timer controller of a context, in milliseconds: one that Node cannot keep is refused. */
217
+ const rhythmOf = (context: Context, timer: Concrete<TimerController<object>>) => {
218
+ const { every } = requestOf(context, SYSTEM).scope.create(timer);
219
+ if (!(every > 0 && every <= MAX_EVERY)) throw new Error(`${timer.name} runs every ${every} ms, but its every is a number of milliseconds above 0, up to ${MAX_EVERY} (24 days): Node runs a longer one every millisecond`);
220
+ return every;
221
+ };
222
+
223
+ /** Runs a timer controller of a context every `every` milliseconds, each turn in a request of its own, and never two turns at once. */
224
+ const run = (context: Context, timer: Concrete<TimerController<object>>, every: number) => {
225
+ let running = false;
226
+ cancels.push(schedule(every, () => {
227
+ if (running) return;
228
+ running = true;
229
+ const turn = (async () => {
230
+ try {
231
+ await requestOf(context, { correlationId: crypto.randomUUID() }, turnsEnd.signal).scope.create(timer).tick();
232
+ } catch (error) {
233
+ reportError(error);
234
+ }
235
+ })().finally(() => {
236
+ running = false;
237
+ turns.delete(turn);
238
+ });
239
+ turns.set(turn, timer.name);
240
+ }));
241
+ };
242
+
117
243
  return {
244
+ /**
245
+ * Starts the timer controllers of the contexts: the server calls it once it serves. A second call starts nothing more. Each rhythm is
246
+ * checked before any timer runs: a timer that is refused leaves none running.
247
+ */
248
+ start: () => {
249
+ if (cancels.length > 0) return;
250
+ const timers = [...contexts.values()].flatMap(context => context.timers.map(timer => ({ context, timer, every: rhythmOf(context, timer) })));
251
+ for (const { context, timer, every } of timers) run(context, timer, every);
252
+ },
253
+ /**
254
+ * Stops what start started, and releases what the containers built: the server calls it when it stops. It ends the events of the
255
+ * contexts first: a feed has no end, and would hold the requests that the server waits for. Then it cancels the timers, and aborts
256
+ * the signal of the turns that run: a work in the background stops with the server. It waits for the turns, for the requests that the
257
+ * server still serves (`requestsDone`), and for the reactions to the events, until the grace ends (`graceOver`, no limit without it).
258
+ * Then the signal of the reactions aborts, and the turns and the reactions that still run get one more second, after which the backend
259
+ * reports them. Last, it releases what each context built, then what shared built. A failure to release is reported.
260
+ */
261
+ stop: async (requestsDone?: Promise<unknown>, graceOver?: AbortSignal) => {
262
+ stopping.abort();
263
+ for (const cancel of cancels.splice(0)) cancel();
264
+ turnsEnd.abort();
265
+ const done = (async () => {
266
+ while (turns.size > 0) await Promise.all([...turns.keys()]);
267
+ await requestsDone;
268
+ await eventBus.settled();
269
+ })();
270
+ if (graceOver === undefined) await done;
271
+ else {
272
+ await untilEndOrAbort(done, graceOver);
273
+ reactionsEnd.abort();
274
+ const late = new AbortController();
275
+ const timer = setTimeout(() => { late.abort(); }, LAST_WAIT_MS);
276
+ await untilEndOrAbort(done, late.signal);
277
+ clearTimeout(timer);
278
+ const running = [...[...turns.values()].map(name => `a turn of ${name}`), ...[...reactions.values()].map(name => `a reaction with ${name}`)];
279
+ if (running.length > 0) reportError(new Error(`The backend stopped before these ended: ${running.join(", ")}`));
280
+ }
281
+ for (const container of [...[...contexts.values()].map(({ container }) => container), shared]) {
282
+ try {
283
+ await container.dispose();
284
+ } catch (error) {
285
+ reportError(error);
286
+ }
287
+ }
288
+ },
118
289
  /** Sends an input DTO to the context that handles it, in a request of its own. */
119
290
  send,
120
291
  /** The controller of a context, built for a request. */
121
- controller: <C>(controller: abstract new (...args: never[]) => C, request: RequestContext = SYSTEM): C =>
122
- requestOf(ownerOf(controller), request).scope.create(controller as unknown as Concrete<C>),
292
+ controller: <C>(controller: abstract new (...args: never[]) => C, request: Correlation = SYSTEM): C =>
293
+ requestOf(ownerOf(controller, () => new Error(`No context has the controller ${controller.name}: write it in the folder of its use case, as <use-case>.<name>.controller.ts`)), request)
294
+ .scope.create(controller as unknown as Concrete<C>),
123
295
  /** Publishes an event of shared on the shared bus. */
124
- publish: (event: DomainEvent, request: RequestContext = SYSTEM) => { eventBus.publish(event, request); },
296
+ publish: (event: DomainEvent, request: Correlation = SYSTEM) => { eventBus.publish(event, request); },
125
297
  redeliver: (published: Published) => { eventBus.redeliver(published.event, published.envelope); },
126
298
  /** Watches every event on the shared bus, in its envelope. */
127
299
  subscribe: (listener: (published: Published) => void) => eventBus.subscribe(listener),
@@ -9,12 +9,14 @@
9
9
  */
10
10
 
11
11
  /**
12
- * The code that a module needs after its own, for the containers:
12
+ * The code that a module needs after its own, for the containers and the message buses:
13
13
  * - after each class that has a constructor, the dependencies of the constructor (DEPENDENCIES in Container.ts): the name of each
14
14
  * parameter, if it is optional, and the token of its type when the type has a value of the same name, declared in the module or
15
15
  * imported from a relative path, from a shortcut of the project (#…, which a package.json or a tsconfig.json maps to a folder) or from
16
16
  * one of the packages given (a port and its token, or a class: the kernel gives EventPublisher, DataDirectory…). The token is read only
17
17
  * when the container needs it, thus a cycle between modules does no harm. The name of the type goes with its token, for the errors;
18
+ * - after each class that has an execute method, the value of the type of its first parameter, found the same way (INPUT in
19
+ * discover.ts): the input DTO of a handler, which the message bus gives it;
18
20
  * - the name of each token declared without one: the name of its constant.
19
21
  * The names are text: the minifier can rename the code.
20
22
  * @param {string} code
@@ -31,6 +33,8 @@ export function declareDependencies(code, filename, parse, packages) {
31
33
  const values = new Set();
32
34
  /** @type {{ name: string; parameters: readonly ESTree.ParamPattern[] }[]} */
33
35
  const classes = [];
36
+ /** @type {{ name: string; input: ESTree.ParamPattern }[]} */
37
+ const executes = [];
34
38
  /** @type {string[]} */
35
39
  const tokens = [];
36
40
 
@@ -57,9 +61,12 @@ export function declareDependencies(code, filename, parse, packages) {
57
61
  values.add(declaration.id.name);
58
62
  const constructor = declaration.body.body.find(member => member.type === "MethodDefinition" && member.kind === "constructor");
59
63
  if (constructor?.type === "MethodDefinition") classes.push({ name: declaration.id.name, parameters: constructor.value.params });
64
+ const execute = declaration.body.body.find(member => member.type === "MethodDefinition" && member.kind === "method" && !member.static && !member.computed
65
+ && member.key.type === "Identifier" && member.key.name === "execute");
66
+ const input = execute?.type === "MethodDefinition" ? execute.value.params[0] : undefined;
67
+ if (input) executes.push({ name: declaration.id.name, input });
60
68
  }
61
69
  }
62
- if (classes.length === 0 && tokens.length === 0) return undefined;
63
70
 
64
71
  /** @type {Map<string, string>} */
65
72
  const namespaces = new Map();
@@ -75,8 +82,7 @@ export function declareDependencies(code, filename, parse, packages) {
75
82
  };
76
83
  /** @type {(parameter: ESTree.ParamPattern, index: number) => string} */
77
84
  const dependencyOf = (parameter, index) => {
78
- const inner = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter;
79
- const pattern = inner.type === "AssignmentPattern" ? inner.left : inner;
85
+ const { inner, pattern } = partsOf(parameter);
80
86
  const name = pattern.type === "Identifier" ? pattern.name : `#${index}`;
81
87
  const typed = tokenOf(pattern.typeAnnotation?.typeAnnotation);
82
88
  const optional = inner.type === "AssignmentPattern" || pattern.optional === true;
@@ -84,9 +90,23 @@ export function declareDependencies(code, filename, parse, packages) {
84
90
  };
85
91
 
86
92
  const declarations = classes.map(({ name, parameters }) => `${name}[Symbol.for("kernel.dependencies")] = [${parameters.map(dependencyOf).join(", ")}];`);
93
+ const inputs = executes.flatMap(({ name, input }) => {
94
+ const typed = tokenOf(partsOf(input).pattern.typeAnnotation?.typeAnnotation);
95
+ return typed ? [`${name}[Symbol.for("kernel.input")] = () => ${typed.token};`] : [];
96
+ });
87
97
  const imports = [...namespaces].map(([source, alias]) => `import * as ${alias} from ${JSON.stringify(source)};`);
88
98
  const names = tokens.map(name => `${name}.name = ${JSON.stringify(name)};`);
89
- return [...imports, ...declarations, ...names].join("\n");
99
+ const lines = [...imports, ...declarations, ...inputs, ...names];
100
+ return lines.length > 0 ? lines.join("\n") : undefined;
101
+ }
102
+
103
+ /**
104
+ * A parameter, without what TypeScript adds around it (`private readonly`), and its pattern, without its default value.
105
+ * @param {ESTree.ParamPattern} parameter
106
+ */
107
+ function partsOf(parameter) {
108
+ const inner = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter;
109
+ return { inner, pattern: inner.type === "AssignmentPattern" ? inner.left : inner };
90
110
  }
91
111
 
92
112
  /** @type {(name: ESTree.ModuleExportName) => string} */
@@ -1,5 +1,4 @@
1
1
  import type { Translation } from "../messaging/ContextMap.js";
2
- import { Input } from "../messaging/Input.js";
3
2
  import type { Binding } from "./Container.js";
4
3
 
5
4
  export type Class = abstract new (...args: never[]) => unknown;
@@ -21,6 +20,22 @@ export function single<T>(found: T[], what: string): T {
21
20
  return found[0];
22
21
  }
23
22
 
23
+ /** Where the plugin of the kernel writes the input DTO of a handler (see declareDependencies.js). */
24
+ export const INPUT = Symbol.for("kernel.input");
25
+
26
+ /**
27
+ * The use case of a handler: the handler, and the input DTO that its execute takes. The plugin of the kernel reads the class from the
28
+ * type of the parameter, as it reads the ports from the types of the constructor.
29
+ */
30
+ export function useCaseOf(path: string, module: Record<string, unknown>): UseCase {
31
+ const handler = single(classesOf(module), `${path} declares one handler`);
32
+ const input = (handler as { [INPUT]?: () => unknown })[INPUT]?.();
33
+ if (typeof input !== "function") {
34
+ throw new Error(`${path}: ${handler.name}.execute names no input DTO. Give its parameter the type of the input DTO, the class that the use case receives.`);
35
+ }
36
+ return { input: input as Class, handler };
37
+ }
38
+
24
39
  /**
25
40
  * The lines of a context map, as its file gives them: the kernel refuses, when it starts, a map that is not a list of lines, rather than
26
41
  * lose what it holds without a word. `isLine` tells a line of the side.
@@ -40,10 +55,10 @@ const isTranslation = ({ shares, event, inputs, events }: Partial<Record<string,
40
55
 
41
56
  /**
42
57
  * Turns the modules found in the backend folder (src/backend by default) into the contexts of the backend: a handler serves the input
43
- * DTO of its use-case folder, and the context map of a context gives the events of shared that it hears and those of its own that it
58
+ * DTO that its execute takes, and the context map of a context gives the events of shared that it hears and those of its own that it
44
59
  * shares.
45
60
  */
46
- export function discover(found: { bindings: Record<string, readonly Binding[]>; dtos: Modules; handlers: Modules; controllers: Modules; maps: Record<string, Translation[]> }): BackendModules {
61
+ export function discover(found: { bindings: Record<string, readonly Binding[]>; handlers: Modules; controllers: Modules; maps: Record<string, Translation[]> }): BackendModules {
47
62
  const contexts: Record<string, ContextModules> = {};
48
63
  const context = (path: string) => contexts[contextOf(path)] ??= { bindings: [], useCases: [], controllers: [], map: [] };
49
64
  let shared: readonly Binding[] = [];
@@ -52,12 +67,7 @@ export function discover(found: { bindings: Record<string, readonly Binding[]>;
52
67
  if (contextOf(path) === "shared") shared = bindings;
53
68
  else context(path).bindings = bindings;
54
69
  }
55
- for (const [path, module] of Object.entries(found.handlers)) {
56
- const dto = path.replace(".handler.ts", ".dto.ts");
57
- const input = single(classesOf(found.dtos[dto] ?? {}).filter(type => type.prototype instanceof Input), `${dto} declares one input DTO`);
58
- const handler = single(classesOf(module), `${path} declares one handler`);
59
- context(path).useCases.push({ input, handler });
60
- }
70
+ for (const [path, module] of Object.entries(found.handlers)) context(path).useCases.push(useCaseOf(path, module));
61
71
  for (const [path, module] of Object.entries(found.controllers)) context(path).controllers.push(...classesOf(module));
62
72
  for (const [path, map] of Object.entries(found.maps)) {
63
73
  if (contextOf(path) === "shared") throw new Error(`the backend context shared hears no other context: it has no context map (${path})`);
@@ -1,8 +1,7 @@
1
- import { Input } from "../messaging/Input.js";
2
1
  import type { Line } from "../messaging/FrontendContextMap.js";
3
2
  import type { StoreDefinition } from "../state/createStore.js";
4
3
  import type { Binding } from "./Container.js";
5
- import { classesOf, linesIn, single, type UseCase } from "./discover.js";
4
+ import { linesIn, useCaseOf, type UseCase } from "./discover.js";
6
5
 
7
6
  /**
8
7
  * A context of the frontend: its bindings, its use cases, its store (none for a context without a use case: its views and its
@@ -20,12 +19,11 @@ const isLine = ({ shares, event, events }: Partial<Record<string, unknown>>) =>
20
19
 
21
20
  /**
22
21
  * Turns the modules found in the frontend folder (src/frontend by default) into the contexts of the frontend: a context has a di.ts,
23
- * a store at most, and its context map if it hears the context shared or shares events with it; a handler serves the input DTO of its
24
- * use-case folder.
22
+ * a store at most, and its context map if it hears the context shared or shares events with it; a handler serves the input DTO that
23
+ * its execute takes.
25
24
  */
26
25
  export function discoverFrontends(found: {
27
26
  bindings: Record<string, readonly Binding[]>;
28
- dtos: Modules;
29
27
  handlers: Modules;
30
28
  stores: Record<string, StoreDefinition<unknown, unknown>>;
31
29
  maps: Record<string, readonly Line[]>;
@@ -46,9 +44,7 @@ export function discoverFrontends(found: {
46
44
  for (const [path, module] of Object.entries(found.handlers)) {
47
45
  const context = contexts[contextOf(path)];
48
46
  if (!context) throw new Error(`${path} belongs to a frontend without a di.ts`);
49
- const dto = path.replace(".handler.ts", ".dto.ts");
50
- const input = single(classesOf(found.dtos[dto] ?? {}).filter(type => type.prototype instanceof Input), `${dto} declares one input DTO`);
51
- context.useCases.push({ input, handler: single(classesOf(module), `${path} declares one handler`) });
47
+ context.useCases.push(useCaseOf(path, module));
52
48
  }
53
49
  for (const [name, context] of Object.entries(contexts)) {
54
50
  if (context.useCases.length > 0 && !context.store) {