@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,391 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-state-view
|
|
3
|
+
authors:
|
|
4
|
+
- Martin Dilger
|
|
5
|
+
description: >
|
|
6
|
+
Implement read slices (JPA-backed projections + REST query endpoint + tests) directly against
|
|
7
|
+
KurrentDB 1.2.x (Java client) in this project's one established pattern: a KurrentDB persistent
|
|
8
|
+
subscription (filtered to this context's streams) feeds a projector method that updates a private
|
|
9
|
+
JPA entity; a separate REST controller queries the repository directly. Use when implementing a new
|
|
10
|
+
read slice / projection in this project. Plain Java only. There is exactly one supported style — do
|
|
11
|
+
not offer alternatives.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# KurrentDB — Read Slice (Java)
|
|
15
|
+
|
|
16
|
+
## Step 0: Discover target project conventions
|
|
17
|
+
|
|
18
|
+
> **Comments & description**: Each element in the slice carries a `comments: string[]` array and a
|
|
19
|
+
> `description` field. Use these as implementation hints. When done, resolve each used comment:
|
|
20
|
+
> `POST <BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/<nodeId>/comments/<commentId>/resolve` (get
|
|
21
|
+
> IDs first via GET on same path).
|
|
22
|
+
|
|
23
|
+
Before writing any code, read the target project's `.build-kit/CLAUDE.md`.
|
|
24
|
+
|
|
25
|
+
**Determine `{basePackage}`** — every code example below is rooted at
|
|
26
|
+
`{basePackage}.slices.{context}.{slicename}`. Resolve `{basePackage}` as documented in
|
|
27
|
+
`.build-kit/CLAUDE.md`'s Structure section.
|
|
28
|
+
|
|
29
|
+
## Step 1: Ensure events exist
|
|
30
|
+
|
|
31
|
+
Before implementing the read slice, verify that all events the projector handles exist in
|
|
32
|
+
`src/main/java/.../{context}/api/` (the context's sealed event interface + `{Context}EventTypes`). If
|
|
33
|
+
they don't, create them **first** — see `build-state-change` Step 2. Do not skip registering a new
|
|
34
|
+
event's type name even for a pure read slice — an unregistered event type is silently invisible to this
|
|
35
|
+
projector too.
|
|
36
|
+
|
|
37
|
+
## Step 2: Implement the read slice
|
|
38
|
+
|
|
39
|
+
If the slice details include `## Scenarios (GWTs)`, use them to derive test cases (Step 5). GWT format
|
|
40
|
+
for read slices: `Given (events) → Then (information)` — no When. Events in Given tell you which
|
|
41
|
+
events the projector handles. The information element in Then describes the expected query result.
|
|
42
|
+
|
|
43
|
+
If the slice description or comments contain `## Implementation Guidelines`, **follow them**.
|
|
44
|
+
|
|
45
|
+
### Slice package structure
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
.../slices/{context}/{slicename}/ (i.e. {basePackage}.slices.{context}.{slicename} — see Step 0)
|
|
49
|
+
├── {SliceName}Entity.java ← @Entity, private to this slice
|
|
50
|
+
├── {SliceName}Repository.java ← Spring Data repository, private to this slice
|
|
51
|
+
├── {SliceName}Summary.java ← read model (query result shape)
|
|
52
|
+
├── {SliceName}Projector.java ← persistent-subscription listener + projection logic
|
|
53
|
+
└── {SliceName}RestApi.java ← @RestController (if REST chosen)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A read slice lives in a single package. **Never reuse another slice's entity/repository** — a read
|
|
57
|
+
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
|
+
### Result DTO rules
|
|
103
|
+
|
|
104
|
+
- If the read model matches the entity **1:1**, expose the summary record directly (`toSummary()`
|
|
105
|
+
above).
|
|
106
|
+
- If the read model contains fields the caller already knows from the query (e.g. the filter field
|
|
107
|
+
itself), omit those from `{SliceName}Summary` and map only what's new.
|
|
108
|
+
|
|
109
|
+
### Projector — persistent subscription
|
|
110
|
+
|
|
111
|
+
A persistent subscription is KurrentDB's own durable, at-least-once, ack/nack-based consumer
|
|
112
|
+
mechanism — the server tracks this group's checkpoint, so there's no local progress table to maintain
|
|
113
|
+
(unlike the OpenCQRS/Axon kits' `ProgressTracker`).
|
|
114
|
+
|
|
115
|
+
**Subscription group name**: pick one stable name per read model (e.g. `"{context}-{slicename}"`) and
|
|
116
|
+
never rename it once deployed — KurrentDB persists the checkpoint under this exact group name.
|
|
117
|
+
Renaming it doesn't fail loudly; the "new" group just starts from `fromStart()` again (or from
|
|
118
|
+
wherever `CreatePersistentSubscriptionToAllOptions` says), reprocessing everything and likely
|
|
119
|
+
duplicating rows unless your upserts are idempotent by natural key (they are here, since `save(...)`
|
|
120
|
+
on a JPA entity with a fixed `@Id` upserts).
|
|
121
|
+
|
|
122
|
+
**Filter to this context's streams**: this read model spans every stream in the context (one stream per
|
|
123
|
+
aggregate instance, e.g. `library-4711`, `library-4712`, ...), so subscribe to `$all` with a
|
|
124
|
+
stream-name-prefix filter rather than to one specific stream.
|
|
125
|
+
|
|
126
|
+
```java
|
|
127
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
128
|
+
|
|
129
|
+
import {basePackage}.slices.{context}.api.{Context}EventTypes;
|
|
130
|
+
import io.kurrent.dbclient.*;
|
|
131
|
+
import jakarta.annotation.PostConstruct;
|
|
132
|
+
import org.springframework.beans.factory.annotation.Autowired;
|
|
133
|
+
import org.springframework.stereotype.Component;
|
|
134
|
+
import org.springframework.transaction.annotation.Transactional;
|
|
135
|
+
|
|
136
|
+
import java.util.concurrent.ExecutionException;
|
|
137
|
+
|
|
138
|
+
@Component
|
|
139
|
+
public class {SliceName}Projector {
|
|
140
|
+
|
|
141
|
+
private static final String GROUP = "{context}-{slicename}";
|
|
142
|
+
|
|
143
|
+
private final KurrentDBPersistentSubscriptionsClient subscriptionsClient;
|
|
144
|
+
private final {SliceName}Repository repository;
|
|
145
|
+
|
|
146
|
+
public {SliceName}Projector(KurrentDBPersistentSubscriptionsClient subscriptionsClient,
|
|
147
|
+
{SliceName}Repository repository) {
|
|
148
|
+
this.subscriptionsClient = subscriptionsClient;
|
|
149
|
+
this.repository = repository;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@PostConstruct
|
|
153
|
+
void start() throws ExecutionException, InterruptedException {
|
|
154
|
+
SubscriptionFilter filter = SubscriptionFilter.newBuilder()
|
|
155
|
+
.addStreamNamePrefix("{context-lower}-")
|
|
156
|
+
.build();
|
|
157
|
+
try {
|
|
158
|
+
subscriptionsClient.createToAll(GROUP,
|
|
159
|
+
CreatePersistentSubscriptionToAllOptions.get().fromStart().filter(filter)).get();
|
|
160
|
+
} catch (ExecutionException e) {
|
|
161
|
+
if (!(e.getCause() instanceof io.grpc.StatusRuntimeException grpcException)
|
|
162
|
+
|| grpcException.getStatus().getCode() != io.grpc.Status.Code.ALREADY_EXISTS) {
|
|
163
|
+
throw e;
|
|
164
|
+
}
|
|
165
|
+
// group already created on a previous startup — expected, not an error
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
subscriptionsClient.subscribeToAll(GROUP, new PersistentSubscriptionListener() {
|
|
169
|
+
@Override
|
|
170
|
+
@Transactional
|
|
171
|
+
public void onEvent(PersistentSubscription subscription, int retryCount, ResolvedEvent event) {
|
|
172
|
+
try {
|
|
173
|
+
var domainEvent = {Context}EventTypes.deserialize(event.getOriginalEvent());
|
|
174
|
+
domainEvent.ifPresent({SliceName}Projector.this::project);
|
|
175
|
+
subscription.ack(event);
|
|
176
|
+
} catch (Exception ex) {
|
|
177
|
+
subscription.nack(NackAction.Park, ex.getMessage(), event);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
@Override
|
|
182
|
+
public void onCancelled(PersistentSubscription subscription, Throwable exception) {
|
|
183
|
+
// logged by the framework's own subscription lifecycle; nothing project-specific needed
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private void project(/* {Context}Event */ Object event) {
|
|
189
|
+
switch (event) {
|
|
190
|
+
case {EventName} e -> repository.save(new {SliceName}Entity(e.idField(), e.{filterField}()));
|
|
191
|
+
default -> { /* not this projection's concern */ }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`domainEvent.ifPresent(...)` already filters out events this context doesn't recognize; `project`'s own
|
|
198
|
+
`switch` further narrows to just the event type(s) this specific read model cares about — every other
|
|
199
|
+
recognized-but-irrelevant event in the context falls through the `default` arm and is still acked
|
|
200
|
+
(acking is "I've seen this event", not "this event changed my data").
|
|
201
|
+
|
|
202
|
+
### Entity + repository template (for filtered queries)
|
|
203
|
+
|
|
204
|
+
Use `findAllBy{FilterField}(...)` in the query endpoint below — DB-level filtering, not client-side.
|
|
205
|
+
|
|
206
|
+
## Step 3: REST query endpoint
|
|
207
|
+
|
|
208
|
+
Separate class — plain constructor-injected Spring bean, unrelated to the persistent-subscription
|
|
209
|
+
listener above:
|
|
210
|
+
|
|
211
|
+
```java
|
|
212
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
213
|
+
|
|
214
|
+
import org.springframework.web.bind.annotation.*;
|
|
215
|
+
import java.util.List;
|
|
216
|
+
|
|
217
|
+
@RestController
|
|
218
|
+
public class {SliceName}RestApi {
|
|
219
|
+
|
|
220
|
+
private final {SliceName}Repository repository;
|
|
221
|
+
|
|
222
|
+
public {SliceName}RestApi({SliceName}Repository repository) {
|
|
223
|
+
this.repository = repository;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
@GetMapping("/api/{context}/{filterField}")
|
|
227
|
+
public List<{SliceName}Summary> query(@PathVariable String {filterField}) {
|
|
228
|
+
return repository.findAllBy{FilterField}({filterField}).stream()
|
|
229
|
+
.map({SliceName}Entity::toSummary)
|
|
230
|
+
.toList();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
This project uses plain Spring **WebMVC** — a blocking return value, not `Mono<...>`.
|
|
236
|
+
|
|
237
|
+
## Step 4: Design test cases
|
|
238
|
+
|
|
239
|
+
Implement the test cases provided in the slice definition. Do not design your own test cases unless
|
|
240
|
+
specifically instructed to do so.
|
|
241
|
+
|
|
242
|
+
### Mapping GWT scenarios to tests
|
|
243
|
+
|
|
244
|
+
| GWT Element | Test code |
|
|
245
|
+
|---|---|
|
|
246
|
+
| `NOTHING` in Given | call the repository query directly with no prior `.save(...)` |
|
|
247
|
+
| Event in Given | call the projector's `project(event)` method directly (package-private — see below) |
|
|
248
|
+
| Information in Then | assert the repository query result / mapped summaries |
|
|
249
|
+
|
|
250
|
+
## Step 5: Implement the test — `@DataJpaTest` + `@Import` + Testcontainers Postgres
|
|
251
|
+
|
|
252
|
+
There is no need to exercise the real persistent-subscription machinery to test a projection's
|
|
253
|
+
business logic — split `project(Object event)` out as its own package-private method (as shown above)
|
|
254
|
+
specifically so a test can call it directly, bypassing `@PostConstruct`/subscribe/ack entirely.
|
|
255
|
+
|
|
256
|
+
**This project is on Spring Boot 4** — `@DataJpaTest` moved to the `spring-boot-data-jpa-test` artifact
|
|
257
|
+
under package `org.springframework.boot.data.jpa.test.autoconfigure` (not the Boot 3
|
|
258
|
+
`org.springframework.boot.test.autoconfigure.orm.jpa` package).
|
|
259
|
+
|
|
260
|
+
**This project's read models target PostgreSQL, not H2** — `@DataJpaTest`'s default behavior tries to
|
|
261
|
+
replace the datasource with an embedded database, which fails outright with no H2/Derby/HSQL on the
|
|
262
|
+
classpath. Use Testcontainers' real Postgres instead:
|
|
263
|
+
|
|
264
|
+
```java
|
|
265
|
+
package {basePackage}.slices.{context}.{slicename};
|
|
266
|
+
|
|
267
|
+
import {basePackage}.slices.{context}.api.{EventName};
|
|
268
|
+
import org.junit.jupiter.api.Test;
|
|
269
|
+
import org.springframework.beans.factory.annotation.Autowired;
|
|
270
|
+
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
|
271
|
+
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
|
272
|
+
import org.springframework.context.annotation.Import;
|
|
273
|
+
import org.testcontainers.containers.PostgreSQLContainer;
|
|
274
|
+
import org.testcontainers.junit.jupiter.Container;
|
|
275
|
+
import org.testcontainers.junit.jupiter.Testcontainers;
|
|
276
|
+
|
|
277
|
+
import static org.assertj.core.api.Assertions.assertThat;
|
|
278
|
+
|
|
279
|
+
@DataJpaTest
|
|
280
|
+
@Testcontainers
|
|
281
|
+
@Import({SliceName}Projector.class)
|
|
282
|
+
class {SliceName}ProjectorTest {
|
|
283
|
+
|
|
284
|
+
@Container
|
|
285
|
+
@ServiceConnection
|
|
286
|
+
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
|
|
287
|
+
|
|
288
|
+
@Autowired
|
|
289
|
+
private {SliceName}Repository repository;
|
|
290
|
+
|
|
291
|
+
// Constructing {SliceName}Projector directly (not @Autowired) avoids @PostConstruct trying to
|
|
292
|
+
// reach a real KurrentDB from this test — pass null for the subscriptions client since project()
|
|
293
|
+
// never uses it.
|
|
294
|
+
private {SliceName}Projector projector;
|
|
295
|
+
|
|
296
|
+
@org.junit.jupiter.api.BeforeEach
|
|
297
|
+
void setUp() {
|
|
298
|
+
projector = new {SliceName}Projector(null, repository);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
@Test
|
|
302
|
+
void givenNoEvents_thenEmptyResult() {
|
|
303
|
+
assertThat(repository.findAllBy{FilterField}("filter-value")).isEmpty();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
@Test
|
|
307
|
+
void givenCreationEvent_thenItemAppears() {
|
|
308
|
+
projector.project(new {EventName}("id-1", "filter-value" /*, other fields */));
|
|
309
|
+
|
|
310
|
+
var result = repository.findAllBy{FilterField}("filter-value").stream()
|
|
311
|
+
.map({SliceName}Entity::toSummary)
|
|
312
|
+
.toList();
|
|
313
|
+
|
|
314
|
+
assertThat(result).containsExactly(new {SliceName}Summary("id-1", "filter-value"));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
@Test
|
|
318
|
+
void itemsAreIsolatedByFilterField() {
|
|
319
|
+
projector.project(new {EventName}("id-1", "group-A"));
|
|
320
|
+
projector.project(new {EventName}("id-2", "group-B"));
|
|
321
|
+
|
|
322
|
+
assertThat(repository.findAllBy{FilterField}("group-A")).hasSize(1);
|
|
323
|
+
assertThat(repository.findAllBy{FilterField}("group-B")).hasSize(1);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
If `project`'s `switch` is package-private (as written above), this test class must live in the same
|
|
329
|
+
package — which it already does, following this stack's convention (`{SliceName}ProjectorTest` next to
|
|
330
|
+
`{SliceName}Projector`).
|
|
331
|
+
|
|
332
|
+
### Key rules
|
|
333
|
+
|
|
334
|
+
- Call `project(event)` directly — never go through `@PostConstruct`/the persistent-subscription
|
|
335
|
+
listener in a test. `onEvent`'s job (deserialize, ack/nack) is KurrentDB-plumbing, already covered
|
|
336
|
+
once, generically, by `{Context}EventTypes` — it's not this slice's business logic to re-test.
|
|
337
|
+
- **Assert with full objects**: `containsExactly(new Summary(...))` rather than field-by-field
|
|
338
|
+
assertions — catches mapping mistakes a partial assertion would miss.
|
|
339
|
+
- **Requires Docker running locally/in CI** — the `PostgreSQLContainer` starts a real container per
|
|
340
|
+
test class. There is no embedded-database fallback in this project.
|
|
341
|
+
|
|
342
|
+
## Step 5b: Storyline-Derived Tests (Optional)
|
|
343
|
+
|
|
344
|
+
`slice.json` may also carry a `storylines[]` array — narrated walkthroughs where the *same* read
|
|
345
|
+
model appears as multiple ordered "beats" across one flow (see `elements[]` on each storyline).
|
|
346
|
+
This is a secondary, supplementary source: `specifications[]` (Step 4) remains the primary and
|
|
347
|
+
default source of test cases. Most slices have no `storylines[]` — skip this step silently when
|
|
348
|
+
there's nothing relevant.
|
|
349
|
+
|
|
350
|
+
For each storyline, find beats whose `type` is `READMODEL`. Two such beats **adjacent with only
|
|
351
|
+
`EVENT` beat(s) between them** describe one clean, isolable projection test:
|
|
352
|
+
|
|
353
|
+
- events = the cumulative ordered `EVENT` beats from the start of the storyline through the
|
|
354
|
+
intervening event(s) — call `projector.project(...)` once per event, in order
|
|
355
|
+
- expected result = the later `READMODEL` beat's `fields`/`examples`/`expectEmptyList`
|
|
356
|
+
|
|
357
|
+
Keep these in a clearly separate `@Nested` class named after the storyline's title:
|
|
358
|
+
|
|
359
|
+
```java
|
|
360
|
+
@Nested
|
|
361
|
+
class StorylineTests {
|
|
362
|
+
@Test
|
|
363
|
+
void afterEvent_readModelShowsExpectedState() {
|
|
364
|
+
projector.project(new {EventName}(/* fields from the intervening beat(s) */));
|
|
365
|
+
|
|
366
|
+
var result = repository.findAllBy{FilterField}("filter-value").stream()
|
|
367
|
+
.map({SliceName}Entity::toSummary)
|
|
368
|
+
.toList();
|
|
369
|
+
|
|
370
|
+
assertThat(result).containsExactly(/* expected shape from the later beat */);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
If a beat between two read-model states is a `COMMAND` rather than an `EVENT`, that half belongs to
|
|
376
|
+
`build-state-change` (its own command-handler test), not here — only project the `EVENT`→`READMODEL`
|
|
377
|
+
half. If a storyline segment involves a `SCREEN`/other untraceable beat, don't force a test — leave it
|
|
378
|
+
undocumented rather than fabricating an assertion.
|
|
379
|
+
|
|
380
|
+
## Final Verification: Does the Implementation Match slice.json?
|
|
381
|
+
|
|
382
|
+
Before marking this slice as `Done`, verify the implementation against slice.json:
|
|
383
|
+
|
|
384
|
+
- [ ] Every field in the read model / query result definition in slice.json has a field in `{SliceName}Summary` — no invented fields
|
|
385
|
+
- [ ] Every event type in `events[]` this projection reacts to is handled in `project`'s `switch` — no events missed or assumed
|
|
386
|
+
- [ ] Every new event's type name is registered in `{Context}EventTypes`
|
|
387
|
+
- [ ] The subscription's group name is stable and won't collide with another slice's
|
|
388
|
+
- [ ] Every GWT scenario in `specifications[]` maps to a test case calling `project(...)` directly
|
|
389
|
+
- [ ] 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
|
|
390
|
+
- [ ] No extra query parameters or filter logic were added beyond what slice.json defines
|
|
391
|
+
- [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Project Configuration
|
|
2
|
+
|
|
3
|
+
This project is built with plain Java, Spring Boot, and Maven, backed directly by
|
|
4
|
+
[KurrentDB](https://docs.kurrent.io) (formerly EventStoreDB) as the event store. **There is no CQRS
|
|
5
|
+
framework layer** — unlike this CLI's OpenCQRS or Axon kits, KurrentDB's Java client
|
|
6
|
+
(`kurrentdb-client`) is a plain SDK with no annotations, no command router, no auto-configuration of
|
|
7
|
+
its own. This project's own conventions (documented below, and in each `build-*` skill) are the entire
|
|
8
|
+
"framework". Plain Java — no Kotlin.
|
|
9
|
+
|
|
10
|
+
`{basePackage}` in this project's Java code (`src/main/java/<basePackage>/slices/...`) is this
|
|
11
|
+
project's own Java package prefix, not a fixed value — resolve it, in order: (1) the package of the
|
|
12
|
+
project's `@SpringBootApplication` class, (2) the package of any existing slice already under
|
|
13
|
+
`.../slices/{context}/{slicename}/`, (3) only if no code exists yet, Maven's `<groupId>` in `pom.xml`.
|
|
14
|
+
Never hardcode `com.example.quickstart` (the shipped quickstart scaffold's package) or any other
|
|
15
|
+
specific package.
|
|
16
|
+
|
|
17
|
+
## Structure
|
|
18
|
+
|
|
19
|
+
- Slices live under `src/main/java/{basePackage}/slices/{context}/{slicename}/` — flat, no
|
|
20
|
+
`write`/`read`/`automation` folder layer in between, except the shared `slices/{context}/api/` folder
|
|
21
|
+
(events + the context's sealed event-union interface, shared by every slice in that context) sitting
|
|
22
|
+
alongside slice folders. Automations live under `slices/{context}/automation/{slicename}/`.
|
|
23
|
+
- **Streams**: one KurrentDB stream per aggregate instance, named `{context-lower}-{id}` (e.g.
|
|
24
|
+
`library-4711`). A parent/child relationship between two slices is its own separate stream with its
|
|
25
|
+
own name — there is no built-in hierarchical-subject mechanism like OpenCQRS's (KurrentDB stream
|
|
26
|
+
names are just opaque strings); if a decision genuinely needs data from another stream, read it
|
|
27
|
+
explicitly via `EventStore`/`KurrentDBClient`, don't try to encode a parent/child path convention.
|
|
28
|
+
- **Events**: plain Java records, implementing that context's sealed marker interface (e.g.
|
|
29
|
+
`LibraryEvent`), living in `slices/{context}/api/`. Every event type has a short, stable string name
|
|
30
|
+
(e.g. `"BookPurchased"`) — **never** the Java classname — registered in that context's
|
|
31
|
+
`{Context}EventTypes` class (serialize/deserialize + the type-name mapping). This is the one registry
|
|
32
|
+
new events must be added to; forgetting to costs nothing at compile time but means the event is
|
|
33
|
+
silently invisible to every reader (`evolve`, a projection, an automation) that scans a stream — see
|
|
34
|
+
`build-state-change` Step 2.
|
|
35
|
+
- **`EventStore`** (`{basePackage}.common.EventStore`) is the one shared low-level helper every write
|
|
36
|
+
slice uses to read/append streams — see its Javadoc. It is intentionally thin; it does not know about
|
|
37
|
+
`decide`/`evolve` or any slice's domain types.
|
|
38
|
+
- **decide/evolve**: every write slice defines its own `evolve(state, event) -> state` (pure, no I/O)
|
|
39
|
+
and `decide(command, state) -> List<Event>` (pure, no I/O, throws on a business-rule violation) as
|
|
40
|
+
static methods — see `build-state-change`. These are genuinely pure Java functions; their tests never
|
|
41
|
+
touch KurrentDB, Spring, or a container.
|
|
42
|
+
- **Read models / automations**: use a [KurrentDB persistent subscription](https://docs.kurrent.io/clients/java/persistent-subscriptions.html)
|
|
43
|
+
(its own named subscription group) — see `build-state-view`/`build-automation`. KurrentDB tracks each
|
|
44
|
+
group's checkpoint durably server-side; there is no local progress-tracking table to maintain (unlike
|
|
45
|
+
the OpenCQRS/Axon kits).
|
|
46
|
+
|
|
47
|
+
## Code Standards
|
|
48
|
+
|
|
49
|
+
- **Language**: plain Java only — no Kotlin, no Lombok.
|
|
50
|
+
- **Records** for commands, events, and immutable write-model state.
|
|
51
|
+
- Ensure all code is properly typed; avoid raw types.
|
|
52
|
+
- Serialize event payloads with `com.fasterxml.jackson.core:jackson-databind`'s `ObjectMapper` — this is
|
|
53
|
+
declared explicitly in `pom.xml` (Spring Boot 4's own internal JSON handling moved to Jackson 3
|
|
54
|
+
`tools.jackson.core`, which is not what `kurrentdb-client`'s own examples/API use).
|
|
55
|
+
|
|
56
|
+
## Development Guidelines
|
|
57
|
+
|
|
58
|
+
1. Each slice should be self-contained and focused on a specific domain.
|
|
59
|
+
2. Maintain clear separation of concerns within each slice.
|
|
60
|
+
3. Only check `src/main/java/{basePackage}/slices/{slicename}/*.java`, do not check subfolders unless
|
|
61
|
+
explicitly tasked to.
|
|
62
|
+
|
|
63
|
+
Ignore case for files and slices in prompts. "CartItems" slice is the same as "cartitems".
|
|
64
|
+
|
|
65
|
+
Do not change test files unless explicitly instructed: `src/test/java/**/*Test.java`.
|
|
66
|
+
|
|
67
|
+
At the start of every session, read `.build-kit/AGENTS.md` if it exists to load accumulated project
|
|
68
|
+
learnings.
|
|
69
|
+
|
|
70
|
+
When starting to work on a slice, invoke the `update-slice-status` skill with `InProgress` status before
|
|
71
|
+
doing anything else.
|
|
72
|
+
|
|
73
|
+
## Building a Slice
|
|
74
|
+
|
|
75
|
+
**CRITICAL: You MUST always use the provided skills to build slices. NEVER implement a slice manually.**
|
|
76
|
+
**ALL fields, event names, command names, and business rules MUST come exclusively from slice.json. Do NOT
|
|
77
|
+
invent, assume, or guess any field or logic not present in the slice definition.**
|
|
78
|
+
|
|
79
|
+
**If, at any point below, the slice's requirements are genuinely ambiguous, contradictory, or missing a
|
|
80
|
+
decision you need in order to proceed — do not guess, and do not build anyway.** Invoke the
|
|
81
|
+
`request-feedback` skill with the specific question; it posts the question as a comment on the slice and
|
|
82
|
+
marks it `Blocked`, and you then stop work on this slice for this run. This is an escalation path, not a
|
|
83
|
+
routine step — read `slice.json` and the matching build skill's own instructions fully first; most slices
|
|
84
|
+
are fully specified and need none of this.
|
|
85
|
+
|
|
86
|
+
When asked to build a slice, always follow this flow:
|
|
87
|
+
|
|
88
|
+
1. Read the slice definition from `.build-kit/.slices/<context>/<slicename>/slice.json`.
|
|
89
|
+
2. Determine the slice type:
|
|
90
|
+
- **Translation** — `sliceType === "TRANSLATION"` → read `description` and `notes` from slice.json for
|
|
91
|
+
hints; default to `/build-automation` if nothing else is specified
|
|
92
|
+
- **Automation** — `processors` array is non-empty → invoke `/build-automation`
|
|
93
|
+
- **State-view** — `projections` or `queries` array is non-empty → invoke `/build-state-view`
|
|
94
|
+
- **State-change** — default (has `commands` / `events`) → invoke `/build-state-change`
|
|
95
|
+
3. Invoke the matching skill and follow its instructions completely. Do not deviate.
|
|
96
|
+
4. **Verify against slice.json**: After the skill completes, check that every command field, event field,
|
|
97
|
+
and specification in slice.json appears in the implementation. No invented fields — if it is not in
|
|
98
|
+
slice.json, it must not be in the code.
|
|
99
|
+
5. Run quality checks (`./mvnw compile -q`, then the slice tests only).
|
|
100
|
+
6. If checks pass, commit with `feat: [Slice Name]` and set slice status to `Done`.
|
|
101
|
+
|
|
102
|
+
After you are done, automatically run the tests for the slice that was edited.
|
|
103
|
+
|
|
104
|
+
## Example Slice Structure
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
src/main/java/{basePackage}/slices/
|
|
108
|
+
├── {context}/
|
|
109
|
+
│ ├── api/ ← {Context}Event sealed interface + records + {Context}EventTypes
|
|
110
|
+
│ ├── {slicename}/ ← state-change slice
|
|
111
|
+
│ │ ├── {SliceName}.java ← write-model record
|
|
112
|
+
│ │ ├── {SliceName}Command.java
|
|
113
|
+
│ │ └── {SliceName}Handling.java ← evolve/decide/handle + Spring wiring
|
|
114
|
+
│ ├── {slicename}/ ← state-view slice
|
|
115
|
+
│ │ ├── {SliceName}Entity.java
|
|
116
|
+
│ │ ├── {SliceName}Repository.java
|
|
117
|
+
│ │ ├── {SliceName}Summary.java
|
|
118
|
+
│ │ ├── {SliceName}Projector.java
|
|
119
|
+
│ │ └── {SliceName}RestApi.java
|
|
120
|
+
│ └── automation/{slicename}/ ← automation slice
|
|
121
|
+
│ └── {AutomationName}Handling.java
|
|
122
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
## KurrentDB-specific
|
|
50
|
+
|
|
51
|
+
- Forgetting to register a new event type in a context's `{Context}EventTypes` class doesn't fail at
|
|
52
|
+
compile time — it fails silently: `evolve`/a projection/an automation just never sees that event type
|
|
53
|
+
when scanning a stream (it's treated as an unrecognized foreign event and skipped). Always add the
|
|
54
|
+
registration line when a slice introduces a new event.
|
|
55
|
+
- An automation dispatching a command by appending to another stream must catch
|
|
56
|
+
`WrongExpectedVersionException` locally when a retried (at-least-once persistent-subscription)
|
|
57
|
+
redelivery is expected to re-hit an already-applied conflict — do not let it propagate out of the
|
|
58
|
+
persistent-subscription listener's `onEvent`, or the message will be nacked/retried forever for no
|
|
59
|
+
reason. Ack after catching it.
|
|
60
|
+
- Creating a persistent subscription group that already exists throws a raw `io.grpc.StatusRuntimeException`
|
|
61
|
+
with status code `ALREADY_EXISTS` (not one of the SDK's own exception classes) — catch and ignore this
|
|
62
|
+
specific case at startup, since subscription-group creation must be idempotent across app restarts.
|
|
63
|
+
`io.grpc:grpc-api` is only a *runtime*-scope transitive dependency of `kurrentdb-client` — declared
|
|
64
|
+
explicitly (compile scope) in `pom.xml` so `io.grpc.Status`/`StatusRuntimeException` are usable in
|
|
65
|
+
application code at all; verified by compiling against the real dependency.
|
|
66
|
+
- Spring Boot 4 moved classic Jackson 2 (`com.fasterxml.jackson.core`) off the default compile
|
|
67
|
+
classpath (its own internals now use Jackson 3, `tools.jackson.core`) — this project declares
|
|
68
|
+
`com.fasterxml.jackson.core:jackson-databind` explicitly in `pom.xml` for that reason. Similarly,
|
|
69
|
+
`@DataJpaTest`/`@WebMvcTest` moved to `org.springframework.boot.data.jpa.test.autoconfigure`/
|
|
70
|
+
`org.springframework.boot.webmvc.test.autoconfigure` respectively.
|
|
71
|
+
- The `docker.kurrent.io/kurrent-latest/kurrentdb:latest` image can be slow or occasionally flaky to
|
|
72
|
+
start under ARM/amd64 emulation (e.g. Apple Silicon Docker Desktop) — a container startup timeout or
|
|
73
|
+
one-off failure there is usually transient; retry before assuming a real problem.
|