@ingenioz-it/caits 0.5.0 → 0.6.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 (42) hide show
  1. package/bin/caits.js +19 -12
  2. package/bin/init.js +16 -7
  3. package/package.json +1 -1
  4. package/quality/architecture/adapters/marko.mjs +7 -4
  5. package/quality/architecture/adapters/scss.mjs +29 -8
  6. package/quality/architecture/adapters/typescript.mjs +2 -2
  7. package/quality/architecture/facts.mjs +137 -30
  8. package/quality/architecture/project.mjs +114 -22
  9. package/quality/architecture.mjs +68 -22
  10. package/quality/check.mjs +20 -14
  11. package/quality/dev.mjs +3 -2
  12. package/quality/mutation-suite.mjs +56 -5
  13. package/quality/mutation-summary.mjs +29 -12
  14. package/quality/mutation.mjs +29 -0
  15. package/quality/terminal.mjs +30 -0
  16. package/quality/test-docs/test-docs.mjs +1 -2
  17. package/src/DomainEvent.ts +4 -1
  18. package/src/di/Container.ts +5 -1
  19. package/src/di/createBackend.ts +51 -8
  20. package/src/di/createFrontend.ts +7 -4
  21. package/src/di/discover.ts +35 -6
  22. package/src/di/discoverFrontends.ts +29 -6
  23. package/src/di/discovered.ts +3 -3
  24. package/src/di/discoveredFrontends.ts +3 -3
  25. package/src/di/frontend.ts +46 -4
  26. package/src/messaging/BackendEventBus.ts +4 -3
  27. package/src/messaging/ContextMap.ts +28 -4
  28. package/src/messaging/EventPublisher.ts +2 -2
  29. package/src/messaging/FrontendContextMap.ts +33 -0
  30. package/src/testing/backend/applications.ts +7 -6
  31. package/src/testing/backend/context-map.ts +4 -1
  32. package/src/testing/frontend/context-map.ts +6 -0
  33. package/src/testing/frontend/frontends.ts +16 -9
  34. package/src/virtual-modules.d.ts +3 -1
  35. package/src/vite.js +20 -13
  36. package/template/caits/architecture.mjs +1000 -177
  37. package/template/caits/eslint.js +18 -0
  38. package/template/caits/imports.mjs +14 -0
  39. package/template/caits/tsconfig.json +1 -1
  40. package/template/caits/vite.ts +4 -2
  41. package/template/project/AGENTS +9 -5
  42. package/template/project/quality/imports.mjs +10 -0
@@ -1,41 +1,58 @@
1
1
  // `caits mutation-summary`: the mutation score of each file, from the report of Stryker. With --survivors, the mutants that survived.
2
+ // In a terminal: the score in green when every mutant is killed, else in red; the score of each file the same way, and its path in cyan;
3
+ // for each survivor, its line dim, its status in red, its mutator in yellow.
2
4
  import { existsSync, readFileSync } from "node:fs";
5
+ import { NOT_RUN } from "./mutation-suite.mjs";
6
+ import { painterOf } from "./terminal.mjs";
3
7
 
4
8
  /** The report that Stryker writes in JSON (quality/caits/stryker.mjs). */
5
- const REPORT = "reports/mutation/mutation.json";
9
+ export const REPORT = "reports/mutation/mutation.json";
6
10
 
7
- /** @typedef {{ status: string; mutatorName: string; replacement?: string; location: { start: { line: number } } }} Mutant */
11
+ /** @typedef {{ status: string; statusReason?: string; mutatorName: string; replacement?: string; location: { start: { line: number } } }} Mutant */
8
12
 
9
13
  /** @type {(file: string) => unknown} */
10
14
  const readJson = file => JSON.parse(readFileSync(file, "utf8"));
11
15
 
16
+ /** Stryker counts a suite that stopped before its tests as a kill: the mutant is not tested (`caits mutation-suite`). @type {(m: Mutant) => boolean} */
17
+ const notTested = m => m.status === "Killed" && Boolean(m.statusReason?.includes(NOT_RUN));
18
+
19
+ /** @type {(m: Mutant) => string} */
20
+ const tag = m => (m.status === "NoCoverage" ? "[no cov]" : m.status === "Survived" ? "[survived]" : "[not tested]");
21
+
12
22
  /**
13
23
  * @param {string[]} args
14
- * @returns {number}
24
+ * @returns {number} 1 when a mutant is not tested
15
25
  */
