@ingenioz-it/caits 0.5.1 → 0.7.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 +158 -31
- package/quality/architecture/project.mjs +121 -27
- 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 +60 -15
- package/src/di/createFrontend.ts +7 -4
- package/src/di/declareDependencies.js +25 -5
- package/src/di/discover.ts +52 -13
- package/src/di/discoverFrontends.ts +30 -11
- 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 +6 -6
- package/src/messaging/ContextMap.ts +30 -7
- package/src/messaging/Controller.ts +2 -3
- package/src/messaging/EventPublisher.ts +2 -2
- package/src/messaging/FrontendContextMap.ts +33 -0
- package/src/messaging/InProcessMessageBus.ts +5 -7
- package/src/messaging/MessageBus.ts +1 -2
- package/src/state/createStore.ts +1 -2
- package/src/testing/backend/application.ts +1 -1
- package/src/testing/backend/applicationScenario.ts +6 -7
- package/src/testing/backend/applications.ts +7 -6
- package/src/testing/backend/context-map.ts +5 -2
- package/src/testing/backend/integration.ts +1 -1
- package/src/testing/frontend/applicationScenario.ts +2 -3
- package/src/testing/frontend/applications.ts +3 -3
- package/src/testing/frontend/context-map.ts +6 -0
- package/src/testing/frontend/frontends.ts +17 -11
- package/src/virtual-modules.d.ts +3 -3
- package/src/vite.js +18 -13
- package/template/caits/architecture.mjs +1040 -187
- 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 +7 -3
- package/template/project/AGENTS +9 -5
- package/template/project/quality/imports.mjs +10 -0
- package/src/messaging/Input.ts +0 -4
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
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { DomainEvent } from "../DomainEvent.js";
|
|
2
2
|
import type { ContextMap, Envelope } from "./ContextMap.js";
|
|
3
|
-
import type { Input } from "./Input.js";
|
|
4
3
|
import type { ReportError } from "./ReportError.js";
|
|
5
4
|
|
|
6
5
|
export type Published = { readonly event: DomainEvent; readonly envelope: Envelope };
|
|
@@ -8,8 +7,9 @@ export type Published = { readonly event: DomainEvent; readonly envelope: Envelo
|
|
|
8
7
|
const nextTurn = () => new Promise(resolve => { setTimeout(resolve, 0); });
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The shared bus: carries the events of shared that the context maps share. The context map of each context turns the events of shared
|
|
11
|
+
* 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
|
|
12
|
+
* reported.
|
|
13
13
|
*/
|
|
14
14
|
export class BackendEventBus {
|
|
15
15
|
private inFlight = new Set<Promise<void>>();
|
|
@@ -17,7 +17,7 @@ export class BackendEventBus {
|
|
|
17
17
|
|
|
18
18
|
constructor(private readonly options: {
|
|
19
19
|
map: ContextMap;
|
|
20
|
-
send: (input:
|
|
20
|
+
send: (input: object, envelope: Envelope) => Promise<unknown>;
|
|
21
21
|
nextEventId: () => string;
|
|
22
22
|
reportError: ReportError;
|
|
23
23
|
}) {}
|
|
@@ -34,7 +34,7 @@ export class BackendEventBus {
|
|
|
34
34
|
void delivery.then(() => { this.inFlight.delete(delivery); });
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/** Watches every event the
|
|
37
|
+
/** Watches every event on the shared bus. */
|
|
38
38
|
subscribe(listener: (published: Published) => void): () => void {
|
|
39
39
|
this.listeners.add(listener);
|
|
40
40
|
return () => { this.listeners.delete(listener); };
|
|
@@ -47,7 +47,7 @@ export class BackendEventBus {
|
|
|
47
47
|
|
|
48
48
|
private async deliver(event: DomainEvent, envelope: Envelope): Promise<void> {
|
|
49
49
|
await nextTurn();
|
|
50
|
-
let inputs:
|
|
50
|
+
let inputs: object[];
|
|
51
51
|
try {
|
|
52
52
|
inputs = this.options.map.inputsFor(event, envelope);
|
|
53
53
|
} catch (error) {
|
|
@@ -1,22 +1,45 @@
|
|
|
1
1
|
import type { DomainEvent } from "../DomainEvent.js";
|
|
2
|
-
import type { Input } from "./Input.js";
|
|
3
2
|
|
|
4
3
|
/** What the shared bus adds to an event it carries: an id of its own, and the correlation id of the request that published it. */
|
|
5
4
|
export type Envelope = { readonly eventId: string; readonly correlationId: string };
|
|
6
5
|
|
|
7
6
|
type EventClass<E> = abstract new (...args: never[]) => E;
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* 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
|
|
10
|
+
* input DTOs of the context, or an event of the context that it shares, which becomes events of the context shared.
|
|
11
|
+
*/
|
|
12
|
+
export type Translation =
|
|
13
|
+
| { readonly shares: false; readonly event: EventClass<DomainEvent>; readonly inputs: (event: never, envelope: Envelope) => object[] }
|
|
14
|
+
| { readonly shares: true; readonly event: EventClass<DomainEvent>; readonly events: (event: never) => DomainEvent[] };
|
|
10
15
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
/**
|
|
17
|
+
* A line that hears the context shared: when this event of shared comes on the shared bus, the handlers of the context execute these
|
|
18
|
+
* input DTOs. A context map names only its own context and the context shared: two contexts never name each other.
|
|
19
|
+
*/
|
|
20
|
+
export function when<E extends DomainEvent>(event: EventClass<E>, inputs: (event: E, envelope: Envelope) => object[]): Translation {
|
|
21
|
+
return { shares: false, event, inputs };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A line that tells the other contexts: when a handler of the context publishes this event of its own, the context publishes these
|
|
26
|
+
* 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
|
|
27
|
+
* of the context holds what the events of shared need.
|
|
28
|
+
*/
|
|
29
|
+
export function share<E extends DomainEvent>(event: EventClass<E>, events: (event: E) => DomainEvent[]): Translation {
|
|
30
|
+
return { shares: true, event, events };
|
|
14
31
|
}
|
|
15
32
|
|
|
16
33
|
export class ContextMap {
|
|
17
34
|
constructor(private readonly translations: Translation[]) {}
|
|
18
35
|
|
|
19
|
-
|
|
20
|
-
|
|
36
|
+
/** The input DTOs that the lines that hear this event of shared give, none when no line hears it. */
|
|
37
|
+
inputsFor(event: DomainEvent, envelope: Envelope): object[] {
|
|
38
|
+
return this.translations.flatMap(translation => !translation.shares && event instanceof translation.event ? translation.inputs(event as never, envelope) : []);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The events of shared that the lines that share this event of the context give, none when no line shares it. */
|
|
42
|
+
eventsFor(event: DomainEvent): DomainEvent[] {
|
|
43
|
+
return this.translations.flatMap(translation => translation.shares && event instanceof translation.event ? translation.events(event as never) : []);
|
|
21
44
|
}
|
|
22
45
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Input } from "./Input.js";
|
|
2
1
|
import type { MessageBus } from "./MessageBus.js";
|
|
3
2
|
import { reportToConsole, type ReportError } from "./ReportError.js";
|
|
4
3
|
|
|
@@ -68,7 +67,7 @@ export function problem(status: number, slug: string, title: string, detail?: st
|
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
/** Turns a request into an input DTO, sends it to the message bus of the request, and turns the output or the error into a response. */
|
|
71
|
-
export abstract class Controller<Dto extends
|
|
70
|
+
export abstract class Controller<Dto extends object, Output> {
|
|
72
71
|
constructor(private bus: MessageBus, private reportError: ReportError = reportToConsole) {}
|
|
73
72
|
|
|
74
73
|
protected abstract toDto(request: Request): Dto | Promise<Dto>;
|
|
@@ -92,7 +91,7 @@ export abstract class Controller<Dto extends Input<Output>, Output> {
|
|
|
92
91
|
try {
|
|
93
92
|
let output: Output;
|
|
94
93
|
try {
|
|
95
|
-
output = await this.bus.send(await this.toDto(request));
|
|
94
|
+
output = await this.bus.send(await this.toDto(request)) as Output;
|
|
96
95
|
} catch (error) {
|
|
97
96
|
return await this.toErrorResponse(error, request);
|
|
98
97
|
}
|
|
@@ -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;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
type EventClass<E> = abstract new (...args: never[]) => E;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One line of the context map of a frontend context: an event of the context shared that it hears, which becomes events of the context,
|
|
5
|
+
* or an event of the context that it shares, which becomes events of the context shared.
|
|
6
|
+
*/
|
|
7
|
+
export type Line = { readonly shares: boolean; readonly event: EventClass<object>; readonly events: (event: never) => object[] };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A line that hears the context shared, in the context map of a frontend context (its context-map.ts): when this event of shared comes
|
|
11
|
+
* on the shared bus, the context publishes these events of its own on its own bus, and its store reads them. A context map names only
|
|
12
|
+
* its own context and the context shared: two contexts never name each other.
|
|
13
|
+
*/
|
|
14
|
+
export function when<E extends object>(event: EventClass<E>, events: (event: E) => object[]): Line {
|
|
15
|
+
return { shares: false, event, events };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A line that tells the other contexts: when a view, a component or a handler of the context publishes this event of its own on its
|
|
20
|
+
* bus, the context publishes these events of shared on the shared bus, and the context map of each context that hears them turns them
|
|
21
|
+
* into events of its own. The event of the context holds what the events of shared need.
|
|
22
|
+
*/
|
|
23
|
+
export function share<E extends object>(event: EventClass<E>, events: (event: E) => object[]): Line {
|
|
24
|
+
return { shares: true, event, events };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The events that a map gives for an event, line by line, none when no line reads it: for an event of shared that it hears, events of
|
|
29
|
+
* the context; for an event of the context that it shares, events of shared.
|
|
30
|
+
*/
|
|
31
|
+
export function eventsFor(map: readonly Line[], event: object): object[] {
|
|
32
|
+
return map.filter(line => event instanceof line.event).flatMap(line => line.events(event as never));
|
|
33
|
+
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import type { Input } from "./Input.js";
|
|
2
1
|
import type { MessageBus } from "./MessageBus.js";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
type InputClass<I extends Input<unknown>> = abstract new (...args: never[]) => I;
|
|
3
|
+
type InputClass<I> = abstract new (...args: never[]) => I;
|
|
6
4
|
|
|
7
5
|
export class UnknownInputError extends Error {
|
|
8
6
|
constructor(readonly inputName: string) {
|
|
9
|
-
super(`No handler
|
|
7
|
+
super(`No handler takes ${inputName}: write one next to its DTO, in <use-case>.handler.ts, with execute(input: ${inputName})`);
|
|
10
8
|
this.name = "UnknownInputError";
|
|
11
9
|
}
|
|
12
10
|
}
|
|
@@ -22,14 +20,14 @@ export class DuplicateHandlerError extends Error {
|
|
|
22
20
|
export class InProcessMessageBus implements MessageBus {
|
|
23
21
|
private handlers = new Map<unknown, (input: never) => unknown>();
|
|
24
22
|
|
|
25
|
-
subscribe<I extends
|
|
23
|
+
subscribe<I extends object>(input: InputClass<I>, handler: (input: I) => unknown): void {
|
|
26
24
|
if (this.handlers.has(input)) throw new DuplicateHandlerError(input.name);
|
|
27
25
|
this.handlers.set(input, handler);
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
async send
|
|
28
|
+
async send(input: object): Promise<unknown> {
|
|
31
29
|
const handler = this.handlers.get(input.constructor);
|
|
32
30
|
if (!handler) throw new UnknownInputError(input.constructor.name);
|
|
33
|
-
return
|
|
31
|
+
return await handler(input as never);
|
|
34
32
|
}
|
|
35
33
|
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { Token } from "../Token.js";
|
|
2
|
-
import type { Input } from "./Input.js";
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Where a controller or a store sends an input DTO: the message bus gives it to the handler of its class, and gives back the output of
|
|
6
5
|
* the handler.
|
|
7
6
|
*/
|
|
8
7
|
export interface MessageBus {
|
|
9
|
-
send
|
|
8
|
+
send(input: object): Promise<unknown>;
|
|
10
9
|
}
|
|
11
10
|
|
|
12
11
|
export const MessageBus = new Token<MessageBus>();
|
package/src/state/createStore.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { EventSubscriber } from "../messaging/EventSubscriber.js";
|
|
2
|
-
import type { Input } from "../messaging/Input.js";
|
|
3
2
|
import type { MessageBus } from "../messaging/MessageBus.js";
|
|
4
3
|
import { reportToConsole, type ReportError } from "../messaging/ReportError.js";
|
|
5
4
|
|
|
@@ -9,7 +8,7 @@ export interface StoreDefinition<S, E> {
|
|
|
9
8
|
/** The state after the event. */
|
|
10
9
|
reduce(state: S, event: E): S;
|
|
11
10
|
/** The input DTO that the event asks the store to send to the message bus, or undefined. It reads the state before the event. */
|
|
12
|
-
send(state: S, event: E):
|
|
11
|
+
send(state: S, event: E): object | undefined;
|
|
13
12
|
}
|
|
14
13
|
|
|
15
14
|
export interface Store<S> {
|
|
@@ -2,7 +2,7 @@ import { test as base } from "vitest";
|
|
|
2
2
|
import { createTestApplication, type TestApplication } from "./applications.js";
|
|
3
3
|
|
|
4
4
|
/** Everything an application test of the backend imports: the test of a use case, written as a scenario. */
|
|
5
|
-
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, vi } from "vitest";
|
|
5
|
+
export { afterAll, afterEach, assert, beforeAll, beforeEach, describe, expect, vi } from "vitest";
|
|
6
6
|
export { applicationScenario } from "./applicationScenario.js";
|
|
7
7
|
export type { TestApplication } from "./applications.js";
|
|
8
8
|
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import { expect } from "vitest";
|
|
2
2
|
import type { DomainEvent } from "../../DomainEvent.js";
|
|
3
|
-
import type { Input } from "../../messaging/Input.js";
|
|
4
3
|
import type { TestApplication } from "./applications.js";
|
|
5
4
|
|
|
6
|
-
type Result
|
|
5
|
+
type Result = { output: unknown } | { error: unknown };
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* An application test, written as a presentation test: the input DTO, the events the handler publishes, then the output DTO or the error
|
|
10
9
|
* that the message bus gives. The events are checked only when the test says which ones it expects.
|
|
11
10
|
*/
|
|
12
|
-
export class ApplicationScenario
|
|
11
|
+
export class ApplicationScenario {
|
|
13
12
|
private readonly expected: DomainEvent[] = [];
|
|
14
13
|
private checksEvents = false;
|
|
15
14
|
|
|
16
|
-
constructor(private readonly app: TestApplication, private readonly dto:
|
|
15
|
+
constructor(private readonly app: TestApplication, private readonly dto: object) {}
|
|
17
16
|
|
|
18
17
|
expectItToPublish(event: DomainEvent): this {
|
|
19
18
|
this.expected.push(event);
|
|
@@ -26,7 +25,7 @@ export class ApplicationScenario<Output> {
|
|
|
26
25
|
return this;
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
async expectTheOutputToBe(output:
|
|
28
|
+
async expectTheOutputToBe(output: object): Promise<void> {
|
|
30
29
|
const from = this.app.published.length;
|
|
31
30
|
const result = await this.run();
|
|
32
31
|
if ("error" in result) throw result.error;
|
|
@@ -42,7 +41,7 @@ export class ApplicationScenario<Output> {
|
|
|
42
41
|
expect(result.error).toStrictEqual(error);
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
private async run(): Promise<Result
|
|
44
|
+
private async run(): Promise<Result> {
|
|
46
45
|
try {
|
|
47
46
|
return { output: await this.app.send(this.dto) };
|
|
48
47
|
} catch (error) {
|
|
@@ -56,5 +55,5 @@ export class ApplicationScenario<Output> {
|
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
export const applicationScenario = (app: TestApplication) => ({
|
|
59
|
-
givenTheDto:
|
|
58
|
+
givenTheDto: (dto: object) => new ApplicationScenario(app, dto)
|
|
60
59
|
});
|
|
@@ -14,7 +14,7 @@ const testBindings: Record<string, readonly Binding[]> = Object.fromEntries(
|
|
|
14
14
|
|
|
15
15
|
type EventType<E extends DomainEvent> = abstract new (...args: never[]) => E;
|
|
16
16
|
|
|
17
|
-
/** The whole backend, built from the bindings of its contexts with their test.di.ts over them, and predictable ids. For the tests of the context
|
|
17
|
+
/** The whole backend, built from the bindings of its contexts with their test.di.ts over them, and predictable ids. For the tests of the context maps. */
|
|
18
18
|
export function createTestBackendApplication() {
|
|
19
19
|
const reportedErrors: unknown[] = [];
|
|
20
20
|
const published: Published[] = [];
|
|
@@ -29,7 +29,7 @@ export function createTestBackendApplication() {
|
|
|
29
29
|
return {
|
|
30
30
|
...backend,
|
|
31
31
|
reportedErrors,
|
|
32
|
-
/** Every event the
|
|
32
|
+
/** Every event of shared that the context maps shared, in its envelope, in the order they were published. */
|
|
33
33
|
published,
|
|
34
34
|
publishedNames: () => published.map(({ event }) => event.constructor.name),
|
|
35
35
|
publishedOf: <E extends DomainEvent>(type: EventType<E>): E[] => published.flatMap(({ event }) => event instanceof type ? [event] : [])
|
|
@@ -37,13 +37,14 @@ export function createTestBackendApplication() {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
* Every context, built from its di.ts with its test.di.ts over it, but alone: no
|
|
41
|
-
* and reads the events the handlers published.
|
|
40
|
+
* Every context, built from its di.ts with its test.di.ts over it, but alone: no context map hears or shares an event. A test sends
|
|
41
|
+
* input DTOs and reads the events of their context that the handlers published.
|
|
42
42
|
*/
|
|
43
43
|
export function createTestApplication() {
|
|
44
44
|
const published: DomainEvent[] = [];
|
|
45
|
-
const
|
|
46
|
-
backend
|
|
45
|
+
const alone = Object.fromEntries(Object.entries(discovered.contexts).map(([name, context]) => [name, { ...context, map: [] }]));
|
|
46
|
+
const backend = createBackend({ ...discovered, contexts: alone }, { replace: testBindings, reportError: () => undefined });
|
|
47
|
+
backend.subscribeToContexts(event => { published.push(event); });
|
|
47
48
|
|
|
48
49
|
return {
|
|
49
50
|
/** Sends an input DTO to the message bus of the context that handles it. */
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { test as base } from "vitest";
|
|
2
2
|
import { createTestBackendApplication, type TestBackendApplication } from "./applications.js";
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Everything a test of the context map imports. `test` checks what the `ContextMap` gives: the input DTOs for an event of shared
|
|
6
|
+
* (`inputsFor`), the events of shared for an event of the context (`eventsFor`).
|
|
7
|
+
*/
|
|
8
|
+
export { afterAll, afterEach, assert, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
6
9
|
export { ContextMap } from "../../messaging/ContextMap.js";
|
|
7
10
|
export type { TestBackendApplication } from "./applications.js";
|
|
8
11
|
|
|
@@ -2,7 +2,7 @@ import { createBackend } from "../../di/createBackend.js";
|
|
|
2
2
|
import { discovered } from "../../di/discovered.js";
|
|
3
3
|
|
|
4
4
|
/** Everything an integration test imports: flows of the context map again, with the real infra implementations. */
|
|
5
|
-
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
5
|
+
export { afterAll, afterEach, assert, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
6
6
|
|
|
7
7
|
/** The whole backend, built from the di.ts of the contexts only: the real infra implementations, as in production. */
|
|
8
8
|
export const productionBackend = () => createBackend(discovered);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { expect } from "vitest";
|
|
2
|
-
import type { Input } from "../../messaging/Input.js";
|
|
3
2
|
import type { TestApplication } from "./applications.js";
|
|
4
3
|
|
|
5
4
|
/**
|
|
@@ -10,7 +9,7 @@ import type { TestApplication } from "./applications.js";
|
|
|
10
9
|
export class ApplicationScenario implements PromiseLike<void> {
|
|
11
10
|
private readonly expected: object[] = [];
|
|
12
11
|
|
|
13
|
-
constructor(private readonly app: TestApplication, private readonly dto:
|
|
12
|
+
constructor(private readonly app: TestApplication, private readonly dto: object) {}
|
|
14
13
|
|
|
15
14
|
expectItToPublish(event: object): this {
|
|
16
15
|
this.expected.push(event);
|
|
@@ -32,5 +31,5 @@ export class ApplicationScenario implements PromiseLike<void> {
|
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
export const applicationScenario = (app: TestApplication) => ({
|
|
35
|
-
givenTheDto: (dto:
|
|
34
|
+
givenTheDto: (dto: object) => new ApplicationScenario(app, dto)
|
|
36
35
|
});
|
|
@@ -4,7 +4,7 @@ import type { Binding } from "../../di/Container.js";
|
|
|
4
4
|
import { createFrontendWithBus } from "../../di/createFrontend.js";
|
|
5
5
|
import { discoveredFrontends } from "../../di/discoveredFrontends.js";
|
|
6
6
|
import type { Fetcher } from "../../di/Fetcher.js";
|
|
7
|
-
import
|
|
7
|
+
import { UnknownInputError } from "../../messaging/InProcessMessageBus.js";
|
|
8
8
|
import type { Token } from "../../Token.js";
|
|
9
9
|
|
|
10
10
|
/** The test.di.ts of each context that has one: the bindings that replace, in tests, those that reach outside the browser. */
|
|
@@ -37,7 +37,7 @@ export function createTestApplication() {
|
|
|
37
37
|
|
|
38
38
|
const ownerOf = (input: object): string => {
|
|
39
39
|
const name = Object.keys(discoveredFrontends).find(context => discoveredFrontends[context].useCases.some(({ input: type }) => type === input.constructor));
|
|
40
|
-
if (name === undefined) throw new
|
|
40
|
+
if (name === undefined) throw new UnknownInputError(input.constructor.name);
|
|
41
41
|
return name;
|
|
42
42
|
};
|
|
43
43
|
|
|
@@ -50,7 +50,7 @@ export function createTestApplication() {
|
|
|
50
50
|
|
|
51
51
|
return {
|
|
52
52
|
/** Sends an input DTO to the message bus of the context that handles it. */
|
|
53
|
-
send:
|
|
53
|
+
send: (input: object): Promise<unknown> => frontend(ownerOf(input)).bus.send(input),
|
|
54
54
|
/** The dependency with this token, built now if no handler needed it before. */
|
|
55
55
|
get: <T>(token: Token<T>): T => {
|
|
56
56
|
const found = holders(token);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything a test of the context map of a frontend context imports: `eventsFor` gives the events of the context that an event of the
|
|
3
|
+
* context shared gives, and the events of shared that an event of the context gives.
|
|
4
|
+
*/
|
|
5
|
+
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
6
|
+
export { eventsFor } from "../../messaging/FrontendContextMap.js";
|
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
import { FrontendEventBus } from "../../messaging/FrontendEventBus.js";
|
|
2
|
-
import type { Input } from "../../messaging/Input.js";
|
|
3
2
|
import { createStore, type StoreDefinition } from "../../state/createStore.js";
|
|
4
|
-
import type {
|
|
3
|
+
import type { EventPublisher } from "../../messaging/EventPublisher.js";
|
|
5
4
|
import { discoveredFrontends } from "../../di/discoveredFrontends.js";
|
|
6
5
|
import { frontendsOn, type Frontends } from "../../di/frontend.js";
|
|
7
6
|
|
|
8
|
-
type
|
|
9
|
-
type
|
|
7
|
+
type EventOf<F> = F extends { events: EventPublisher<infer E> } ? E : never;
|
|
8
|
+
type StoreOf<F> = F extends { store: infer S } ? S : undefined;
|
|
9
|
+
|
|
10
|
+
/** The presentation of a context in a test: its frontend, the DTOs that its store sent, and the events that it published. */
|
|
11
|
+
export type TestPresentation<F> = { events: EventPublisher<EventOf<F>>; store: StoreOf<F>; sent: object[]; published: EventOf<F>[] };
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
|
-
* The presentation of a context, as its views get it: its EventPublisher and its store
|
|
13
|
-
* records the DTOs it receives
|
|
14
|
+
* The presentation of a context, as its views and its components get it: its EventPublisher and its store (none, for a context
|
|
15
|
+
* without a store). The message bus of the store is a Fake: it records the DTOs it receives in `sent`, and answers nothing. A test
|
|
16
|
+
* publishes the events of the handlers itself. `published` records every event of the bus, in their order: those that the views and
|
|
17
|
+
* the components publish. The context map is left aside: its own test reads what it shares.
|
|
14
18
|
*/
|
|
15
|
-
export function testPresentation<Name extends keyof Frontends>(name: Name):
|
|
19
|
+
export function testPresentation<Name extends keyof Frontends>(name: Name): TestPresentation<Frontends[Name]> {
|
|
16
20
|
const events = new FrontendEventBus<EventOf<Frontends[Name]>>();
|
|
17
|
-
const sent:
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
const sent: object[] = [];
|
|
22
|
+
const published: EventOf<Frontends[Name]>[] = [];
|
|
23
|
+
events.subscribe(event => { published.push(event); });
|
|
24
|
+
const definition = discoveredFrontends[name].store as StoreDefinition<unknown, EventOf<Frontends[Name]>> | undefined;
|
|
25
|
+
const store = definition && createStore(definition, { events, bus: { send: (input: object) => { sent.push(input); return Promise.resolve(undefined); } } });
|
|
26
|
+
return { events, store, sent, published } as unknown as TestPresentation<Frontends[Name]>;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
/** A $global as Marko gives it to the views: the address of the page, and frontends already built. */
|
package/src/virtual-modules.d.ts
CHANGED
|
@@ -4,10 +4,9 @@ declare module "virtual:kernel/backend" {
|
|
|
4
4
|
import type { Translation } from "@ingenioz-it/caits/messaging/ContextMap";
|
|
5
5
|
type Modules = Record<string, Record<string, unknown>>;
|
|
6
6
|
export const bindings: Record<string, readonly Binding[]>;
|
|
7
|
-
export const dtos: Modules;
|
|
8
7
|
export const handlers: Modules;
|
|
9
8
|
export const controllers: Modules;
|
|
10
|
-
export const
|
|
9
|
+
export const maps: Record<string, Translation[]>;
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
declare module "virtual:kernel/backend-test" {
|
|
@@ -17,12 +16,13 @@ declare module "virtual:kernel/backend-test" {
|
|
|
17
16
|
|
|
18
17
|
declare module "virtual:kernel/frontend" {
|
|
19
18
|
import type { Binding } from "@ingenioz-it/caits/di/Container";
|
|
19
|
+
import type { Line } from "@ingenioz-it/caits/messaging/FrontendContextMap";
|
|
20
20
|
import type { StoreDefinition } from "@ingenioz-it/caits/state/createStore";
|
|
21
21
|
type Modules = Record<string, Record<string, unknown>>;
|
|
22
22
|
export const bindings: Record<string, readonly Binding[]>;
|
|
23
|
-
export const dtos: Modules;
|
|
24
23
|
export const handlers: Modules;
|
|
25
24
|
export const stores: Record<string, StoreDefinition<unknown, unknown>>;
|
|
25
|
+
export const maps: Record<string, readonly Line[]>;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
declare module "virtual:kernel/frontend-test" {
|
package/src/vite.js
CHANGED
|
@@ -58,17 +58,16 @@ function dependencies(source) {
|
|
|
58
58
|
const CONTEXT_MODULES = {
|
|
59
59
|
"virtual:kernel/backend": [
|
|
60
60
|
["bindings", "backend/*/di.ts", true],
|
|
61
|
-
["
|
|
62
|
-
["
|
|
63
|
-
["
|
|
64
|
-
["map", "backend/context-map.ts", true]
|
|
61
|
+
["handlers", "backend/*/{command,query}/*/*.handler.ts"],
|
|
62
|
+
["controllers", "backend/*/{command,query}/*/*.controller.ts"],
|
|
63
|
+
["maps", "backend/*/context-map.ts", true]
|
|
65
64
|
],
|
|
66
65
|
"virtual:kernel/backend-test": [["bindings", "backend/*/test.di.ts", true]],
|
|
67
66
|
"virtual:kernel/frontend": [
|
|
68
67
|
["bindings", "frontend/*/di.ts", true],
|
|
69
|
-
["
|
|
70
|
-
["
|
|
71
|
-
["
|
|
68
|
+
["handlers", "frontend/*/{command,query}/*/*.handler.ts"],
|
|
69
|
+
["stores", "frontend/*/presentation/store/*Store.ts", true],
|
|
70
|
+
["maps", "frontend/*/context-map.ts", true]
|
|
72
71
|
],
|
|
73
72
|
"virtual:kernel/frontend-test": [["bindings", "frontend/*/test.di.ts", true]]
|
|
74
73
|
};
|
|
@@ -86,9 +85,9 @@ function contexts(source) {
|
|
|
86
85
|
/** @type {(pattern: string, onlyDefault?: boolean) => string} */
|
|
87
86
|
const glob = (pattern, onlyDefault = false) =>
|
|
88
87
|
`import.meta.glob(${JSON.stringify(`${source}/${pattern}`)}, { eager: true${onlyDefault ? `, import: "default"` : ""} })`;
|
|
89
|
-
/** A pattern as a regular expression: `*` is a part of a name, without "/". */
|
|
90
|
-
const matcherOf = (/** @type {string} */ pattern) =>
|
|
91
|
-
|
|
88
|
+
/** A pattern as a regular expression: `*` is a part of a name, without "/", and `{command,query}` one of these names. */
|
|
89
|
+
const matcherOf = (/** @type {string} */ pattern) => new RegExp(`^${`${source}/${pattern}`.replace(/[.+?^$()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*")
|
|
90
|
+
.replace(/\{([^}]*)\}/g, (/** @type {string} */ _, /** @type {string} */ names) => `(?:${names.split(",").join("|")})`)}$`);
|
|
92
91
|
const modules = Object.entries(CONTEXT_MODULES).map(([id, exports]) => ({
|
|
93
92
|
id,
|
|
94
93
|
code: exports.map(([name, pattern, onlyDefault]) => `export const ${name} = ${glob(pattern, onlyDefault)};`).join("\n"),
|
|
@@ -102,9 +101,15 @@ function contexts(source) {
|
|
|
102
101
|
// Vite processes the package as the code of the project, also when it is installed: it holds TypeScript and imports these modules.
|
|
103
102
|
ssr: { noExternal: [KERNEL] },
|
|
104
103
|
optimizeDeps: { exclude: [KERNEL] },
|
|
105
|
-
// The shortcut #shared leads to the backend context shared from every file
|
|
106
|
-
// Vite reads an alias before the "imports" of the package.json of a
|
|
107
|
-
|
|
104
|
+
// The shortcut #shared leads to the backend context shared from every file, and #frontend-shared to the frontend context shared
|
|
105
|
+
// (the tsconfig.json of the project says it to TypeScript). Vite reads an alias before the "imports" of the package.json of a
|
|
106
|
+
// context (#context), which cannot lead out of the context.
|
|
107
|
+
resolve: {
|
|
108
|
+
alias: [
|
|
109
|
+
{ find: /^#shared\//, replacement: `${source}/backend/shared/` },
|
|
110
|
+
{ find: /^#frontend-shared\//, replacement: `${source}/frontend/shared/` }
|
|
111
|
+
]
|
|
112
|
+
}
|
|
108
113
|
}),
|
|
109
114
|
configResolved: config => { root = config.root; },
|
|
110
115
|
configureServer: server => {
|