@eventmodelers/cli 1.0.45 → 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.
Files changed (102) hide show
  1. package/README.md +4 -1
  2. package/cli.js +53 -1
  3. package/package.json +2 -2
  4. package/shared/build-kit/lib/checks/README.md +59 -0
  5. package/shared/build-kit/lib/ralph.js +110 -28
  6. package/shared/build-kit/lib/util/find-slice.cjs +59 -0
  7. package/shared/build-kit/ralph-claude.js +6 -2
  8. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +106 -102
  9. package/stacks/blank/templates/build-kit/lib/prompt.md +102 -106
  10. package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +422 -0
  11. package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
  12. package/stacks/kurrent/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +65 -0
  13. package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +418 -0
  14. package/stacks/kurrent/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +41 -0
  15. package/stacks/kurrent/templates/.claude/skills/build-state-change/references/integration-test-patterns.md +64 -0
  16. package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +391 -0
  17. package/stacks/kurrent/templates/build-kit/CLAUDE.md +122 -0
  18. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +73 -0
  19. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +169 -0
  20. package/stacks/kurrent/templates/build-kit/lib/prompt.md +128 -0
  21. package/stacks/kurrent/templates/root/README.md +46 -0
  22. package/stacks/kurrent/templates/root/docker-compose.yml +45 -0
  23. package/stacks/kurrent/templates/root/mvnw +259 -0
  24. package/stacks/kurrent/templates/root/mvnw.cmd +149 -0
  25. package/stacks/kurrent/templates/root/pom.xml +152 -0
  26. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
  27. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/common/EventStore.java +91 -0
  28. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/config/KurrentConfiguration.java +42 -0
  29. package/stacks/kurrent/templates/root/src/main/resources/application.properties +14 -0
  30. package/stacks/kurrent/templates/root/src/main/resources/static/index.html +11 -0
  31. package/stacks/node/templates/build-kit/CLAUDE.md +22 -0
  32. package/stacks/node/templates/build-kit/lib/check-commit-scope.cjs +123 -0
  33. package/stacks/node/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
  34. package/stacks/node/templates/build-kit/lib/checks/10-slice-scope.cjs +29 -0
  35. package/stacks/node/templates/build-kit/lib/checks/20-append-only-migrations.cjs +20 -0
  36. package/stacks/node/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
  37. package/stacks/node/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
  38. package/stacks/node/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
  39. package/stacks/node/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
  40. package/stacks/node/templates/root/.githooks/pre-commit +11 -0
  41. package/stacks/node/templates/root/package.json +2 -1
  42. package/stacks/node/templates/root/setup-env.sh +7 -1
  43. package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +434 -0
  44. package/stacks/opencqrs/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
  45. package/stacks/opencqrs/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +62 -0
  46. package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +413 -0
  47. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +46 -0
  48. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +145 -0
  49. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/test-fixture-patterns.md +85 -0
  50. package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +354 -0
  51. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +101 -0
  52. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +58 -0
  53. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +169 -0
  54. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +128 -0
  55. package/stacks/opencqrs/templates/root/README.md +42 -0
  56. package/stacks/opencqrs/templates/root/docker-compose.yml +37 -0
  57. package/stacks/opencqrs/templates/root/mvnw +259 -0
  58. package/stacks/opencqrs/templates/root/mvnw.cmd +149 -0
  59. package/stacks/opencqrs/templates/root/pom.xml +139 -0
  60. package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
  61. package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/config/CqrsConfiguration.java +74 -0
  62. package/stacks/opencqrs/templates/root/src/main/resources/application.properties +23 -0
  63. package/stacks/opencqrs/templates/root/src/main/resources/schema.sql +19 -0
  64. package/stacks/opencqrs/templates/root/src/main/resources/static/index.html +11 -0
  65. package/stacks/supabase/templates/build-kit/CLAUDE.md +25 -0
  66. package/stacks/supabase/templates/build-kit/lib/check-commit-scope.cjs +126 -0
  67. package/stacks/supabase/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
  68. package/stacks/supabase/templates/build-kit/lib/checks/10-slice-scope.cjs +34 -0
  69. package/stacks/supabase/templates/build-kit/lib/checks/20-append-only-migrations.cjs +21 -0
  70. package/stacks/supabase/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
  71. package/stacks/supabase/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
  72. package/stacks/supabase/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
  73. package/stacks/supabase/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
  74. package/stacks/supabase/templates/root/.githooks/pre-commit +11 -0
  75. package/stacks/supabase/templates/root/package.json +2 -1
  76. package/stacks/supabase/templates/root/setup-env.sh +7 -1
  77. package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +313 -0
  78. package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +42 -0
  79. package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +376 -0
  80. package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +42 -0
  81. package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md +78 -0
  82. package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +338 -0
  83. package/stacks/umadb/templates/build-kit/CLAUDE.md +94 -0
  84. package/stacks/umadb/templates/build-kit/lib/AGENT.md +47 -0
  85. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +169 -0
  86. package/stacks/umadb/templates/build-kit/lib/prompt.md +128 -0
  87. package/stacks/umadb/templates/root/.mvn/wrapper/maven-wrapper.properties +19 -0
  88. package/stacks/umadb/templates/root/README.md +48 -0
  89. package/stacks/umadb/templates/root/docker-compose.yml +29 -0
  90. package/stacks/umadb/templates/root/mvnw +259 -0
  91. package/stacks/umadb/templates/root/mvnw.cmd +149 -0
  92. package/stacks/umadb/templates/root/pom.xml +151 -0
  93. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java +12 -0
  94. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/config/UmaDbConfig.java +39 -0
  95. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/DecisionModelLoader.java +76 -0
  96. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java +40 -0
  97. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventDispatcher.java +100 -0
  98. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/OptimisticConcurrencyException.java +14 -0
  99. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/SliceEventListener.java +23 -0
  100. package/stacks/umadb/templates/root/src/main/resources/application.properties +17 -0
  101. package/stacks/umadb/templates/root/src/test/java/io/umadb/quickstart/testsupport/InMemoryUmaDbClient.java +135 -0
  102. 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>
@@ -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
+ }
@@ -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.