@eventmodelers/cli 1.0.45 → 1.0.47

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 (123) hide show
  1. package/README.md +5 -2
  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/shared/skills/learn-eventmodelers-api/SKILL.md +52 -46
  9. package/shared/skills/request-feedback/SKILL.md +7 -5
  10. package/stacks/axon/templates/build-kit/CLAUDE.md +1 -1
  11. package/stacks/blank/templates/build-kit/CLAUDE.md +1 -1
  12. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +106 -102
  13. package/stacks/blank/templates/build-kit/lib/prompt.md +102 -106
  14. package/stacks/cratis-csharp/templates/build-kit/CLAUDE.md +9 -0
  15. package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +422 -0
  16. package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
  17. package/stacks/kurrent/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +65 -0
  18. package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +418 -0
  19. package/stacks/kurrent/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +41 -0
  20. package/stacks/kurrent/templates/.claude/skills/build-state-change/references/integration-test-patterns.md +64 -0
  21. package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +391 -0
  22. package/stacks/kurrent/templates/build-kit/CLAUDE.md +124 -0
  23. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +73 -0
  24. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +169 -0
  25. package/stacks/kurrent/templates/build-kit/lib/prompt.md +128 -0
  26. package/stacks/kurrent/templates/root/README.md +46 -0
  27. package/stacks/kurrent/templates/root/docker-compose.yml +45 -0
  28. package/stacks/kurrent/templates/root/mvnw +259 -0
  29. package/stacks/kurrent/templates/root/mvnw.cmd +149 -0
  30. package/stacks/kurrent/templates/root/pom.xml +152 -0
  31. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
  32. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/common/EventStore.java +91 -0
  33. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/config/KurrentConfiguration.java +42 -0
  34. package/stacks/kurrent/templates/root/src/main/resources/application.properties +14 -0
  35. package/stacks/kurrent/templates/root/src/main/resources/static/index.html +11 -0
  36. package/stacks/modeling-kit/templates/.claude/skills/add-next-slice/SKILL.md +2 -2
  37. package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +0 -1
  38. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/SKILL.md +1 -1
  39. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/references/examples.md +2 -2
  40. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -2
  41. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +1 -1
  42. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-interview-protocol/SKILL.md +1 -1
  43. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +4 -4
  44. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md +1 -1
  45. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/SKILL.md +1 -1
  46. package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +1 -1
  47. package/stacks/modeling-kit/templates/.claude/skills/handle-comment/SKILL.md +13 -4
  48. package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +2 -2
  49. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +5 -5
  50. package/stacks/modeling-kit/templates/kit/AGENTS.md +1 -1
  51. package/stacks/modeling-kit/templates/kit/CLAUDE.md +2 -2
  52. package/stacks/node/templates/build-kit/CLAUDE.md +23 -1
  53. package/stacks/node/templates/build-kit/lib/check-commit-scope.cjs +123 -0
  54. package/stacks/node/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
  55. package/stacks/node/templates/build-kit/lib/checks/10-slice-scope.cjs +29 -0
  56. package/stacks/node/templates/build-kit/lib/checks/20-append-only-migrations.cjs +20 -0
  57. package/stacks/node/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
  58. package/stacks/node/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
  59. package/stacks/node/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
  60. package/stacks/node/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
  61. package/stacks/node/templates/root/.githooks/pre-commit +11 -0
  62. package/stacks/node/templates/root/package.json +2 -1
  63. package/stacks/node/templates/root/setup-env.sh +7 -1
  64. package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +434 -0
  65. package/stacks/opencqrs/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
  66. package/stacks/opencqrs/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +62 -0
  67. package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +413 -0
  68. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +46 -0
  69. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +145 -0
  70. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/test-fixture-patterns.md +85 -0
  71. package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +354 -0
  72. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +103 -0
  73. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +58 -0
  74. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +169 -0
  75. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +128 -0
  76. package/stacks/opencqrs/templates/root/README.md +42 -0
  77. package/stacks/opencqrs/templates/root/docker-compose.yml +37 -0
  78. package/stacks/opencqrs/templates/root/mvnw +259 -0
  79. package/stacks/opencqrs/templates/root/mvnw.cmd +149 -0
  80. package/stacks/opencqrs/templates/root/pom.xml +139 -0
  81. package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
  82. package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/config/CqrsConfiguration.java +74 -0
  83. package/stacks/opencqrs/templates/root/src/main/resources/application.properties +23 -0
  84. package/stacks/opencqrs/templates/root/src/main/resources/schema.sql +19 -0
  85. package/stacks/opencqrs/templates/root/src/main/resources/static/index.html +11 -0
  86. package/stacks/supabase/templates/build-kit/CLAUDE.md +26 -1
  87. package/stacks/supabase/templates/build-kit/lib/check-commit-scope.cjs +126 -0
  88. package/stacks/supabase/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
  89. package/stacks/supabase/templates/build-kit/lib/checks/10-slice-scope.cjs +34 -0
  90. package/stacks/supabase/templates/build-kit/lib/checks/20-append-only-migrations.cjs +21 -0
  91. package/stacks/supabase/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
  92. package/stacks/supabase/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
  93. package/stacks/supabase/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
  94. package/stacks/supabase/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
  95. package/stacks/supabase/templates/root/.githooks/pre-commit +11 -0
  96. package/stacks/supabase/templates/root/package.json +2 -1
  97. package/stacks/supabase/templates/root/setup-env.sh +7 -1
  98. package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +313 -0
  99. package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +42 -0
  100. package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +376 -0
  101. package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +42 -0
  102. package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md +78 -0
  103. package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +338 -0
  104. package/stacks/umadb/templates/build-kit/CLAUDE.md +94 -0
  105. package/stacks/umadb/templates/build-kit/lib/AGENT.md +47 -0
  106. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +169 -0
  107. package/stacks/umadb/templates/build-kit/lib/prompt.md +128 -0
  108. package/stacks/umadb/templates/root/.mvn/wrapper/maven-wrapper.properties +19 -0
  109. package/stacks/umadb/templates/root/README.md +48 -0
  110. package/stacks/umadb/templates/root/docker-compose.yml +29 -0
  111. package/stacks/umadb/templates/root/mvnw +259 -0
  112. package/stacks/umadb/templates/root/mvnw.cmd +149 -0
  113. package/stacks/umadb/templates/root/pom.xml +151 -0
  114. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java +12 -0
  115. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/config/UmaDbConfig.java +39 -0
  116. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/DecisionModelLoader.java +76 -0
  117. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java +40 -0
  118. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventDispatcher.java +100 -0
  119. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/OptimisticConcurrencyException.java +14 -0
  120. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/SliceEventListener.java +23 -0
  121. package/stacks/umadb/templates/root/src/main/resources/application.properties +17 -0
  122. package/stacks/umadb/templates/root/src/test/java/io/umadb/quickstart/testsupport/InMemoryUmaDbClient.java +135 -0
  123. package/stacks/umadb/templates/root/src/test/resources/application.properties +8 -0
