@arts-n-crafts/ts 3.13.1
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/README.md +58 -0
- package/dist/index.cjs +659 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +420 -0
- package/dist/index.d.ts +420 -0
- package/dist/index.js +625 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# @arts-and-crafts
|
|
2
|
+
|
|
3
|
+
> Arts and Crafts was a reaction against the industrialization and excess of the Victorian era, and it sought to celebrate traditional craftsmanship and materials. Arts and Crafts architecture is characterized by a simple and functional design, the use of natural materials, and a focus on craftsmanship.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
To provide structure and organization to your codebase, making it easier to understand (maintainable), test (testable), adapt (flexible), scale (scalable) and withstand failure (robust).
|
|
8
|
+
|
|
9
|
+
### Simplicity
|
|
10
|
+
|
|
11
|
+
The idea here is that a web application (hereafter, a system) has three types of entrypoints. The actor could be a producer making a POST request (a command), or a consumer retrieving data via a GET request (a query). The actor could also be a publisher sending a message (an event). To summarize, the entrypoints are:
|
|
12
|
+
- Commands;
|
|
13
|
+
- Queries, and
|
|
14
|
+
- Events.
|
|
15
|
+
|
|
16
|
+
### Usecases
|
|
17
|
+
|
|
18
|
+
Based on the idea of a system consisting of only having to handle commands, queries and events, we can provide a clear separation of concerns. This allows us to focus on each type of entrypoint independently, making it easier to reason about and maintain the codebase.
|
|
19
|
+
|
|
20
|
+
So, each entrypoint is a separate usecase. Thus, in a organized and structured system, each usecase is independent of other usecases (decoupled) and the usecase itself should contain all its required logic.
|
|
21
|
+
|
|
22
|
+
### Modularity
|
|
23
|
+
|
|
24
|
+
Modularity is a key principle in software development. It involves breaking down a system into smaller, independent modules that can be developed, tested, and maintained separately.
|
|
25
|
+
|
|
26
|
+
Usecases belong to a specific domain. Domains are organized as separate modules, each responsible for a specific aspect of the system, often also with their own terminology and domain-specific language.
|
|
27
|
+
|
|
28
|
+
Domains consist of behavior (usecases). These usecases could be part of a workflow. Workflows could cross multiple domains.
|
|
29
|
+
|
|
30
|
+
### Workflows
|
|
31
|
+
|
|
32
|
+
Workflows are a way to organize usecases into a sequence of steps. Each step in a workflow is a usecase. Workflows can be used to model complex business processes.
|
|
33
|
+
|
|
34
|
+
### System design
|
|
35
|
+
|
|
36
|
+
Event-Storming
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
## Contributing
|
|
43
|
+
|
|
44
|
+
This codebase is written in TypeScript and uses Bun as the runtime.
|
|
45
|
+
|
|
46
|
+
To install dependencies:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bun install
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
To run:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
bun run test
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This project was created using `bun init` in bun v1.2.0. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
// src/core/utils/createCommand.ts
|
|
6
|
+
function createCommand(type, aggregateId, payload, metadata = {}) {
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
id: crypto.randomUUID(),
|
|
9
|
+
type,
|
|
10
|
+
aggregateId: String(aggregateId),
|
|
11
|
+
payload,
|
|
12
|
+
kind: "command",
|
|
13
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
14
|
+
metadata
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function createQuery(type, payload, metadata = {}) {
|
|
18
|
+
return Object.freeze({
|
|
19
|
+
id: crypto.randomUUID(),
|
|
20
|
+
type,
|
|
21
|
+
payload,
|
|
22
|
+
kind: "query",
|
|
23
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
24
|
+
metadata
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/core/utils/isCommand.ts
|
|
29
|
+
function isCommand(candidate) {
|
|
30
|
+
if (candidate === null)
|
|
31
|
+
return false;
|
|
32
|
+
if (typeof candidate !== "object")
|
|
33
|
+
return false;
|
|
34
|
+
if (!("type" in candidate))
|
|
35
|
+
return false;
|
|
36
|
+
return "kind" in candidate && candidate.kind === "command";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/core/utils/isQuery.ts
|
|
40
|
+
function isQuery(candidate) {
|
|
41
|
+
if (candidate === null)
|
|
42
|
+
return false;
|
|
43
|
+
if (typeof candidate !== "object")
|
|
44
|
+
return false;
|
|
45
|
+
if (!("type" in candidate))
|
|
46
|
+
return false;
|
|
47
|
+
return "kind" in candidate && candidate.kind === "query";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/domain/Specification/Specification.ts
|
|
51
|
+
var Specification = class {
|
|
52
|
+
and(other) {
|
|
53
|
+
return new AndSpecification(this, other);
|
|
54
|
+
}
|
|
55
|
+
or(other) {
|
|
56
|
+
return new OrSpecification(this, other);
|
|
57
|
+
}
|
|
58
|
+
not() {
|
|
59
|
+
return new NotSpecification(this);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
var AndSpecification = class extends Specification {
|
|
63
|
+
constructor(left, right) {
|
|
64
|
+
super();
|
|
65
|
+
this.left = left;
|
|
66
|
+
this.right = right;
|
|
67
|
+
}
|
|
68
|
+
isSatisfiedBy(entity) {
|
|
69
|
+
return this.left.isSatisfiedBy(entity) && this.right.isSatisfiedBy(entity);
|
|
70
|
+
}
|
|
71
|
+
toQuery() {
|
|
72
|
+
return {
|
|
73
|
+
type: "and",
|
|
74
|
+
nodes: [this.left.toQuery(), this.right.toQuery()]
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
var OrSpecification = class extends Specification {
|
|
79
|
+
constructor(left, right) {
|
|
80
|
+
super();
|
|
81
|
+
this.left = left;
|
|
82
|
+
this.right = right;
|
|
83
|
+
}
|
|
84
|
+
isSatisfiedBy(entity) {
|
|
85
|
+
return this.left.isSatisfiedBy(entity) || this.right.isSatisfiedBy(entity);
|
|
86
|
+
}
|
|
87
|
+
toQuery() {
|
|
88
|
+
return {
|
|
89
|
+
type: "or",
|
|
90
|
+
nodes: [this.left.toQuery(), this.right.toQuery()]
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
var NotSpecification = class extends Specification {
|
|
95
|
+
constructor(spec) {
|
|
96
|
+
super();
|
|
97
|
+
this.spec = spec;
|
|
98
|
+
}
|
|
99
|
+
isSatisfiedBy(entity) {
|
|
100
|
+
return !this.spec.isSatisfiedBy(entity);
|
|
101
|
+
}
|
|
102
|
+
toQuery() {
|
|
103
|
+
return {
|
|
104
|
+
type: "not",
|
|
105
|
+
node: this.spec.toQuery()
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// src/domain/Specification/utils/createQueryNode.ts
|
|
111
|
+
function createQueryNode(type, field, value) {
|
|
112
|
+
switch (type) {
|
|
113
|
+
case "eq":
|
|
114
|
+
case "gt":
|
|
115
|
+
case "lt":
|
|
116
|
+
return { type, field, value };
|
|
117
|
+
case "and":
|
|
118
|
+
case "or":
|
|
119
|
+
return { type, nodes: value };
|
|
120
|
+
case "not":
|
|
121
|
+
return { type, node: value };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/domain/Specification/implementations/FieldEquals.specification.ts
|
|
126
|
+
var FieldEquals = class extends Specification {
|
|
127
|
+
constructor(field, value) {
|
|
128
|
+
super();
|
|
129
|
+
this.field = field;
|
|
130
|
+
this.value = value;
|
|
131
|
+
}
|
|
132
|
+
isSatisfiedBy(entity) {
|
|
133
|
+
return entity[this.field] === this.value;
|
|
134
|
+
}
|
|
135
|
+
toQuery() {
|
|
136
|
+
return createQueryNode("eq", this.field, this.value);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// src/utils/fail/fail.ts
|
|
141
|
+
function fail(anExpression) {
|
|
142
|
+
return () => {
|
|
143
|
+
throw anExpression;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/utils/invariant/invariant.ts
|
|
148
|
+
function invariant(condition, onInvalid) {
|
|
149
|
+
if (!condition)
|
|
150
|
+
onInvalid();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/domain/Specification/implementations/FieldGreaterThan.specification.ts
|
|
154
|
+
var FieldGreaterThan = class extends Specification {
|
|
155
|
+
constructor(field, value) {
|
|
156
|
+
super();
|
|
157
|
+
this.field = field;
|
|
158
|
+
this.value = value;
|
|
159
|
+
}
|
|
160
|
+
isNumber(value) {
|
|
161
|
+
return typeof value === "number";
|
|
162
|
+
}
|
|
163
|
+
isSatisfiedBy(entity) {
|
|
164
|
+
const field = entity[this.field];
|
|
165
|
+
invariant(
|
|
166
|
+
this.isNumber(field),
|
|
167
|
+
fail(
|
|
168
|
+
new TypeError(`Field ${String(this.field)} is not a number`)
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
return field > this.value;
|
|
172
|
+
}
|
|
173
|
+
toQuery() {
|
|
174
|
+
return createQueryNode("gt", this.field, this.value);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
function createDomainEvent(type, aggregateId, payload, metadata = {}) {
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
id: crypto.randomUUID(),
|
|
180
|
+
type,
|
|
181
|
+
aggregateId,
|
|
182
|
+
payload,
|
|
183
|
+
source: "internal",
|
|
184
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
185
|
+
metadata
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/domain/utils/isEvent.ts
|
|
190
|
+
function isEvent(event) {
|
|
191
|
+
if (typeof event !== "object")
|
|
192
|
+
return false;
|
|
193
|
+
if (event === null)
|
|
194
|
+
return false;
|
|
195
|
+
if (!("type" in event))
|
|
196
|
+
return false;
|
|
197
|
+
return "source" in event;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/domain/utils/isDomainEvent.ts
|
|
201
|
+
function isDomainEvent(event) {
|
|
202
|
+
return isEvent(event) && "aggregateId" in event && event.source === "internal";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/infrastructure/CommandBus/implementations/SimpleCommandBus.ts
|
|
206
|
+
var SimpleCommandBus = class {
|
|
207
|
+
handlers = /* @__PURE__ */ new Map();
|
|
208
|
+
register(aTypeOfCommand, anHandler) {
|
|
209
|
+
if (this.handlers.has(aTypeOfCommand)) {
|
|
210
|
+
throw new Error(`Handler already registered for command type: ${aTypeOfCommand}`);
|
|
211
|
+
}
|
|
212
|
+
this.handlers.set(aTypeOfCommand, anHandler);
|
|
213
|
+
}
|
|
214
|
+
async execute(aCommand) {
|
|
215
|
+
const handler = this.handlers.get(aCommand.type);
|
|
216
|
+
if (!handler) {
|
|
217
|
+
throw new Error(`No handler found for command type: ${aCommand.type}`);
|
|
218
|
+
}
|
|
219
|
+
return handler.execute(aCommand);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// src/infrastructure/Database/Database.ts
|
|
224
|
+
var Operation = /* @__PURE__ */ ((Operation2) => {
|
|
225
|
+
Operation2["CREATE"] = "CREATE";
|
|
226
|
+
Operation2["PUT"] = "PUT";
|
|
227
|
+
Operation2["PATCH"] = "PATCH";
|
|
228
|
+
Operation2["DELETE"] = "DELETE";
|
|
229
|
+
return Operation2;
|
|
230
|
+
})(Operation || {});
|
|
231
|
+
|
|
232
|
+
// src/infrastructure/Database/implementations/SimpleDatabase.exceptions.ts
|
|
233
|
+
var RecordNotFoundException = class extends Error {
|
|
234
|
+
constructor(id) {
|
|
235
|
+
super(`Record not found for id: ${id}`);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
var DuplicateRecordException = class extends Error {
|
|
239
|
+
constructor(id) {
|
|
240
|
+
super(`Duplicate record found for id: ${id}`);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
var DatabaseOfflineException = class extends Error {
|
|
244
|
+
constructor() {
|
|
245
|
+
super(`Database is offline`);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// src/infrastructure/Database/implementations/SimpleDatabase.ts
|
|
250
|
+
var SimpleDatabase = class {
|
|
251
|
+
datasource = /* @__PURE__ */ new Map();
|
|
252
|
+
simulateOffline = false;
|
|
253
|
+
async query(tableName, specification) {
|
|
254
|
+
if (this.simulateOffline)
|
|
255
|
+
throw new DatabaseOfflineException();
|
|
256
|
+
const tableRecords = this.datasource.get(tableName) || [];
|
|
257
|
+
return tableRecords.filter((record) => specification.isSatisfiedBy(record));
|
|
258
|
+
}
|
|
259
|
+
async execute(tableName, statement) {
|
|
260
|
+
if (this.simulateOffline)
|
|
261
|
+
throw new DatabaseOfflineException();
|
|
262
|
+
if (!this.datasource.has(tableName))
|
|
263
|
+
this.datasource.set(tableName, []);
|
|
264
|
+
const table = this.datasource.get(tableName);
|
|
265
|
+
switch (statement.operation) {
|
|
266
|
+
case "CREATE" /* CREATE */: {
|
|
267
|
+
const isDuplicate = table.some((item) => item.id === statement.payload.id);
|
|
268
|
+
if (isDuplicate)
|
|
269
|
+
throw new DuplicateRecordException(statement.payload.id);
|
|
270
|
+
table.push(statement.payload);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
case "PUT" /* PUT */: {
|
|
274
|
+
const index = table.findIndex((item) => item.id === statement.payload.id);
|
|
275
|
+
if (index === -1)
|
|
276
|
+
throw new RecordNotFoundException(statement.payload.id);
|
|
277
|
+
table[index] = statement.payload;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
case "PATCH" /* PATCH */: {
|
|
281
|
+
const index = table.findIndex((item) => item.id === statement.payload.id);
|
|
282
|
+
if (index === -1)
|
|
283
|
+
throw new RecordNotFoundException(statement.payload.id);
|
|
284
|
+
table[index] = { ...table[index], ...statement.payload };
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case "DELETE" /* DELETE */: {
|
|
288
|
+
const index = table.findIndex((item) => item.id === statement.payload.id);
|
|
289
|
+
if (index === -1)
|
|
290
|
+
throw new RecordNotFoundException(statement.payload.id);
|
|
291
|
+
table.splice(index, 1);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
goOffline() {
|
|
297
|
+
this.simulateOffline = true;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// src/infrastructure/EventBus/implementations/SimpleEventBus.ts
|
|
302
|
+
var SimpleEventBus = class {
|
|
303
|
+
handlers = /* @__PURE__ */ new Map();
|
|
304
|
+
subscribe(stream, aHandler) {
|
|
305
|
+
const handlersForType = this.handlers.get(stream) ?? [];
|
|
306
|
+
handlersForType.push(aHandler);
|
|
307
|
+
this.handlers.set(stream, handlersForType);
|
|
308
|
+
}
|
|
309
|
+
async consume(stream, anEvent) {
|
|
310
|
+
if (!this.handlers.has(stream))
|
|
311
|
+
return;
|
|
312
|
+
const handlers = this.handlers.get(stream);
|
|
313
|
+
await Promise.all(handlers.map(async (handler) => {
|
|
314
|
+
return handler.handle(anEvent);
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
async publish(stream, anEvent) {
|
|
318
|
+
await this.consume(stream, anEvent);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
function createIntegrationEvent(type, payload, metadata) {
|
|
322
|
+
return Object.freeze({
|
|
323
|
+
id: crypto.randomUUID(),
|
|
324
|
+
type,
|
|
325
|
+
payload,
|
|
326
|
+
source: "external",
|
|
327
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
328
|
+
metadata: {
|
|
329
|
+
...metadata
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/infrastructure/EventBus/utils/isIntegrationEvent.ts
|
|
335
|
+
function isIntegrationEvent(event) {
|
|
336
|
+
return isEvent(event) && event.source === "external";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/utils/isEqual/isEqual.ts
|
|
340
|
+
function isEqual(a, b) {
|
|
341
|
+
if (a === b) {
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
const bothAreObjects = a && b && typeof a === "object" && typeof b === "object";
|
|
345
|
+
return Boolean(
|
|
346
|
+
bothAreObjects && Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([k, v]) => isEqual(v, b[k]))
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/utils/parseAsError/parseAsError.ts
|
|
351
|
+
function parseAsError(value) {
|
|
352
|
+
if (value instanceof Error)
|
|
353
|
+
return value;
|
|
354
|
+
if (typeof value === "string")
|
|
355
|
+
return new Error(value);
|
|
356
|
+
try {
|
|
357
|
+
const json = JSON.stringify(value);
|
|
358
|
+
return new Error(json ?? String(value));
|
|
359
|
+
} catch {
|
|
360
|
+
return new Error("Unknown error");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/utils/streamKey/makeStreamKey.ts
|
|
365
|
+
function makeStreamKey(streamName, aggregateId) {
|
|
366
|
+
return `${streamName}#${aggregateId}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// src/infrastructure/EventStore/utils/createStoredEvent.ts
|
|
370
|
+
function createStoredEvent(streamName, version, event) {
|
|
371
|
+
return Object.freeze({
|
|
372
|
+
id: event.id,
|
|
373
|
+
streamKey: makeStreamKey(streamName, event.aggregateId),
|
|
374
|
+
version,
|
|
375
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
376
|
+
event
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/infrastructure/EventStore/implementations/SimpleEventStore.exceptions.ts
|
|
381
|
+
var MultipleAggregatesException = class extends Error {
|
|
382
|
+
constructor() {
|
|
383
|
+
super("EventStore append does not support multiple aggregates to be stored");
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// src/infrastructure/EventStore/implementations/SimpleEventStore.ts
|
|
388
|
+
var SimpleEventStore = class {
|
|
389
|
+
constructor(database, outbox) {
|
|
390
|
+
this.database = database;
|
|
391
|
+
this.outbox = outbox;
|
|
392
|
+
}
|
|
393
|
+
tableName = "event_store";
|
|
394
|
+
async load(streamName, aggregateId) {
|
|
395
|
+
const streamKey = makeStreamKey(streamName, aggregateId);
|
|
396
|
+
const specification = new FieldEquals("streamKey", streamKey);
|
|
397
|
+
const storedEvents = await this.database.query(this.tableName, specification);
|
|
398
|
+
return storedEvents.map((storedEvent) => storedEvent.event);
|
|
399
|
+
}
|
|
400
|
+
async append(streamName, events) {
|
|
401
|
+
const uniqueAggregateIds = new Set(events.map((event2) => event2.aggregateId));
|
|
402
|
+
if (uniqueAggregateIds.size > 1)
|
|
403
|
+
throw new MultipleAggregatesException();
|
|
404
|
+
const event = events[0];
|
|
405
|
+
const currentStream = await this.load(streamName, event.aggregateId);
|
|
406
|
+
const eventsToStore = events.map((event2) => createStoredEvent(streamName, currentStream.length + 1, event2));
|
|
407
|
+
await Promise.all(
|
|
408
|
+
eventsToStore.map(
|
|
409
|
+
async (payload) => this.database.execute(this.tableName, { operation: "CREATE" /* CREATE */, payload })
|
|
410
|
+
)
|
|
411
|
+
);
|
|
412
|
+
await Promise.all(events.map(async (event2) => this.outbox?.enqueue(event2)));
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// src/infrastructure/Outbox/implementations/GenericOutboxWorker.ts
|
|
417
|
+
var GenericOutboxWorker = class {
|
|
418
|
+
constructor(outbox, eventBus, stream) {
|
|
419
|
+
this.outbox = outbox;
|
|
420
|
+
this.eventBus = eventBus;
|
|
421
|
+
this.stream = stream;
|
|
422
|
+
}
|
|
423
|
+
async runOnce() {
|
|
424
|
+
const pending = await this.outbox.getPending();
|
|
425
|
+
await Promise.all(pending.map(async (entry) => {
|
|
426
|
+
try {
|
|
427
|
+
await this.eventBus.publish(this.stream, entry.event);
|
|
428
|
+
await this.outbox.markAsPublished(entry.id);
|
|
429
|
+
} catch {
|
|
430
|
+
await this.outbox.markAsFailed(entry.id);
|
|
431
|
+
}
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
async tick() {
|
|
435
|
+
await this.runOnce();
|
|
436
|
+
}
|
|
437
|
+
start(intervalMs) {
|
|
438
|
+
setInterval(() => {
|
|
439
|
+
void this.tick().catch(console.error);
|
|
440
|
+
}, intervalMs);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// src/infrastructure/Outbox/implementations/InMemoryOutbox.ts
|
|
445
|
+
var InMemoryOutbox = class {
|
|
446
|
+
entries = [];
|
|
447
|
+
idCounter = 0;
|
|
448
|
+
async enqueue(event) {
|
|
449
|
+
this.entries.push({
|
|
450
|
+
id: (this.idCounter++).toString(),
|
|
451
|
+
event,
|
|
452
|
+
published: false,
|
|
453
|
+
retryCount: 0
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
async getPending(limit = 100) {
|
|
457
|
+
return this.entries.filter((e) => !e.published).slice(0, limit);
|
|
458
|
+
}
|
|
459
|
+
async markAsPublished(id) {
|
|
460
|
+
const entry = this.entries.find((e) => e.id === id);
|
|
461
|
+
if (entry) {
|
|
462
|
+
entry.published = true;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
async markAsFailed(id) {
|
|
466
|
+
const entry = this.entries.find((e) => e.id === id);
|
|
467
|
+
if (entry) {
|
|
468
|
+
entry.retryCount += 1;
|
|
469
|
+
entry.lastAttemptAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
function createOutboxEntry(event) {
|
|
474
|
+
return Object.freeze({
|
|
475
|
+
id: crypto.randomUUID(),
|
|
476
|
+
event,
|
|
477
|
+
published: false,
|
|
478
|
+
retryCount: 0,
|
|
479
|
+
lastAttemptAt: void 0
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/infrastructure/QueryBus/implementations/SimpleQueryBus.ts
|
|
484
|
+
var SimpleQueryBus = class {
|
|
485
|
+
handlers = /* @__PURE__ */ new Map();
|
|
486
|
+
register(aTypeOfQuery, anHandler) {
|
|
487
|
+
if (this.handlers.has(aTypeOfQuery)) {
|
|
488
|
+
throw new Error(`Handler already registered for query type: ${aTypeOfQuery}`);
|
|
489
|
+
}
|
|
490
|
+
this.handlers.set(aTypeOfQuery, anHandler);
|
|
491
|
+
}
|
|
492
|
+
async execute(aQuery) {
|
|
493
|
+
const handler = this.handlers.get(aQuery.type);
|
|
494
|
+
if (!handler) {
|
|
495
|
+
throw new Error(`No handler found for query type: ${aQuery.type}`);
|
|
496
|
+
}
|
|
497
|
+
return handler.execute(aQuery);
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// src/infrastructure/Repository/implementations/SimpleRepository.ts
|
|
502
|
+
var SimpleRepository = class {
|
|
503
|
+
constructor(eventStore, streamName, evolveFn, initialState) {
|
|
504
|
+
this.eventStore = eventStore;
|
|
505
|
+
this.streamName = streamName;
|
|
506
|
+
this.evolveFn = evolveFn;
|
|
507
|
+
this.initialState = initialState;
|
|
508
|
+
}
|
|
509
|
+
async load(aggregateId) {
|
|
510
|
+
const pastEvents = await this.eventStore.load(this.streamName, aggregateId);
|
|
511
|
+
return pastEvents.reduce(this.evolveFn, this.initialState(aggregateId));
|
|
512
|
+
}
|
|
513
|
+
async store(events) {
|
|
514
|
+
await Promise.all(
|
|
515
|
+
events.map(
|
|
516
|
+
async (event) => this.eventStore.append(
|
|
517
|
+
this.streamName,
|
|
518
|
+
[event]
|
|
519
|
+
)
|
|
520
|
+
)
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// src/infrastructure/ScenarioTest/ScenarioTest.ts
|
|
526
|
+
var ScenarioTest = class {
|
|
527
|
+
constructor(streamName, eventBus, eventStore, commandBus, queryBus, repository, outboxWorker) {
|
|
528
|
+
this.streamName = streamName;
|
|
529
|
+
this.eventBus = eventBus;
|
|
530
|
+
this.eventStore = eventStore;
|
|
531
|
+
this.commandBus = commandBus;
|
|
532
|
+
this.queryBus = queryBus;
|
|
533
|
+
this.repository = repository;
|
|
534
|
+
this.outboxWorker = outboxWorker;
|
|
535
|
+
}
|
|
536
|
+
givenInput = [];
|
|
537
|
+
whenInput;
|
|
538
|
+
given(...events) {
|
|
539
|
+
this.givenInput = events;
|
|
540
|
+
return {
|
|
541
|
+
when: this.when.bind(this),
|
|
542
|
+
then: this.then.bind(this)
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
when(action) {
|
|
546
|
+
this.whenInput = action;
|
|
547
|
+
return {
|
|
548
|
+
then: this.then.bind(this)
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
async then(thenInput) {
|
|
552
|
+
const domainEvents = this.givenInput.filter(isDomainEvent);
|
|
553
|
+
const integrationEvents = this.givenInput.filter(isIntegrationEvent);
|
|
554
|
+
await Promise.all([
|
|
555
|
+
this.repository.store(domainEvents),
|
|
556
|
+
integrationEvents.map(async (event) => this.eventBus.consume(this.streamName, event))
|
|
557
|
+
]);
|
|
558
|
+
if (!this.whenInput) {
|
|
559
|
+
throw new Error("In the ScenarioTest, the when-step cannot be empty");
|
|
560
|
+
}
|
|
561
|
+
if (isCommand(this.whenInput)) {
|
|
562
|
+
invariant(isDomainEvent(thenInput), fail(new TypeError('When "command" expects a domain event in the then-step')));
|
|
563
|
+
await this.handleCommand(this.whenInput, thenInput);
|
|
564
|
+
}
|
|
565
|
+
if (isQuery(this.whenInput)) {
|
|
566
|
+
invariant(Array.isArray(thenInput), fail(new TypeError('When "query" expects an array of expected results in the then-step')));
|
|
567
|
+
await this.handleQuery(this.whenInput, thenInput);
|
|
568
|
+
}
|
|
569
|
+
if (isDomainEvent(this.whenInput) || isIntegrationEvent(this.whenInput)) {
|
|
570
|
+
invariant(isDomainEvent(thenInput), fail(new TypeError('When "domain event" or "integration event" expects a domain event in the then-step')));
|
|
571
|
+
await this.handleEvent(this.whenInput, thenInput);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
async handleCommand(command, outcome) {
|
|
575
|
+
await this.commandBus.execute(command);
|
|
576
|
+
const actualEvents = await this.eventStore.load(this.streamName, outcome.aggregateId);
|
|
577
|
+
const foundEvent = actualEvents.findLast(
|
|
578
|
+
(event) => isDomainEvent(event) && event.aggregateId === outcome.aggregateId && event.type === outcome.type
|
|
579
|
+
);
|
|
580
|
+
invariant(!!foundEvent, fail(new Error("ScenarioTest: event/command was not found")));
|
|
581
|
+
invariant(
|
|
582
|
+
outcome.type === foundEvent.type,
|
|
583
|
+
fail(new Error("ScenarioTest: event/command type was not equal"))
|
|
584
|
+
);
|
|
585
|
+
invariant(
|
|
586
|
+
outcome.aggregateId === foundEvent.aggregateId,
|
|
587
|
+
fail(new Error("ScenarioTest: event/command aggregate id was not equal"))
|
|
588
|
+
);
|
|
589
|
+
invariant(
|
|
590
|
+
isEqual(outcome.payload, foundEvent.payload),
|
|
591
|
+
fail(new Error("ScenarioTest: event/command payload was not equal"))
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
async handleQuery(query, expected) {
|
|
595
|
+
await this.outboxWorker.tick();
|
|
596
|
+
const actual = await this.queryBus.execute(query);
|
|
597
|
+
invariant(
|
|
598
|
+
isEqual(actual, expected),
|
|
599
|
+
fail(new Error("ScenarioTest: a different query result was returned"))
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
async handleEvent(event, outcome) {
|
|
603
|
+
await this.eventBus.publish(this.streamName, event);
|
|
604
|
+
const actualEvents = await this.eventStore.load(this.streamName, outcome.aggregateId);
|
|
605
|
+
const foundEvent = actualEvents.findLast(
|
|
606
|
+
(event2) => isEvent(event2) && event2.aggregateId === outcome.aggregateId && event2.type === outcome.type
|
|
607
|
+
);
|
|
608
|
+
invariant(!!foundEvent, fail(new Error("ScenarioTest: event was not found")));
|
|
609
|
+
invariant(isEvent(foundEvent), fail(new Error("ScenarioTest: event is not of type event")));
|
|
610
|
+
invariant(
|
|
611
|
+
outcome.type === foundEvent.type,
|
|
612
|
+
fail(new Error("ScenarioTest: event type was not equal"))
|
|
613
|
+
);
|
|
614
|
+
invariant(
|
|
615
|
+
outcome.aggregateId === foundEvent.aggregateId,
|
|
616
|
+
fail(new Error("ScenarioTest: event aggregate id was not equal"))
|
|
617
|
+
);
|
|
618
|
+
invariant(
|
|
619
|
+
isEqual(outcome.payload, foundEvent.payload),
|
|
620
|
+
fail(new Error("ScenarioTest: event payload was not equal"))
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
exports.AndSpecification = AndSpecification;
|
|
626
|
+
exports.FieldEquals = FieldEquals;
|
|
627
|
+
exports.FieldGreaterThan = FieldGreaterThan;
|
|
628
|
+
exports.GenericOutboxWorker = GenericOutboxWorker;
|
|
629
|
+
exports.InMemoryOutbox = InMemoryOutbox;
|
|
630
|
+
exports.NotSpecification = NotSpecification;
|
|
631
|
+
exports.Operation = Operation;
|
|
632
|
+
exports.OrSpecification = OrSpecification;
|
|
633
|
+
exports.ScenarioTest = ScenarioTest;
|
|
634
|
+
exports.SimpleCommandBus = SimpleCommandBus;
|
|
635
|
+
exports.SimpleDatabase = SimpleDatabase;
|
|
636
|
+
exports.SimpleEventBus = SimpleEventBus;
|
|
637
|
+
exports.SimpleEventStore = SimpleEventStore;
|
|
638
|
+
exports.SimpleQueryBus = SimpleQueryBus;
|
|
639
|
+
exports.SimpleRepository = SimpleRepository;
|
|
640
|
+
exports.Specification = Specification;
|
|
641
|
+
exports.createCommand = createCommand;
|
|
642
|
+
exports.createDomainEvent = createDomainEvent;
|
|
643
|
+
exports.createIntegrationEvent = createIntegrationEvent;
|
|
644
|
+
exports.createOutboxEntry = createOutboxEntry;
|
|
645
|
+
exports.createQuery = createQuery;
|
|
646
|
+
exports.createQueryNode = createQueryNode;
|
|
647
|
+
exports.createStoredEvent = createStoredEvent;
|
|
648
|
+
exports.fail = fail;
|
|
649
|
+
exports.invariant = invariant;
|
|
650
|
+
exports.isCommand = isCommand;
|
|
651
|
+
exports.isDomainEvent = isDomainEvent;
|
|
652
|
+
exports.isEqual = isEqual;
|
|
653
|
+
exports.isEvent = isEvent;
|
|
654
|
+
exports.isIntegrationEvent = isIntegrationEvent;
|
|
655
|
+
exports.isQuery = isQuery;
|
|
656
|
+
exports.makeStreamKey = makeStreamKey;
|
|
657
|
+
exports.parseAsError = parseAsError;
|
|
658
|
+
//# sourceMappingURL=index.cjs.map
|
|
659
|
+
//# sourceMappingURL=index.cjs.map
|