@ingenioz-it/caits 0.5.1 → 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.
- package/bin/caits.js +17 -12
- package/bin/init.js +15 -6
- package/package.json +1 -1
- package/quality/architecture/adapters/marko.mjs +7 -4
- package/quality/architecture/adapters/scss.mjs +29 -8
- package/quality/architecture/adapters/typescript.mjs +2 -2
- package/quality/architecture/facts.mjs +137 -30
- package/quality/architecture/project.mjs +114 -22
- package/quality/architecture.mjs +68 -22
- package/quality/check.mjs +20 -14
- package/quality/dev.mjs +3 -2
- package/quality/mutation-suite.mjs +56 -5
- package/quality/mutation-summary.mjs +28 -11
- package/quality/mutation.mjs +5 -6
- package/quality/terminal.mjs +30 -0
- package/quality/test-docs/test-docs.mjs +1 -2
- package/src/DomainEvent.ts +4 -1
- package/src/di/Container.ts +5 -1
- package/src/di/createBackend.ts +51 -8
- package/src/di/createFrontend.ts +7 -4
- package/src/di/discover.ts +35 -6
- package/src/di/discoverFrontends.ts +29 -6
- package/src/di/discovered.ts +3 -3
- package/src/di/discoveredFrontends.ts +3 -3
- package/src/di/frontend.ts +46 -4
- package/src/messaging/BackendEventBus.ts +4 -3
- package/src/messaging/ContextMap.ts +28 -4
- package/src/messaging/EventPublisher.ts +2 -2
- package/src/messaging/FrontendContextMap.ts +33 -0
- package/src/testing/backend/applications.ts +7 -6
- package/src/testing/backend/context-map.ts +4 -1
- package/src/testing/frontend/context-map.ts +6 -0
- package/src/testing/frontend/frontends.ts +16 -9
- package/src/virtual-modules.d.ts +3 -1
- package/src/vite.js +20 -13
- package/template/caits/architecture.mjs +1000 -177
- package/template/caits/eslint.js +18 -0
- package/template/caits/imports.mjs +14 -0
- package/template/caits/tsconfig.json +1 -1
- package/template/caits/vite.ts +4 -2
- package/template/project/AGENTS +9 -5
- 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
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
|
|
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
|
-
|
|
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
|
-
|
|
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} ${
|
|
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
|
}
|
package/quality/mutation.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// `caits mutation`: the mutation tests (Stryker, with quality/stryker.config.mjs), then the score of each file and the mutants that
|
|
2
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`).
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
4
5
|
import { existsSync, rmSync } from "node:fs";
|
|
5
6
|
import { REPORT, run as summary } from "./mutation-summary.mjs";
|
|
@@ -9,7 +10,7 @@ export const STRYKER_CONFIG = "quality/stryker.config.mjs";
|
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* @param {string[]} args the options of Stryker
|
|
12
|
-
* @returns {Promise<number>} the exit code of Stryker
|
|
13
|
+
* @returns {Promise<number>} the exit code of Stryker, or 1 when a mutant is not tested
|
|
13
14
|
*/
|
|
14
15
|
export async function run(args) {
|
|
15
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.`);
|
|
@@ -22,9 +23,7 @@ export async function run(args) {
|
|
|
22
23
|
stryker.on("error", () => resolve(1));
|
|
23
24
|
stryker.on("close", code => resolve(code ?? 1));
|
|
24
25
|
});
|
|
25
|
-
if (existsSync(REPORT))
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
return status;
|
|
26
|
+
if (!existsSync(REPORT)) return status;
|
|
27
|
+
console.log("");
|
|
28
|
+
return summary(["--survivors"]) || status;
|
|
30
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", "
|
|
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
|
/**
|
package/src/DomainEvent.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
/**
|
|
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
|
}
|
package/src/di/Container.ts
CHANGED
|
@@ -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)
|
|
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. */
|
package/src/di/createBackend.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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) => {
|
|
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(
|
|
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
|
|
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 => {
|
package/src/di/createFrontend.ts
CHANGED
|
@@ -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
|
-
/**
|
|
32
|
-
|
|
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
|
}
|
package/src/di/discover.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
14
|
-
* and a handler serves the input DTO of its
|
|
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
|
|
26
|
-
|
|
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
|
}
|
package/src/di/discovered.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference path="../virtual-modules.d.ts" />
|
|
2
|
-
import { bindings, dtos, handlers, controllers,
|
|
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
|
|
6
|
-
export const discovered = discover({ bindings, dtos, handlers, controllers,
|
|
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
|
|
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 });
|
package/src/di/frontend.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
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;
|