@cratis/pi 2.24.1 → 2.25.0
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/package/corpus/rules/java.md +134 -0
- package/package/corpus/rules/kotlin.md +172 -0
- package/package/corpus/rules/profiles.md +24 -3
- package/package/corpus/skills/cratis-arc-command-kotlin/SKILL.md +367 -0
- package/package/corpus/skills/cratis-arc-query-kotlin/SKILL.md +225 -0
- package/package/corpus/skills/cratis-arc-validation-kotlin/SKILL.md +285 -0
- package/package/profile-catalog.json +81 -8
- package/package.json +1 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cratis-arc-command-kotlin
|
|
3
|
+
description: Define a Cratis Arc command in Kotlin or Java — the @Command class, its handle() method, the optional provide() step, Chronicle event responses, and command authorization. Use when adding a command to a Kotlin or Java Arc application, choosing what handle() should return, or wiring Chronicle event append from a command. Do not use for the .NET Arc command shape, for validation-only changes, or for query/read-model definition.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Define an Arc command in Kotlin or Java
|
|
8
|
+
|
|
9
|
+
Arc.Kotlin brings the same command shape as Arc .NET to the JVM: a class carries
|
|
10
|
+
the user's intent and owns its own handler. KSP (the Kotlin Symbol Processor)
|
|
11
|
+
discovers it at compile time and generates a reflection-free `CommandHandler` —
|
|
12
|
+
there is no controller or handler class to write by hand.
|
|
13
|
+
|
|
14
|
+
## Verified product sources
|
|
15
|
+
|
|
16
|
+
Verified against `Arc.Kotlin` at tag **`v7.3.0`**, re-verified at commit
|
|
17
|
+
`d0aa6a4` (14 commits past the tag), against the documented consumer setup:
|
|
18
|
+
JDK 17, Gradle 8.14.4, Kotlin 2.4.20, KSP 2.3.12, Spring Boot 4.1.x. The
|
|
19
|
+
re-verification picked up a real behavior correction to authorization
|
|
20
|
+
override — see "Protect the command" below — confirmed against
|
|
21
|
+
`ArcSymbolProcessorAuthorizationPrecedenceCompilationTest` and
|
|
22
|
+
`buildAuthorization` in `ArcSymbolProcessor.kt`, not documentation prose alone:
|
|
23
|
+
two of Arc.Kotlin's own reference pages (`reference/annotations.md`,
|
|
24
|
+
`troubleshooting.md`) are currently stale on this point relative to
|
|
25
|
+
`guides/security.md`, `reference/parity.md`, and the compiler source. The local workspace version is `0.0.0-SNAPSHOT`; use a released version
|
|
26
|
+
from Maven Central for a real application.
|
|
27
|
+
|
|
28
|
+
| Artifact | Purpose |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `io.cratis:arc` | `io.cratis.arc.artifacts.Command`, `CommandKey`, `FromServices` |
|
|
31
|
+
| `io.cratis:arc-ksp` | the KSP processor that generates the command handler |
|
|
32
|
+
| `io.cratis:arc-spring-boot-starter` | Spring Boot auto-configuration, the command pipeline host |
|
|
33
|
+
| `io.cratis:arc-chronicle-spring-boot-starter` | optional — Chronicle event append from a returned event |
|
|
34
|
+
| `io.cratis.arc` Gradle plugin | applies Kotlin/JVM + KSP, adds the dependencies, configures the module name and proxy output |
|
|
35
|
+
|
|
36
|
+
Arc.Kotlin has no Chronicle dependency by default. Add the optional Chronicle
|
|
37
|
+
starter only when a command should append a returned event or read a Chronicle
|
|
38
|
+
read model; without it, `handle()` returns an ordinary response value.
|
|
39
|
+
|
|
40
|
+
Java applications still need Kotlin and KSP on the build — Arc generates Kotlin
|
|
41
|
+
adapters for Java declarations even in an all-Java application. A Java project
|
|
42
|
+
uses the identical Gradle plugin and `cratisArc {}` block below; only the
|
|
43
|
+
application source files differ (Java records instead of Kotlin data classes).
|
|
44
|
+
|
|
45
|
+
## Set up the project
|
|
46
|
+
|
|
47
|
+
```kotlin
|
|
48
|
+
// settings.gradle.kts
|
|
49
|
+
pluginManagement {
|
|
50
|
+
repositories {
|
|
51
|
+
mavenCentral()
|
|
52
|
+
gradlePluginPortal()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```kotlin
|
|
58
|
+
// build.gradle.kts
|
|
59
|
+
plugins {
|
|
60
|
+
id("io.cratis.arc") version "<version>"
|
|
61
|
+
kotlin("plugin.spring") version "2.4.20"
|
|
62
|
+
id("org.springframework.boot") version "4.1.1"
|
|
63
|
+
id("io.spring.dependency-management") version "1.1.7"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
repositories {
|
|
67
|
+
mavenCentral()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
cratisArc {
|
|
71
|
+
moduleName.set("<ModuleName>")
|
|
72
|
+
dependencyVersion.set("<version>")
|
|
73
|
+
endpoints {
|
|
74
|
+
segmentsToSkip.set(<packageSegmentsUnderModuleRoot>)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
dependencies {
|
|
79
|
+
implementation("io.cratis:arc-spring-boot-starter:<version>")
|
|
80
|
+
implementation("org.springframework.boot:spring-boot-starter-webmvc")
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The plugin applies Kotlin/JVM and KSP, targets JDK 17, and treats warnings as
|
|
85
|
+
errors. `moduleName` names the generated artifact module and manifest; every
|
|
86
|
+
module in an application needs a distinct one. `endpoints.segmentsToSkip` must
|
|
87
|
+
match `cratis.arc.endpoints.segments-to-skip-for-route` in
|
|
88
|
+
`application.properties` — the Spring Boot starter fails startup with a named
|
|
89
|
+
mismatch when proxies were generated and the two settings disagree.
|
|
90
|
+
|
|
91
|
+
## Declare the command
|
|
92
|
+
|
|
93
|
+
```kotlin
|
|
94
|
+
package example.tasks
|
|
95
|
+
|
|
96
|
+
import io.cratis.arc.artifacts.Command
|
|
97
|
+
import io.cratis.arc.artifacts.CommandKey
|
|
98
|
+
import io.cratis.chronicle.events.EventType
|
|
99
|
+
|
|
100
|
+
@EventType
|
|
101
|
+
data class TaskCreated(val title: String)
|
|
102
|
+
|
|
103
|
+
@Command
|
|
104
|
+
data class CreateTask(@CommandKey val id: String, val title: String) {
|
|
105
|
+
fun handle(): TaskCreated = TaskCreated(title)
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```java
|
|
110
|
+
package example.tasks;
|
|
111
|
+
|
|
112
|
+
import io.cratis.arc.artifacts.Command;
|
|
113
|
+
import java.util.concurrent.CompletableFuture;
|
|
114
|
+
import java.util.concurrent.CompletionStage;
|
|
115
|
+
|
|
116
|
+
@Command
|
|
117
|
+
public record CreateTask(String title) {
|
|
118
|
+
public CompletionStage<TaskCreated> handle(TaskRepository repository) {
|
|
119
|
+
var task = repository.create(title);
|
|
120
|
+
return CompletableFuture.completedFuture(new TaskCreated(task.id(), task.title()));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Rules the framework actually enforces:
|
|
126
|
+
|
|
127
|
+
- `@Command` (`io.cratis.arc.artifacts.Command`) targets a class — a Kotlin
|
|
128
|
+
class or data class, or a Java record. The type must also declare a public
|
|
129
|
+
instance `handle` method; KSP reports `ARCKSP0100` when a type looks
|
|
130
|
+
command-like but lacks `@Command`.
|
|
131
|
+
- Only top-level **public** classes qualify. Kotlin's implicit-public
|
|
132
|
+
visibility counts — spelling `public` is style, not a requirement — but
|
|
133
|
+
private, internal, and Java package-private classes fail closed
|
|
134
|
+
(`ARCKSP0101`/`ARCKSP0102`).
|
|
135
|
+
- `handle` resolves unannotated parameters from Spring by type, exactly like a
|
|
136
|
+
constructor-injected `@Service`. There is no separate DI container to wire.
|
|
137
|
+
- `handle` may return `Unit`/`void`, a plain response value, a suspend result
|
|
138
|
+
(Kotlin), or `CompletionStage<T>` (Java). The generated adapter runs inside
|
|
139
|
+
Arc's bounded application coroutine scope — never `GlobalScope`, never
|
|
140
|
+
thread-local request state.
|
|
141
|
+
- Name the command as the action — `CreateTask`, not `CreateTaskCommand`.
|
|
142
|
+
- KSP keeps constructor properties in declaration order, then appends public
|
|
143
|
+
declared body properties in name order. A body `@CommandKey`, `@Valid`, and
|
|
144
|
+
field/getter validation constraints are retained the same way constructor
|
|
145
|
+
properties are; a constructor key plus a body key fails with `ARCKSP0106`.
|
|
146
|
+
- Mark server-only state `@field:JsonIgnore` or `@get:JsonIgnore`, or keep it
|
|
147
|
+
nonpublic. A computed or read-only property reached through command input
|
|
148
|
+
fails with `ARCKSP0300` — use a backed property, ignore the member, or
|
|
149
|
+
return a separate output model instead.
|
|
150
|
+
|
|
151
|
+
## Fetch what the handler needs with `provide()`
|
|
152
|
+
|
|
153
|
+
`provide` is an optional public instance method that runs **after**
|
|
154
|
+
authorization and validation but **before** `handle`. `POST
|
|
155
|
+
<command-route>/validate` never invokes it. Its parameters resolve from Spring,
|
|
156
|
+
exactly like `handle`'s.
|
|
157
|
+
|
|
158
|
+
```kotlin
|
|
159
|
+
@Command
|
|
160
|
+
data class CompleteTask(val taskId: TaskId) {
|
|
161
|
+
suspend fun provide(tasks: Tasks): Task = tasks.get(taskId)
|
|
162
|
+
|
|
163
|
+
fun handle(task: Task, audit: AuditLog): TaskCompleted {
|
|
164
|
+
audit.record(task.id)
|
|
165
|
+
return TaskCompleted(task.id)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- Return one value, a `Pair`, a `Triple`, `CommandProvidedValues`, or an
|
|
171
|
+
`ArcOneOf` alternative. Provided values are matched to `handle`'s parameters
|
|
172
|
+
in declaration order; each match is consumed once, and an unmatched
|
|
173
|
+
parameter falls back to Spring.
|
|
174
|
+
- A returned `CommandResult`, `ValidationResult`, a non-empty validation-result
|
|
175
|
+
collection, or `AuthorizationResult` is a control signal — it short-circuits
|
|
176
|
+
`handle` instead of feeding it. This is how a `provide` rejects a request the
|
|
177
|
+
user can act on; do not throw for that.
|
|
178
|
+
- Java retains `CommandProvidedValues.of(...)` / `CommandResponseValues.of(...)`
|
|
179
|
+
factories; Kotlin has `commandProvidedValuesOf(...)` /
|
|
180
|
+
`commandResponseValuesOf(...)` helpers for explicit ordered aggregates.
|
|
181
|
+
|
|
182
|
+
## Return a Chronicle event
|
|
183
|
+
|
|
184
|
+
Requires the optional `io.cratis:arc-chronicle-spring-boot-starter`. A plain
|
|
185
|
+
event response needs a stable command key backed by `String`, `UUID`, a
|
|
186
|
+
number, or a concept wrapping one of those — mark it `@CommandKey`.
|
|
187
|
+
|
|
188
|
+
```kotlin
|
|
189
|
+
@Command
|
|
190
|
+
data class CreateTask(@CommandKey val id: String, val title: String) {
|
|
191
|
+
fun handle(): TaskCreated = TaskCreated(title)
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Without a usable `@CommandKey`, Arc returns an error validation result
|
|
196
|
+
(`reason: "rule"`, `reasonDetail: "commandKey"`) rather than guessing an
|
|
197
|
+
event-source identifier.
|
|
198
|
+
|
|
199
|
+
| Return | What Arc does |
|
|
200
|
+
| --- | --- |
|
|
201
|
+
| A single `@EventType` value | Appended to the command key |
|
|
202
|
+
| A non-empty collection/array of `@EventType` values | All appended to the command key, atomically |
|
|
203
|
+
| `EventForEventSourceId(id, event)` | Appended to `id` instead — the cross-stream write |
|
|
204
|
+
| A collection mixing plain events and `EventForEventSourceId` | Plain events use the command key, wrapped events keep their own id; appended atomically through the cross-stream path |
|
|
205
|
+
| `EventsWithConcurrencyScopes` | Routed events only, with explicit per-source-id concurrency scopes |
|
|
206
|
+
| Anything else, or a value that mixes an event with a non-event | Becomes (or corrupts) the response — a mixed response containing a non-`@EventType` value fails before anything is appended |
|
|
207
|
+
|
|
208
|
+
```kotlin
|
|
209
|
+
import io.cratis.arc.chronicle.eventsWithConcurrencyScopes
|
|
210
|
+
|
|
211
|
+
fun handle(): EventsWithConcurrencyScopes = eventsWithConcurrencyScopes {
|
|
212
|
+
event("account-42", FundsWithdrawn(100))
|
|
213
|
+
event("ledger-2025", LedgerEntryAdded("account-42", 100))
|
|
214
|
+
concurrencyScope("account-42") {
|
|
215
|
+
withEventSourceId()
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Java builds the same shape with `EventsWithConcurrencyScopes.builder()` and
|
|
221
|
+
fluent `event(...)`.
|
|
222
|
+
|
|
223
|
+
Declare stable Chronicle routing defaults on the command when its events share
|
|
224
|
+
them, rather than repeating metadata on every `EventForEventSourceId`:
|
|
225
|
+
|
|
226
|
+
```kotlin
|
|
227
|
+
@Command
|
|
228
|
+
@CommandEventSourceType("Task")
|
|
229
|
+
@CommandEventStreamType("Tasks")
|
|
230
|
+
@CommandEventStreamId("active")
|
|
231
|
+
@CommandEventSubject("task-owner")
|
|
232
|
+
data class CreateTask(@CommandKey val id: String, val title: String) {
|
|
233
|
+
fun handle(): TaskCreated = TaskCreated(title)
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Each annotation is optional and must be nonblank with no control characters
|
|
238
|
+
when present (`ARCKSP0110` otherwise). Omit one to keep Chronicle's own
|
|
239
|
+
fallback: source/stream type default to `Default`, stream id/subject default
|
|
240
|
+
to the event-source identifier. An explicit `EventForEventSourceId` value keeps
|
|
241
|
+
its own metadata; only its missing fields inherit the command's declaration.
|
|
242
|
+
Implement `CommandEventStreamIdProvider` or `CommandEventSubjectProvider`
|
|
243
|
+
instead when a value depends on the command instance — never combine a
|
|
244
|
+
provider with the matching static annotation (`ARCKSP0110`).
|
|
245
|
+
|
|
246
|
+
A returned event, a mixed collection, or `EventsWithConcurrencyScopes` commits
|
|
247
|
+
through one staged transaction per outermost command-execution root: any child
|
|
248
|
+
or root failure discards every staged event, and a successful root appends
|
|
249
|
+
once with `appendMany`. Constraint violations surface as a validation result
|
|
250
|
+
with `reason: "constraintViolation"`; concurrency failures surface as
|
|
251
|
+
`concurrencyViolation` with expected/actual sequence numbers.
|
|
252
|
+
|
|
253
|
+
## Inject a Chronicle read model into the handler
|
|
254
|
+
|
|
255
|
+
When `ChronicleOptions` lists a read-model artifact, `handle` (or `provide`)
|
|
256
|
+
can request the **current** instance for the command's own resolved key as an
|
|
257
|
+
unannotated parameter — resolution uses the generated command key and the
|
|
258
|
+
captured tenant store:
|
|
259
|
+
|
|
260
|
+
```kotlin
|
|
261
|
+
@Command
|
|
262
|
+
data class Withdraw(@CommandKey val accountId: String, val amount: Int) {
|
|
263
|
+
fun handle(balance: AccountBalance): FundsWithdrawn {
|
|
264
|
+
require(balance.available >= amount)
|
|
265
|
+
return FundsWithdrawn(amount)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
A value from `provide` still takes precedence over this injection. A missing
|
|
271
|
+
model leaves the dependency unresolved rather than silently falling through to
|
|
272
|
+
another store; an invalid key or unavailable store fails the command.
|
|
273
|
+
|
|
274
|
+
## Protect the command
|
|
275
|
+
|
|
276
|
+
```kotlin
|
|
277
|
+
@Command
|
|
278
|
+
@Authorize(roles = ["admin"])
|
|
279
|
+
data class DeactivateAccount(@CommandKey val accountId: String) {
|
|
280
|
+
fun handle(): AccountDeactivated = AccountDeactivated()
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Use `@AllowAnonymous`, `@Authorize(policy = "...")`, `@Authorize(roles = [...])`,
|
|
285
|
+
or repeatable `@Roles`. **The operation replaces the class wholesale, in
|
|
286
|
+
either direction — this matches Arc .NET's `AuthorizationEvaluator.IsAuthorized(MethodInfo)`,
|
|
287
|
+
which resolves the method's own metadata first and falls back to the
|
|
288
|
+
declaring type only when the method declares none.** When `handle` declares
|
|
289
|
+
any authorization metadata of its own, only *its* policy/roles/schemes/anonymity
|
|
290
|
+
apply and the class declaration is discarded entirely — so an operation can
|
|
291
|
+
both narrow (a `@Roles("auditor")` operation on a class requiring `admin`
|
|
292
|
+
admits auditors and rejects admins) and *widen* access (an `@AllowAnonymous`
|
|
293
|
+
operation on an `@Authorize`-protected class is reachable without
|
|
294
|
+
authentication — the shape a login-screen query needs on an otherwise
|
|
295
|
+
protected read model):
|
|
296
|
+
|
|
297
|
+
```kotlin
|
|
298
|
+
@ReadModel
|
|
299
|
+
@Authorize
|
|
300
|
+
data class AuthenticationQueryItem(val message: String) {
|
|
301
|
+
companion object {
|
|
302
|
+
// Overrides the class: anyone may subscribe to this one.
|
|
303
|
+
@JvmStatic
|
|
304
|
+
@AllowAnonymous
|
|
305
|
+
fun anonymous(@FromServices source: AuthenticationQuerySource): Flow<AuthenticationQueryItem> =
|
|
306
|
+
source.observeAnonymous()
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Combining `@AllowAnonymous` with `@Authorize`/`@Roles` is only ever a
|
|
312
|
+
contradiction — and only ever fails with `ARCKSP0108` — **on the exact same
|
|
313
|
+
declaration** (both on the class, or both on the operation). Splitting them
|
|
314
|
+
across a class and its operation is not a conflict; it is the override
|
|
315
|
+
mechanism above.
|
|
316
|
+
|
|
317
|
+
## Generate the TypeScript proxy
|
|
318
|
+
|
|
319
|
+
```kotlin
|
|
320
|
+
cratisArc {
|
|
321
|
+
moduleName.set("<ModuleName>")
|
|
322
|
+
dependencyVersion.set("<version>")
|
|
323
|
+
proxies {
|
|
324
|
+
outputDirectory.set(layout.buildDirectory.dir("generated/arc-proxies"))
|
|
325
|
+
segmentsToSkip.set(<packageSegmentsUnderModuleRoot>)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
An unset `proxies.outputDirectory` skips generation entirely. `proxies.segmentsToSkip`
|
|
331
|
+
controls the generated **file path**; the separate `endpoints.segmentsToSkip`
|
|
332
|
+
(and its runtime twin `cratis.arc.endpoints.segments-to-skip-for-route`)
|
|
333
|
+
controls the **HTTP route**. Mixing the two up produces the wrong file layout
|
|
334
|
+
or the wrong route, not a startup error — they are easy to confuse because
|
|
335
|
+
they read almost identically. The generated proxy's runtime contract
|
|
336
|
+
(`route`, `execute()`, `validate()`, `CommandResult`) is the same
|
|
337
|
+
`@cratis/arc` client shape as Arc .NET; see the `cratis-arc-command` skill for
|
|
338
|
+
that contract.
|
|
339
|
+
|
|
340
|
+
## Route near misses
|
|
341
|
+
|
|
342
|
+
- Adding or changing a rejection rule on an existing command:
|
|
343
|
+
`cratis-arc-validation-kotlin`.
|
|
344
|
+
- Defining or changing a `@ReadModel` query: `cratis-arc-query-kotlin`.
|
|
345
|
+
- Talking to Chronicle outside a command (a plain worker, a reactor's own
|
|
346
|
+
event types, direct append): `cratis-chronicle-client-kotlin`.
|
|
347
|
+
- The C# Arc command shape and its causation-chain rules: `cratis-arc-command`.
|
|
348
|
+
|
|
349
|
+
## Verify
|
|
350
|
+
|
|
351
|
+
- The command is a public top-level class or Java record, carries `@Command`,
|
|
352
|
+
and declares a public instance `handle`.
|
|
353
|
+
- Every returned event type carries `@EventType`, and a plain event response
|
|
354
|
+
has a usable `@CommandKey`.
|
|
355
|
+
- Cross-stream events are wrapped in `EventForEventSourceId`; a mixed
|
|
356
|
+
collection contains only events and wrappers, nothing else.
|
|
357
|
+
- Rejections from `provide` or a validator are `ValidationResult`s, never
|
|
358
|
+
thrown exceptions the user is meant to act on.
|
|
359
|
+
- `@AllowAnonymous` is not combined with `@Authorize`/`@Roles` on the exact
|
|
360
|
+
same declaration (class+class or operation+operation) — splitting them
|
|
361
|
+
across a class and its operation is the intended override, not an error.
|
|
362
|
+
- `endpoints.segmentsToSkip` (build) and `segments-to-skip-for-route` (runtime
|
|
363
|
+
properties) agree; the application starts without the mismatch error.
|
|
364
|
+
- `./gradlew build` is clean with zero warnings and zero errors (Kotlin
|
|
365
|
+
`allWarningsAsErrors`, Java `-Xlint:all -Werror`).
|
|
366
|
+
- The generated proxy exists at the configured output directory when one was
|
|
367
|
+
configured, and the frontend compiles against it.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cratis-arc-query-kotlin
|
|
3
|
+
description: Add a query to a Cratis Arc @ReadModel in Kotlin or Java — companion-object or static query methods, GET vs RFC QUERY, and observable queries returning Kotlin Flow, JDK Flow.Publisher, or RxJava 3 Observable over SSE and WebSocket. Use when exposing read-model data from a Kotlin or Java Arc application. Do not use for the .NET Arc query shape, for command definition, or for how events populate the read model (Chronicle projections/reducers).
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Add a query to an Arc read model in Kotlin or Java
|
|
8
|
+
|
|
9
|
+
A `@ReadModel` is a plain Kotlin or Java type whose companion-object (Kotlin) or
|
|
10
|
+
static (Java) methods are queries. KSP generates a reflection-free
|
|
11
|
+
`QueryPerformer` per method; there is no controller to write.
|
|
12
|
+
|
|
13
|
+
## Verified product sources
|
|
14
|
+
|
|
15
|
+
Verified against `Arc.Kotlin` at tag **`v7.3.0`**, re-verified at commit
|
|
16
|
+
`d0aa6a4` (14 commits past the tag), documented consumer setup: JDK 17,
|
|
17
|
+
Kotlin 2.4.20, KSP 2.3.12, Spring Boot 4.1.x. `io.cratis:arc` supplies
|
|
18
|
+
`io.cratis.arc.artifacts.ReadModel`, `io.cratis.arc.queries.Path`,
|
|
19
|
+
`@QueryHttpMethod`, `@QueryTransport`, `ObservableState<T>`. The optional
|
|
20
|
+
`arc-rxjava3` artifact adds RxJava 3 `Observable` support at runtime. The
|
|
21
|
+
re-verification added `ObservableState<T>` (new since the tag) and the
|
|
22
|
+
`observable-queries.allowed-origins` WebSocket setting — both covered below.
|
|
23
|
+
|
|
24
|
+
## Declare a one-shot query
|
|
25
|
+
|
|
26
|
+
```kotlin
|
|
27
|
+
@ReadModel
|
|
28
|
+
@AllowAnonymous
|
|
29
|
+
data class TaskView(val id: String, val title: String) {
|
|
30
|
+
companion object {
|
|
31
|
+
@JvmStatic
|
|
32
|
+
@Path("/api/tasks/by-id")
|
|
33
|
+
suspend fun byId(id: String, @FromServices repository: TaskRepository): TaskView? =
|
|
34
|
+
repository.byId(id)
|
|
35
|
+
|
|
36
|
+
@JvmStatic
|
|
37
|
+
@Path("/api/tasks")
|
|
38
|
+
fun all(@FromServices repository: TaskRepository): List<TaskView> = repository.all()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```java
|
|
44
|
+
@ReadModel
|
|
45
|
+
@AllowAnonymous
|
|
46
|
+
public record TaskView(String id, String title) {
|
|
47
|
+
@Path("/api/tasks")
|
|
48
|
+
public static List<TaskView> all(@FromServices TaskRepository repository) {
|
|
49
|
+
return repository.all();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Rules the framework enforces:
|
|
55
|
+
|
|
56
|
+
- Query methods live on the read model's Kotlin **companion object** or as
|
|
57
|
+
Java **static** methods. A custom instance method on a `@ReadModel` fails
|
|
58
|
+
compilation.
|
|
59
|
+
- Mark dependency parameters `@FromServices`; caller-supplied arguments stay
|
|
60
|
+
unannotated. A query may also declare exact non-null `QueryRequest` and
|
|
61
|
+
`QueryContext` parameters in any position — KSP resolves service, request,
|
|
62
|
+
and context parameters from the execution context while preserving
|
|
63
|
+
declaration order.
|
|
64
|
+
- Java query methods return a value or `CompletionStage<T>`; generated
|
|
65
|
+
performers await it inside Arc's request coroutine context.
|
|
66
|
+
- Kotlin caller parameters may declare defaults. GET/QUERY/observable
|
|
67
|
+
subscriptions preserve *omission* (leaving the argument out of
|
|
68
|
+
`QueryRequest.arguments`) so the generated performer runs the Kotlin default
|
|
69
|
+
expression; an explicit JSON/subscription `null` is a *supplied* value, not
|
|
70
|
+
omission, and is accepted only by a nullable parameter. Java query
|
|
71
|
+
parameters have no default feature, and overloaded query methods are
|
|
72
|
+
unsupported in either language.
|
|
73
|
+
- Only public, non-generic, non-overloaded methods whose return type contains
|
|
74
|
+
the enclosing read-model shape are treated as queries; adding `@Path`,
|
|
75
|
+
`@QueryHttpMethod`, or `@QueryTransport` explicitly marks a method as a
|
|
76
|
+
query, so an invalid annotated return still fails compilation.
|
|
77
|
+
|
|
78
|
+
## Choose a route and method
|
|
79
|
+
|
|
80
|
+
Without `@Path`, Arc derives a route from `cratis.arc.endpoints` settings
|
|
81
|
+
(prefix, skipped package segments, kebab case, artifact name). `@Path`
|
|
82
|
+
preserves what you write verbatim.
|
|
83
|
+
|
|
84
|
+
| Transport | When | Shape |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| `GET` | scalar arguments | `?id=123` |
|
|
87
|
+
| `QUERY` (RFC 9110) | structured arguments, paging, sorting; enabled by `enable-query-http-method` | JSON body `{arguments, paging, sorting}` |
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
curl -sS 'http://localhost:8080/api/tasks/by-id?id=123'
|
|
91
|
+
|
|
92
|
+
curl -sS -X QUERY http://localhost:8080/api/tasks \
|
|
93
|
+
-H 'Content-Type: application/json' \
|
|
94
|
+
-d '{"arguments":{},"paging":{"page":0,"pageSize":25},"sorting":{"field":"title","direction":"ascending"}}'
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
GET reserves `page`, `pageSize`, `sortBy`, `sortDirection`. Client argument
|
|
98
|
+
names match case-insensitively for GET and QUERY. QUERY responses always carry
|
|
99
|
+
`Cache-Control: no-store`. Prefer GET for a query with `DateOnly`/`TimeOnly`
|
|
100
|
+
parameters — the pinned `@cratis/arc` client's QUERY body serializes those
|
|
101
|
+
component objects with native `JSON.stringify` rather than their typed
|
|
102
|
+
serializer, which GET does not go through.
|
|
103
|
+
|
|
104
|
+
`@QueryHttpMethod(GET|QUERY|AUTO)` sets the generated proxy's preference. Put
|
|
105
|
+
it on the `@ReadModel` class for a default, and override per method.
|
|
106
|
+
|
|
107
|
+
## Declare an observable query
|
|
108
|
+
|
|
109
|
+
Return one of three streaming families from a companion/static method; KSP
|
|
110
|
+
generates an `OBSERVABLE` performer automatically — no `@QueryTransport`
|
|
111
|
+
needed for these:
|
|
112
|
+
|
|
113
|
+
- Kotlin `kotlinx.coroutines.flow.Flow<T>` / `Flow<List<T>>`
|
|
114
|
+
- JDK `java.util.concurrent.Flow.Publisher<T>` / `Publisher<List<T>>`
|
|
115
|
+
- RxJava 3 `Observable<T>` / `ObservableSource<T>` / `Subject<T>` — requires
|
|
116
|
+
the optional `arc-rxjava3` artifact at runtime
|
|
117
|
+
|
|
118
|
+
A one-shot return (`T`, `List<T>`, `Page<T>`) stays request-response.
|
|
119
|
+
|
|
120
|
+
```kotlin
|
|
121
|
+
@ReadModel
|
|
122
|
+
@AllowAnonymous
|
|
123
|
+
data class TaskView(val id: String, val title: String) {
|
|
124
|
+
companion object {
|
|
125
|
+
@JvmStatic
|
|
126
|
+
@Path("/api/tasks/watch")
|
|
127
|
+
fun watch(@FromServices repository: TaskRepository): Flow<List<TaskView>> =
|
|
128
|
+
repository.watchAll()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Give a Java query a current value to answer GET with
|
|
134
|
+
|
|
135
|
+
A plain `SubmissionPublisher` emits but holds nothing — nobody subscribing
|
|
136
|
+
learns the current value until the next change, and a snapshot `GET` against
|
|
137
|
+
it comes back `202 Not Ready` forever if nothing has happened yet. Kotlin has
|
|
138
|
+
`MutableStateFlow` for this; the JDK has no equivalent, so Arc supplies
|
|
139
|
+
`ObservableState<T>`:
|
|
140
|
+
|
|
141
|
+
```java
|
|
142
|
+
@Component
|
|
143
|
+
public final class TaskSource {
|
|
144
|
+
private final ObservableState<List<TaskView>> tasks = new ObservableState<>(List.of());
|
|
145
|
+
|
|
146
|
+
public Flow.Publisher<List<TaskView>> observe() {
|
|
147
|
+
return tasks;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public void publish(List<TaskView> updated) {
|
|
151
|
+
tasks.set(updated);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`ObservableState<T>` is a `Flow.Publisher<T>`, so a query method returns it
|
|
157
|
+
directly. Every subscriber sees the current value first and then each change;
|
|
158
|
+
a subscriber that falls behind sees only the newest value, not a backlog —
|
|
159
|
+
the right behavior for state rather than a stream of discrete events. Reach
|
|
160
|
+
for it whenever a **Java** observable query should also answer a plain `GET`.
|
|
161
|
+
Kotlin code has no reason to — return `MutableStateFlow` as a `Flow` instead.
|
|
162
|
+
|
|
163
|
+
### Consuming it
|
|
164
|
+
|
|
165
|
+
The same generated route serves three direct transports:
|
|
166
|
+
|
|
167
|
+
| Transport | Selected by | Behavior |
|
|
168
|
+
| --- | --- | --- |
|
|
169
|
+
| HTTP GET/QUERY | ordinary request | `200` with the current value when the source is a `StateFlow` (already holds one); a cold `Flow`/`Publisher` returns `202` (not ready) unless `waitForFirstResult=true`, optionally bounded by `waitForFirstResultTimeout=<seconds>` |
|
|
170
|
+
| SSE | `Accept: text/event-stream` | each result as `data: {QueryResult}\n\n` |
|
|
171
|
+
| WebSocket | upgrading the query route | `{"type":"Data","data":{QueryResult}}` frames, plus `Ping`/`Pong` |
|
|
172
|
+
|
|
173
|
+
Multiple subscriptions can multiplex over one physical connection through
|
|
174
|
+
`/.cratis/queries/ws`, or `/.cratis/queries/sse` with POST
|
|
175
|
+
`/.cratis/queries/sse/subscribe` / `/unsubscribe`. Every subscription still
|
|
176
|
+
passes through its own query's authorization — a protected query terminates
|
|
177
|
+
`Unauthorized` on that subscription without disturbing others sharing the
|
|
178
|
+
connection.
|
|
179
|
+
|
|
180
|
+
Set `transferMode=delta` to get the full first snapshot followed by change
|
|
181
|
+
sets only; `transferMode=full` sends every result as a full snapshot with no
|
|
182
|
+
change set. Omitting it keeps the legacy behavior of always sending both. With
|
|
183
|
+
`delta`, Arc prefers a stable item identity; without one it falls back to
|
|
184
|
+
serialized-JSON identity and reports only additions/removals (a changed field
|
|
185
|
+
is a removal plus an addition, never a replacement) — change sets never encode
|
|
186
|
+
reordering.
|
|
187
|
+
|
|
188
|
+
## Configure the WebSocket origin for a separate dev server
|
|
189
|
+
|
|
190
|
+
Spring answers a cross-origin WebSocket handshake with `403`, and a browser
|
|
191
|
+
reports that as a socket that silently never opens rather than as a visible
|
|
192
|
+
error. Serving the frontend from a dev server on another port (Vite, webpack
|
|
193
|
+
dev server) makes every observable-query WebSocket handshake cross-origin.
|
|
194
|
+
Set the allowed origin explicitly:
|
|
195
|
+
|
|
196
|
+
```properties
|
|
197
|
+
cratis.arc.observable-queries.allowed-origins=http://localhost:5173
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Leave it empty in a real deployment — the WebSocket handshake is not subject
|
|
201
|
+
to the same-origin policy `fetch` obeys, so this setting is what stops another
|
|
202
|
+
site from opening a socket carrying your visitor's cookies.
|
|
203
|
+
|
|
204
|
+
## Route near misses
|
|
205
|
+
|
|
206
|
+
- Defining or changing a `@Command`: `cratis-arc-command-kotlin`.
|
|
207
|
+
- Adding a query-argument rejection rule: `cratis-arc-validation-kotlin`.
|
|
208
|
+
- How events populate the read model in the first place (Chronicle projections
|
|
209
|
+
and reducers): `cratis-chronicle-client-kotlin`, `cratis-chronicle-projection`,
|
|
210
|
+
`cratis-chronicle-reducer`.
|
|
211
|
+
- Server-side paging semantics shared with Arc .NET: `cratis-arc-query-paging`.
|
|
212
|
+
- The C# Arc query/read-model shape: the vertical-slices and Arc React skills.
|
|
213
|
+
|
|
214
|
+
## Verify
|
|
215
|
+
|
|
216
|
+
- Query methods live on the companion object (Kotlin) or as static methods
|
|
217
|
+
(Java), are public, non-generic, and not overloaded.
|
|
218
|
+
- Every dependency parameter is `@FromServices`; caller arguments are not.
|
|
219
|
+
- `@Path`, if declared, matches the route the frontend expects; otherwise the
|
|
220
|
+
conventional route (prefix, skipped segments, kebab case) is understood.
|
|
221
|
+
- An observable query's return type is one of the three recognized streaming
|
|
222
|
+
families, and the RxJava artifact is present when `Observable` is used.
|
|
223
|
+
- A `delta`-mode observable query has a stable item identity, not incidental
|
|
224
|
+
reliance on serialized-JSON fallback identity.
|
|
225
|
+
- `./gradlew build` is clean with zero warnings and zero errors.
|