@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,85 @@
|
|
|
1
|
+
# CommandHandlingTestFixture Cheat Sheet
|
|
2
|
+
|
|
3
|
+
`@CommandHandlingTest` (package `com.opencqrs.framework.command`, from the `framework-test` module) is a
|
|
4
|
+
narrow Spring test slice — it discovers this project's `@CommandHandlerConfiguration` classes, but never
|
|
5
|
+
touches EventSourcingDB, HTTP, or a real database. Inject one `CommandHandlingTestFixture<YourCommand>`
|
|
6
|
+
per test method via `@Autowired`.
|
|
7
|
+
|
|
8
|
+
## Given
|
|
9
|
+
|
|
10
|
+
| Call | Meaning |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `.given().nothing()` | no prior events/state — the creation case |
|
|
13
|
+
| `.given().events(e1, e2, ...)` | replays these through your `@StateRebuilding` methods to build prior state. **Prefer this** — it exercises the same reconstruction path production uses |
|
|
14
|
+
| `.given().state(new WriteModel(...))` | injects prior state directly, bypassing `@StateRebuilding` entirely. Only reach for this when replaying realistic events is impractical |
|
|
15
|
+
|
|
16
|
+
## When
|
|
17
|
+
|
|
18
|
+
`.when(new YourCommand(...))` — executes the command handler under test.
|
|
19
|
+
|
|
20
|
+
## Then — outcome
|
|
21
|
+
|
|
22
|
+
- `.succeeds()` — command executed without throwing
|
|
23
|
+
- `.fails().throwing(ExceptionClass.class)` — asserts the exact exception type thrown
|
|
24
|
+
|
|
25
|
+
## Then — result value
|
|
26
|
+
|
|
27
|
+
`.succeeds().havingResult(expectedValue)` — asserts the command handler method's return value.
|
|
28
|
+
Chain this **before** `.allEvents()`/`.nextEvents()` if you also want to assert published events on the
|
|
29
|
+
same fixture call.
|
|
30
|
+
|
|
31
|
+
## Then — published events
|
|
32
|
+
|
|
33
|
+
Two complementary entry points:
|
|
34
|
+
|
|
35
|
+
- `.allEvents()` — operates on the complete captured event list, no cursor (fine for a fixture used once
|
|
36
|
+
per test)
|
|
37
|
+
- `.nextEvents()` — a consuming cursor; use this if you called `.havingResult(...)` first and want to
|
|
38
|
+
keep asserting on the same fixture
|
|
39
|
+
|
|
40
|
+
Matcher methods (call one of these on the result of `.allEvents()`/`.nextEvents()`):
|
|
41
|
+
|
|
42
|
+
| Method | Behavior |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `.exactly(payload1, payload2, ...)` | events match the given payloads in order, by `Object.equals` — records make this a plain field-by-field match |
|
|
45
|
+
| `.single(consumer)` | exactly one event was captured, **and** it matches the consumer's assertion |
|
|
46
|
+
| `.once(consumer)` | exactly one event matches the consumer's predicate; other unrelated events may also exist |
|
|
47
|
+
| `.any(consumer)` | at least one event matches |
|
|
48
|
+
| `.every(consumer)` | every captured event matches |
|
|
49
|
+
| `.none(consumer)` | no event matches |
|
|
50
|
+
|
|
51
|
+
`consumer` typically uses one of:
|
|
52
|
+
|
|
53
|
+
- `e -> e.ofType(SomeEvent.class)` — type-only check
|
|
54
|
+
- `e -> e.asserting(a -> a.commandSubject().payloadType(SomeEvent.class))` — asserts the event's subject
|
|
55
|
+
equals the command's subject, and its payload type
|
|
56
|
+
- `e -> e.asserting(a -> a.commandSubject().noMetaData().payloadType(SomeEvent.class))` — same, plus
|
|
57
|
+
asserts no metadata was published with the event
|
|
58
|
+
|
|
59
|
+
## Mocking collaborators
|
|
60
|
+
|
|
61
|
+
Exactly like `@SpringBootTest` — `@MockitoBean` at the test class level:
|
|
62
|
+
|
|
63
|
+
```java
|
|
64
|
+
@CommandHandlingTest
|
|
65
|
+
class {SliceName}HandlingTest {
|
|
66
|
+
|
|
67
|
+
@MockitoBean
|
|
68
|
+
private SomeCollaborator someCollaborator;
|
|
69
|
+
|
|
70
|
+
@Test
|
|
71
|
+
void test(@Autowired CommandHandlingTestFixture<{SliceName}Command> fixture) {
|
|
72
|
+
doReturn(someValue).when(someCollaborator).someMethod(any());
|
|
73
|
+
// ...
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## What this fixture deliberately does NOT do
|
|
79
|
+
|
|
80
|
+
- No event upcasting, no event-type-string resolution — events are matched/compared as plain Java
|
|
81
|
+
objects.
|
|
82
|
+
- No real event store — nothing is ever actually persisted.
|
|
83
|
+
- Command metadata propagation onto published events (the `opencqrs.metadata.propagation.keys`
|
|
84
|
+
mechanism) is **not** exercised — this fixture only cares about the command handler's own logic.
|
|
85
|
+
- State caching is disabled — every `.when(...)` call rebuilds state from the given events/state fresh.
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-state-view
|
|
3
|
+
authors:
|
|
4
|
+
- Martin Dilger
|
|
5
|
+
description: >
|
|
6
|
+
Implement read slices (JPA-backed projections + REST query endpoint + tests) using OpenCQRS 2.0.0
|
|
7
|
+
(Spring Boot, EventSourcingDB) in this project's one established pattern: Event(s) → @EventHandling
|
|
8
|
+
projector method(s) updating a private JPA entity → a separate REST controller querying the
|
|
9
|
+
repository directly. Use when implementing a new read slice / projection in this project. Plain Java
|
|
10
|
+
only. There is exactly one supported style — do not offer alternatives.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# OpenCQRS — Read Slice (Java)
|
|
14
|
+
|
|
15
|
+
## Step 0: Discover target project conventions
|
|
16
|
+
|
|
17
|
+
> **Comments & description**: Each element in the slice carries a `comments: string[]` array and a
|
|
18
|
+
> `description` field. Use these as implementation hints. When done, resolve each used comment:
|
|
19
|
+
> `POST <BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/<nodeId>/comments/<commentId>/resolve` (get
|
|
20
|
+
> IDs first via GET on same path).
|
|
21
|
+
|
|
22
|
+
Before writing any code, read the target project's `.build-kit/CLAUDE.md`.
|
|
23
|
+
|
|
24
|
+
**Determine `{basePackage}`** — every code example below is rooted at
|
|
25
|
+
`{basePackage}.slices.{context}.{slicename}`. Resolve `{basePackage}` as documented in
|
|
26
|
+
`.build-kit/CLAUDE.md`'s Structure section.
|
|
27
|
+
|
|
28
|
+
## Step 1: Ensure events exist
|
|
29
|
+
|
|
30
|
+
Before implementing the read slice, verify that all events the projector handles exist in
|
|
31
|
+
`src/main/java/.../{context}/api/`. If they don't, create them **first** (plain records — see
|
|
32
|
+
`build-state-change` Step 2), and register each new one's type string in `CqrsConfiguration`'s
|
|
33
|
+
`eventTypeResolver()` bean. Do not skip this even for a pure read slice — an unregistered event type
|
|
34
|
+
fails at runtime the first time it's read back.
|
|
35
|
+
|
|
36
|
+
## Step 2: Implement the read slice
|
|
37
|
+
|
|
38
|
+
If the slice details include `## Scenarios (GWTs)`, use them to derive test cases (Step 5). GWT format
|
|
39
|
+
for read slices: `Given (events) → Then (information)` — no When. Events in Given tell you which events
|
|
40
|
+
the projector handles. The information element in Then describes the expected query result.
|
|
41
|
+
|
|
42
|
+
If the slice description or comments contain `## Implementation Guidelines`, **follow them**.
|
|
43
|
+
|
|
44
|
+
### Slice package structure
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
.../slices/{context}/{slicename}/ (i.e. {basePackage}.slices.{context}.{slicename} — see Step 0)
|
|
48
|
+
├── {SliceName}Entity.java ← @Entity, private to this slice
|
|
49
|
+
├── {SliceName}Repository.java ← Spring Data repository, private to this slice
|
|
50
|
+
├── {SliceName}Summary.java ← read model (query result shape)
|
|
51
|
+
├── {SliceName}Projector.java ← @Service with @EventHandling method(s)
|
|
52
|
+
└── {SliceName}RestApi.java ← @RestController (if REST chosen)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A read slice lives in a single package. **Do NOT add Domain/Application/Presentation section
|
|
56
|
+
comments** — those are only for write slices. **Never reuse another slice's entity/repository** — a
|
|
57
|
+
read model is private to the slice that owns it, even if another slice's projector happens to need
|
|
58
|
+
similar-looking data.
|
|
59
|
+
|
|
60
|
+
### Entity + repository
|
|
61
|
+
|
|
62
|
+
```java
|
|
63
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
64
|
+
|
|
65
|
+
import jakarta.persistence.*;
|
|
66
|
+
|
|
67
|
+
@Entity
|
|
68
|
+
@Table(name = "{context}_{slicename}", indexes = {
|
|
69
|
+
@Index(name = "idx_{context}_{slicename}_{col}", columnList = "{filterField}")
|
|
70
|
+
})
|
|
71
|
+
class {SliceName}Entity {
|
|
72
|
+
|
|
73
|
+
@Id
|
|
74
|
+
private String id;
|
|
75
|
+
private String {filterField};
|
|
76
|
+
// ... other fields
|
|
77
|
+
|
|
78
|
+
protected {SliceName}Entity() {}
|
|
79
|
+
|
|
80
|
+
{SliceName}Entity(String id, String {filterField}) {
|
|
81
|
+
this.id = id;
|
|
82
|
+
this.{filterField} = {filterField};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
{SliceName}Summary toSummary() {
|
|
86
|
+
return new {SliceName}Summary(id, {filterField});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```java
|
|
92
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
93
|
+
|
|
94
|
+
import org.springframework.data.repository.CrudRepository;
|
|
95
|
+
import java.util.List;
|
|
96
|
+
|
|
97
|
+
interface {SliceName}Repository extends CrudRepository<{SliceName}Entity, String> {
|
|
98
|
+
List<{SliceName}Entity> findAllBy{FilterField}(String {filterField});
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Add a derived-query method (`findAllBy{FilterField}`) for filtered queries instead of `findAll()` +
|
|
103
|
+
client-side filtering — DB-level filtering, not in-memory.
|
|
104
|
+
|
|
105
|
+
### Result DTO rules
|
|
106
|
+
|
|
107
|
+
- If the read model matches the entity **1:1**, expose the summary record directly (`toSummary()`
|
|
108
|
+
above).
|
|
109
|
+
- If the read model contains fields the caller already knows from the query (e.g. the filter field
|
|
110
|
+
itself), omit those from `{SliceName}Summary` and map only what's new.
|
|
111
|
+
|
|
112
|
+
### Projector
|
|
113
|
+
|
|
114
|
+
`@EventHandling`-annotated methods receive their collaborators as **method parameters** annotated
|
|
115
|
+
`@Autowired` (resolved from the `ApplicationContext` at dispatch time) — not via the class's
|
|
116
|
+
constructor. This is the framework's actual injection mechanism for these methods; a constructor field
|
|
117
|
+
would be wrong here.
|
|
118
|
+
|
|
119
|
+
**Processing-group name**: pick one stable name per read model (e.g. `"{context}-{slicename}"`) and
|
|
120
|
+
never rename it once deployed — it's the literal key `JdbcProgressTracker` persists this projection's
|
|
121
|
+
checkpoint under (`EVENTHANDLER_PROGRESS.GROUP_KEY`). Renaming it doesn't fail loudly; it just orphans
|
|
122
|
+
the old checkpoint and makes this projection silently replay from the beginning on next start. If the
|
|
123
|
+
slice has two or more `@EventHandling` methods, define a small private meta-annotation instead of
|
|
124
|
+
repeating the group-name string on every method:
|
|
125
|
+
|
|
126
|
+
```java
|
|
127
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
128
|
+
|
|
129
|
+
import com.opencqrs.framework.eventhandler.EventHandling;
|
|
130
|
+
import java.lang.annotation.*;
|
|
131
|
+
|
|
132
|
+
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
|
|
133
|
+
@Retention(RetentionPolicy.RUNTIME)
|
|
134
|
+
@EventHandling("{context}-{slicename}")
|
|
135
|
+
@interface {SliceName}Handling {}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
(If the slice has exactly one handler method, use `@EventHandling("{context}-{slicename}")` directly
|
|
139
|
+
instead — no meta-annotation needed.)
|
|
140
|
+
|
|
141
|
+
```java
|
|
142
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
143
|
+
|
|
144
|
+
import {basePackage}.slices.{context}.api.*;
|
|
145
|
+
import org.springframework.beans.factory.annotation.Autowired;
|
|
146
|
+
import org.springframework.stereotype.Service;
|
|
147
|
+
import org.springframework.transaction.annotation.Transactional;
|
|
148
|
+
|
|
149
|
+
@Service
|
|
150
|
+
@Transactional
|
|
151
|
+
public class {SliceName}Projector {
|
|
152
|
+
|
|
153
|
+
@{SliceName}Handling
|
|
154
|
+
public void on({EventName} event, @Autowired {SliceName}Repository repository) {
|
|
155
|
+
repository.save(new {SliceName}Entity(event.idField(), event.{filterField}()));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`@Transactional` on the class (or per-method) wraps each single event-handling call in its own
|
|
161
|
+
transaction — this is a Spring convention layered on top, not something `@EventHandling` provides for
|
|
162
|
+
free. Omitting it does **not** mean no transaction runs at all: since this project's
|
|
163
|
+
`JdbcProgressTracker` has `setProceedTransactionally(true)` (see `CqrsConfiguration`), the handler
|
|
164
|
+
already participates in the same transaction as the checkpoint update, making the projection write and
|
|
165
|
+
the checkpoint advance atomic together — but only add an explicit `@Transactional` if this projector's
|
|
166
|
+
own logic needs a transaction boundary beyond that (e.g. multiple repository calls that must commit
|
|
167
|
+
together).
|
|
168
|
+
|
|
169
|
+
### REST query endpoint
|
|
170
|
+
|
|
171
|
+
Separate class — this is plain Spring wiring (constructor-injected field), not an `@EventHandling`
|
|
172
|
+
method, so no per-method `@Autowired` is needed here:
|
|
173
|
+
|
|
174
|
+
```java
|
|
175
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
176
|
+
|
|
177
|
+
import org.springframework.web.bind.annotation.*;
|
|
178
|
+
import java.util.List;
|
|
179
|
+
|
|
180
|
+
@RestController
|
|
181
|
+
public class {SliceName}RestApi {
|
|
182
|
+
|
|
183
|
+
private final {SliceName}Repository repository;
|
|
184
|
+
|
|
185
|
+
public {SliceName}RestApi({SliceName}Repository repository) {
|
|
186
|
+
this.repository = repository;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@GetMapping("/api/{context}/{filterField}")
|
|
190
|
+
public List<{SliceName}Summary> query(@PathVariable String {filterField}) {
|
|
191
|
+
return repository.findAllBy{FilterField}({filterField}).stream()
|
|
192
|
+
.map({SliceName}Entity::toSummary)
|
|
193
|
+
.toList();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
This project uses plain Spring **WebMVC** — a blocking return value, not `Mono<...>`.
|
|
199
|
+
|
|
200
|
+
## Step 3: Design test cases
|
|
201
|
+
|
|
202
|
+
Implement the test cases provided in the slice definition. Do not design your own test cases unless
|
|
203
|
+
specifically instructed to do so.
|
|
204
|
+
|
|
205
|
+
### Mapping GWT scenarios to tests
|
|
206
|
+
|
|
207
|
+
| GWT Element | Test code |
|
|
208
|
+
|---|---|
|
|
209
|
+
| `NOTHING` in Given | call the repository query directly with no prior `.save(...)` |
|
|
210
|
+
| Event in Given | call `projector.on(event, repository)` directly |
|
|
211
|
+
| Information in Then | assert the repository query result / mapped summaries |
|
|
212
|
+
|
|
213
|
+
## Step 4: Implement the test — `@DataJpaTest` + `@Import`
|
|
214
|
+
|
|
215
|
+
There is no built-in test fixture for `@EventHandling` methods (unlike `build-state-change`'s
|
|
216
|
+
`CommandHandlingTestFixture`) — write a plain JPA test slice instead. `@DataJpaTest` boots only JPA
|
|
217
|
+
infrastructure (no EventSourcingDB, no HTTP, no full application context); `@Import` pulls the plain
|
|
218
|
+
`@Service` projector class into that same narrow context so its `@Autowired` repository parameter
|
|
219
|
+
resolves correctly when you call it directly.
|
|
220
|
+
|
|
221
|
+
**This project is on Spring Boot 4** — `@DataJpaTest` moved to the `spring-boot-data-jpa-test` artifact
|
|
222
|
+
under package `org.springframework.boot.data.jpa.test.autoconfigure` (not the Boot 3
|
|
223
|
+
`org.springframework.boot.test.autoconfigure.orm.jpa` package). Verified by compiling and running
|
|
224
|
+
against the real dependency — don't "correct" this import back to the Boot 3 package.
|
|
225
|
+
|
|
226
|
+
**This project's read models target PostgreSQL, not H2** — `@DataJpaTest`'s default behavior tries to
|
|
227
|
+
replace the datasource with an embedded database, which fails outright with no H2/Derby/HSQL on the
|
|
228
|
+
classpath (`Failed to replace DataSource with an embedded database for tests`, verified). Use
|
|
229
|
+
Testcontainers' real Postgres instead, exactly like the project's other Postgres-backed tests
|
|
230
|
+
(`spring-boot-testcontainers` + `testcontainers-postgresql` are already on the test classpath):
|
|
231
|
+
|
|
232
|
+
```java
|
|
233
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
234
|
+
|
|
235
|
+
import org.junit.jupiter.api.Test;
|
|
236
|
+
import org.springframework.beans.factory.annotation.Autowired;
|
|
237
|
+
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
|
238
|
+
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
|
239
|
+
import org.springframework.context.annotation.Import;
|
|
240
|
+
import org.testcontainers.containers.PostgreSQLContainer;
|
|
241
|
+
import org.testcontainers.junit.jupiter.Container;
|
|
242
|
+
import org.testcontainers.junit.jupiter.Testcontainers;
|
|
243
|
+
|
|
244
|
+
import static org.assertj.core.api.Assertions.assertThat;
|
|
245
|
+
|
|
246
|
+
@DataJpaTest
|
|
247
|
+
@Testcontainers
|
|
248
|
+
@Import({SliceName}Projector.class)
|
|
249
|
+
class {SliceName}ProjectorTest {
|
|
250
|
+
|
|
251
|
+
@Container
|
|
252
|
+
@ServiceConnection
|
|
253
|
+
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
|
|
254
|
+
|
|
255
|
+
@Autowired
|
|
256
|
+
private {SliceName}Projector projector;
|
|
257
|
+
|
|
258
|
+
@Autowired
|
|
259
|
+
private {SliceName}Repository repository;
|
|
260
|
+
|
|
261
|
+
@Test
|
|
262
|
+
void givenNoEvents_thenEmptyResult() {
|
|
263
|
+
assertThat(repository.findAllBy{FilterField}("filter-value")).isEmpty();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
@Test
|
|
267
|
+
void givenCreationEvent_thenItemAppears() {
|
|
268
|
+
projector.on(new {CreationEvent}("id-1", "filter-value" /*, other fields */), repository);
|
|
269
|
+
|
|
270
|
+
var result = repository.findAllBy{FilterField}("filter-value").stream()
|
|
271
|
+
.map({SliceName}Entity::toSummary)
|
|
272
|
+
.toList();
|
|
273
|
+
|
|
274
|
+
assertThat(result).containsExactly(new {SliceName}Summary("id-1", "filter-value"));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
@Test
|
|
278
|
+
void itemsAreIsolatedByFilterField() {
|
|
279
|
+
projector.on(new {CreationEvent}("id-1", "group-A"), repository);
|
|
280
|
+
projector.on(new {CreationEvent}("id-2", "group-B"), repository);
|
|
281
|
+
|
|
282
|
+
assertThat(repository.findAllBy{FilterField}("group-A")).hasSize(1);
|
|
283
|
+
assertThat(repository.findAllBy{FilterField}("group-B")).hasSize(1);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Key rules
|
|
289
|
+
|
|
290
|
+
- Call the `@EventHandling` method directly (`projector.on(event, repository)`) — you're calling a
|
|
291
|
+
plain public Java method, not going through the framework's dispatch machinery. The same method also
|
|
292
|
+
works standalone in production; `@DataJpaTest` just gives you a real repository to pass it.
|
|
293
|
+
- **Assert with full objects**: `containsExactly(new Summary(...))` rather than field-by-field
|
|
294
|
+
assertions — catches mapping mistakes a partial assertion would miss.
|
|
295
|
+
- If the projector method also declares a `Map<String, ?> metadata` parameter, pass `Map.of()` (or the
|
|
296
|
+
relevant test values) directly — no need to route it through any dispatch machinery.
|
|
297
|
+
- **Requires Docker running locally/in CI** — the `PostgreSQLContainer` starts a real container per test
|
|
298
|
+
class. This is slower than a mocked unit test, but exercises the exact same schema/dialect production
|
|
299
|
+
uses; there is no embedded-database fallback in this project (see above).
|
|
300
|
+
|
|
301
|
+
## Step 4b: Storyline-Derived Tests (Optional)
|
|
302
|
+
|
|
303
|
+
`slice.json` may also carry a `storylines[]` array — narrated walkthroughs where the *same* read
|
|
304
|
+
model appears as multiple ordered "beats" across one flow (see `elements[]` on each storyline).
|
|
305
|
+
This is a secondary, supplementary source: `specifications[]` (Step 3) remains the primary and
|
|
306
|
+
default source of test cases. Most slices have no `storylines[]` — skip this step silently when
|
|
307
|
+
there's nothing relevant.
|
|
308
|
+
|
|
309
|
+
A storyline embedded in this slice's slice.json already belongs entirely to this slice — no need
|
|
310
|
+
to match beats against `readmodels[]` by id/title. For each storyline, find beats whose `type` is
|
|
311
|
+
`READMODEL`. Two such beats **adjacent with only `EVENT` beat(s) between them** describe one clean,
|
|
312
|
+
isolable projection test:
|
|
313
|
+
|
|
314
|
+
- events = the cumulative ordered `EVENT` beats from the start of the storyline through the
|
|
315
|
+
intervening event(s)
|
|
316
|
+
- expected result = the later `READMODEL` beat's `fields`/`examples`/`expectEmptyList`
|
|
317
|
+
|
|
318
|
+
Write these as ordinary `@Test` methods (the same `projector.on(event, repository)` pattern applies
|
|
319
|
+
unchanged), but keep them in a clearly separate `@Nested` class named after the storyline's title, so
|
|
320
|
+
they never get confused with the exhaustive `specifications[]` suite:
|
|
321
|
+
|
|
322
|
+
```java
|
|
323
|
+
@Nested
|
|
324
|
+
class StorylineTests {
|
|
325
|
+
@Test
|
|
326
|
+
void afterEvent_readModelShowsExpectedState() {
|
|
327
|
+
projector.on(new {EventName}(/* fields from the intervening beat(s) */), repository);
|
|
328
|
+
|
|
329
|
+
var result = repository.findAllBy{FilterField}("filter-value").stream()
|
|
330
|
+
.map({SliceName}Entity::toSummary)
|
|
331
|
+
.toList();
|
|
332
|
+
|
|
333
|
+
assertThat(result).containsExactly(/* expected shape from the later beat */);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
If a beat between two read-model states is a `COMMAND` rather than an `EVENT`, that half belongs
|
|
339
|
+
to `build-state-change` (its own command-handler test), not here — only project the `EVENT`→
|
|
340
|
+
`READMODEL` half. If a storyline segment involves a `SCREEN`/other untraceable beat, don't force a
|
|
341
|
+
test — leave it undocumented in code rather than fabricating an assertion.
|
|
342
|
+
|
|
343
|
+
## Final Verification: Does the Implementation Match slice.json?
|
|
344
|
+
|
|
345
|
+
Before marking this slice as `Done`, verify the implementation against slice.json:
|
|
346
|
+
|
|
347
|
+
- [ ] Every field in the read model / query result definition in slice.json has a field in `{SliceName}Summary` — no invented fields
|
|
348
|
+
- [ ] Every event type in `events[]` has an `@EventHandling` method in the projector — no events missed or assumed
|
|
349
|
+
- [ ] Every new event's type string is registered in `CqrsConfiguration`'s `eventTypeResolver()`
|
|
350
|
+
- [ ] The projector's processing-group name is stable and won't collide with another slice's
|
|
351
|
+
- [ ] Every GWT scenario in `specifications[]` maps to a test case in `{SliceName}ProjectorTest`
|
|
352
|
+
- [ ] If `storylines[]` is present: every adjacent READMODEL↔READMODEL beat pair for this slice's read model (with only EVENT beats between) has a `@Nested` storyline test — or was deliberately skipped as untraceable
|
|
353
|
+
- [ ] No extra query parameters or filter logic were added beyond what slice.json defines
|
|
354
|
+
- [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Project Configuration
|
|
2
|
+
|
|
3
|
+
This project is built with [OpenCQRS](https://docs.opencqrs.com), Spring Boot, and Maven, backed by
|
|
4
|
+
[EventSourcingDB](https://www.eventsourcingdb.io) for events. **Plain Java — no Kotlin.**
|
|
5
|
+
|
|
6
|
+
`{basePackage}` in this project's Java code (`src/main/java/<basePackage>/slices/...`) is this project's
|
|
7
|
+
own Java package prefix, not a fixed value — resolve it, in order: (1) the package of the project's
|
|
8
|
+
`@SpringBootApplication` class, (2) the package of any existing slice already under
|
|
9
|
+
`.../slices/{context}/{slicename}/`, (3) only if no code exists yet, Maven's `<groupId>` in `pom.xml`.
|
|
10
|
+
Never hardcode `com.example.quickstart` (the shipped quickstart scaffold's package) or any other specific
|
|
11
|
+
package.
|
|
12
|
+
|
|
13
|
+
## Structure
|
|
14
|
+
|
|
15
|
+
- Slices live under `src/main/java/{basePackage}/slices/{context}/{slicename}/` — flat, no
|
|
16
|
+
`write`/`read`/`automation` folder layer in between, except the shared `slices/{context}/api/` folder
|
|
17
|
+
(commands + events for that context) sitting alongside slice folders.
|
|
18
|
+
- Every event type used anywhere in the project must be registered in
|
|
19
|
+
`src/main/java/{basePackage}/config/CqrsConfiguration.java`'s `eventTypeResolver()` bean — this is a
|
|
20
|
+
single project-wide map, not per-slice. Forgetting to add a new event here doesn't fail at compile time;
|
|
21
|
+
it fails at runtime the first time that event is read back (`EventTypeResolutionException`, or silently
|
|
22
|
+
falls back to a classname-based type that breaks across renames). Whenever `build-state-change` or
|
|
23
|
+
`build-automation` introduces a new event, add its line to this map as part of that slice — never skip
|
|
24
|
+
it.
|
|
25
|
+
- Subjects are hierarchical, path-like strings (`Command.getSubject()`), e.g. `/book/{isbn}`,
|
|
26
|
+
`/book/{isbn}/page/{page}`. A child slice's subject nested under a parent's is how OpenCQRS models
|
|
27
|
+
parent/child aggregate relationships — see `build-state-change`'s Step 1 for how this replaces
|
|
28
|
+
compound-identifier/tagging schemes other frameworks need.
|
|
29
|
+
- **Command handlers always derive their state from [`@StateRebuilding`
|
|
30
|
+
methods](https://docs.opencqrs.com/reference/extension_points/state_rebuilding_handler/) reconstructing
|
|
31
|
+
the write model from sourced events — never from an external database, JPA repository, or any other
|
|
32
|
+
read model.** If a rule needs data from another subject, add a `@StateRebuilding` method for that
|
|
33
|
+
event type and use `sourcingMode = SourcingMode.RECURSIVE`; do not query a database instead. See
|
|
34
|
+
`build-state-change`'s Step 4.
|
|
35
|
+
|
|
36
|
+
## Code Standards
|
|
37
|
+
|
|
38
|
+
- **Language**: plain Java only — no Kotlin, no Lombok.
|
|
39
|
+
- **Records** for commands, events, and immutable write-model state.
|
|
40
|
+
- Ensure all code is properly typed; avoid raw types.
|
|
41
|
+
|
|
42
|
+
## Development Guidelines
|
|
43
|
+
|
|
44
|
+
1. Each slice should be self-contained and focused on a specific domain.
|
|
45
|
+
2. Maintain clear separation of concerns within each slice.
|
|
46
|
+
3. Only check `src/main/java/{basePackage}/slices/{slicename}/*.java`, do not check subfolders unless
|
|
47
|
+
explicitly tasked to.
|
|
48
|
+
|
|
49
|
+
Ignore case for files and slices in prompts. "CartItems" slice is the same as "cartitems".
|
|
50
|
+
|
|
51
|
+
Do not change test files unless explicitly instructed: `src/test/java/**/*Test.java`.
|
|
52
|
+
|
|
53
|
+
At the start of every session, read `.build-kit/AGENTS.md` if it exists to load accumulated project
|
|
54
|
+
learnings.
|
|
55
|
+
|
|
56
|
+
When starting to work on a slice, invoke the `update-slice-status` skill with `InProgress` status before
|
|
57
|
+
doing anything else.
|
|
58
|
+
|
|
59
|
+
## Building a Slice
|
|
60
|
+
|
|
61
|
+
**CRITICAL: You MUST always use the provided skills to build slices. NEVER implement a slice manually.**
|
|
62
|
+
**ALL fields, event names, command names, and business rules MUST come exclusively from slice.json. Do NOT
|
|
63
|
+
invent, assume, or guess any field or logic not present in the slice definition.**
|
|
64
|
+
|
|
65
|
+
**If, at any point below, the slice's requirements are genuinely ambiguous, contradictory, or missing a
|
|
66
|
+
decision you need in order to proceed — do not guess, and do not build anyway.** Invoke the
|
|
67
|
+
`request-feedback` skill with the specific question; it posts the question as a comment on the slice and
|
|
68
|
+
marks it `Blocked`, and you then stop work on this slice for this run. This is an escalation path, not a
|
|
69
|
+
routine step — read `slice.json` and the matching build skill's own instructions fully first; most slices
|
|
70
|
+
are fully specified and need none of this.
|
|
71
|
+
|
|
72
|
+
When asked to build a slice, always follow this flow:
|
|
73
|
+
|
|
74
|
+
1. Read the slice definition from `.build-kit/.slices/<context>/<slicename>/slice.json`.
|
|
75
|
+
2. Determine the slice type:
|
|
76
|
+
- **Translation** — `sliceType === "TRANSLATION"` → read `description` and `notes` from slice.json for
|
|
77
|
+
hints; default to `/build-automation` if nothing else is specified
|
|
78
|
+
- **Automation** — `processors` array is non-empty → invoke `/build-automation`
|
|
79
|
+
- **State-view** — `projections` or `queries` array is non-empty → invoke `/build-state-view`
|
|
80
|
+
- **State-change** — default (has `commands` / `events`) → invoke `/build-state-change`
|
|
81
|
+
3. Invoke the matching skill and follow its instructions completely. Do not deviate.
|
|
82
|
+
4. **Verify against slice.json**: After the skill completes, check that every command field, event field,
|
|
83
|
+
and specification in slice.json appears in the implementation. No invented fields — if it is not in
|
|
84
|
+
slice.json, it must not be in the code.
|
|
85
|
+
5. Run quality checks (`./mvnw compile -q`, then the slice tests only).
|
|
86
|
+
6. If checks pass, commit with `feat: [Slice Name]` and set slice status to `Done`.
|
|
87
|
+
|
|
88
|
+
After you are done, automatically run the tests for the slice that was edited.
|
|
89
|
+
|
|
90
|
+
## Example Slice Structure
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
src/main/java/{basePackage}/slices/
|
|
94
|
+
├── {context}/
|
|
95
|
+
│ ├── api/ ← shared Command/Event records for this context
|
|
96
|
+
│ ├── {slicename}/ ← state-change slice
|
|
97
|
+
│ │ ├── {SliceName}Command.java
|
|
98
|
+
│ │ ├── {SliceName}.java ← write-model record
|
|
99
|
+
│ │ └── {SliceName}Handling.java
|
|
100
|
+
│ └── automation/{slicename}/ ← automation slice
|
|
101
|
+
```
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Agent Learnings
|
|
2
|
+
|
|
3
|
+
Patterns and gotchas discovered during task processing. Update this file whenever you encounter something reusable.
|
|
4
|
+
|
|
5
|
+
## tasks.json
|
|
6
|
+
|
|
7
|
+
- Tasks are objects with `id`, `createdAt`, and `payload` (a `SliceChangedPayload`).
|
|
8
|
+
- After completing a task, remove it from the array entirely — do not add a status field.
|
|
9
|
+
- Write `[]` to `tasks.json` if the last task is completed.
|
|
10
|
+
|
|
11
|
+
## SliceChangedPayload fields
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
event always "slice:changed"
|
|
15
|
+
organizationId org UUID or null
|
|
16
|
+
boardId board UUID
|
|
17
|
+
sliceId SLICE_BORDER node UUID — use this with /load-slice
|
|
18
|
+
sliceTitle human-readable slice name (may be null)
|
|
19
|
+
sliceStatus e.g. "Created", "InProgress", "Done", "Blocked" (may be null)
|
|
20
|
+
timestamp unix ms when the change was emitted
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Slice files
|
|
24
|
+
|
|
25
|
+
The realtime agent writes one file per slice on startup and after each `slice:changed` event:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
.slices/<context>/<sliceName>/slice.json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- `<context>` is the slice's context value, or `default` if none.
|
|
32
|
+
- `<sliceName>` is the slice title lowercased with spaces removed (e.g. `"Enable User"` → `enableuser`).
|
|
33
|
+
|
|
34
|
+
These files are always up to date — read them directly before invoking any skill.
|
|
35
|
+
|
|
36
|
+
## Skill Usage
|
|
37
|
+
|
|
38
|
+
- Always run `/connect` first to load credentials from `.eventmodelers/config.json` before calling any other skill.
|
|
39
|
+
- `/load-slice sliceId=<uuid>` re-fetches all slices from the API, refreshes the slice files, and returns the requested slice. Use it when you need a guaranteed-fresh view of a specific slice.
|
|
40
|
+
- Read `.slices/<context>/<sliceName>/slice.json` directly when you already know the context and name and the file is recent enough.
|
|
41
|
+
|
|
42
|
+
## Board API
|
|
43
|
+
|
|
44
|
+
- The `boardId` and `organizationId` from each payload provide full context — pass them to skills.
|
|
45
|
+
- Node events use `node:created`, `node:changed`, `node:deleted` — always POST to `/api/org/:orgId/boards/:boardId/nodes/events`.
|
|
46
|
+
- Slice metadata (title, status) lives on the SLICE_BORDER node under `meta.sliceStatus` and `meta.title`.
|
|
47
|
+
- `/update-slice-status` rejects moving a slice into a status it's already in — this is a concurrency guard, not a bug. It means another agent already claimed the slice. Treat it as `ALREADY_IN_STATUS`, skip that slice, and move on to the next `Planned` one instead of erroring out.
|
|
48
|
+
|
|
49
|
+
## OpenCQRS-specific
|
|
50
|
+
|
|
51
|
+
- Forgetting to register a new event type in `CqrsConfiguration`'s `eventTypeResolver()` bean doesn't fail
|
|
52
|
+
at compile time — it fails at runtime the first time that event is read back. Always add the line when
|
|
53
|
+
a slice introduces a new event.
|
|
54
|
+
- An automation's event-handling method that dispatches a command via `CommandRouter` must catch
|
|
55
|
+
`CommandSubjectAlreadyExistsException`/`CommandSubjectDoesNotExistException` locally when a retried
|
|
56
|
+
(at-least-once) delivery is expected to re-hit an already-applied conflict — letting a
|
|
57
|
+
`NonTransientException` escape an `@EventHandling` method halts that entire processing group's loop, not
|
|
58
|
+
just that one event.
|