@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,422 @@
1
+ ---
2
+ name: build-automation
3
+ authors:
4
+ - Martin Dilger
5
+ description: >
6
+ Implement automation slices (Event → Command) directly against KurrentDB 1.2.x (Java client) in this
7
+ project's one established pattern: a KurrentDB persistent subscription reacts to a trigger event by
8
+ invoking the target slice's own *Handling.handle(command) (reusing its decide/evolve validation),
9
+ guarded against at-least-once redelivery by catching whatever exception that target's decide throws
10
+ for its own "already done" case. Automations can be stateless (direct event-to-command mapping) or
11
+ backed by a private JPA read model (to look up data needed for command construction). Use when
12
+ implementing a new automation / event-to-command reactor from a slice.json event model in this
13
+ project. Plain Java only. There is exactly one supported style — do not offer alternatives.
14
+ ---
15
+
16
+ # KurrentDB — Automation Slice (Java)
17
+
18
+ An automation reacts to an event by dispatching a command. In Event Modeling: the **orange** stripe.
19
+
20
+ **There is no dedicated "processor"/"reactor" abstraction distinct from a projection here** — an
21
+ automation uses the exact same KurrentDB persistent-subscription mechanism `build-state-view` uses,
22
+ just reacting by calling the target slice's `*Handling.handle(command)` instead of (or in addition to)
23
+ writing to a repository. Everything `build-state-view` documents about subscription-group naming,
24
+ `@PostConstruct` startup, and idempotent group creation applies here unchanged — this skill only adds
25
+ what's specific to **dispatching a command safely under at-least-once delivery**, which is the one
26
+ thing that's easy to get wrong.
27
+
28
+ There are two kinds:
29
+
30
+ - **Stateless**: the trigger event alone carries everything the target command needs.
31
+ - **With a private read model**: needs data that isn't in the trigger event itself (e.g. "look up this
32
+ book's page count"), built by projecting one event and read when reacting to another.
33
+
34
+ ## Step 0: Discover target project conventions
35
+
36
+ > **Comments & description**: Each element in the slice carries a `comments: string[]` array and a
37
+ > `description` field. Use these as implementation hints. When done, resolve each used comment:
38
+ > `POST <BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/<nodeId>/comments/<commentId>/resolve` (get
39
+ > IDs first via GET on same path).
40
+
41
+ Read the target project's `.build-kit/CLAUDE.md` and explore existing slices for the subscription-group
42
+ naming convention already in use.
43
+
44
+ **Determine `{basePackage}`** — every path below is rooted at
45
+ `{basePackage}.slices.{context}.automation.{slicename}`. Resolve `{basePackage}` as documented in
46
+ `.build-kit/CLAUDE.md`'s Structure section.
47
+
48
+ ## Step 1: Understand the input
49
+
50
+ Extract these elements regardless of input format:
51
+
52
+ | Element | What to extract |
53
+ |---|---|
54
+ | **Trigger event** | Which event triggers the automation, and which condition filters it |
55
+ | **Target command** | Which command to dispatch, with what properties |
56
+ | **Mapping logic** | How event properties map to command properties |
57
+ | **Read model needed?** | Does the automation need data NOT in the trigger event itself? |
58
+
59
+ If the Event Modeling artifact includes slice details with `## Scenarios (GWTs)`, use them to derive
60
+ test cases. GWT format for automations: `Given (events) → Then (command | NOTHING)`. Events in Given
61
+ include read-model-building events first, trigger event last.
62
+
63
+ `slice.json` may also carry an optional `storylines[]` array — see the "Storyline-Derived Tests"
64
+ section under Step 5 for how a trigger-event beat in one of these can add a supplementary test.
65
+
66
+ If the slice details contain `## Implementation Guidelines`, **follow them**.
67
+
68
+ ### Stateless vs. with-read-model decision
69
+
70
+ Choose **with a private read model** when:
71
+
72
+ - The automation needs data that is NOT in the trigger event (e.g. "find this book's page count")
73
+ - The automation must iterate over a collection to dispatch multiple commands
74
+ - Two different events are involved: one builds the read model, another triggers the dispatch
75
+
76
+ Choose **stateless** when all command fields can be derived directly from the trigger event.
77
+
78
+ ## Step 2: Ensure events exist
79
+
80
+ All events the automation handles must exist in `src/main/java/.../{context}/api/`. If they don't,
81
+ create them **first** (see `build-state-change` Step 2), including registering each new event's type
82
+ name in `{Context}EventTypes`.
83
+
84
+ ## Step 3: Dispatching the target command — the part that's easy to get wrong
85
+
86
+ **Prefer calling the target slice's own `*Handling.handle(command)`** over hand-rolling a raw
87
+ `EventStore.append(...)` call — this reuses that slice's own `decide`/`evolve` validation instead of
88
+ duplicating (or worse, bypassing) it.
89
+
90
+ ```java
91
+ @Component
92
+ public class {AutomationName}Handling {
93
+
94
+ private final {TargetSlice}Handling targetHandling;
95
+
96
+ public {AutomationName}Handling({TargetSlice}Handling targetHandling) {
97
+ this.targetHandling = targetHandling;
98
+ }
99
+
100
+ void react({TriggerEvent} event) {
101
+ targetHandling.handle(new {TargetCommand}Command(event.idField() /*, mapped fields */));
102
+ }
103
+ }
104
+ ```
105
+
106
+ **This alone is not safe.** KurrentDB persistent subscriptions guarantee only **at-least-once**
107
+ delivery — the same event can, and eventually will, be redelivered after a crash or a nack/retry that
108
+ happens between the target command actually succeeding and this subscription's ack reaching the server.
109
+ `targetHandling.handle(...)` will then run a second time for the same logical trigger, replay the
110
+ target stream (which now already has the event from the first successful dispatch), and call the
111
+ target's `decide` again — which is exactly where the target slice's **own** business rule for "this was
112
+ already done" fires.
113
+
114
+ **Catch whatever exception the target's `decide` throws for that case — right at the dispatch call —
115
+ and treat it as success, not a failure:**
116
+
117
+ ```java
118
+ void react({TriggerEvent} event) {
119
+ try {
120
+ targetHandling.handle(new {TargetCommand}Command(event.idField()));
121
+ } catch ({TargetAlreadyDoneException} e) {
122
+ // already dispatched on a prior (redelivered) attempt — safe to ignore
123
+ }
124
+ }
125
+ ```
126
+
127
+ **There is no single, uniform exception type for this** — unlike a framework with a built-in
128
+ subject-existence check, the exception here is whatever that specific target slice's own `decide`
129
+ throws for its "already happened" branch (read that slice's `SKILL.md`-generated code, or its
130
+ `specifications[]`, to find out — it might be `IllegalStateException`, or a dedicated exception class
131
+ like `{X}AlreadyLentException`). **Read the target command's `decide` implementation before writing
132
+ this catch block** — guessing the wrong exception type here means a genuine redelivery crashes the
133
+ automation instead of being silently absorbed.
134
+
135
+ **If the target has no `*Handling` of its own** — e.g. a bare "mark as done" fact with no independent
136
+ command/decide, just a stream that either has one specific event or doesn't — append directly instead,
137
+ using `StreamState.noStream()` as the precondition, and catch KurrentDB's own
138
+ `WrongExpectedVersionException`:
139
+
140
+ ```java
141
+ void react({TriggerEvent} event) {
142
+ var eventData = EventData.builderAsJson(UUID.randomUUID(), "{FactEventName}",
143
+ {Context}EventTypes.serialize(new {FactEventName}(event.idField()))).build();
144
+ try {
145
+ eventStore.append("{context-lower}-" + event.idField() + "-{factsuffix}", StreamState.noStream(),
146
+ List.of(eventData));
147
+ } catch (WrongExpectedVersionException e) {
148
+ // already dispatched on a prior (redelivered) attempt — safe to ignore
149
+ }
150
+ }
151
+ ```
152
+
153
+ See [references/idempotent-dispatch-patterns.md](references/idempotent-dispatch-patterns.md) for the
154
+ full decision table and why swallowing the exception locally (not letting it propagate out of the
155
+ subscription listener's `onEvent`) matters.
156
+
157
+ ## Step 4: Implement the automation
158
+
159
+ ### Stateless automation
160
+
161
+ ```java
162
+ package {basePackage}.slices.{context}.automation.{slicename};
163
+
164
+ import {basePackage}.slices.{context}.api.*;
165
+ import {basePackage}.slices.{context}.{targetslicename}.{TargetSlice}Handling;
166
+ import {basePackage}.slices.{context}.{targetslicename}.{TargetCommand}Command;
167
+ import io.kurrent.dbclient.*;
168
+ import jakarta.annotation.PostConstruct;
169
+ import org.springframework.stereotype.Component;
170
+
171
+ import java.util.concurrent.ExecutionException;
172
+
173
+ @Component
174
+ public class {AutomationName}Handling {
175
+
176
+ private static final String GROUP = "{context}-{slicename}";
177
+
178
+ private final KurrentDBPersistentSubscriptionsClient subscriptionsClient;
179
+ private final {TargetSlice}Handling targetHandling;
180
+
181
+ public {AutomationName}Handling(KurrentDBPersistentSubscriptionsClient subscriptionsClient,
182
+ {TargetSlice}Handling targetHandling) {
183
+ this.subscriptionsClient = subscriptionsClient;
184
+ this.targetHandling = targetHandling;
185
+ }
186
+
187
+ @PostConstruct
188
+ void start() throws ExecutionException, InterruptedException {
189
+ SubscriptionFilter filter = SubscriptionFilter.newBuilder()
190
+ .addStreamNamePrefix("{context-lower}-")
191
+ .build();
192
+ try {
193
+ subscriptionsClient.createToAll(GROUP,
194
+ CreatePersistentSubscriptionToAllOptions.get().fromStart().filter(filter)).get();
195
+ } catch (ExecutionException e) {
196
+ if (!(e.getCause() instanceof io.grpc.StatusRuntimeException grpcException)
197
+ || grpcException.getStatus().getCode() != io.grpc.Status.Code.ALREADY_EXISTS) {
198
+ throw e;
199
+ }
200
+ }
201
+
202
+ subscriptionsClient.subscribeToAll(GROUP, new PersistentSubscriptionListener() {
203
+ @Override
204
+ public void onEvent(PersistentSubscription subscription, int retryCount, ResolvedEvent event) {
205
+ try {
206
+ {Context}EventTypes.deserialize(event.getOriginalEvent())
207
+ .filter({TriggerEvent}.class::isInstance)
208
+ .map({TriggerEvent}.class::cast)
209
+ .ifPresent({AutomationName}Handling.this::react);
210
+ subscription.ack(event);
211
+ } catch (Exception ex) {
212
+ subscription.nack(NackAction.Park, ex.getMessage(), event);
213
+ }
214
+ }
215
+
216
+ @Override
217
+ public void onCancelled(PersistentSubscription subscription, Throwable exception) {
218
+ }
219
+ });
220
+ }
221
+
222
+ private void react({TriggerEvent} event) {
223
+ try {
224
+ targetHandling.handle(new {TargetCommand}Command(event.idField() /*, mapped fields */));
225
+ } catch ({TargetAlreadyDoneException} e) {
226
+ // already dispatched on a prior (redelivered) attempt — safe to ignore
227
+ }
228
+ }
229
+ }
230
+ ```
231
+
232
+ Note that `react`'s idempotency catch is deliberately narrow (Step 3) — only the target's own
233
+ "already done" exception is swallowed; every other exception propagates out of `onEvent`'s try block
234
+ into the outer `catch (Exception ex)`, which `nack(Park, ...)`s it. That outer catch is a safety net for
235
+ genuine failures, not a substitute for the specific idempotency catch — don't rely on it to mask
236
+ redelivery, it parks the message instead of acking it, which stops progress for this trigger entirely.
237
+
238
+ ### Automation with a private read model
239
+
240
+ When the automation needs stored state, put everything in one package. **Never reuse another slice's
241
+ entity/repository** — build a private one, exactly like `build-state-view`'s own private-entity rule.
242
+ Both the "build" and "react" logic must be reached via the **same subscription group**, so KurrentDB's
243
+ `$all` global commit order guarantees the setup event is seen before the trigger event.
244
+
245
+ ```java
246
+ @Component
247
+ public class {AutomationName}Handling {
248
+
249
+ // ... GROUP, @PostConstruct start() as above, but onEvent dispatches to whichever of these two
250
+ // matches the deserialized event's type ...
251
+
252
+ private void onSetupEvent({SetupEvent} event) {
253
+ repository.save(new {AutomationName}Entity(event.idField(), event.someLookupValue()));
254
+ }
255
+
256
+ private void react({TriggerEvent} event) {
257
+ var entry = repository.findById(event.idField()).orElseThrow();
258
+ try {
259
+ targetHandling.handle(new {TargetCommand}Command(entry.id(), /* derived fields */));
260
+ } catch ({TargetAlreadyDoneException} e) {
261
+ // already dispatched on a prior (redelivered) attempt — safe to ignore
262
+ }
263
+ }
264
+ }
265
+ ```
266
+
267
+ **Iterating over multiple matches** (dispatch a command per matching entry): use
268
+ `repository.findAllBy...(...)` instead of `findById`, and loop, catching the target's idempotency
269
+ exception around each individual `targetHandling.handle(...)` call rather than around the whole loop —
270
+ one entry's already-handled conflict must not stop the others from being dispatched.
271
+
272
+ ### Alternative to a private read model: reading events directly
273
+
274
+ For a stateless, read-only lookup that doesn't need its own durable table, `KurrentDBClient` can be
275
+ `@Autowired`/constructor-injected directly to read another stream on demand (via the shared `EventStore`
276
+ helper) instead of maintaining a private projection. Reach for this only for genuinely occasional,
277
+ non-hot-path lookups — a private JPA read model built incrementally (above) is the default, well-trodden
278
+ choice for anything on the automation's main dispatch path.
279
+
280
+ ## Step 5: Feature flags (optional)
281
+
282
+ Same house convention as `build-state-change`/`build-state-view` — see
283
+ [references/feature-flag-patterns.md](references/feature-flag-patterns.md). **Enable both the
284
+ automation and its target command's write slice** in any test that exercises the automation end-to-end.
285
+
286
+ ## Step 6: Implement tests
287
+
288
+ Same principle as `build-state-view`: split the reacting logic out as its own package-private method
289
+ (`react`/`onSetupEvent` above) specifically so a test can call it directly, bypassing
290
+ `@PostConstruct`/subscribe/ack/the persistent-subscription machinery entirely.
291
+
292
+ **Stateless automation** — a plain unit test with the real target `*Handling` (which itself needs only
293
+ a real or Testcontainers-backed `EventStore`, no Spring context — see `build-state-change`'s
294
+ [references/integration-test-patterns.md](../build-state-change/references/integration-test-patterns.md)):
295
+
296
+ ```java
297
+ class {AutomationName}HandlingTest {
298
+
299
+ static GenericContainer<?> kurrentdb = /* same Testcontainers setup as build-state-change's
300
+ integration-test-patterns.md */;
301
+ static {AutomationName}Handling automation;
302
+
303
+ @BeforeAll
304
+ static void setUp() {
305
+ kurrentdb.start();
306
+ var eventStore = new EventStore(/* KurrentDBClient pointed at the container */);
307
+ automation = new {AutomationName}Handling(null, new {TargetSlice}Handling(eventStore));
308
+ }
309
+
310
+ @Test
311
+ void conditionMet_dispatchesCommand() {
312
+ var event = new {TriggerEvent}("entity-1" /*, fields that meet the condition */);
313
+
314
+ automation.react(event); // package-private, called directly — no subscription needed
315
+
316
+ // assert via the target stream, e.g. eventStore.read(...) and check the expected event landed
317
+ }
318
+
319
+ @Test
320
+ void redeliveredEvent_doesNotThrow() {
321
+ var event = new {TriggerEvent}("entity-1" /*, fields that meet the condition */);
322
+
323
+ automation.react(event); // first delivery
324
+ automation.react(event); // simulated redelivery — must not throw
325
+ }
326
+ }
327
+ ```
328
+
329
+ Passing `null` for the `KurrentDBPersistentSubscriptionsClient` constructor argument is safe here
330
+ because `react`/`onSetupEvent` never touch it — only `@PostConstruct start()` does, and the test never
331
+ calls that.
332
+
333
+ **Automation with a private read model** — use `@DataJpaTest` + `@Import` + Testcontainers Postgres
334
+ (see `build-state-view`'s Step 5), constructing the automation directly (not `@Autowired`) so its
335
+ `@PostConstruct` never runs in the test:
336
+
337
+ ```java
338
+ @DataJpaTest
339
+ @Testcontainers
340
+ @Import({AutomationName}Entity.class)
341
+ class {AutomationName}HandlingTest {
342
+
343
+ @Container
344
+ @ServiceConnection
345
+ static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
346
+
347
+ @Autowired
348
+ private {AutomationName}Repository repository;
349
+
350
+ @Test
351
+ void setupThenTrigger_dispatchesForMatchingEntry() {
352
+ var automation = new {AutomationName}Handling(null, targetHandlingStub(), repository);
353
+
354
+ automation.onSetupEvent(new {SetupEvent}("entity-1", 42L));
355
+ automation.react(new {TriggerEvent}("entity-1"));
356
+
357
+ // assert the target stream received the expected command's resulting event
358
+ }
359
+ }
360
+ ```
361
+
362
+ ### Test cases to cover
363
+
364
+ **Stateless automations:**
365
+ 1. Condition met → expected command dispatched (assert via the target stream's resulting event)
366
+ 2. Condition not met → no command dispatched
367
+ 3. Redelivered trigger event (target already handled) → the target's idempotency exception is
368
+ swallowed, `react` does not throw
369
+
370
+ **Automations with a read model:**
371
+ 1. Setup + trigger with a matching entry → command dispatched for that entry
372
+ 2. Setup + trigger with no matching entry → no command dispatched (or however the slice defines that
373
+ case)
374
+ 3. Temporal ordering: only entries set up *before* the trigger are found — reflects that both methods
375
+ are reached through the same subscription group and therefore see events in commit order
376
+
377
+ ### Mapping GWT scenarios to tests
378
+
379
+ | GWT Element | Test code |
380
+ |---|---|
381
+ | Event in Given | `automation.onSetupEvent(new Event(...))` (read-model phase) or direct setup |
382
+ | Command in Then | assert the target stream's resulting event landed |
383
+ | NOTHING in Then | assert the target stream is unchanged / still empty |
384
+
385
+ ### Storyline-Derived Tests (Optional)
386
+
387
+ `slice.json` may also carry a `storylines[]` array — narrated walkthroughs with an ordered
388
+ `elements[]` "beats" sequence (EVENT/COMMAND/READMODEL/...). This is a secondary, supplementary
389
+ source; `specifications[]` above stays the primary and default source of test cases. Most slices
390
+ have no `storylines[]` — skip silently when there's nothing relevant.
391
+
392
+ Find a beat whose `type` is `EVENT` immediately followed by a `COMMAND` beat. That pair is a ready-made
393
+ test: `given` = the cumulative preceding `EVENT` beats (setup events) through the trigger beat, `then` =
394
+ the resulting event on the target stream, built from the command beat's fields — same shape as the
395
+ "Mapping GWT Scenarios to Tests" row above, just sourced from the storyline instead of
396
+ `specifications[]`.
397
+
398
+ If the beat following the trigger event isn't a COMMAND this automation dispatches (e.g. it's a
399
+ READMODEL or SCREEN beat), don't force a test — leave it undocumented rather than fabricating an
400
+ assertion.
401
+
402
+ ## References
403
+
404
+ - [Feature Flag Patterns](references/feature-flag-patterns.md) — `@ConditionalOnProperty`
405
+ - [Idempotent Command Dispatch](references/idempotent-dispatch-patterns.md) — the full decision table for which exception to catch, and why swallowing it locally matters
406
+
407
+ ---
408
+
409
+ ## Final Verification: Does the Implementation Match slice.json?
410
+
411
+ Before marking this slice as `Done`, verify the implementation against slice.json:
412
+
413
+ - [ ] The trigger event in the automation matches the trigger event in slice.json exactly
414
+ - [ ] The command dispatched matches the target command defined in slice.json
415
+ - [ ] All fields mapped from trigger event (or private read model) to command come from slice.json — no invented mappings
416
+ - [ ] The dispatch call is wrapped to swallow the target's specific "already done" exception — never left to propagate
417
+ - [ ] If a private read model is used: it is private to this slice, and both event-handling methods are reached through the same subscription group
418
+ - [ ] Every new event's type name is registered in `{Context}EventTypes`
419
+ - [ ] Every GWT scenario in `specifications[]` maps to a test case, including a redelivery/idempotency-swallowed case
420
+ - [ ] If `storylines[]` is present: every trigger-EVENT→target-COMMAND beat pair for this automation has a storyline test — or was deliberately skipped as untraceable
421
+ - [ ] No filtering conditions were invented — all conditions come from slice.json `description` or `comments`
422
+ - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
@@ -0,0 +1,19 @@
1
+ # Feature Flag Patterns
2
+
3
+ House convention (not a KurrentDB requirement), identical to `build-state-change`'s.
4
+
5
+ ```java
6
+ @Component
7
+ @ConditionalOnProperty(prefix = "slices.{context}.automation", name = "{slicename}.enabled")
8
+ public class {AutomationName}Handling {
9
+ // ...
10
+ }
11
+ ```
12
+
13
+ Add to all config files when using `@ConditionalOnProperty`:
14
+
15
+ - `src/main/resources/application.properties` — `slices.{context}.automation.{slicename}.enabled=true`
16
+ - `src/test/resources/application.properties` — `slices.{context}.automation.{slicename}.enabled=false`
17
+
18
+ **Enable BOTH the automation and its target command's write slice** in any test exercising the
19
+ automation end-to-end — the target command handler won't run if its own slice's flag is off.
@@ -0,0 +1,65 @@
1
+ # Idempotent Command Dispatch — Why And How
2
+
3
+ ## The delivery guarantee you're actually working with
4
+
5
+ A KurrentDB persistent subscription guarantees **at-least-once** delivery, never exactly-once. Quoting
6
+ its own documented contract: "clients must acknowledge (or not acknowledge) messages"; an unacked or
7
+ nacked-and-retried message is redelivered, and a crash between a handler's side effect succeeding and
8
+ its `ack` reaching the server has exactly the same effect. An automation's whole job is a side effect
9
+ with an external consequence (dispatching a command) — it cannot opt out of this contract, so it must
10
+ be written to tolerate running twice for the same trigger.
11
+
12
+ ## Which exception to catch depends on how the target dispatches
13
+
14
+ Unlike a framework with one uniform "subject already exists" exception regardless of which slice you're
15
+ calling, this stack's automations call directly into the target slice's own `*Handling.handle(command)`
16
+ (see `build-state-change`) — so the exception a redelivery produces is whatever **that target's own
17
+ `decide`** throws for its "already done" branch.
18
+
19
+ | How the automation dispatches | What a redelivered dispatch throws | What to catch |
20
+ |---|---|---|
21
+ | `targetHandling.handle(command)`, target's `decide` has a business rule for "already done" (state != null, or a specific flag) | Whatever that `decide` throws — often `IllegalStateException`, sometimes a dedicated exception class | That exact exception type — read the target's `decide` implementation (or its own `specifications[]`) to find out, don't guess |
22
+ | Raw `eventStore.append(streamId, StreamState.noStream(), events)` with no target `*Handling` at all (a bare fact stream) | `WrongExpectedVersionException` (KurrentDB's own optimistic-concurrency exception) | `io.kurrent.dbclient.WrongExpectedVersionException` |
23
+
24
+ **Never guess the exception type for the first row.** A too-broad catch (`catch (Exception e)`) around
25
+ the whole dispatch would also silently swallow a genuine mapping bug or a real validation failure as if
26
+ it were a harmless redelivery. A too-narrow catch (the wrong exception class) lets a genuine redelivery
27
+ conflict propagate uncaught, which is exactly the failure mode described next.
28
+
29
+ ## Why "catch it, don't just log and continue elsewhere" matters
30
+
31
+ If the idempotency exception is allowed to escape the persistent-subscription listener's `onEvent`
32
+ method entirely (not just fall through to the outer `catch (Exception ex)` safety net), it gets nacked
33
+ as `Park` by that outer catch — the message is parked (put on the poison queue), not acknowledged. A
34
+ parked message stops making progress; it does not silently retry forever, but it does sit there
35
+ unresolved until someone manually replays it (`replayParkedMessagesToStream`/`ToAll`), which is not what
36
+ you want for an entirely expected, harmless redelivery.
37
+
38
+ Do **not**:
39
+ - catch `Exception`/`Throwable` broadly around the whole `react`/`onSetupEvent` method — that would
40
+ also hide genuine bugs (a real mapping error, a real, unexpected validation failure) as if they were
41
+ harmless redeliveries
42
+ - rely on the outer `catch (Exception ex)` in `onEvent` to mask redelivery — it parks the message
43
+ instead of acking it, which is the opposite of "safe to ignore"
44
+
45
+ Do:
46
+ - catch the *specific* exception type the target's own idempotency check produces, at the
47
+ `targetHandling.handle(...)` (or `eventStore.append(...)`) call site only
48
+ - let every other exception type propagate normally into `onEvent`'s outer catch, which parks it for
49
+ operator attention — that's the correct outcome for a real failure
50
+
51
+ ## Iterating over multiple dispatches
52
+
53
+ When one trigger event fans out to several dispatches (one per matching entry in a private read model),
54
+ wrap **each individual call**, not the loop as a whole — one entry's already-handled conflict must not
55
+ prevent the others from being attempted:
56
+
57
+ ```java
58
+ for (var entry : matchingEntries) {
59
+ try {
60
+ targetHandling.handle(new {TargetCommand}Command(entry.id() /*, ... */));
61
+ } catch ({TargetAlreadyDoneException} e) {
62
+ // this entry was already dispatched on a prior attempt — continue with the rest
63
+ }
64
+ }
65
+ ```