@eventmodelers/cli 1.0.44 → 1.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/cli.js +53 -1
- package/package.json +2 -2
- package/shared/build-kit/lib/checks/README.md +59 -0
- package/shared/build-kit/lib/ralph.js +110 -28
- package/shared/build-kit/lib/util/find-slice.cjs +59 -0
- package/shared/build-kit/ralph-claude.js +6 -2
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +106 -102
- package/stacks/blank/templates/build-kit/lib/prompt.md +102 -106
- package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +422 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +65 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +418 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +41 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/references/integration-test-patterns.md +64 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +391 -0
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +122 -0
- package/stacks/kurrent/templates/build-kit/lib/AGENT.md +73 -0
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/kurrent/templates/root/README.md +46 -0
- package/stacks/kurrent/templates/root/docker-compose.yml +45 -0
- package/stacks/kurrent/templates/root/mvnw +259 -0
- package/stacks/kurrent/templates/root/mvnw.cmd +149 -0
- package/stacks/kurrent/templates/root/pom.xml +152 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/common/EventStore.java +91 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/config/KurrentConfiguration.java +42 -0
- package/stacks/kurrent/templates/root/src/main/resources/application.properties +14 -0
- package/stacks/kurrent/templates/root/src/main/resources/static/index.html +11 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +4 -2
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +1 -1
- package/stacks/node/templates/build-kit/CLAUDE.md +22 -0
- package/stacks/node/templates/build-kit/lib/check-commit-scope.cjs +123 -0
- package/stacks/node/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
- package/stacks/node/templates/build-kit/lib/checks/10-slice-scope.cjs +29 -0
- package/stacks/node/templates/build-kit/lib/checks/20-append-only-migrations.cjs +20 -0
- package/stacks/node/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
- package/stacks/node/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
- package/stacks/node/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
- package/stacks/node/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
- package/stacks/node/templates/root/.githooks/pre-commit +11 -0
- package/stacks/node/templates/root/README.md +67 -0
- package/stacks/node/templates/root/package.json +2 -1
- package/stacks/node/templates/root/setup-env.sh +7 -1
- package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +434 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +62 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +413 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +46 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +145 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/test-fixture-patterns.md +85 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +354 -0
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +101 -0
- package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +58 -0
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/opencqrs/templates/root/README.md +42 -0
- package/stacks/opencqrs/templates/root/docker-compose.yml +37 -0
- package/stacks/opencqrs/templates/root/mvnw +259 -0
- package/stacks/opencqrs/templates/root/mvnw.cmd +149 -0
- package/stacks/opencqrs/templates/root/pom.xml +139 -0
- package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
- package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/config/CqrsConfiguration.java +74 -0
- package/stacks/opencqrs/templates/root/src/main/resources/application.properties +23 -0
- package/stacks/opencqrs/templates/root/src/main/resources/schema.sql +19 -0
- package/stacks/opencqrs/templates/root/src/main/resources/static/index.html +11 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +25 -0
- package/stacks/supabase/templates/build-kit/lib/check-commit-scope.cjs +126 -0
- package/stacks/supabase/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
- package/stacks/supabase/templates/build-kit/lib/checks/10-slice-scope.cjs +34 -0
- package/stacks/supabase/templates/build-kit/lib/checks/20-append-only-migrations.cjs +21 -0
- package/stacks/supabase/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
- package/stacks/supabase/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
- package/stacks/supabase/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
- package/stacks/supabase/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
- package/stacks/supabase/templates/root/.githooks/pre-commit +11 -0
- package/stacks/supabase/templates/root/package.json +2 -1
- package/stacks/supabase/templates/root/setup-env.sh +7 -1
- package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +313 -0
- package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +42 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +376 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +42 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md +78 -0
- package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +338 -0
- package/stacks/umadb/templates/build-kit/CLAUDE.md +94 -0
- package/stacks/umadb/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/umadb/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/umadb/templates/root/.mvn/wrapper/maven-wrapper.properties +19 -0
- package/stacks/umadb/templates/root/README.md +48 -0
- package/stacks/umadb/templates/root/docker-compose.yml +29 -0
- package/stacks/umadb/templates/root/mvnw +259 -0
- package/stacks/umadb/templates/root/mvnw.cmd +149 -0
- package/stacks/umadb/templates/root/pom.xml +151 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java +12 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/config/UmaDbConfig.java +39 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/DecisionModelLoader.java +76 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java +40 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventDispatcher.java +100 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/OptimisticConcurrencyException.java +14 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/SliceEventListener.java +23 -0
- package/stacks/umadb/templates/root/src/main/resources/application.properties +17 -0
- package/stacks/umadb/templates/root/src/test/java/io/umadb/quickstart/testsupport/InMemoryUmaDbClient.java +135 -0
- package/stacks/umadb/templates/root/src/test/resources/application.properties +8 -0
|
@@ -0,0 +1,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
|
package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md
ADDED
|
@@ -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
|
+
```
|