16
26
  export function run(args) {
17
27
  if (!existsSync(REPORT)) throw new Error(`${REPORT} is missing: run the mutation tests first (npm run test:mutation).`);
18
28
  const report = /** @type {{ files: Record<string, { source: string; mutants: Mutant[] }> }} */ (readJson(REPORT));
19
29
  const rows = [];
20
- let killedTotal = 0, validTotal = 0;
30
+ let killedTotal = 0, validTotal = 0, notTestedTotal = 0;
21
31
  for (const [file, data] of Object.entries(report.files)) {
22
32
  /** @type {(status: string) => number} */
23
33
  const count = status => data.mutants.filter(m => m.status === status).length;
24
- const killed = count("Killed") + count("Timeout");
34
+ const untested = data.mutants.filter(notTested).length;
35
+ const killed = count("Killed") - untested + count("Timeout");
25
36
  const survived = count("Survived");
26
37
  const noCoverage = count("NoCoverage");
27
- const valid = killed + survived + noCoverage;
28
- killedTotal += killed; validTotal += valid;
29
- rows.push({ file, killed, survived, noCoverage, score: valid ? (100 * killed) / valid : 100, survivors: data.mutants.filter(m => m.status === "Survived" || m.status === "NoCoverage"), source: data.source.split("\n") });
38
+ const valid = killed + survived + noCoverage + untested;
39
+ killedTotal += killed; validTotal += valid; notTestedTotal += untested;
40
+ rows.push({ file, killed, survived, noCoverage, untested, score: valid ? (100 * killed) / valid : 100, survivors: data.mutants.filter(m => m.status === "Survived" || m.status === "NoCoverage" || notTested(m)), source: data.source.split("\n") });
30
41
  }
31
42
  rows.sort((a, b) => a.score - b.score);
32
43
  const verbose = args.includes("--survivors");
33
- console.log(`Mutation score: ${(validTotal ? (100 * killedTotal) / validTotal : 100).toFixed(1)}% (${killedTotal} killed of ${validTotal} valid mutants)\n`);
44
+ const paint = painterOf(process.stdout);
45
+ /** @type {(killed: number, valid: number) => "green" | "red"} */
46
+ const colorOf = (killed, valid) => killed === valid ? "green" : "red";
47
+ console.log(`${paint([colorOf(killedTotal, validTotal), "bold"], `Mutation score: ${(validTotal ? (100 * killedTotal) / validTotal : 100).toFixed(1)}% (${killedTotal} killed of ${validTotal} valid mutants)`)}\n`);
34
48
  for (const row of rows) {
35
- console.log(`${row.score.toFixed(0).padStart(4)}% ${row.file} (killed ${row.killed}, survived ${row.survived}, no coverage ${row.noCoverage})`);
49
+ const valid = row.killed + row.survived + row.noCoverage + row.untested;
50
+ console.log(`${paint(colorOf(row.killed, valid), `${row.score.toFixed(0).padStart(4)}%`)} ${paint("cyan", row.file)} (killed ${row.killed}, survived ${row.survived}, no coverage ${row.noCoverage}${row.untested ? `, not tested ${row.untested}` : ""})`);
36
51
  if (verbose && row.survivors.length) {
37
- for (const m of row.survivors) console.log(` L${m.location.start.line} ${m.status === "NoCoverage" ? "[no cov]" : "[survived]"} ${m.mutatorName}: ${(row.source[m.location.start.line - 1] ?? "").trim().slice(0, 90)} → ${String(m.replacement ?? "").slice(0, 60)}`);
52
+ for (const m of row.survivors) console.log(` ${paint("dim", `L${m.location.start.line}`)} ${paint("red", tag(m))} ${paint("yellow", m.mutatorName)}: ${(row.source[m.location.start.line - 1] ?? "").trim().slice(0, 90)} ${paint("dim", "→")} ${String(m.replacement ?? "").slice(0, 60)}`);
38
53
  }
39
54
  }
40
- return 0;
55
+ if (!notTestedTotal) return 0;
56
+ console.log(`\n${paint("red", `${notTestedTotal} ${notTestedTotal === 1 ? "mutant is" : "mutants are"} not tested: the unit tests did not run, and Stryker counts ${notTestedTotal === 1 ? "it" : "them"} as killed. Run the mutation tests again. Why they did not run: reports/mutation/index.html.`)}`);
57
+ return 1;
41
58
  }
