@eventmodelers/cli 1.0.44 → 1.0.46
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 +4 -1
- package/cli.js +53 -1
- package/package.json +2 -2
- package/shared/build-kit/lib/checks/README.md +59 -0
- package/shared/build-kit/lib/ralph.js +110 -28
- package/shared/build-kit/lib/util/find-slice.cjs +59 -0
- package/shared/build-kit/ralph-claude.js +6 -2
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +106 -102
- package/stacks/blank/templates/build-kit/lib/prompt.md +102 -106
- package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +422 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +65 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +418 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +41 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/references/integration-test-patterns.md +64 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +391 -0
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +122 -0
- package/stacks/kurrent/templates/build-kit/lib/AGENT.md +73 -0
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/kurrent/templates/root/README.md +46 -0
- package/stacks/kurrent/templates/root/docker-compose.yml +45 -0
- package/stacks/kurrent/templates/root/mvnw +259 -0
- package/stacks/kurrent/templates/root/mvnw.cmd +149 -0
- package/stacks/kurrent/templates/root/pom.xml +152 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/common/EventStore.java +91 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/config/KurrentConfiguration.java +42 -0
- package/stacks/kurrent/templates/root/src/main/resources/application.properties +14 -0
- package/stacks/kurrent/templates/root/src/main/resources/static/index.html +11 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +4 -2
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +1 -1
- package/stacks/node/templates/build-kit/CLAUDE.md +22 -0
- package/stacks/node/templates/build-kit/lib/check-commit-scope.cjs +123 -0
- package/stacks/node/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
- package/stacks/node/templates/build-kit/lib/checks/10-slice-scope.cjs +29 -0
- package/stacks/node/templates/build-kit/lib/checks/20-append-only-migrations.cjs +20 -0
- package/stacks/node/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
- package/stacks/node/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
- package/stacks/node/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
- package/stacks/node/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
- package/stacks/node/templates/root/.githooks/pre-commit +11 -0
- package/stacks/node/templates/root/README.md +67 -0
- package/stacks/node/templates/root/package.json +2 -1
- package/stacks/node/templates/root/setup-env.sh +7 -1
- package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +434 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +62 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +413 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +46 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +145 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/test-fixture-patterns.md +85 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +354 -0
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +101 -0
- package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +58 -0
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/opencqrs/templates/root/README.md +42 -0
- package/stacks/opencqrs/templates/root/docker-compose.yml +37 -0
- package/stacks/opencqrs/templates/root/mvnw +259 -0
- package/stacks/opencqrs/templates/root/mvnw.cmd +149 -0
- package/stacks/opencqrs/templates/root/pom.xml +139 -0
- package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
- package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/config/CqrsConfiguration.java +74 -0
- package/stacks/opencqrs/templates/root/src/main/resources/application.properties +23 -0
- package/stacks/opencqrs/templates/root/src/main/resources/schema.sql +19 -0
- package/stacks/opencqrs/templates/root/src/main/resources/static/index.html +11 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +25 -0
- package/stacks/supabase/templates/build-kit/lib/check-commit-scope.cjs +126 -0
- package/stacks/supabase/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
- package/stacks/supabase/templates/build-kit/lib/checks/10-slice-scope.cjs +34 -0
- package/stacks/supabase/templates/build-kit/lib/checks/20-append-only-migrations.cjs +21 -0
- package/stacks/supabase/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
- package/stacks/supabase/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
- package/stacks/supabase/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
- package/stacks/supabase/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
- package/stacks/supabase/templates/root/.githooks/pre-commit +11 -0
- package/stacks/supabase/templates/root/package.json +2 -1
- package/stacks/supabase/templates/root/setup-env.sh +7 -1
- package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +313 -0
- package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +42 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +376 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +42 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md +78 -0
- package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +338 -0
- package/stacks/umadb/templates/build-kit/CLAUDE.md +94 -0
- package/stacks/umadb/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/umadb/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/umadb/templates/root/.mvn/wrapper/maven-wrapper.properties +19 -0
- package/stacks/umadb/templates/root/README.md +48 -0
- package/stacks/umadb/templates/root/docker-compose.yml +29 -0
- package/stacks/umadb/templates/root/mvnw +259 -0
- package/stacks/umadb/templates/root/mvnw.cmd +149 -0
- package/stacks/umadb/templates/root/pom.xml +151 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java +12 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/config/UmaDbConfig.java +39 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/DecisionModelLoader.java +76 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java +40 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventDispatcher.java +100 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/OptimisticConcurrencyException.java +14 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/SliceEventListener.java +23 -0
- package/stacks/umadb/templates/root/src/main/resources/application.properties +17 -0
- package/stacks/umadb/templates/root/src/test/java/io/umadb/quickstart/testsupport/InMemoryUmaDbClient.java +135 -0
- package/stacks/umadb/templates/root/src/test/resources/application.properties +8 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
3
|
+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
4
|
+
<modelVersion>4.0.0</modelVersion>
|
|
5
|
+
<groupId>io.umadb.quickstart</groupId>
|
|
6
|
+
<artifactId>quickstart</artifactId>
|
|
7
|
+
<name>UmaDB Quickstart</name>
|
|
8
|
+
<version>0.0.1-SNAPSHOT</version>
|
|
9
|
+
|
|
10
|
+
<properties>
|
|
11
|
+
<java.version>21</java.version>
|
|
12
|
+
<maven.compiler.release>21</maven.compiler.release>
|
|
13
|
+
<umadb-client.version>0.7</umadb-client.version>
|
|
14
|
+
<spring-boot.version>3.4.1</spring-boot.version>
|
|
15
|
+
<testcontainers.version>1.20.4</testcontainers.version>
|
|
16
|
+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
17
|
+
</properties>
|
|
18
|
+
|
|
19
|
+
<dependencies>
|
|
20
|
+
<!-- Plain Spring MVC, not WebFlux: UmaDbClient's core API is blocking (returns Iterator,
|
|
21
|
+
not a Publisher), so a reactive web layer would only hide the blocking calls behind
|
|
22
|
+
a Mono, not remove them. Virtual threads (see application.properties) is this
|
|
23
|
+
project's answer to "many concurrent blocking gRPC calls" instead. -->
|
|
24
|
+
<dependency>
|
|
25
|
+
<groupId>org.springframework.boot</groupId>
|
|
26
|
+
<artifactId>spring-boot-starter-web</artifactId>
|
|
27
|
+
</dependency>
|
|
28
|
+
|
|
29
|
+
<!-- Read model persistence: Spring Data JPA + PostgreSQL, default for all projections -->
|
|
30
|
+
<dependency>
|
|
31
|
+
<groupId>org.springframework.boot</groupId>
|
|
32
|
+
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
|
33
|
+
</dependency>
|
|
34
|
+
<dependency>
|
|
35
|
+
<groupId>org.postgresql</groupId>
|
|
36
|
+
<artifactId>postgresql</artifactId>
|
|
37
|
+
<scope>runtime</scope>
|
|
38
|
+
</dependency>
|
|
39
|
+
|
|
40
|
+
<!-- UmaDB Java client (io.github.domenicdev is the published groupId; the classes
|
|
41
|
+
themselves live under the io.umadb.client package - see UmaDbConfig.java) -->
|
|
42
|
+
<dependency>
|
|
43
|
+
<groupId>io.github.domenicdev</groupId>
|
|
44
|
+
<artifactId>umadb-java-client</artifactId>
|
|
45
|
+
<version>${umadb-client.version}</version>
|
|
46
|
+
</dependency>
|
|
47
|
+
|
|
48
|
+
<!-- JSON codec for event/command payloads -->
|
|
49
|
+
<dependency>
|
|
50
|
+
<groupId>com.fasterxml.jackson.core</groupId>
|
|
51
|
+
<artifactId>jackson-databind</artifactId>
|
|
52
|
+
</dependency>
|
|
53
|
+
|
|
54
|
+
<!-- Auto-starts docker-compose.yml services (umadb, postgres) on app run -->
|
|
55
|
+
<dependency>
|
|
56
|
+
<groupId>org.springframework.boot</groupId>
|
|
57
|
+
<artifactId>spring-boot-docker-compose</artifactId>
|
|
58
|
+
<scope>runtime</scope>
|
|
59
|
+
<optional>true</optional>
|
|
60
|
+
</dependency>
|
|
61
|
+
|
|
62
|
+
<!-- Testing -->
|
|
63
|
+
<dependency>
|
|
64
|
+
<groupId>org.springframework.boot</groupId>
|
|
65
|
+
<artifactId>spring-boot-starter-test</artifactId>
|
|
66
|
+
<scope>test</scope>
|
|
67
|
+
</dependency>
|
|
68
|
+
<dependency>
|
|
69
|
+
<groupId>org.junit.jupiter</groupId>
|
|
70
|
+
<artifactId>junit-jupiter</artifactId>
|
|
71
|
+
<scope>test</scope>
|
|
72
|
+
</dependency>
|
|
73
|
+
<!-- @DataJpaTest's embedded database for fast, container-free projector tests -->
|
|
74
|
+
<dependency>
|
|
75
|
+
<groupId>com.h2database</groupId>
|
|
76
|
+
<artifactId>h2</artifactId>
|
|
77
|
+
<scope>test</scope>
|
|
78
|
+
</dependency>
|
|
79
|
+
<dependency>
|
|
80
|
+
<groupId>org.springframework.boot</groupId>
|
|
81
|
+
<artifactId>spring-boot-testcontainers</artifactId>
|
|
82
|
+
<scope>test</scope>
|
|
83
|
+
</dependency>
|
|
84
|
+
<dependency>
|
|
85
|
+
<groupId>org.testcontainers</groupId>
|
|
86
|
+
<artifactId>testcontainers</artifactId>
|
|
87
|
+
<scope>test</scope>
|
|
88
|
+
</dependency>
|
|
89
|
+
<dependency>
|
|
90
|
+
<groupId>org.testcontainers</groupId>
|
|
91
|
+
<artifactId>postgresql</artifactId>
|
|
92
|
+
<scope>test</scope>
|
|
93
|
+
</dependency>
|
|
94
|
+
<dependency>
|
|
95
|
+
<groupId>org.testcontainers</groupId>
|
|
96
|
+
<artifactId>junit-jupiter</artifactId>
|
|
97
|
+
<scope>test</scope>
|
|
98
|
+
</dependency>
|
|
99
|
+
<dependency>
|
|
100
|
+
<groupId>org.awaitility</groupId>
|
|
101
|
+
<artifactId>awaitility</artifactId>
|
|
102
|
+
<version>4.2.2</version>
|
|
103
|
+
<scope>test</scope>
|
|
104
|
+
</dependency>
|
|
105
|
+
</dependencies>
|
|
106
|
+
|
|
107
|
+
<build>
|
|
108
|
+
<plugins>
|
|
109
|
+
<plugin>
|
|
110
|
+
<groupId>org.apache.maven.plugins</groupId>
|
|
111
|
+
<artifactId>maven-compiler-plugin</artifactId>
|
|
112
|
+
<configuration>
|
|
113
|
+
<release>${maven.compiler.release}</release>
|
|
114
|
+
<parameters>true</parameters>
|
|
115
|
+
</configuration>
|
|
116
|
+
</plugin>
|
|
117
|
+
<plugin>
|
|
118
|
+
<groupId>org.springframework.boot</groupId>
|
|
119
|
+
<artifactId>spring-boot-maven-plugin</artifactId>
|
|
120
|
+
</plugin>
|
|
121
|
+
<plugin>
|
|
122
|
+
<groupId>org.apache.maven.plugins</groupId>
|
|
123
|
+
<artifactId>maven-surefire-plugin</artifactId>
|
|
124
|
+
<configuration>
|
|
125
|
+
<!-- Mockito's inline mock maker (Byte Buddy) needs this on a JDK newer than
|
|
126
|
+
its officially-supported range; harmless on older JDKs too. -->
|
|
127
|
+
<argLine>-Dnet.bytebuddy.experimental=true</argLine>
|
|
128
|
+
</configuration>
|
|
129
|
+
</plugin>
|
|
130
|
+
</plugins>
|
|
131
|
+
</build>
|
|
132
|
+
|
|
133
|
+
<dependencyManagement>
|
|
134
|
+
<dependencies>
|
|
135
|
+
<dependency>
|
|
136
|
+
<groupId>org.springframework.boot</groupId>
|
|
137
|
+
<artifactId>spring-boot-dependencies</artifactId>
|
|
138
|
+
<version>${spring-boot.version}</version>
|
|
139
|
+
<type>pom</type>
|
|
140
|
+
<scope>import</scope>
|
|
141
|
+
</dependency>
|
|
142
|
+
<dependency>
|
|
143
|
+
<groupId>org.testcontainers</groupId>
|
|
144
|
+
<artifactId>testcontainers-bom</artifactId>
|
|
145
|
+
<version>${testcontainers.version}</version>
|
|
146
|
+
<type>pom</type>
|
|
147
|
+
<scope>import</scope>
|
|
148
|
+
</dependency>
|
|
149
|
+
</dependencies>
|
|
150
|
+
</dependencyManagement>
|
|
151
|
+
</project>
|
package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
package io.umadb.quickstart;
|
|
2
|
+
|
|
3
|
+
import org.springframework.boot.SpringApplication;
|
|
4
|
+
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|
5
|
+
|
|
6
|
+
@SpringBootApplication
|
|
7
|
+
public class QuickstartApplication {
|
|
8
|
+
|
|
9
|
+
public static void main(String[] args) {
|
|
10
|
+
SpringApplication.run(QuickstartApplication.class, args);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package io.umadb.quickstart.config;
|
|
2
|
+
|
|
3
|
+
import io.umadb.client.UmaDbClient;
|
|
4
|
+
import jakarta.annotation.PreDestroy;
|
|
5
|
+
import org.springframework.beans.factory.annotation.Value;
|
|
6
|
+
import org.springframework.context.annotation.Bean;
|
|
7
|
+
import org.springframework.context.annotation.Configuration;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Wires the shared {@link UmaDbClient} used by every command handler's read/append calls.
|
|
11
|
+
* UmaDB has no Spring Boot starter of its own (unlike Axon Framework) - the client is a
|
|
12
|
+
* plain library, so this project connects and shuts it down by hand, the same way the Axon
|
|
13
|
+
* quickstart wires its {@code TokenStore} bean manually.
|
|
14
|
+
* <p>
|
|
15
|
+
* {@link io.umadb.quickstart.eventstore.EventDispatcher} does NOT use this bean - it opens
|
|
16
|
+
* its own separate connection for the long-lived subscription. See that class's Javadoc.
|
|
17
|
+
*/
|
|
18
|
+
@Configuration
|
|
19
|
+
public class UmaDbConfig {
|
|
20
|
+
|
|
21
|
+
private UmaDbClient client;
|
|
22
|
+
|
|
23
|
+
@Bean
|
|
24
|
+
public UmaDbClient umaDbClient(
|
|
25
|
+
@Value("${umadb.host}") String host,
|
|
26
|
+
@Value("${umadb.port}") int port
|
|
27
|
+
) {
|
|
28
|
+
client = UmaDbClient.builder().withHost(host).withPort(port).build();
|
|
29
|
+
client.connect();
|
|
30
|
+
return client;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@PreDestroy
|
|
34
|
+
void shutdown() {
|
|
35
|
+
if (client != null) {
|
|
36
|
+
client.shutdown();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
package io.umadb.quickstart.eventstore;
|
|
2
|
+
|
|
3
|
+
import io.umadb.client.*;
|
|
4
|
+
import org.springframework.stereotype.Component;
|
|
5
|
+
|
|
6
|
+
import java.util.List;
|
|
7
|
+
import java.util.function.BiConsumer;
|
|
8
|
+
import java.util.function.Supplier;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Shared read-decide-append loop every write slice's command handler uses - this project's
|
|
12
|
+
* hand-rolled equivalent of what an event-sourcing framework's aggregate repository would
|
|
13
|
+
* normally do, since the raw UmaDB client has no such layer of its own.
|
|
14
|
+
* <p>
|
|
15
|
+
* A command handler: (1) builds a {@link Query} scoped to the command's id tag(s) - see
|
|
16
|
+
* each slice's own {@code Decision.relevantEvents(...)} - (2) calls {@link #load} to replay
|
|
17
|
+
* matching prior events into a fresh decision-model instance, (3) checks the decision's
|
|
18
|
+
* flags/fields and throws if a business rule is violated, (4) calls {@link #append} with the
|
|
19
|
+
* SAME query and the position {@link #load} observed, so a conflicting event appended
|
|
20
|
+
* concurrently (by another instance of this same command, racing) fails the append instead
|
|
21
|
+
* of silently corrupting the decision - UmaDB's {@code AppendCondition.failIfExistsAfter}
|
|
22
|
+
* is what makes this a real consistency boundary, not just a convention.
|
|
23
|
+
*/
|
|
24
|
+
@Component
|
|
25
|
+
public class DecisionModelLoader {
|
|
26
|
+
|
|
27
|
+
private final UmaDbClient client;
|
|
28
|
+
|
|
29
|
+
public DecisionModelLoader(UmaDbClient client) {
|
|
30
|
+
this.client = client;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The decision instance folded from prior matching events, plus the position observed while reading it. */
|
|
34
|
+
public record Loaded<D>(D decision, long lastPosition) {
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reads every event matching {@code query}, applying each to a fresh decision instance in
|
|
39
|
+
* store order. {@code apply} receives the raw {@link Event} - most decisions only need to
|
|
40
|
+
* know a matching event of a given {@code type()} existed (a boolean flag); only decode the
|
|
41
|
+
* payload via {@link EventCodec#fromEvent} when a scenario actually needs a field's value.
|
|
42
|
+
*/
|
|
43
|
+
public <D> Loaded<D> load(Query query, Supplier<D> newDecision, BiConsumer<D, Event> apply) {
|
|
44
|
+
long lastPosition = client.getHeadPosition();
|
|
45
|
+
D decision = newDecision.get();
|
|
46
|
+
var batches = client.handle(ReadRequest.of(query));
|
|
47
|
+
while (batches.hasNext()) {
|
|
48
|
+
for (SequencedEvent sequencedEvent : batches.next().events()) {
|
|
49
|
+
apply.accept(decision, sequencedEvent.event());
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return new Loaded<>(decision, lastPosition);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Appends the given domain event, guarded by an {@code AppendCondition} that fails if any
|
|
57
|
+
* event matching {@code consistencyBoundary} was appended after {@code lastPosition} - i.e.
|
|
58
|
+
* since this command's {@link #load} call read the state it decided against.
|
|
59
|
+
*
|
|
60
|
+
* @param consistencyBoundary same query {@link #load} used to read the decision this append follows from
|
|
61
|
+
* @param lastPosition the position {@link #load} returned alongside that decision
|
|
62
|
+
* @throws OptimisticConcurrencyException if a conflicting event was appended concurrently
|
|
63
|
+
*/
|
|
64
|
+
public void append(Object domainEvent, String type, List<String> tags, Query consistencyBoundary, long lastPosition) {
|
|
65
|
+
Event event = EventCodec.toEvent(domainEvent, type, tags);
|
|
66
|
+
try {
|
|
67
|
+
client.handle(new AppendRequest(
|
|
68
|
+
List.of(event),
|
|
69
|
+
AppendCondition.failIfExistsAfter(consistencyBoundary, lastPosition)
|
|
70
|
+
));
|
|
71
|
+
} catch (UmaDbException.IntegrityException e) {
|
|
72
|
+
throw new OptimisticConcurrencyException(
|
|
73
|
+
"Conflicting event(s) matching the consistency boundary were appended concurrently for event type " + type, e);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
package io.umadb.quickstart.eventstore;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
4
|
+
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
5
|
+
import io.umadb.client.Event;
|
|
6
|
+
|
|
7
|
+
import java.util.List;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Serializes domain event/command records to and from the opaque {@code byte[]} payload
|
|
11
|
+
* an UmaDB {@link Event} carries. UmaDB itself has no notion of a payload schema - this
|
|
12
|
+
* codec is this project's own convention (plain JSON via Jackson), not part of the UmaDB
|
|
13
|
+
* client API.
|
|
14
|
+
*/
|
|
15
|
+
public final class EventCodec {
|
|
16
|
+
|
|
17
|
+
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
18
|
+
|
|
19
|
+
private EventCodec() {
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Serializes a domain event record into a new UmaDB {@link Event} with a generated id. */
|
|
23
|
+
public static Event toEvent(Object domainEvent, String type, List<String> tags) {
|
|
24
|
+
try {
|
|
25
|
+
byte[] data = MAPPER.writeValueAsBytes(domainEvent);
|
|
26
|
+
return Event.of(type, tags, data);
|
|
27
|
+
} catch (JsonProcessingException e) {
|
|
28
|
+
throw new IllegalStateException("Failed to serialize event of type " + type, e);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Deserializes an UmaDB {@link Event}'s payload back into the given domain event record type. */
|
|
33
|
+
public static <T> T fromEvent(Event event, Class<T> type) {
|
|
34
|
+
try {
|
|
35
|
+
return MAPPER.readValue(event.data(), type);
|
|
36
|
+
} catch (Exception e) {
|
|
37
|
+
throw new IllegalStateException("Failed to deserialize event of type " + event.type(), e);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
package io.umadb.quickstart.eventstore;
|
|
2
|
+
|
|
3
|
+
import io.umadb.client.SubscribeRequest;
|
|
4
|
+
import io.umadb.client.UmaDbClient;
|
|
5
|
+
import jakarta.annotation.PreDestroy;
|
|
6
|
+
import org.slf4j.Logger;
|
|
7
|
+
import org.slf4j.LoggerFactory;
|
|
8
|
+
import org.springframework.beans.factory.annotation.Value;
|
|
9
|
+
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
|
10
|
+
import org.springframework.context.event.EventListener;
|
|
11
|
+
import org.springframework.stereotype.Component;
|
|
12
|
+
|
|
13
|
+
import java.util.List;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fans out every event UmaDB has ever recorded (and every one appended from now on) to every
|
|
17
|
+
* {@link SliceEventListener} bean in the application - read-model projectors and automation
|
|
18
|
+
* processors alike. UmaDB has no built-in event-processor/subscription-group concept the way
|
|
19
|
+
* a framework like Axon does; this class is this project's one shared implementation of that
|
|
20
|
+
* plumbing, so no individual slice re-implements subscribe-and-fan-out itself.
|
|
21
|
+
* <p>
|
|
22
|
+
* Uses its own dedicated {@link UmaDbClient} connection (built directly from
|
|
23
|
+
* {@code umadb.host}/{@code umadb.port}, not the shared command-handling bean from
|
|
24
|
+
* {@link io.umadb.quickstart.config.UmaDbConfig}) because {@link UmaDbClient#subscribe} never
|
|
25
|
+
* returns until {@link UmaDbClient#shutdown()} is called - sharing a client would mean
|
|
26
|
+
* shutting down the dispatcher also kills every command handler's connection, and vice versa.
|
|
27
|
+
* <p>
|
|
28
|
+
* <b>Quickstart-level checkpointing only:</b> resubscribes from the very beginning of the
|
|
29
|
+
* stream on every application start (not from a persisted position). Every listener must
|
|
30
|
+
* therefore be idempotent under full replay - {@code AllCustomersProjector}'s JPA
|
|
31
|
+
* {@code save(...)} is (upsert by primary key), and {@code AutoSubscribeToDefaultCourseProcessor}
|
|
32
|
+
* relies on {@code SubscribeToCourseCommandHandler}'s own decision model to no-op a repeat
|
|
33
|
+
* dispatch. A production project should persist {@code getHeadPosition()} (or a per-listener
|
|
34
|
+
* cursor) and resume from there instead of always starting at 0 - UmaDB's {@code TrackingInfo}
|
|
35
|
+
* is the right tool for that (see {@code build-automation}'s translation-slice guidance for a
|
|
36
|
+
* worked example), but only fits naturally where the listener itself also appends an event;
|
|
37
|
+
* a pure projection has nothing to attach it to.
|
|
38
|
+
*/
|
|
39
|
+
@Component
|
|
40
|
+
public class EventDispatcher {
|
|
41
|
+
|
|
42
|
+
private static final Logger log = LoggerFactory.getLogger(EventDispatcher.class);
|
|
43
|
+
|
|
44
|
+
private final String host;
|
|
45
|
+
private final int port;
|
|
46
|
+
private final List<SliceEventListener> listeners;
|
|
47
|
+
|
|
48
|
+
private volatile UmaDbClient subscriptionClient;
|
|
49
|
+
private volatile boolean shuttingDown;
|
|
50
|
+
|
|
51
|
+
public EventDispatcher(
|
|
52
|
+
@Value("${umadb.host}") String host,
|
|
53
|
+
@Value("${umadb.port}") int port,
|
|
54
|
+
List<SliceEventListener> listeners
|
|
55
|
+
) {
|
|
56
|
+
this.host = host;
|
|
57
|
+
this.port = port;
|
|
58
|
+
this.listeners = List.copyOf(listeners);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@EventListener(ApplicationReadyEvent.class)
|
|
62
|
+
public void start() {
|
|
63
|
+
subscriptionClient = UmaDbClient.builder().withHost(host).withPort(port).build();
|
|
64
|
+
subscriptionClient.connect();
|
|
65
|
+
Thread.ofVirtual().name("umadb-event-dispatcher").start(this::runLoop);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private void runLoop() {
|
|
69
|
+
try {
|
|
70
|
+
var subscription = subscriptionClient.subscribe(SubscribeRequest.all());
|
|
71
|
+
while (subscription.hasNext()) {
|
|
72
|
+
for (var sequencedEvent : subscription.next().events()) {
|
|
73
|
+
var event = sequencedEvent.event();
|
|
74
|
+
for (SliceEventListener listener : listeners) {
|
|
75
|
+
if (listener.supports(event.type())) {
|
|
76
|
+
try {
|
|
77
|
+
listener.onEvent(event);
|
|
78
|
+
} catch (Exception e) {
|
|
79
|
+
log.error("Listener {} failed handling event type {} at position {}",
|
|
80
|
+
listener.getClass().getSimpleName(), event.type(), sequencedEvent.position(), e);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch (Exception e) {
|
|
87
|
+
if (!shuttingDown) {
|
|
88
|
+
log.error("Event dispatcher subscription terminated unexpectedly", e);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@PreDestroy
|
|
94
|
+
void shutdown() {
|
|
95
|
+
shuttingDown = true;
|
|
96
|
+
if (subscriptionClient != null) {
|
|
97
|
+
subscriptionClient.shutdown();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package io.umadb.quickstart.eventstore;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when a {@link DecisionModelLoader#append} call is rejected because an event
|
|
5
|
+
* matching the decision's own query was appended by someone else between the decision's
|
|
6
|
+
* read and its append - i.e. UmaDB's {@code AppendCondition} failed
|
|
7
|
+
* ({@code UmaDbException.IntegrityException}). Callers may retry the whole command from
|
|
8
|
+
* scratch (re-read, re-decide, re-append) or surface this as a conflict to the caller.
|
|
9
|
+
*/
|
|
10
|
+
public class OptimisticConcurrencyException extends RuntimeException {
|
|
11
|
+
public OptimisticConcurrencyException(String message, Throwable cause) {
|
|
12
|
+
super(message, cause);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package io.umadb.quickstart.eventstore;
|
|
2
|
+
|
|
3
|
+
import io.umadb.client.Event;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Implemented by every read-model projector and automation processor that reacts to events
|
|
7
|
+
* off the live {@link EventDispatcher} subscription - this project's equivalent of an
|
|
8
|
+
* {@code @EventHandler}, since UmaDB has no annotation-driven dispatch of its own.
|
|
9
|
+
* Implementations decode the payload themselves (via {@link EventCodec#fromEvent}) once
|
|
10
|
+
* {@link #supports} confirms the type.
|
|
11
|
+
* <p>
|
|
12
|
+
* Named {@code Slice}EventListener, not {@code EventListener} - Spring's own
|
|
13
|
+
* {@code org.springframework.context.event.EventListener} annotation has that exact simple
|
|
14
|
+
* name, and {@link EventDispatcher} needs both in the same file.
|
|
15
|
+
*/
|
|
16
|
+
public interface SliceEventListener {
|
|
17
|
+
|
|
18
|
+
/** Whether this listener reacts to events of the given UmaDB {@code Event.type()}. */
|
|
19
|
+
boolean supports(String eventType);
|
|
20
|
+
|
|
21
|
+
/** Handle one matching event. Called on the dispatcher's subscription thread - keep it fast and idempotent. */
|
|
22
|
+
void onEvent(Event event);
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
spring.application.name=UmaDB Quickstart
|
|
2
|
+
spring.threads.virtual.enabled=true
|
|
3
|
+
|
|
4
|
+
umadb.host=localhost
|
|
5
|
+
umadb.port=50051
|
|
6
|
+
|
|
7
|
+
spring.datasource.url=jdbc:postgresql://localhost:5432/quickstart
|
|
8
|
+
spring.datasource.username=quickstart
|
|
9
|
+
spring.datasource.password=quickstart
|
|
10
|
+
spring.jpa.hibernate.ddl-auto=update
|
|
11
|
+
|
|
12
|
+
spring.docker.compose.enabled=true
|
|
13
|
+
spring.docker.compose.file=docker-compose.yml
|
|
14
|
+
|
|
15
|
+
# Each write/automation slice's build skill adds one line here of the form
|
|
16
|
+
# slices.{context}.write.{slicename}.enabled=true (or slices.{context}.automation.{slicename}.enabled=true)
|
|
17
|
+
# - see build-kit/CLAUDE.md's feature-flag convention.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
package io.umadb.quickstart.testsupport;
|
|
2
|
+
|
|
3
|
+
import io.umadb.client.*;
|
|
4
|
+
|
|
5
|
+
import java.util.*;
|
|
6
|
+
import java.util.concurrent.CopyOnWriteArrayList;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fast, in-process fake of {@link UmaDbClient} for command-handler unit tests - this project's
|
|
10
|
+
* hand-rolled stand-in for what {@code AxonTestFixture} gives Axon Framework projects, since
|
|
11
|
+
* UmaDB ships no test-fixture library of its own (only a Testcontainers-based integration test
|
|
12
|
+
* setup - see {@code UmaDbContainerTest} for that style instead).
|
|
13
|
+
* <p>
|
|
14
|
+
* Reimplements the real server's documented matching rules ({@link Query}/{@link QueryItem}) and
|
|
15
|
+
* conflict/idempotency semantics ({@link AppendCondition}, id-based dedup) closely enough for
|
|
16
|
+
* command-handler tests, but is NOT a certified reproduction of the real server - anything
|
|
17
|
+
* subtle should still be verified against a real instance via Testcontainers
|
|
18
|
+
* ({@code UmaDbContainerTest}). {@link #subscribe} is intentionally unsupported: no test in this
|
|
19
|
+
* project needs it, since projectors are tested via a direct {@code on(event)} call instead (see
|
|
20
|
+
* build-state-view) and the one thing that genuinely needs a live subscription
|
|
21
|
+
* ({@link io.umadb.quickstart.eventstore.EventDispatcher}) is covered by the Testcontainers test.
|
|
22
|
+
*/
|
|
23
|
+
public class InMemoryUmaDbClient implements UmaDbClient {
|
|
24
|
+
|
|
25
|
+
private final List<SequencedEvent> store = new CopyOnWriteArrayList<>();
|
|
26
|
+
|
|
27
|
+
@Override
|
|
28
|
+
public void connect() {
|
|
29
|
+
// no-op: nothing to connect to
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@Override
|
|
33
|
+
public synchronized AppendResponse handle(AppendRequest appendRequest) {
|
|
34
|
+
// Idempotency: if every event in this request already exists by id, no-op and return
|
|
35
|
+
// the position of the last of those - mirrors the real server's documented behaviour
|
|
36
|
+
// (verified against UmaDbClientTest#testIdempotentAppendReturnsSamePosition upstream).
|
|
37
|
+
boolean allAlreadyExist = appendRequest.events().stream().allMatch(this::existsById);
|
|
38
|
+
if (allAlreadyExist && !appendRequest.events().isEmpty()) {
|
|
39
|
+
long position = appendRequest.events().stream()
|
|
40
|
+
.mapToLong(e -> positionOf(e).orElseThrow())
|
|
41
|
+
.max().orElseThrow();
|
|
42
|
+
return new AppendResponse(position);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (appendRequest.condition() != null) {
|
|
46
|
+
AppendCondition condition = appendRequest.condition();
|
|
47
|
+
long after = condition.after() == null ? 0L : condition.after();
|
|
48
|
+
boolean conflict = store.stream()
|
|
49
|
+
.anyMatch(se -> se.position() > after && matches(condition.failIfEventsMatch(), se.event()));
|
|
50
|
+
if (conflict) {
|
|
51
|
+
throw new UmaDbException.IntegrityException(
|
|
52
|
+
"Event(s) matching the append condition already exist after position " + after);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
long position = store.size();
|
|
57
|
+
for (Event event : appendRequest.events()) {
|
|
58
|
+
position++;
|
|
59
|
+
store.add(new SequencedEvent(position, event, appendRequest.trackingInfo()));
|
|
60
|
+
}
|
|
61
|
+
return new AppendResponse(position);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Override
|
|
65
|
+
public synchronized Iterator<ReadResponse> handle(ReadRequest readRequest) {
|
|
66
|
+
long start = readRequest.start() == null ? 0L : readRequest.start();
|
|
67
|
+
boolean backwards = readRequest.backwards() != null && readRequest.backwards();
|
|
68
|
+
|
|
69
|
+
List<SequencedEvent> matched = store.stream()
|
|
70
|
+
.filter(se -> se.position() > start)
|
|
71
|
+
.filter(se -> matches(readRequest.query(), se.event()))
|
|
72
|
+
.sorted(backwards
|
|
73
|
+
? Comparator.comparingLong(SequencedEvent::position).reversed()
|
|
74
|
+
: Comparator.comparingLong(SequencedEvent::position))
|
|
75
|
+
.toList();
|
|
76
|
+
|
|
77
|
+
if (readRequest.limit() != null && matched.size() > readRequest.limit()) {
|
|
78
|
+
matched = matched.subList(0, readRequest.limit());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
long head = store.isEmpty() ? 0L : store.get(store.size() - 1).position();
|
|
82
|
+
return List.of(new ReadResponse(matched, head)).iterator();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Override
|
|
86
|
+
public Iterator<SubscribeResponse> subscribe(SubscribeRequest subscribeRequest) {
|
|
87
|
+
throw new UnsupportedOperationException(
|
|
88
|
+
"InMemoryUmaDbClient does not support subscribe() - test projectors via a direct "
|
|
89
|
+
+ "on(event) call instead, and test EventDispatcher itself against a real "
|
|
90
|
+
+ "instance via Testcontainers (see UmaDbContainerTest).");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@Override
|
|
94
|
+
public synchronized Optional<Long> getTrackingInfo(String source) {
|
|
95
|
+
return store.stream()
|
|
96
|
+
.filter(se -> se.trackingInfo() != null && se.trackingInfo().source().equals(source))
|
|
97
|
+
.max(Comparator.comparingLong(SequencedEvent::position))
|
|
98
|
+
.map(se -> se.trackingInfo().position());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Override
|
|
102
|
+
public synchronized long getHeadPosition() {
|
|
103
|
+
return store.isEmpty() ? 0L : store.get(store.size() - 1).position();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@Override
|
|
107
|
+
public void shutdown() {
|
|
108
|
+
// no-op
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private boolean existsById(Event event) {
|
|
112
|
+
return store.stream().anyMatch(se -> se.event().id().equals(event.id()));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private Optional<Long> positionOf(Event event) {
|
|
116
|
+
return store.stream()
|
|
117
|
+
.filter(se -> se.event().id().equals(event.id()))
|
|
118
|
+
.map(SequencedEvent::position)
|
|
119
|
+
.max(Long::compareTo);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** {@code Query}/{@code QueryItem} matching rules, replicated from their own javadoc. */
|
|
123
|
+
private static boolean matches(Query query, Event event) {
|
|
124
|
+
if (query == null || query.items().isEmpty()) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return query.items().stream().anyMatch(item -> matches(item, event));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private static boolean matches(QueryItem item, Event event) {
|
|
131
|
+
boolean typeMatches = item.types().isEmpty() || item.types().contains(event.type());
|
|
132
|
+
boolean tagsMatch = item.tags().isEmpty() || event.tags().containsAll(item.tags());
|
|
133
|
+
return typeMatches && tagsMatch;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
spring.datasource.url=jdbc:postgresql://localhost:5432/quickstart
|
|
2
|
+
spring.datasource.username=quickstart
|
|
3
|
+
spring.datasource.password=quickstart
|
|
4
|
+
|
|
5
|
+
# Each write/automation slice's build skill adds one line here of the form
|
|
6
|
+
# slices.{context}.write.{slicename}.enabled=false (or slices.{context}.automation.{slicename}.enabled=false)
|
|
7
|
+
# so fast unit tests never boot a real UmaDbClient/EventDispatcher. A Testcontainers-based
|
|
8
|
+
# end-to-end test (see build-state-view's reference) overrides these back to true for itself.
|