@@ -0,0 +1,376 @@
1
+ ---
2
+ name: build-state-change
3
+ authors:
4
+ - Martin Dilger
5
+ description: >
6
+ Implement DCB-style write slices against the raw UmaDB Java client in this project's one
7
+ established pattern: Command record → mutable Decision class (a Query + an apply(Event) fold) →
8
+ @Component CommandHandler using DecisionModelLoader → InMemoryUmaDbClient-based unit test (no
9
+ Spring context, no Docker). Use when implementing a new write slice / command handler from a
10
+ slice.json event model in this project. There is exactly one supported style — do not offer
11
+ alternatives.
12
+ ---
13
+
14
+ # UmaDB — Write Slice
15
+
16
+ One pattern only. Directory layout is flat — `src/main/java/.../slices/{context}/{slicename}/`, no
17
+ `write`/`read`/`automation` folder layer in between (only the shared `slices/{context}/events/`
18
+ folder sits alongside slice folders). Every step below is grounded in the `RegisterCustomer` slice
19
+ (single id field, test in `RegisterCustomerCommandHandlerTest`) and, for the compound-identifier
20
+ case in Step 1, the `SubscribeToCourse` slice (test in `SubscribeToCourseCommandHandlerTest`) —
21
+ both verified, compiled and passing under `mvn test` against
22
+ `io.github.domenicdev:umadb-java-client:0.7` and a real `umadb/umadb:0.7.5` server.
23
+
24
+ UmaDB itself has no annotation-driven modelling layer at all (no `@Command`/`@Event`/`@Aggregate`
25
+ the way Axon Framework has) — every convention below (the Decision class shape, the tag-string
26
+ format, the `DecisionModelLoader` read-decide-append loop) is this project's own, hand-rolled once
27
+ in the shared `eventstore` package and reused by every slice, not something UmaDB enforces itself.
28
+
29
+ ## Step 0: Read the slice definition
30
+
31
+ Read `.build-kit/.slices/{context}/{slicename}/slice.json`. Extract, and use **only** what's there:
32
+
33
+ - `commands[].fields[]` → Command record fields, in order
34
+ - `events[].fields[]` → Event record fields, in order
35
+ - `specifications[]` (GWT scenarios) → one test method per scenario
36
+ - Which command field(s) have `idAttribute: true` — these become the tag(s) the command's Query and
37
+ the resulting event are both scoped to (see Step 1/Step 3)
38
+ - `storylines[]` (optional, may be absent) → narrated walkthroughs with ordered `elements[]`
39
+ "beats"; see Step 7b for how a COMMAND beat in one of these can add a supplementary test
40
+
41
+ Never invent a field, business rule, or event that isn't in slice.json.
42
+
43
+ ## Step 0a: Determine `{basePackage}`
44
+
45
+ Every code example below is rooted at `{basePackage}.slices.{context}.{slicename}`. Resolve
46
+ `{basePackage}` as documented in `.build-kit/CLAUDE.md` — never hardcode `io.umadb.quickstart` (the
47
+ shipped quickstart scaffold's package) or any other specific package.
48
+
49
+ ## Step 1: Command
50
+
51
+ **Exactly one field has `idAttribute: true`** — no annotation needed (UmaDB has nothing like
52
+ `@TargetEntityId`); the field is just referenced directly wherever the id is needed:
53
+
54
+ ```java
55
+ package {basePackage}.slices.{context}.{slicename};
56
+
57
+ public record {SliceName}Command(String field1, String idField) {}
58
+ ```
59
+
60
+ **Two or more fields have `idAttribute: true`** — combine them into a compound id record, with a
61
+ convenience method building it from the command. Verified against `SubscribeToCourseCommand`
62
+ (`email` + `courseId` both `idAttribute: true`):
63
+
64
+ ```java
65
+ package {basePackage}.slices.{context}.{slicename};
66
+
67
+ public record {SliceName}Id(String field1, String field2) {}
68
+ ```
69
+
70
+ ```java
71
+ package {basePackage}.slices.{context}.{slicename};
72
+
73
+ public record {SliceName}Command(String field1, String field2) {
74
+
75
+ public {SliceName}Id identifier() {
76
+ return new {SliceName}Id(field1, field2);
77
+ }
78
+ }
79
+ ```
80
+
81
+ The Decision's `relevantEvents(...)` (Step 3) and the CommandHandler (Step 4) both take this
82
+ `{SliceName}Id` wherever a single id string would otherwise appear.
83
+
84
+ ## Step 2: Event — only if it doesn't already exist
85
+
86
+ Check `src/main/java/.../{context}/events/` first; add to the existing sealed interface rather than
87
+ creating a duplicate.
88
+
89
+ ```java
90
+ package {basePackage}.slices.{context}.events;
91
+
92
+ public record {EventName}(String field1, String idField) implements {Context}Event {
93
+
94
+ public static final String TYPE = "{Context}.{EventName}";
95
+ }
96
+ ```
97
+
98
+ `TYPE` is this project's own convention for what goes into UmaDB's `Event.type()` — UmaDB has no
99
+ `@Event(namespace, name, version)` annotation of its own. Add the tag constant to the context's
100
+ `EventTags` class if it isn't already there:
101
+
102
+ ```java
103
+ public static final String {TAG_CONSTANT} = "idField";
104
+ ```
105
+
106
+ ## Step 3: Decision class
107
+
108
+ Package-private, mutable field(s) per fact a `specifications[]` scenario actually branches on —
109
+ **not** an immutable state record with free-standing decide/evolve functions.
110
+
111
+ **Derive the field(s) from this slice's `specifications[]` (Step 0), not from the event's shape.**
112
+ Each GWT scenario's `given`/`then` pair states the one decision the command handler must make and
113
+ the prior fact that decision depends on — that fact is the field. A "given no prior activity" /
114
+ "given already {X}" pair means one boolean flag for {X}; a scenario that discriminates on a value
115
+ (not just presence/absence) means a value field holding that value, not a boolean.
116
+
117
+ ```java
118
+ package {basePackage}.slices.{context}.{slicename};
119
+
120
+ import io.umadb.client.Event;
121
+ import io.umadb.client.Query;
122
+ import io.umadb.client.QueryItem;
123
+ import {basePackage}.slices.{context}.events.{EventName};
124
+ import {basePackage}.slices.{context}.events.EventTags;
125
+
126
+ import java.util.List;
127
+
128
+ class {SliceName}Decision {
129
+
130
+ boolean <ruleFlag>;
131
+
132
+ static Query relevantEvents(String idField) {
133
+ return Query.of(QueryItem.of(
134
+ List.of({EventName}.TYPE),
135
+ List.of(EventTags.tag(EventTags.{TAG_CONSTANT}, idField))
136
+ ));
137
+ }
138
+
139
+ void apply(Event event) {
140
+ if (event.type().equals({EventName}.TYPE)) {
141
+ this.<ruleFlag> = true;
142
+ }
143
+ }
144
+ }
145
+ ```
146
+
147
+ `apply` takes the raw `io.umadb.client.Event`, not a decoded domain object — most decisions only
148
+ need to know a matching event of a given `type()` existed (a boolean flag). Only decode the payload
149
+ (`EventCodec.fromEvent(event, {EventName}.class)`) when a scenario needs an actual field value, not
150
+ just presence/absence.
151
+
152
+ **Tag each event type by what THIS decision actually needs checked for it — not uniformly.** When
153
+ multiple event types feed one decision (a `Query` with several `QueryItem`s, OR'd together), each
154
+ item gets its own tag set, chosen per the specific invariant that event type is being loaded to
155
+ verify — this is context-dependent, not a fixed property of the event type itself. Verified worked
156
+ example — `SubscribeToCourseDecision`, id is `SubscriptionId(email, courseId)`, two rules, two
157
+ different tag scopes on two different event types:
158
+
159
+ ```java
160
+ static Query relevantEvents(SubscriptionId id) {
161
+ return Query.of(List.of(
162
+ // "is this customer registered at all" — scoped to email only
163
+ QueryItem.of(List.of(CustomerRegistered.TYPE), List.of(EventTags.tag(EventTags.EMAIL, id.email()))),
164
+ // "did this customer already subscribe to THIS course" — scoped to email + courseId
165
+ QueryItem.of(List.of(SubscribedToCourse.TYPE), List.of(
166
+ EventTags.tag(EventTags.EMAIL, id.email()),
167
+ EventTags.tag(EventTags.COURSE_ID, id.courseId())))
168
+ ));
169
+ }
170
+ ```
171
+
172
+ `CustomerRegistered` only needs the `email` tag — "is this customer registered" doesn't involve a
173
+ course. `SubscribedToCourse` needs **both** `email` and `courseId` together — the rule is "already
174
+ subscribed to *this* course", not "subscribed to any course". Getting the tag scope wrong doesn't
175
+ fail loudly: too wide silently pulls in unrelated events, too narrow silently drops events the rule
176
+ needed. See [references/umadb-query-patterns.md](references/umadb-query-patterns.md) for the full
177
+ `Query`/`QueryItem` matching rules this relies on.
178
+
179
+ ## Step 4: Command handler
180
+
181
+ Uses the shared `DecisionModelLoader` (in `{basePackage}.eventstore`) — never call
182
+ `UmaDbClient.handle(ReadRequest...)`/`.handle(AppendRequest...)` directly from a command handler;
183
+ the loader is this project's one shared read-decide-append loop (see its Javadoc for why).
184
+
185
+ ```java
186
+ package {basePackage}.slices.{context}.{slicename};
187
+
188
+ import {basePackage}.eventstore.DecisionModelLoader;
189
+ import {basePackage}.slices.{context}.events.{EventName};
190
+ import {basePackage}.slices.{context}.events.EventTags;
191
+ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
192
+ import org.springframework.stereotype.Component;
193
+
194
+ import java.util.List;
195
+
196
+ @Component
197
+ @ConditionalOnProperty(prefix = "slices.{context}.write", name = "{slicename}.enabled")
198
+ public class {SliceName}CommandHandler {
199
+
200
+ private final DecisionModelLoader loader;
201
+
202
+ public {SliceName}CommandHandler(DecisionModelLoader loader) {
203
+ this.loader = loader;
204
+ }
205
+
206
+ public void handle({SliceName}Command command) {
207
+ var query = {SliceName}Decision.relevantEvents(command.idField());
208
+ var loaded = loader.load(query, {SliceName}Decision::new, {SliceName}Decision::apply);
209
+
210
+ if (loaded.decision().<ruleFlag>) {
211
+ throw new IllegalStateException("...");
212
+ }
213
+
214
+ loader.append(
215
+ new {EventName}(command.field1(), command.idField()),
216
+ {EventName}.TYPE,
217
+ List.of(EventTags.tag(EventTags.{TAG_CONSTANT}, command.idField())),
218
+ query,
219
+ loaded.lastPosition()
220
+ );
221
+ }
222
+ }
223
+ ```
224
+
225
+ `loader.append` throws `OptimisticConcurrencyException` (from `{basePackage}.eventstore`) if a
226
+ conflicting event was appended concurrently between this handler's `load` and `append` calls — this
227
+ IS the consistency boundary, not just a convenience; see
228
+ [references/umadb-query-patterns.md](references/umadb-query-patterns.md).
229
+
230
+ ## Step 5: REST endpoint — only if slice.json shows an inbound `SCREEN` dependency on the command
231
+
232
+ ```java
233
+ package {basePackage}.slices.{context}.{slicename};
234
+
235
+ import {basePackage}.eventstore.OptimisticConcurrencyException;
236
+ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
237
+ import org.springframework.http.ResponseEntity;
238
+ import org.springframework.web.bind.annotation.PostMapping;
239
+ import org.springframework.web.bind.annotation.RequestBody;
240
+ import org.springframework.web.bind.annotation.RestController;
241
+
242
+ @RestController
243
+ @ConditionalOnProperty(prefix = "slices.{context}.write", name = "{slicename}.enabled")
244
+ public class {SliceName}RestController {
245
+
246
+ private final {SliceName}CommandHandler commandHandler;
247
+
248
+ public {SliceName}RestController({SliceName}CommandHandler commandHandler) {
249
+ this.commandHandler = commandHandler;
250
+ }
251
+
252
+ @PostMapping("/api/{context}/{resource}")
253
+ public ResponseEntity<Void> handle(@RequestBody {SliceName}RequestBody body) {
254
+ try {
255
+ commandHandler.handle(new {SliceName}Command(body.field1(), body.idField()));
256
+ return ResponseEntity.ok().build();
257
+ } catch (IllegalStateException | OptimisticConcurrencyException e) {
258
+ return ResponseEntity.badRequest().build();
259
+ }
260
+ }
261
+
262
+ public record {SliceName}RequestBody(String field1, String idField) {}
263
+ }
264
+ ```
265
+
266
+ If the only inbound dependency is another slice's `AUTOMATION`, skip this step — an automation
267
+ calls the command handler directly (see `build-automation`), it doesn't need HTTP.
268
+
269
+ This project uses plain Spring MVC (`spring-boot-starter-web`), **not** WebFlux — `UmaDbClient`'s
270
+ core API is blocking (returns `Iterator`, not a reactive `Publisher`), so a reactive controller
271
+ would only hide the blocking calls behind a `Mono`, not remove them. Plain `ResponseEntity<...>`,
272
+ not `Mono<ResponseEntity<...>>`.
273
+
274
+ ## Step 6: Feature flag
275
+
276
+ Every slice component (command handler, REST controller) gets `@ConditionalOnProperty(prefix =
277
+ "slices.{context}.write", name = "{slicename}.enabled")` — the Decision class does not need it.
278
+ Wire the flag in both places:
279
+
280
+ - `src/main/resources/application.properties` — `slices.{context}.write.{slicename}.enabled=true`
281
+ - `src/test/resources/application.properties` — `slices.{context}.write.{slicename}.enabled=false`
282
+
283
+ See [references/feature-flag-patterns.md](references/feature-flag-patterns.md) for the full
284
+ pattern. This flag is irrelevant to the Step 7 test below — that test never boots Spring, so
285
+ `@ConditionalOnProperty` never runs.
286
+
287
+ ## Step 7: Test — `InMemoryUmaDbClient`, no Spring context, no Docker
288
+
289
+ UmaDB ships no test-fixture library (unlike Axon Framework's `axon-test`) — this project's
290
+ `InMemoryUmaDbClient` (`src/test/java/.../testsupport/`, already in the root scaffold) is a
291
+ from-scratch fake `UmaDbClient` implementation for exactly this. Write given/when/then as plain
292
+ JUnit + AssertJ, one test method per GWT scenario in slice.json's `specifications[]`:
293
+
294
+ ```java
295
+ package {basePackage}.slices.{context}.{slicename};
296
+
297
+ import {basePackage}.eventstore.DecisionModelLoader;
298
+ import {basePackage}.testsupport.InMemoryUmaDbClient;
299
+ import org.junit.jupiter.api.BeforeEach;
300
+ import org.junit.jupiter.api.DisplayName;
301
+ import org.junit.jupiter.api.Test;
302
+
303
+ import static org.assertj.core.api.Assertions.assertThatNoException;
304
+ import static org.assertj.core.api.Assertions.assertThatThrownBy;
305
+
306
+ class {SliceName}CommandHandlerTest {
307
+
308
+ private {SliceName}CommandHandler commandHandler;
309
+
310
+ @BeforeEach
311
+ void setUp() {
312
+ var loader = new DecisionModelLoader(new InMemoryUmaDbClient());
313
+ commandHandler = new {SliceName}CommandHandler(loader);
314
+ }
315
+
316
+ @Test
317
+ @DisplayName("given no prior activity, when {sliceName}, then succeeds")
318
+ void happyPath() {
319
+ assertThatNoException().isThrownBy(
320
+ () -> commandHandler.handle(new {SliceName}Command("value1", "id-1")));
321
+ }
322
+
323
+ @Test
324
+ @DisplayName("given <rule already true>, when {sliceName}, then rejected")
325
+ void ruleViolation() {
326
+ commandHandler.handle(new {SliceName}Command("value1", "id-1"));
327
+
328
+ assertThatThrownBy(() -> commandHandler.handle(new {SliceName}Command("value2", "id-1")))
329
+ .isInstanceOf(IllegalStateException.class);
330
+ }
331
+ }
332
+ ```
333
+
334
+ When a slice's decision needs prior events from ANOTHER slice's command handler first (like
335
+ `SubscribeToCourse` needing a `RegisterCustomer` to have happened), construct both command handlers
336
+ from the SAME `DecisionModelLoader`/`InMemoryUmaDbClient` instance and call the prerequisite handler
337
+ directly in the test — see `SubscribeToCourseCommandHandlerTest`. Don't hand-craft raw `Event`
338
+ objects as a shortcut; go through the real command handler so the test also exercises that
339
+ handler's own tagging.
340
+
341
+ Full cheat sheet — `Query`/`QueryItem` matching, `AppendCondition` semantics, idempotent-append
342
+ behaviour, why `subscribe()` isn't faked: see
343
+ [references/umadb-query-patterns.md](references/umadb-query-patterns.md).
344
+
345
+ ## Step 7b: Storyline-Derived Tests (Optional)
346
+
347
+ `slice.json` may carry a `storylines[]` array alongside `specifications[]` — narrated walkthroughs
348
+ where an ordered sequence of `elements[]` "beats" (EVENT/COMMAND/READMODEL/...) shows one use case
349
+ end to end. This is a secondary, supplementary source — `specifications[]` (Step 7) stays the
350
+ primary and default source of test methods. Most slices have no `storylines[]`; skip this step
351
+ silently when there's nothing relevant.
352
+
353
+ Find beats whose `type` is `COMMAND`. For each such beat: `given` = the cumulative ordered `EVENT`
354
+ beats preceding it in the storyline (dispatched through their own real command handlers, per Step
355
+ 7's guidance), `when` = the command built from the beat's `fields`, `then` = the `EVENT` beat(s)
356
+ immediately following it — asserted by reading the event back via a second `client.handle(ReadRequest...)`
357
+ call, or more simply by asserting the command handler didn't throw and trusting Step 7's own
358
+ per-field tests to have already covered the event's shape.
359
+
360
+ Do **not** try to also assert read-model state in this same test — that half belongs to
361
+ `build-state-view`'s own storyline step, since this test never touches a projector. If the beat
362
+ immediately after the command isn't an EVENT, don't force a test — leave it undocumented rather than
363
+ fabricating an assertion.
364
+
365
+ ## Final Verification
366
+
367
+ Before considering the slice done:
368
+
369
+ - [ ] Every field in slice.json's `commands[]` is in the Command record — no invented fields, none missing
370
+ - [ ] Every field in slice.json's `events[]` is in the Event record — no invented fields, none missing
371
+ - [ ] Every `specifications[]` scenario has a corresponding test method
372
+ - [ ] If `storylines[]` is present: every COMMAND beat for this slice's command has a storyline test — or was deliberately skipped as untraceable
373
+ - [ ] No business rule exists in the handler that isn't traceable to slice.json's `description`/`comments`
374
+ - [ ] The Decision's `relevantEvents` query and the append's consistency-boundary query are the SAME query
375
+ - [ ] `mvn compile -q`, then run the slice's own tests only
376
+ - [ ] If checks pass, commit with `feat: {Slice Name}` and set slice status to `Done`
@@ -0,0 +1,42 @@
1
+ # Feature Flag Patterns
2
+
3
+ This project's one supported approach: `@ConditionalOnProperty` on the command handler and REST
4
+ controller (**not** on the Decision class - verified against the `RegisterCustomer` and
5
+ `SubscribeToCourse` slices, neither of which puts the annotation on its decision-model class).
6
+ Read slices (`build-state-view`) are never feature-flagged - only write and automation slices are.
7
+ Examples use a generic `Ordering` bounded context.
8
+
9
+ ## Annotation on slice components
10
+
11
+ ```java
12
+ // Command handler
13
+ @ConditionalOnProperty(prefix = "slices.ordering.write", name = "placeorder.enabled")
14
+ @Component
15
+ public class PlaceOrderCommandHandler { ... }
16
+
17
+ // REST controller (if applicable)
18
+ @ConditionalOnProperty(prefix = "slices.ordering.write", name = "placeorder.enabled")
19
+ @RestController
20
+ public class PlaceOrderRestController { ... }
21
+ ```
22
+
23
+ ## `application.properties` (main — enable by default)
24
+
25
+ ```properties
26
+ slices.ordering.write.placeorder.enabled=true
27
+ slices.ordering.automation.notifycustomeronorder.enabled=true
28
+ ```
29
+
30
+ ## `application.properties` (test — disable by default)
31
+
32
+ ```properties
33
+ slices.ordering.write.placeorder.enabled=false
34
+ slices.ordering.automation.notifycustomeronorder.enabled=false
35
+ ```
36
+
37
+ This only matters for tests that boot a Spring context (`@SpringBootTest`). The
38
+ `InMemoryUmaDbClient` unit-test pattern in
39
+ [umadb-query-patterns.md](umadb-query-patterns.md) never boots Spring, so these properties don't
40
+ affect it either way - a Testcontainers-based end-to-end test (see `build-state-view`'s reference)
41
+ overrides them back to `true` for itself via `@DynamicPropertySource`, since it's the one test that
42
+ does need the real beans wired.
@@ -0,0 +1,78 @@
1
+ # UmaDB Query, AppendCondition & Test Fake — Cheat Sheet
2
+
3
+ Verified against `io.github.domenicdev:umadb-java-client:0.7` (package `io.umadb.client`) and a real
4
+ `umadb/umadb:0.7.5` server (Testcontainers), and against the `RegisterCustomer`/`SubscribeToCourse`
5
+ slices this pattern was built from.
6
+
7
+ ## `Query` / `QueryItem` matching semantics
8
+
9
+ A `Query` is a list of `QueryItem`s. **Items are OR'd together** - an event matches the query if it
10
+ matches ANY item. Within one item:
11
+
12
+ - `types` (OR): event matches if `types` is empty, or the event's `type()` is one of them
13
+ - `tags` (AND): event matches if `tags` is empty, or the event's `tags()` contains ALL of them
14
+ - an item matches only if BOTH the type condition and the tag condition match
15
+
16
+ An **empty `Query`** (`Query.empty()`, or `Query.of(List.of())`) matches every event - never pass
17
+ `null`/empty expecting "match nothing".
18
+
19
+ ```java
20
+ // "CustomerRegistered for this email" OR "SubscribedToCourse for this email+courseId"
21
+ Query.of(List.of(
22
+ QueryItem.of(List.of(CustomerRegistered.TYPE), List.of(EventTags.tag(EventTags.EMAIL, email))),
23
+ QueryItem.of(List.of(SubscribedToCourse.TYPE), List.of(
24
+ EventTags.tag(EventTags.EMAIL, email), EventTags.tag(EventTags.COURSE_ID, courseId)))
25
+ ));
26
+ ```
27
+
28
+ **Tag scope is a property of the rule, not the event type.** The same event type can legitimately
29
+ need a wider or narrower tag set depending on which decision is consuming it - see
30
+ `SubscribeToCourseDecision.relevantEvents` (`build-state-change` Step 3): `CustomerRegistered` is
31
+ scoped to `email` alone (the rule is "registered at all"), but `SubscribedToCourse` is scoped to
32
+ `email` AND `courseId` together (the rule is "subscribed to THIS course", not "subscribed to any
33
+ course"). Getting this wrong doesn't fail loudly - too wide silently pulls in unrelated events, too
34
+ narrow silently drops events the rule needed.
35
+
36
+ ## `AppendCondition` - the actual consistency boundary
37
+
38
+ ```java
39
+ AppendCondition.failIfExistsAfter(query, lastPosition)
40
+ ```
41
+
42
+ Fails the append (throws `UmaDbException.IntegrityException`) if any event matching `query` exists
43
+ at a position **strictly greater than** `lastPosition` - i.e. appeared after the position this
44
+ command's `DecisionModelLoader.load` call observed via `getHeadPosition()`. Always pass the SAME
45
+ query used to load the decision - a narrower/different query here silently weakens the consistency
46
+ guarantee (a conflicting write could slip through undetected).
47
+
48
+ `DecisionModelLoader.append` already wraps this and translates the exception into
49
+ `OptimisticConcurrencyException` - don't call `AppendCondition`/`client.handle(AppendRequest...)`
50
+ directly from a command handler; use the loader.
51
+
52
+ **Idempotency**: appending an `Event` whose `id` (a `UUID`) already exists in the store is a no-op
53
+ that returns the existing position, regardless of any condition - verified against the client's own
54
+ `UmaDbClientTest#testIdempotentAppendReturnsSamePosition`. `Event.of(...)` generates a random id per
55
+ call, so this only matters if you're deliberately re-sending the exact same `Event` instance/id for
56
+ retry safety - not something a normal command handler needs to think about.
57
+
58
+ ## Testing: `InMemoryUmaDbClient`, not a fixture DSL
59
+
60
+ UmaDB has no test-fixture library (unlike `axon-test`'s `AxonTestFixture`). This project's
61
+ `src/test/java/.../testsupport/InMemoryUmaDbClient` (shipped in the root scaffold) is a from-scratch
62
+ `UmaDbClient` implementation replicating the matching/condition/idempotency rules above, fast and
63
+ container-free. Write given/when/then as plain JUnit, not through a fixture DSL:
64
+
65
+ ```java
66
+ var loader = new DecisionModelLoader(new InMemoryUmaDbClient());
67
+ var commandHandler = new RegisterCustomerCommandHandler(loader);
68
+
69
+ // given no prior activity, when registerCustomer, then succeeds
70
+ assertThatNoException().isThrownBy(() -> commandHandler.handle(new RegisterCustomerCommand(...)));
71
+ ```
72
+
73
+ `InMemoryUmaDbClient.subscribe(...)` deliberately throws `UnsupportedOperationException` - no
74
+ command-handler test needs it (that's `build-state-view`/`build-automation`'s territory, tested via
75
+ a direct `on(event)` call instead - see those skills). The one thing that genuinely needs a live
76
+ subscription (`EventDispatcher` itself) is proven against a REAL `umadb/umadb` server via
77
+ Testcontainers instead - see `build-state-view`'s reference integration test. Don't try to make the
78
+ fake support subscription; use the real container for that one case.