@@ -0,0 +1,29 @@
1
+ // `caits mutation`: the mutation tests (Stryker, with quality/stryker.config.mjs), then the score of each file and the mutants that
2
+ // survived, whether Stryker passed or not. Stryker alone ends with its threshold when the score is too low: the survivors say what to fix.
3
+ // The run fails also when a mutant is not tested, which Stryker counts as killed (`caits mutation-summary`).
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync, rmSync } from "node:fs";
6
+ import { REPORT, run as summary } from "./mutation-summary.mjs";
7
+
8
+ /** The configuration of Stryker in a project: it imports the defaults of CAITS (quality/caits/stryker.mjs). */
9
+ export const STRYKER_CONFIG = "quality/stryker.config.mjs";
10
+
11
+ /**
12
+ * @param {string[]} args the options of Stryker
13
+ * @returns {Promise<number>} the exit code of Stryker, or 1 when a mutant is not tested
14
+ */
15
+ export async function run(args) {
16
+ if (!existsSync(STRYKER_CONFIG)) throw new Error(`${STRYKER_CONFIG} is missing: it gives the configuration of the mutation tests. Run \`npx caits init .\` to write it.`);
17
+ // A run that stops before its report must not show the summary of the run before.
18
+ rmSync(REPORT, { force: true });
19
+ // Ctrl+C and `caits check` stop the whole group of processes: Stryker and its test runners stop with this command.
20
+ const stryker = spawn("npx", ["stryker", "run", STRYKER_CONFIG, ...args], { stdio: "inherit" });
21
+ /** @type {number} */
22
+ const status = await new Promise(resolve => {
23
+ stryker.on("error", () => resolve(1));
24
+ stryker.on("close", code => resolve(code ?? 1));
25
+ });
26
+ if (!existsSync(REPORT)) return status;
27
+ console.log("");
28
+ return summary(["--survivors"]) || status;
29
+ }
@@ -0,0 +1,30 @@
1
+ // The colors of the outputs of CAITS. A tool prints its lines through the painter of its stream: in a terminal, the painter colors the
2
+ // parts of a line; in a pipe or a file (an AI agent, a log), it gives the text as it is, without a code. NO_COLOR turns the colors off,
3
+ // FORCE_COLOR turns them on, as for the other tools. Each kind of line has its colors: caits.dev shows the outputs with the same ones.
4
+ import { styleText } from "node:util";
5
+
6
+ /** @typedef {"bold" | "dim" | "red" | "green" | "yellow" | "cyan"} Style */
7
+ /** Gives the text with the styles, or as it is. @typedef {(style: Style | Style[], text: string) => string} Paint */
8
+
9
+ /**
10
+ * The painter of a stream: it colors when the stream is a terminal, or when FORCE_COLOR asks for colors, and never with NO_COLOR.
11
+ * @param {NodeJS.WriteStream} stream
12
+ * @returns {Paint}
13
+ */
14
+ export const painterOf = stream => (style, text) => styleText(style, text, { stream });
15
+
16
+ /** The painter of a text without colors. @type {Paint} */
17
+ export const plain = (_style, text) => text;
18
+
19
+ /** Whether a painter colors. @type {(paint: Paint) => boolean} */
20
+ export const colors = paint => paint("bold", "x") !== "x";
21
+
22
+ /**
23
+ * A command, then what it does, after two spaces or more: the command in cyan. A line without an explanation is all command.
24
+ * @param {string} step
25
+ * @param {Paint} paint
26
+ */
27
+ export function commandOf(step, paint) {
28
+ const [, command = step, explanation = ""] = /^(\S.*?)(\s{2,}.*)?$/.exec(step) ?? [];
29
+ return `${paint("cyan", command)}${explanation}`;
30
+ }
@@ -28,13 +28,12 @@ const readJson = file => JSON.parse(readFileSync(file, "utf8"));
28
28
 
29
29
  /** @type {Record<string, number>} */
30
30
  const PROJECTS = { "End-to-end": 0, Backend: 1, Frontend: 2, Other: 3 };
31
- const LAYERS = ["presentation", "application", "domain", "infra"];
31
+ const LAYERS = ["presentation", "command", "query", "event", "domain", "infra"];
32
32
 
33
33
  /** A path relative to the project root, with "/" on every system. @type {(file: string) => string} */
34
34
  export const fromProjectRoot = file => relative(projectRoot, file).split(sep).join("/");
35
35
 
36
36
  /** Removes the terminal colors from an error message. @type {(text: string) => string} */
37
- // eslint-disable-next-line no-control-regex -- a color of the terminal starts with the character ESC.
38
37
  export const withoutColors = text => text.replace(/\u001b\[[0-9;]*m/g, "");
39
38
 
40
39
  /**
@@ -1,4 +1,7 @@
1
- /** An event a context publishes: a fact that the context map may turn into input DTOs of other contexts. The publisher never waits for them. */
1
+ /**
2
+ * An event a context publishes: a fact of the context, that its context map may share as an event of the context shared, which the
3
+ * context maps of the other contexts turn into input DTOs of their own. The publisher never waits for them.
4
+ */
2
5
  export abstract class DomainEvent {
3
6
  declare protected readonly fact?: true;
4
7
  }
@@ -52,7 +52,11 @@ export class Container {
52
52
  private readonly built = new Map<Key, unknown>();
53
53
 
54
54
  constructor(bindings: readonly Binding[] = [], private readonly parent?: Container) {
55
- for (const binding of bindings) this.bindings.set(binding.key, binding);
55
+ for (const binding of bindings) {
56
+ // A di.ts that gives something else would lose it without a word: the kernel refuses it when it starts.
57
+ if (!isKey((binding as { key?: unknown } | undefined)?.key)) throw new Error(`A di.ts gives bindings, made with bind(Key).to(…), .toValue(…) or .toFactory(…): ${JSON.stringify(binding) ?? typeof binding} is not one`);
58
+ this.bindings.set(binding.key, binding);
59
+ }
56
60
  }
57
61
 
58
62
  /** A container whose bindings come first, and which asks this container for the others. */
@@ -1,7 +1,7 @@
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 { ContextMap } from "../messaging/ContextMap.js";
4
+ import { ContextMap, type Envelope, type Translation } from "../messaging/ContextMap.js";
5
5
  import { EventPublisher } from "../messaging/EventPublisher.js";
6
6
  import { InProcessMessageBus } from "../messaging/InProcessMessageBus.js";
7
7
  import type { Input } from "../messaging/Input.js";
@@ -18,20 +18,27 @@ export type BackendOptions = {
18
18
  nextEventId?: () => string;
19
19
  };
20
20
 
21
- type Context = { readonly container: Container; readonly useCases: readonly UseCase[] };
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[] };
22
23
 
23
24
  const SYSTEM: RequestContext = { correlationId: "system" };
24
25
 
26
+ /** The context whose events are those of shared: its handlers publish them on the shared bus. */
27
+ const SHARED = "shared";
28
+
25
29
  /**
26
30
  * Builds every context in a container of its own, child of the container of shared: the dependencies of a context are private to it,
27
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
28
- * and its message bus: its handlers and its controllers are built there. The backend event bus carries the events between contexts.
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
33
+ * 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
+ * of shared that it hears into inputs of the context. The handlers of the context shared publish its events there.
29
35
  */
30
36
  export function createBackend(modules: BackendModules, options: BackendOptions = {}) {
31
37
  const reportError = options.reportError ?? reportToConsole;
32
38
  const shared = new Container([bind(ReportError).toValue(reportError), ...modules.shared, ...(options.replace?.shared ?? [])]);
33
39
  const contexts = new Map<string, Context>();
34
40
  const owners = new Map<unknown, Context>();
41
+ const listeners = new Set<(event: DomainEvent) => void>();
35
42
 
36
43
  const ownerOf = (owned: Class): Context => {
37
44
  const context = owners.get(owned);
@@ -40,11 +47,11 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
40
47
  };
41
48
 
42
49
  /** The container of a request, and its message bus: each input DTO gets a new handler. */
43
- const requestOf = ({ container, useCases }: Context, request: RequestContext) => {
50
+ const requestOf = ({ container, useCases, shares }: Context, request: RequestContext) => {
44
51
  const bus = new InProcessMessageBus();
45
52
  const scope = container.child([
46
53
  bind(RequestContext).toValue(request),
47
- bind(EventPublisher).toValue({ publish: (event: DomainEvent) => { eventBus.publish(event, request); } }),
54
+ bind(EventPublisher).toValue({ publish: (event: DomainEvent) => { tell(shares, event, request); } }),
48
55
  ...(container.has(MessageBus) ? [] : [bind(MessageBus).toValue(bus)])
49
56
  ]);
50
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);
@@ -54,15 +61,44 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
54
61
  const send = async <Output>(input: Input<Output>, request: RequestContext = SYSTEM): Promise<Output> =>
55
62
  requestOf(ownerOf(input.constructor as Class), request).bus.send(input);
56
63
 
64
+ /**
65
+ * 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
+ * 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
+ */
68
+ const tell = (shares: Context["shares"], event: DomainEvent, request: RequestContext) => {
69
+ for (const listener of [...listeners]) listener(event);
70
+ let told: DomainEvent[];
71
+ try {
72
+ told = shares(event);
73
+ } catch (error) {
74
+ reportError(error);
75
+ return;
76
+ }
77
+ for (const sharedEvent of told) eventBus.publish(sharedEvent, request);
78
+ };
79
+
80
+ // The lines that hear shared in the context maps, as one: each line gives the inputs of its own context, never those of another.
81
+ const lines = Object.entries(modules.contexts).flatMap(([name, { map }]) => map.flatMap((line): Translation[] => line.shares ? [] : [{
82
+ shares: false,
83
+ event: line.event,
84
+ inputs: (event: never, envelope: Envelope) => {
85
+ const inputs = line.inputs(event, envelope);
86
+ const foreign = inputs.find(input => ownerOf(input.constructor as Class) !== contexts.get(name));
87
+ 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
+ return inputs;
89
+ }
90
+ }]));
57
91
  const eventBus = new BackendEventBus({
58
- map: new ContextMap(modules.map),
92
+ map: new ContextMap(lines),
59
93
  send: (input, envelope) => send(input, { correlationId: envelope.correlationId }),
60
94
  nextEventId: options.nextEventId ?? (() => crypto.randomUUID()),
61
95
  reportError
62
96
  });
63
97
 
64
- for (const [name, { bindings, useCases, controllers }] of Object.entries(modules.contexts)) {
65
- const context = { container: shared.child([...bindings, ...(options.replace?.[name] ?? [])]), useCases };
98
+ for (const [name, { bindings, useCases, controllers, map }] of Object.entries(modules.contexts)) {
99
+ const contextMap = new ContextMap(map);
100
+ const shares = name === SHARED ? (event: DomainEvent) => [event] : (event: DomainEvent) => contextMap.eventsFor(event);
101
+ const context = { container: shared.child([...bindings, ...(options.replace?.[name] ?? [])]), useCases, shares };
66
102
  for (const { input } of useCases) owners.set(input, context);
67
103
  for (const controller of controllers) owners.set(controller, context);
68
104
  contexts.set(name, context);
@@ -84,9 +120,16 @@ export function createBackend(modules: BackendModules, options: BackendOptions =
84
120
  /** The controller of a context, built for a request. */
85
121
  controller: <C>(controller: abstract new (...args: never[]) => C, request: RequestContext = SYSTEM): C =>
86
122
  requestOf(ownerOf(controller), request).scope.create(controller as unknown as Concrete<C>),
123
+ /** Publishes an event of shared on the shared bus. */
87
124
  publish: (event: DomainEvent, request: RequestContext = SYSTEM) => { eventBus.publish(event, request); },
88
125
  redeliver: (published: Published) => { eventBus.redeliver(published.event, published.envelope); },
126
+ /** Watches every event on the shared bus, in its envelope. */
89
127
  subscribe: (listener: (published: Published) => void) => eventBus.subscribe(listener),
128
+ /** Watches every event that the handlers publish in their context, before their context map shares it. */
129
+ subscribeToContexts: (listener: (event: DomainEvent) => void): (() => void) => {
130
+ listeners.add(listener);
131
+ return () => { listeners.delete(listener); };
132
+ },
90
133
  settled: () => eventBus.settled(),
91
134
  /** For the tests: the dependency with this token, built now if nothing needed it before. */
92
135
  get: <T>(token: Token<T>): T => {
@@ -11,7 +11,7 @@ import { Fetcher } from "./Fetcher.js";
11
11
  type Handler = new () => { execute(input: unknown): unknown };
12
12
 
13
13
  /**
14
- * Builds the frontend of a context: its event bus, its message bus with the handlers of its use cases, its store, and a container with
14
+ * Builds the frontend of a context: its event bus, its message bus with the handlers of its use cases, its store if it has one, and a container with
15
15
  * the bindings of its di.ts. The container gives each class what its constructor needs: a handler gets the event bus as its
16
16
  * EventPublisher. The store gets it as its EventSubscriber.
17
17
  */
@@ -25,11 +25,14 @@ function build(context: FrontendModules, fetch: Fetcher, replace: readonly Bindi
25
25
  container.check(handler as Handler);
26
26
  bus.subscribe(input as never, received => container.create(handler as Handler).execute(received) as never);
27
27
  }
28
- return { events, store: createStore(context.store, { events, bus }), bus, container };
28
+ return { events, store: context.store && createStore(context.store, { events, bus }), bus, container };
29
29
  }
30
30
 
31
- /** The frontend of a context, as the views get it: the publisher of its events, and the store they subscribe to. */
32
- export function createFrontend(context: FrontendModules, fetch: Fetcher, replace: readonly Binding[] = []): { events: EventPublisher<unknown>; store: Store<unknown> } {
31
+ /**
32
+ * The frontend of a context, as the views get it: the publisher of its events, and the store they subscribe to (none, for a context
33
+ * without a store). The kernel subscribes to its events too, for its context map: the events of the context that it shares.
34
+ */
35
+ export function createFrontend(context: FrontendModules, fetch: Fetcher, replace: readonly Binding[] = []): { events: FrontendEventBus<unknown>; store: Store<unknown> | undefined } {
33
36
  const { events, store } = build(context, fetch, replace);
34
37
  return { events, store };
35
38
  }
@@ -4,8 +4,12 @@ import type { Binding } from "./Container.js";
4
4
 
5
5
  export type Class = abstract new (...args: never[]) => unknown;
6
6
  export type UseCase = { readonly input: Class; readonly handler: Class };
7
- export type ContextModules = { bindings: readonly Binding[]; useCases: UseCase[]; controllers: Class[] };
8
- export type BackendModules = { shared: readonly Binding[]; contexts: Record<string, ContextModules>; map: Translation[] };
7
+ /**
8
+ * A context of the backend: its bindings, its use cases, its controllers, and its context map: the events of shared that it hears, and
9
+ * the events of its own that it shares.
10
+ */
11
+ export type ContextModules = { bindings: readonly Binding[]; useCases: UseCase[]; controllers: Class[]; map: Translation[] };
12
+ export type BackendModules = { shared: readonly Binding[]; contexts: Record<string, ContextModules> };
9
13
 
10
14
  type Modules = Record<string, Record<string, unknown>>;
11
15
 
@@ -17,10 +21,31 @@ export function single<T>(found: T[], what: string): T {
17
21
  return found[0];
18
22
  }
19
23
 
20
- /** Turns the modules found in the backend folder (src/backend by default) into the contexts of the backend: a handler serves the input DTO of its use-case folder. */
21
- export function discover(found: { bindings: Record<string, readonly Binding[]>; dtos: Modules; handlers: Modules; controllers: Modules; map: Record<string, Translation[]> }): BackendModules {
24
+ /**
25
+ * 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
+ * lose what it holds without a word. `isLine` tells a line of the side.
27
+ */
28
+ export function linesIn<L>(path: string, map: unknown, isLine: (line: Partial<Record<string, unknown>>) => boolean, kernel: string): L[] {
29
+ const refused = (what: string) => new Error(`${path} lists the lines of its context map, export default [when(…), share(…)], made with ${kernel}: ${what}`);
30
+ if (!Array.isArray(map)) throw refused(`${JSON.stringify(map) ?? typeof map} is not a list`);
31
+ for (const line of map as unknown[]) {
32
+ if (!isLine(line ?? {})) throw refused(`${JSON.stringify(line) ?? typeof line} is not a line`);
33
+ }
34
+ return map as L[];
35
+ }
36
+
37
+ /** A line of a backend context map: one that hears gives inputs, one that shares gives events. */
38
+ const isTranslation = ({ shares, event, inputs, events }: Partial<Record<string, unknown>>) =>
39
+ typeof event === "function" && (shares === false ? typeof inputs === "function" : shares === true && typeof events === "function");
40
+
41
+ /**
42
+ * 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
44
+ * shares.
45
+ */
46
+ export function discover(found: { bindings: Record<string, readonly Binding[]>; dtos: Modules; handlers: Modules; controllers: Modules; maps: Record<string, Translation[]> }): BackendModules {
22
47
  const contexts: Record<string, ContextModules> = {};
23
- const context = (path: string) => contexts[contextOf(path)] ??= { bindings: [], useCases: [], controllers: [] };
48
+ const context = (path: string) => contexts[contextOf(path)] ??= { bindings: [], useCases: [], controllers: [], map: [] };
24
49
  let shared: readonly Binding[] = [];
25
50
 
26
51
  for (const [path, bindings] of Object.entries(found.bindings)) {
@@ -34,6 +59,10 @@ export function discover(found: { bindings: Record<string, readonly Binding[]>;
34
59
  context(path).useCases.push({ input, handler });
35
60
  }
36
61
  for (const [path, module] of Object.entries(found.controllers)) context(path).controllers.push(...classesOf(module));
62
+ for (const [path, map] of Object.entries(found.maps)) {
63
+ if (contextOf(path) === "shared") throw new Error(`the backend context shared hears no other context: it has no context map (${path})`);
64
+ context(path).map = linesIn(path, map, isTranslation, "@ingenioz-it/caits/messaging/ContextMap");
65
+ }
37
66
 
38
- return { shared, contexts, map: Object.values(found.map)[0] ?? [] };
67
+ return { shared, contexts };
39
68
  }
@@ -1,29 +1,47 @@
1
1
  import { Input } from "../messaging/Input.js";
2
+ import type { Line } from "../messaging/FrontendContextMap.js";
2
3
  import type { StoreDefinition } from "../state/createStore.js";
3
4
  import type { Binding } from "./Container.js";
4
- import { classesOf, single, type UseCase } from "./discover.js";
5
+ import { classesOf, linesIn, single, type UseCase } from "./discover.js";
5
6
 
6
- export type FrontendModules = { bindings: readonly Binding[]; useCases: UseCase[]; store: StoreDefinition<unknown, unknown> };
7
+ /**
8
+ * A context of the frontend: its bindings, its use cases, its store (none for a context without a use case: its views and its
9
+ * components publish events, and read no state), and its context map: the events of the context shared that it hears, and the events
10
+ * of its own that it shares.
11
+ */
12
+ export type FrontendModules = { bindings: readonly Binding[]; useCases: UseCase[]; store?: StoreDefinition<unknown, unknown>; map: readonly Line[] };
7
13
 
8
14
  type Modules = Record<string, Record<string, unknown>>;
9
15
 
10
16
  const contextOf = (path: string) => /frontend\/([^/]+)\//.exec(path)![1];
11
17
 
18
+ /** A line of a frontend context map: one that hears and one that shares both give events. */
19
+ const isLine = ({ shares, event, events }: Partial<Record<string, unknown>>) => typeof shares === "boolean" && typeof event === "function" && typeof events === "function";
20
+
12
21
  /**
13
- * Turns the modules found in the frontend folder (src/frontend by default) into the contexts of the frontend: a context has a di.ts and a store,
14
- * and a handler serves the input DTO of its use-case folder.
22
+ * 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.
15
25
  */
16
26
  export function discoverFrontends(found: {
17
27
  bindings: Record<string, readonly Binding[]>;
18
28
  dtos: Modules;
19
29
  handlers: Modules;
20
30
  stores: Record<string, StoreDefinition<unknown, unknown>>;
31
+ maps: Record<string, readonly Line[]>;
21
32
  }): Record<string, FrontendModules> {
22
33
  const contexts: Record<string, FrontendModules> = {};
23
34
  for (const [path, bindings] of Object.entries(found.bindings)) {
24
35
  const name = contextOf(path);
25
- const store = single(Object.entries(found.stores).filter(([storePath]) => contextOf(storePath) === name).map(([, definition]) => definition), `the frontend ${name} declares one store in presentation/store/`);
26
- contexts[name] = { bindings, useCases: [], store };
36
+ const stores = Object.entries(found.stores).filter(([storePath]) => contextOf(storePath) === name).map(([, definition]) => definition);
37
+ if (stores.length > 1) throw new Error(`the frontend ${name} declares one store at most in presentation/store/ (found ${stores.length})`);
38
+ contexts[name] = { bindings, useCases: [], store: stores[0], map: [] };
39
+ }
40
+ for (const [path, map] of Object.entries(found.maps)) {
41
+ const context = contexts[contextOf(path)];
42
+ if (!context) throw new Error(`${path} belongs to a frontend without a di.ts`);
43
+ if (contextOf(path) === "shared") throw new Error(`the frontend context shared hears no other context: it has no context map (${path})`);
44
+ context.map = linesIn(path, map, isLine, "@ingenioz-it/caits/messaging/FrontendContextMap");
27
45
  }
28
46
  for (const [path, module] of Object.entries(found.handlers)) {
29
47
  const context = contexts[contextOf(path)];
@@ -32,5 +50,10 @@ export function discoverFrontends(found: {
32
50
  const input = single(classesOf(found.dtos[dto] ?? {}).filter(type => type.prototype instanceof Input), `${dto} declares one input DTO`);
33
51
  context.useCases.push({ input, handler: single(classesOf(module), `${path} declares one handler`) });
34
52
  }
53
+ for (const [name, context] of Object.entries(contexts)) {
54
+ if (context.useCases.length > 0 && !context.store) {
55
+ throw new Error(`the frontend ${name} has use cases, but no store to send their input DTOs: it declares one in presentation/store/`);
56
+ }
57
+ }
35
58
  return contexts;
36
59
  }
@@ -1,6 +1,6 @@
1
1
  /// <reference path="../virtual-modules.d.ts" />
2
- import { bindings, dtos, handlers, controllers, map } from "virtual:kernel/backend";
2
+ import { bindings, dtos, handlers, controllers, maps } from "virtual:kernel/backend";
3
3
  import { discover } from "./discover.js";
4
4
 
5
- /** Every context of the backend: its di.ts, its use cases and its controllers, and the context map. The plugin of the kernel finds them. */
6
- export const discovered = discover({ bindings, dtos, handlers, controllers, map });
5
+ /** Every context of the backend: its di.ts, its use cases, its controllers and its context map. The plugin of the kernel finds them. */
6
+ export const discovered = discover({ bindings, dtos, handlers, controllers, maps });
@@ -1,6 +1,6 @@
1
1
  /// <reference path="../virtual-modules.d.ts" />
2
- import { bindings, dtos, handlers, stores } from "virtual:kernel/frontend";
2
+ import { bindings, dtos, handlers, maps, stores } from "virtual:kernel/frontend";
3
3
  import { discoverFrontends } from "./discoverFrontends.js";
4
4
 
5
- /** Every context of the frontend: its di.ts, its use cases and its store. The plugin of the kernel finds them. */
6
- export const discoveredFrontends = discoverFrontends({ bindings, dtos, handlers, stores });
5
+ /** Every context of the frontend: its di.ts, its use cases, its store and its context map. The plugin of the kernel finds them. */
6
+ export const discoveredFrontends = discoverFrontends({ bindings, dtos, handlers, stores, maps });
@@ -1,3 +1,5 @@
1
+ import type { EventPublisher } from "../messaging/EventPublisher.js";
2
+ import { eventsFor, type Line } from "../messaging/FrontendContextMap.js";
1
3
  import { createFrontend } from "./createFrontend.js";
2
4
  import { discoveredFrontends } from "./discoveredFrontends.js";
3
5
  import type { Fetcher } from "./Fetcher.js";
@@ -6,7 +8,6 @@ import type { Fetcher } from "./Fetcher.js";
6
8
  * The frontends of the contexts, by name. Each context adds its own, next to its type:
7
9
  * `declare module "@ingenioz-it/caits/di/frontend" { interface Frontends { counter: CounterFrontend } }`.
8
10
  */
9
- // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- the contexts fill it
10
11
  export interface Frontends {}
11
12
 
12
13
  const kept = Symbol("frontends");
@@ -17,17 +18,58 @@ export function frontendsOn($global: object): Map<string, unknown> {
17
18
  return ($global as Global)[kept] ??= new Map<string, unknown>();
18
19
  }
19
20
 
21
+ /** The context whose bus is the shared bus: the context maps share its events there, and hear them. */
22
+ const SHARED = "shared";
23
+
20
24
  /**
21
25
  * The frontend of a context, for the views of one $global: the server has one $global per request, the browser one per page.
22
26
  * It is built the first time a view asks. On the server, it reaches the API with the fetch of the request (Marko Run, in the
23
- * same process); in the browser, with the fetch of the browser.
27
+ * same process); in the browser, with the fetch of the browser. The bus of the context shared is the shared bus of the $global: the
28
+ * context map of a context shares there the events of shared that the events of its context give, and each of them goes to the
29
+ * contexts whose context map hears it.
24
30
  */
25
31
  export function frontendOf<Name extends keyof Frontends>($global: object, name: Name): Frontends[Name] {
32
+ return frontendNamed($global, name) as Frontends[Name];
33
+ }
34
+
35
+ /** The frontend of the context of this name on a $global, built the first time that something asks for it. */
36
+ function frontendNamed($global: object, name: string): unknown {
26
37
  const frontends = frontendsOn($global);
27
38
  if (!frontends.has(name)) {
28
39
  const context = discoveredFrontends[name];
29
40
  if (!context) throw new Error(`No frontend context is named ${name}`);
30
- frontends.set(name, createFrontend(context, ($global as Global).fetch ?? ((input, init) => fetch(input, init))));
41
+ const frontend = createFrontend(context, ($global as Global).fetch ?? ((input, init) => fetch(input, init)));
42
+ frontends.set(name, frontend);
43
+ if (name === SHARED) frontend.events.subscribe(event => { hear($global, event as object); });
44
+ frontend.events.subscribe(event => { tell($global, context.map, event as object); });
45
+ }
46
+ return frontends.get(name);
47
+ }
48
+
49
+ /** The lines of a map that hear the context shared, or those that share with it. */
50
+ const lines = (map: readonly Line[], shares: boolean) => map.filter(line => line.shares === shares);
51
+
52
+ /**
53
+ * An event of a context, on its bus: the events of shared that the lines of its map that share give go to the shared bus of the $global.
54
+ * The frontend of shared is built when a context first shares an event.
55
+ */
56
+ function tell($global: object, map: readonly Line[], event: object): void {
57
+ const told = eventsFor(lines(map, true), event);
58
+ if (told.length === 0) return;
59
+ const { events: shared } = frontendNamed($global, SHARED) as { events: EventPublisher<object> };
60
+ for (const sharedEvent of told) shared.publish(sharedEvent);
61
+ }
62
+
63
+ /**
64
+ * An event of the context shared, on the shared bus of a $global: each context whose context map hears it publishes the events that
65
+ * the lines that hear it give on its own bus, its frontend built if no view asked for it yet. A context that does not hear it is not
66
+ * built.
67
+ */
68
+ function hear($global: object, event: object): void {
69
+ for (const [name, context] of Object.entries(discoveredFrontends)) {
70
+ const events = eventsFor(lines(context.map, false), event);
71
+ if (events.length === 0) continue;
72
+ const { events: bus } = frontendNamed($global, name) as { events: EventPublisher<object> };
73
+ for (const own of events) bus.publish(own);
31
74
  }
32
- return frontends.get(name) as Frontends[Name];
33
75
  }
@@ -8,8 +8,9 @@ export type Published = { readonly event: DomainEvent; readonly envelope: Envelo
8
8
  const nextTurn = () => new Promise(resolve => { setTimeout(resolve, 0); });
9
9
 
10
10
  /**
11
- * Carries the events of the contexts. The context map turns each event into input DTOs, and each input goes to the context that
12
- * handles it. The publisher never waits for the reactions; every failure is reported.
11
+ * The shared bus: carries the events of shared that the context maps share. The context map of each context turns the events of shared
12
+ * that it hears into input DTOs of the context, and each input goes to it. The publisher never waits for the reactions; every failure is
13
+ * reported.
13
14
  */
14
15
  export class BackendEventBus {
15
16
  private inFlight = new Set<Promise<void>>();
@@ -34,7 +35,7 @@ export class BackendEventBus {
34
35
  void delivery.then(() => { this.inFlight.delete(delivery); });
35
36
  }
36
37
 
37
- /** Watches every event the contexts publish. */
38
+ /** Watches every event on the shared bus. */
38
39
  subscribe(listener: (published: Published) => void): () => void {
39
40
  this.listeners.add(listener);
40
41
  return () => { this.listeners.delete(listener); };
@@ -6,17 +6,41 @@ export type Envelope = { readonly eventId: string; readonly correlationId: strin
6
6
 
7
7
  type EventClass<E> = abstract new (...args: never[]) => E;
8
8
 
9
- export type Translation = { readonly event: EventClass<DomainEvent>; readonly inputs: (event: never, envelope: Envelope) => Input<unknown>[] };
9
+ /**
10
+ * One line of the context map of a backend context (its context-map.ts): an event of the context shared that it hears, which becomes
11
+ * input DTOs of the context, or an event of the context that it shares, which becomes events of the context shared.
12
+ */
13
+ export type Translation =
14
+ | { readonly shares: false; readonly event: EventClass<DomainEvent>; readonly inputs: (event: never, envelope: Envelope) => Input<unknown>[] }
15
+ | { readonly shares: true; readonly event: EventClass<DomainEvent>; readonly events: (event: never) => DomainEvent[] };
10
16
 
11
- /** One line of the context map: an event of a context becomes input DTOs, for whichever contexts handle them. */
17
+ /**
18
+ * A line that hears the context shared: when this event of shared comes on the shared bus, the handlers of the context execute these
19
+ * input DTOs. A context map names only its own context and the context shared: two contexts never name each other.
20
+ */
12
21
  export function when<E extends DomainEvent>(event: EventClass<E>, inputs: (event: E, envelope: Envelope) => Input<unknown>[]): Translation {
13
- return { event, inputs };
22
+ return { shares: false, event, inputs };
23
+ }
24
+
25
+ /**
26
+ * A line that tells the other contexts: when a handler of the context publishes this event of its own, the context publishes these
27
+ * events of shared on the shared bus, and the context map of each context that hears them turns them into inputs of its own. The event
28
+ * of the context holds what the events of shared need.
29
+ */
30
+ export function share<E extends DomainEvent>(event: EventClass<E>, events: (event: E) => DomainEvent[]): Translation {
31
+ return { shares: true, event, events };
14
32
  }
15
33
 
16
34
  export class ContextMap {
17
35
  constructor(private readonly translations: Translation[]) {}
18
36
 
37
+ /** The input DTOs that the lines that hear this event of shared give, none when no line hears it. */
19
38
  inputsFor(event: DomainEvent, envelope: Envelope): Input<unknown>[] {
20
- return this.translations.filter(translation => event instanceof translation.event).flatMap(translation => translation.inputs(event as never, envelope));
39
+ return this.translations.flatMap(translation => !translation.shares && event instanceof translation.event ? translation.inputs(event as never, envelope) : []);
40
+ }
41
+
42
+ /** The events of shared that the lines that share this event of the context give, none when no line shares it. */
43
+ eventsFor(event: DomainEvent): DomainEvent[] {
44
+ return this.translations.flatMap(translation => translation.shares && event instanceof translation.event ? translation.events(event as never) : []);
21
45
  }
22
46
  }
@@ -2,8 +2,8 @@ import { Token } from "../Token.js";
2
2
 
3
3
  /**
4
4
  * Publishes the events of a context: the handlers get it, and the views of the frontend. It cannot subscribe, and it gives nothing back.
5
- * In the backend, the events go later to the other contexts, through the context map. In the frontend, they go at once to the store of
6
- * the same context.
5
+ * In the backend, the context map of the context shares them as events of shared, which go later to the other contexts. In the frontend,
6
+ * they go at once to the store of the same context, and its context map shares them.
7
7
  */
8
8
  export interface EventPublisher<E> {
9
9
  publish(event: E): void;