diff --git a/.agents/tasks/de-event-sourcing-plan.md b/.agents/tasks/de-event-sourcing-plan.md index 82b74fd10f1..cbafd906112 100644 --- a/.agents/tasks/de-event-sourcing-plan.md +++ b/.agents/tasks/de-event-sourcing-plan.md @@ -152,16 +152,16 @@ Get the ADR reviewed and approved by the product owner before Phase B. Open points with recommendations: -| # | Question | Recommendation | -|----|-----------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| A1 | Name and shape of the import receptor annotation | **Revised 2026-07-05 (ADR D1): event import is dropped entirely** — usage research found one dormant production usage in six years. Superseded original recommendation: an `@Import` receptor mirroring `@Assign` with routing via `setupImportRouting` | -| A2 | Fate of classes that still declare `@Apply` after cutover | Fail fast: `AggregateClass` (**and `AggregatePartClass`**) raises a `ModelError` at model-building time with a migration message. Silent non-invocation is unacceptable. (`@Apply` on a `ProcessManager` is invalid and unsupported — the one such downstream fixture is fixed in `model-tools`; see Phase E) | -| A3 | Version advancement | Aggregate version advances **+1 per command handler, not per event** (product decision) — the `ProcessManager` semantics. Reuse the PM path: one `CommandDispatchingPhase` + `VersionIncrement.sequentially`. Emitted events carry the resulting version; the per-event `VersionSequence` is removed. Confirm/adjust emitted-event version stamping and document the change from prior per-event versions. Own test | +| # | Question | Recommendation | +|----|-----------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| A1 | Name and shape of the import receptor annotation | **Revised 2026-07-05 (ADR D1): event import is dropped entirely** — usage research found one dormant production usage in six years. Superseded original recommendation: an `@Import` receptor mirroring `@Assign` with routing via `setupImportRouting` | +| A2 | Fate of classes that still declare `@Apply` after cutover | Fail fast: `AggregateClass` (**and `AggregatePartClass`**) raises a `ModelError` at model-building time with a migration message. Silent non-invocation is unacceptable. (`@Apply` on a `ProcessManager` is invalid and unsupported — the one such downstream fixture is fixed in `model-tools`; see Phase E) | +| A3 | Version advancement | Aggregate version advances **+1 per command handler, not per event** (product decision) — the `ProcessManager` semantics. Reuse the PM path: one `CommandDispatchingPhase` + `VersionIncrement.sequentially`. Emitted events carry the resulting version; the per-event `VersionSequence` is removed. Confirm/adjust emitted-event version stamping and document the change from prior per-event versions. Own test | | A4 | State mutation without emitted events | Command handlers and reactors work the same way (**may** update state via `builder()`, may call `setArchived()`/`setDeleted()` — state update never forced), differing only in emission: `@Assign` **must emit ≥1 event or reject**; `@React` **may emit zero events**. *(The `@Import` clause originally here is void — event import is dropped; ADR D1, revised 2026-07-05.)* No blanket "builder touched but no event → reject" and no new "must change state" guard. Lifecycle-flag flips that lived in appliers migrate into the handler body. See ADR D4 | -| A5 | Deduplication + recent-history window | Delivery layer owns dedup; the aggregate `IdempotencyGuard` is **opt-in per repository, off by default** (`useIdempotencyGuard()`), kept so it can be removed later. Recent history loaded **lazily on demand** from the journal tail, bounded by `historyDepth` (default 100, = old `DEFAULT_SNAPSHOT_TRIGGER`; per-repository = per-aggregate-type). Guard-off dispatch does only the state read; guard-on pays the bounded journal read. Delivery durable dedup needs a configured `deduplicationWindow` in production. See ADR D5 | -| A6 | Rejection/exception semantics | Transaction rollback discards builder mutations; nothing is stored or posted. Verify `Transaction` rollback covers this; add tests | -| A7 | Journal trimming without snapshots | Snapshot-index `truncateOlderThan` dies with snapshots. Phase D introduces count/date-based trimming; until then journal grows append-only | -| A8 | `state()` visibility inside an open transaction | Handlers read pre-transaction state, mutate via `builder()` (same shape as `ProcessManager`) | +| A5 | Deduplication + recent-history window | Delivery layer owns dedup; the aggregate `IdempotencyGuard` is **opt-in per repository, off by default** (`useIdempotencyGuard()`), kept so it can be removed later. Recent history loaded **lazily on demand** from the journal tail, bounded by `historyDepth` (default 100, = old `DEFAULT_SNAPSHOT_TRIGGER`; per-repository = per-aggregate-type). Guard-off dispatch does only the state read; guard-on pays the bounded journal read. Delivery durable dedup needs a configured `deduplicationWindow` in production. See ADR D5 | +| A6 | Rejection/exception semantics | Transaction rollback discards builder mutations; nothing is stored or posted. Verify `Transaction` rollback covers this; add tests | +| A7 | Journal trimming without snapshots | Snapshot-index `truncateOlderThan` dies with snapshots. Phase D introduces count/date-based trimming; until then journal grows append-only | +| A8 | `state()` visibility inside an open transaction | Handlers read pre-transaction state, mutate via `builder()` (same shape as `ProcessManager`) | ## Phase B — Core runtime cutover (`server` module) @@ -448,8 +448,8 @@ repos except the deprecated annotation type itself.** | **Aggregate version stops advancing** once replay is gone | A3: reuse the PM per-dispatch `VersionIncrement.sequentially` (+1 per command); dedicated test asserting version increments by exactly 1 per command, regardless of event count | | Dropping the eager aggregate dedup guard could let duplicates through after a JVM restart / cache eviction when no delivery `deduplicationWindow` is set | Guard kept as opt-in backstop (A5/D5); document that production sets a `deduplicationWindow`; when guard on, verify `clearEnrichments` keeps `pastMessage` and test journal-tail dedup with no snapshot | | Accidentally removing the shared `EventPlayer` type breaks `Projection` | Drop only `Aggregate`'s `implements`; keep the entity-layer type | -| A4: over-strict emission guard rejects a legal empty `@React` | Per-receptor rule (only `@Assign` must emit ≥1 event); tests for an empty reactor and a lifecycle-only handler | -| Removing event import breaks unknown external users of `ImportBus` / `BlackBox.importsEvent` | Org-wide research (2026-07-05, ADR D1 revision) found none outside the dormant `auth`; replacement idioms documented in the migration guide (PR-B3) | +| A4: over-strict emission guard rejects a legal empty `@React` | Per-receptor rule (only `@Assign` must emit ≥1 event); tests for an empty reactor and a lifecycle-only handler | +| Removing event import breaks unknown external users of `ImportBus` / `BlackBox.importsEvent` | Org-wide research (2026-07-05, ADR D1 revision) found none outside the dormant `auth`; replacement idioms documented in the migration guide (PR-B3) | | PR-B2 is large and cannot land green-per-commit | Accept it as one atomic commit; fixture edits are mechanical and reviewable in bulk; keep runtime diff Java-minimal | | Storage backends (incl. `delivery-server` redis/hazelcast) break on state-write semantics | Phase C smoke builds all vendors before the rollout wave | | `AggregatePart` silently breaks | PR-B2 step 11 covers `AggregatePart`/`AggregatePartClass`/`PartFactory` in the A2 fail-fast and the state-load path | diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt index 10f9f0dba19..e58734dab0f 100644 --- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt +++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt @@ -36,7 +36,7 @@ object Validation { /** * The version of the Validation library artifacts. */ - const val version = "2.0.0-SNAPSHOT.446" + const val version = "2.0.0-SNAPSHOT.447" const val group = Spine.toolsGroup private const val prefix = "validation" diff --git a/docs/adr/0001-aggregates-without-event-sourcing.md b/docs/adr/0001-aggregates-without-event-sourcing.md index fa52a6ffa72..d73cd7e8ed4 100644 --- a/docs/adr/0001-aggregates-without-event-sourcing.md +++ b/docs/adr/0001-aggregates-without-event-sourcing.md @@ -54,18 +54,18 @@ The runtime facts this ADR is grounded in (verified against the tree ## Decision summary -| # | Decision | -|---|----------| -| D1 | **Revised 2026-07-05: event import is dropped.** No `@Import` receptor; `ImportBus`, the import endpoint/routing, and `BlackBox.importsEvent` are removed in PR-B2; `InboxLabel.IMPORT_EVENT` and the `EventImported` system event are deprecated for wire compatibility. External facts enter via `(external) = true` reactions or context gateways. | -| D2 | `@Apply` on any aggregate (incl. `AggregatePart`) is a **`ModelError` at model-building time** after cutover — fail fast, never a silent no-op. | -| D3 | Aggregate version advances **+1 per command dispatch**, not per event — `ProcessManager` semantics via `CommandDispatchingPhase` + `VersionIncrement.sequentially`. | -| D4 | State update is never forced in any handler. Only `@Assign` must emit ≥1 event (or reject); `@React` emission is optional. Persistence must trigger on a business-state change, not only on events/lifecycle. | -| D5 | Dedup is the delivery layer's job; the aggregate `IdempotencyGuard` is **opt-in, off by default**. Recent history is loaded **lazily** from the journal tail, bounded by `historyDepth` (default 100). | -| D6 | A handler mutates the open transaction's `builder()`; validation happens **once, at commit**; any failure rolls the whole transaction back. This structurally eliminates partial validity (brief problem #2). | -| D7 | Snapshot-index journal trimming is dropped; count/date-based trimming is deferred to Phase D. The journal is append-only until then. | -| D8 | Handlers read the **pre-transaction** `state()` and mutate via `builder()`; the applier-only access guard is relaxed to allow `@Assign`/`@React` to touch the builder. | -| D9 | *(added 2026-07-04, from PR review)* Preventive validation: `tryAlter {}` runs a mutation on a scratch builder, validates it via the commit path, and merges only when clean — otherwise it returns the violations and leaves live state untouched. `@Assign` rejects; `@React` skips the update (returning `NoReaction` speaks only about emission — D4). The D6 commit-time safety net is unchanged. Companion (2026-07-05): `ValidatingBuilder.validate()` probes any builder in place. | -| D10 | *(added 2026-07-04, from PR review)* Business history access states its window explicitly: `historyBackward(depth)` / `historyContains(depth, predicate)` read up to `depth` latest journal events. The parameterless forms are deprecated, delegating with `depth = historyDepth`; `historyDepth` is demoted to the guard's window plus that default. | +| # | Decision | +|-----|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| D1 | **Revised 2026-07-05: event import is dropped.** No `@Import` receptor; `ImportBus`, the import endpoint/routing, and `BlackBox.importsEvent` are removed in PR-B2; `InboxLabel.IMPORT_EVENT` and the `EventImported` system event are deprecated for wire compatibility. External facts enter via `(external) = true` reactions or context gateways. | +| D2 | `@Apply` on any aggregate (incl. `AggregatePart`) is a **`ModelError` at model-building time** after cutover — fail fast, never a silent no-op. | +| D3 | Aggregate version advances **+1 per command dispatch**, not per event — `ProcessManager` semantics via `CommandDispatchingPhase` + `VersionIncrement.sequentially`. | +| D4 | State update is never forced in any handler. Only `@Assign` must emit ≥1 event (or reject); `@React` emission is optional. Persistence must trigger on a business-state change, not only on events/lifecycle. | +| D5 | Dedup is the delivery layer's job; the aggregate `IdempotencyGuard` is **opt-in, off by default**. Recent history is loaded **lazily** from the journal tail, bounded by `historyDepth` (default 100). | +| D6 | A handler mutates the open transaction's `builder()`; validation happens **once, at commit**; any failure rolls the whole transaction back. This structurally eliminates partial validity (brief problem #2). | +| D7 | Snapshot-index journal trimming is dropped; count/date-based trimming is deferred to Phase D. The journal is append-only until then. | +| D8 | Handlers read the **pre-transaction** `state()` and mutate via `builder()`; the applier-only access guard is relaxed to allow `@Assign`/`@React` to touch the builder. | +| D9 | *(added 2026-07-04, from PR review)* Preventive validation: `tryAlter {}` runs a mutation on a scratch builder, validates it via the commit path, and merges only when clean — otherwise it returns the violations and leaves live state untouched. `@Assign` rejects; `@React` skips the update (returning `NoReaction` speaks only about emission — D4). The D6 commit-time safety net is unchanged. Companion (2026-07-05): `ValidatingBuilder.validate()` probes any builder in place. | +| D10 | *(added 2026-07-04, from PR review)* Business history access states its window explicitly: `historyBackward(depth)` / `historyContains(depth, predicate)` read up to `depth` latest journal events. The parameterless forms are deprecated, delegating with `depth = historyDepth`; `historyDepth` is demoted to the guard's window plus that default. | --- @@ -259,9 +259,9 @@ differ only in whether an emitted event is required. **Updating state is never forced**: a handler may emit events without touching state (e.g. a command that only records a request), so we do not constrain the author's business logic: -| Receptor | Contract | -|----------|----------| -| `@Assign` (command) | **May** update state via `builder()` and/or set lifecycle flags via `setArchived()` / `setDeleted()` — state update is optional. **Must emit ≥1 event or reject** — empty success stays illegal. | +| Receptor | Contract | +|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `@Assign` (command) | **May** update state via `builder()` and/or set lifecycle flags via `setArchived()` / `setDeleted()` — state update is optional. **Must emit ≥1 event or reject** — empty success stays illegal. | | `@React` (reaction) | Same as `@Assign`, **except emitting an event is optional** — a reactor may emit zero events. May update state and set lifecycle flags, or neither. `AggregateEventReactionEndpoint.onEmptyResult` stays a no-op (`AggregateEventReactionEndpoint.java:60-67`). | `setArchived()` / `setDeleted()` are explicitly allowed in `@Assign` and `@React` @@ -523,10 +523,15 @@ immediately, having had exactly this gap all along): (`AbstractEntity.java:328`). - Merge mechanics — `clear()` + `mergeFrom(candidate)` on the live builder, or swapping the transaction's builder as `incrementStateAndVersion` already - does (`Transaction.java:343`) — fixed in PR-B1. + does (`Transaction.java:343`) — fixed in PR-B1. *Fixed: `clear()` + + `mergeFrom` — the `Transaction.initAll` pattern; the transaction keeps its + builder reference, and no new `Transaction` mutator is needed.* - Java callers: via the existing `@file:JvmName("TransactionalEntityExtensions")` facade or a thin protected - method — decided in PR-B1. + method — decided in PR-B1. *Decided: the facade. A same-name `protected` + member taking a `Consumer` would shadow the extension in Kotlin + subclasses (a member beats an extension in overload resolution), breaking + the receiver-lambda idiom `tryAlter { … }` at every Kotlin call site.* - KDoc must warn that `block` operates on its receiver (the scratch) only; nesting `alter {}` / `update {}` inside `block` would bypass the scratch and dirty the live builder. diff --git a/docs/dependencies/dependencies.md b/docs/dependencies/dependencies.md index a3ae95a0ea4..9602db2b252 100644 --- a/docs/dependencies/dependencies.md +++ b/docs/dependencies/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine:spine-client:2.0.0-SNAPSHOT.400` +# Dependencies of `io.spine:spine-client:2.0.0-SNAPSHOT.401` ## Runtime 1. **Group** : com.google.android. **Name** : annotations. **Version** : 4.1.1.4. @@ -1085,14 +1085,14 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using +This report was generated on **Mon Jul 06 17:47:41 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:client-testlib:2.0.0-SNAPSHOT.400` +# Dependencies of `io.spine.tools:client-testlib:2.0.0-SNAPSHOT.401` ## Runtime 1. **Group** : com.google.android. **Name** : annotations. **Version** : 4.1.1.4. @@ -2273,14 +2273,14 @@ This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using +This report was generated on **Mon Jul 06 17:47:41 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine:spine-core:2.0.0-SNAPSHOT.400` +# Dependencies of `io.spine:spine-core:2.0.0-SNAPSHOT.401` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -3301,14 +3301,14 @@ This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using +This report was generated on **Mon Jul 06 17:47:41 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:core-testlib:2.0.0-SNAPSHOT.400` +# Dependencies of `io.spine.tools:core-testlib:2.0.0-SNAPSHOT.401` ## Runtime 1. **Group** : com.google.android. **Name** : annotations. **Version** : 4.1.1.4. @@ -4363,14 +4363,14 @@ This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using +This report was generated on **Mon Jul 06 17:47:41 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine:spine-server:2.0.0-SNAPSHOT.400` +# Dependencies of `io.spine:spine-server:2.0.0-SNAPSHOT.401` ## Runtime 1. **Group** : com.google.android. **Name** : annotations. **Version** : 4.1.1.4. @@ -5467,14 +5467,14 @@ This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using +This report was generated on **Mon Jul 06 17:47:41 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine:spine-server-otel:2.0.0-SNAPSHOT.400` +# Dependencies of `io.spine:spine-server-otel:2.0.0-SNAPSHOT.401` ## Runtime 1. **Group** : com.google.android. **Name** : annotations. **Version** : 4.1.1.4. @@ -6647,14 +6647,14 @@ This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using +This report was generated on **Mon Jul 06 17:47:41 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.tools:server-testlib:2.0.0-SNAPSHOT.400` +# Dependencies of `io.spine.tools:server-testlib:2.0.0-SNAPSHOT.401` ## Runtime 1. **Group** : com.google.android. **Name** : annotations. **Version** : 4.1.1.4. @@ -7887,6 +7887,6 @@ This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Sat Jul 04 02:29:31 WEST 2026** using +This report was generated on **Mon Jul 06 17:47:41 WEST 2026** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). \ No newline at end of file diff --git a/docs/dependencies/pom.xml b/docs/dependencies/pom.xml index fd2ac52a41b..f8b90f39cab 100644 --- a/docs/dependencies/pom.xml +++ b/docs/dependencies/pom.xml @@ -10,7 +10,7 @@ all modules and does not describe the project structure per-subproject. --> io.spine core-jvm -2.0.0-SNAPSHOT.400 +2.0.0-SNAPSHOT.401 2015 @@ -122,7 +122,7 @@ all modules and does not describe the project structure per-subproject. io.spine spine-validation-jvm-runtime - 2.0.0-SNAPSHOT.446 + 2.0.0-SNAPSHOT.447 compile @@ -319,7 +319,7 @@ all modules and does not describe the project structure per-subproject. io.spine.tools validation-java-bundle - 2.0.0-SNAPSHOT.446 + 2.0.0-SNAPSHOT.447 net.sourceforge.pmd diff --git a/server/src/main/kotlin/io/spine/server/entity/TransactionalEntityExts.kt b/server/src/main/kotlin/io/spine/server/entity/TransactionalEntityExts.kt index b5c5a42a23a..f518698fecc 100644 --- a/server/src/main/kotlin/io/spine/server/entity/TransactionalEntityExts.kt +++ b/server/src/main/kotlin/io/spine/server/entity/TransactionalEntityExts.kt @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -30,7 +30,9 @@ package io.spine.server.entity import io.spine.base.EntityState import io.spine.core.Version +import io.spine.validation.ConstraintViolation import io.spine.validation.ValidatingBuilder +import io.spine.validation.ValidationException /** * Extends [TransactionalEntity] with the `update` block for accessing @@ -97,6 +99,93 @@ public fun , S : EntityState, B : V block(builder) } +/** + * Attempts to change the state of this entity, modifying the live state + * [builder][TransactionalEntity.builder] only if the resulting state is valid. + * + * The [block] runs on a scratch copy of the live builder, and the candidate state + * it produces is validated before being applied: + * + * 1. If the candidate state is valid, the changes are merged into the live builder, + * and an empty list is returned. + * 2. If the candidate state violates constraints declared in the entity state type, + * the live builder is left untouched, and the violations are returned. + * + * This makes `tryAlter` the validate-before-apply counterpart of [alter]: a receptor + * can reject a command or skip a reaction *before* an invalid change reaches the live + * builder, instead of failing the whole dispatch when the transaction is committed. + * + * For example, a reaction that reserves the ordered items only when the stock + * allows it: + * + * ```kotlin + * @React + * fun on(event: BulkOrderPlaced): EitherOf2 { + * val violations = tryAlter { + * inStock -= event.quantity + * } + * return if (violations.isEmpty()) { + * EitherOf2.withA(stockReserved { /* … */ }) + * } else { + * EitherOf2.withB(noReaction()) + * } + * } + * ``` + * + * A constraint enforced at setter time, such as `(set_once)`, makes the generated + * builder throw [ValidationException] from inside [block] — against the scratch copy, + * not the live builder. `tryAlter` catches this exception and returns its violations, + * so both setter-time and build-time constraints surface uniformly as the returned + * list. Any other exception thrown by [block] propagates unchanged, leaving the live + * builder untouched. + * + * Consecutive calls compose: each call validates the cumulative candidate state, + * including the changes applied by the preceding successful calls. + * + * To probe the current content of a builder in place — including content + * that is already invalid — without the protective scratch copy, + * use [ValidatingBuilder.validate]. + * + * **Warning:** [block] must mutate only its receiver — the scratch builder. + * Calling [alter] or [update] from inside [block] bypasses the scratch copy + * and modifies the live builder directly, defeating the purpose of this function. + * Also, avoid building other messages, such as events, inside [block]: any + * [ValidationException] raised in [block] is reported as a withheld state change, + * even if a foreign message failing its own validation caused it. + * + * **API Note:** This function is not `inline` because [TransactionalEntity.builder] is + * `protected` while inline functions can use only `public` API. + * + * @param I the type of the entity identifiers. + * @param E the type of the transactional entity. + * @param S the type of the entity state. + * @param B the type of the entity state builder. + * + * @param block the mutation to attempt, running on a scratch copy of the live builder. + * @return an empty list if the changes were applied to the live builder, + * or the constraint violations of the candidate state if the changes were withheld. + * @see alter for the version of this method that applies changes unconditionally. + */ +public fun , S : EntityState, B : ValidatingBuilder> + E.tryAlter(block: B.() -> Unit): List { + val live = builder() + @Suppress("UNCHECKED_CAST") // `clone()` of a builder returns the same builder type. + val scratch = live.clone() as B + try { + block(scratch) + } catch (e: ValidationException) { + return e.constraintViolations + } + val candidate = scratch.buildPartial() + val violations = checkEntityState(candidate) + if (violations.isEmpty()) { + // Merge in place: the open transaction keeps the reference to the live builder. + live.clear() + live.mergeFrom(candidate) + } + return violations +} + /** * Obtains the entity identifier. * diff --git a/server/src/test/java/io/spine/server/entity/TransactionalEntityExtensionsJavaSpec.java b/server/src/test/java/io/spine/server/entity/TransactionalEntityExtensionsJavaSpec.java new file mode 100644 index 00000000000..d15522d065c --- /dev/null +++ b/server/src/test/java/io/spine/server/entity/TransactionalEntityExtensionsJavaSpec.java @@ -0,0 +1,99 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.server.entity; + +import io.spine.server.entity.given.Given; +import io.spine.server.entity.given.tryalter.StockKeeper; +import io.spine.test.tryalter.Stock; +import io.spine.validation.NonValidated; +import kotlin.Unit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.google.common.truth.Truth.assertThat; +import static java.util.Objects.requireNonNull; + +/** + * Verifies that the extensions declared in {@code TransactionalEntityExts.kt} + * are usable from Java via the {@link TransactionalEntityExtensions} facade. + */ +@DisplayName("`TransactionalEntityExtensions` should") +class TransactionalEntityExtensionsJavaSpec { + + private static final String ID = "stock-from-java"; + + @Test + @DisplayName("apply a valid change via `tryAlter()`") + void applyValidChange() { + var pm = stockKeeper(10); + + var violations = TransactionalEntityExtensions.tryAlter(pm, builder -> { + builder.setInStock(20); + return Unit.INSTANCE; + }); + + assertThat(violations).isEmpty(); + assertThat(liveState(pm).getInStock()).isEqualTo(20); + } + + @Test + @DisplayName("return violations from `tryAlter()`, leaving the live builder untouched") + void returnViolations() { + var pm = stockKeeper(10); + + var violations = TransactionalEntityExtensions.tryAlter(pm, builder -> { + builder.setInStock(-1); + return Unit.INSTANCE; + }); + + assertThat(violations).hasSize(1); + assertThat(liveState(pm).getInStock()).isEqualTo(10); + } + + private static StockKeeper stockKeeper(int amount) { + var state = Stock.newBuilder() + .setId(ID) + .setInStock(amount) + .build(); + var pm = Given.processManagerOfClass(StockKeeper.class) + .withId(ID) + .withState(state) + .build(); + // The upcast is required: the package-private members of `TransactionalEntity` + // are not inherited by `StockKeeper`, which resides in another package. + TransactionalEntity entity = pm; + var tx = new StubTransaction<>(entity, /* active = */ true, /* stateChanged = */ false); + entity.injectTransaction(tx); + return pm; + } + + private static @NonValidated Stock liveState(StockKeeper pm) { + TransactionalEntity entity = pm; + var tx = requireNonNull(entity.transaction()); + return tx.builder().buildPartial(); + } +} diff --git a/server/src/test/kotlin/io/spine/server/entity/TransactionalEntityExtensionsSpec.kt b/server/src/test/kotlin/io/spine/server/entity/TransactionalEntityExtensionsSpec.kt index 9bc8439fc0b..89589643972 100644 --- a/server/src/test/kotlin/io/spine/server/entity/TransactionalEntityExtensionsSpec.kt +++ b/server/src/test/kotlin/io/spine/server/entity/TransactionalEntityExtensionsSpec.kt @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -23,12 +23,35 @@ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ + package io.spine.server.entity +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe +import io.spine.server.BoundedContext +import io.spine.server.BoundedContextBuilder +import io.spine.server.entity.given.Given +import io.spine.server.entity.given.tryalter.StockKeeper +import io.spine.server.entity.given.tryalter.StockKeeperRepo import io.spine.server.test.shared.StringEntity +import io.spine.server.type.CommandEnvelope +import io.spine.server.type.EventEnvelope +import io.spine.test.tryalter.Stock +import io.spine.test.tryalter.command.replenishStock +import io.spine.test.tryalter.event.bulkOrderPlaced +import io.spine.test.tryalter.stock import io.spine.testing.TestValues.randomString +import io.spine.testing.client.TestActorRequestFactory +import io.spine.testing.server.TestEventFactory +import io.spine.validation.NonValidated +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @DisplayName("Kotlin extensions of `TransactionalEntity` should") @@ -61,8 +84,154 @@ internal class TransactionalEntityExtensionsSpec { entity.version.isIncrement(prevVersion) shouldBe true entity.id shouldBe prevId } + + @Nested inner class + `add 'tryAlter' block that should` { + + @Test + fun `apply a valid change to the live builder`() { + val pm = stockKeeper(amount = 10) + + val violations = pm.tryAlter { inStock = 15 } + + violations.shouldBeEmpty() + pm.liveState().inStock shouldBe 15 + } + + @Test + fun `return violations, leaving the live builder untouched`() { + val pm = stockKeeper(amount = 10) + + val violations = pm.tryAlter { inStock = -1 } + + violations shouldHaveSize 1 + violations[0].fieldPath.getFieldName(0) shouldBe "in_stock" + pm.liveState().inStock shouldBe 10 + } + + @Test + fun `fold setter-thrown violations into the returned list`() { + val initialVendor = "Acme Corp." + val pm = stockKeeper(vendorName = initialVendor) + + // The `(set_once)` option makes the generated setter throw + // a `ValidationException` when the field is assigned again. + val violations = pm.tryAlter { vendor = "Widgets Inc." } + + violations shouldHaveSize 1 + violations[0].fieldPath.getFieldName(0) shouldBe "vendor" + pm.liveState().vendor shouldBe initialVendor + } + + @Test + fun `compose consecutive calls, validating the cumulative state`() { + val pm = stockKeeper(amount = 0) + + pm.tryAlter { inStock = 5 }.shouldBeEmpty() + pm.tryAlter { inStock -= 10 }.shouldNotBeEmpty() + pm.tryAlter { inStock -= 3 }.shouldBeEmpty() + + pm.liveState().inStock shouldBe 2 + } + + @Test + fun `propagate exceptions other than validation ones`() { + val pm = stockKeeper(amount = 10) + + shouldThrow { + pm.tryAlter { + inStock = 3 + error("Simulated failure.") + } + } + + pm.liveState().inStock shouldBe 10 + } + + @Test + fun `leave no trace of a failed attempt after the transaction commits`() { + val pm = stockKeeper(amount = 10) + val initialState = pm.state() + val initialVersion = pm.version() + + pm.tryAlter { inStock = -100 }.shouldNotBeEmpty() + + val tx = pm.transaction() + tx.shouldNotBeNull() + tx.commit() + + // `changed()` is the condition on which the framework stores an entity. + pm.changed() shouldBe false + pm.state() shouldBe initialState + pm.version() shouldBe initialVersion + } + } + + @Nested inner class + `when used in a receptor of a process manager` { + + private val requestFactory = TestActorRequestFactory(javaClass) + private val eventFactory = TestEventFactory.newInstance(javaClass) + private lateinit var context: BoundedContext + private lateinit var repo: StockKeeperRepo + + @BeforeEach + fun registerRepository() { + repo = StockKeeperRepo() + context = BoundedContextBuilder.assumingTests().build() + context.internalAccess().register(repo) + } + + @AfterEach + fun closeContext() { + context.close() + } + + @Test + fun `store the state changed by a clean call`() { + replenish(10) + placeBulkOrder(3) + + val pm = repo.find(STOCK_ID).orElseThrow() + pm.state().inStock shouldBe 7 + } + + @Test + fun `not store a process manager when the only change is withheld`() { + replenish(10) + val before = repo.find(STOCK_ID).orElseThrow() + val stateBefore = before.state() + val versionBefore = before.version() + + // The order exceeds the stock, so the reactor withholds the update + // and emits nothing. + placeBulkOrder(25) + + val after = repo.find(STOCK_ID).orElseThrow() + after.state() shouldBe stateBefore + after.version() shouldBe versionBefore + } + + private fun replenish(quantity: Int) { + val command = replenishStock { + stock = STOCK_ID + this.quantity = quantity + } + repo.dispatchCommand(CommandEnvelope.of(requestFactory.command().create(command))) + } + + private fun placeBulkOrder(quantity: Int) { + val order = bulkOrderPlaced { + stock = STOCK_ID + this.quantity = quantity + } + repo.dispatch(EventEnvelope.of(eventFactory.createEvent(order))) + } + } } +private const val STOCK_ID = "stock-under-test" + /** * Creates the test fixture entity and injects a stub transaction so * that [TransactionalEntity.builder] is available. @@ -75,6 +244,37 @@ private fun createEntity() : Fixture { return entity } +/** + * Creates a [StockKeeper] with the given state and injects a stub transaction + * so that its live builder is available to [tryAlter]. + */ +private fun stockKeeper(amount: Int = 0, vendorName: String = ""): StockKeeper { + val pm = Given.processManagerOfClass(StockKeeper::class.java) + .withId(STOCK_ID) + .withState( + stock { + id = STOCK_ID + inStock = amount + vendor = vendorName + } + ) + .build() + val tx: Transaction = + StubTransaction(pm, /* active = */ true, /* stateChanged = */ false) + pm.injectTransaction(tx) + return pm +} + +/** + * Obtains the current content of the live builder of this process manager. + */ +private fun StockKeeper.liveState(): @NonValidated Stock { + val tx = requireNotNull(transaction()) { + "The process manager under test must have a transaction injected." + } + return tx.builder().buildPartial() +} + /** * An entity that uses the [TransactionalEntity] extension functions in its [doUpdate] * and [doAlter] methods. diff --git a/server/src/testFixtures/kotlin/io/spine/server/entity/given/tryalter/StockContext.kt b/server/src/testFixtures/kotlin/io/spine/server/entity/given/tryalter/StockContext.kt new file mode 100644 index 00000000000..02fc24df1d0 --- /dev/null +++ b/server/src/testFixtures/kotlin/io/spine/server/entity/given/tryalter/StockContext.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.server.entity.given.tryalter + +import io.spine.server.command.Assign +import io.spine.server.entity.alter +import io.spine.server.entity.tryAlter +import io.spine.server.event.NoReaction +import io.spine.server.event.React +import io.spine.server.procman.ProcessManager +import io.spine.server.procman.ProcessManagerRepository +import io.spine.server.tuple.EitherOf2 +import io.spine.test.tryalter.Stock +import io.spine.test.tryalter.command.ReplenishStock +import io.spine.test.tryalter.event.BulkOrderPlaced +import io.spine.test.tryalter.event.StockReplenished +import io.spine.test.tryalter.event.StockReserved +import io.spine.test.tryalter.event.stockReplenished +import io.spine.test.tryalter.event.stockReserved + +/** + * A process manager maintaining a [Stock] — a state with declared constraints: + * the number of items cannot go negative, and the vendor name is assigned only once. + * + * The reaction to [BulkOrderPlaced] guards the constraints preventively with + * [tryAlter]: an order exceeding the stock is not applied, and no event is emitted. + */ +class StockKeeper(id: String) : ProcessManager(id) { + + @Assign + internal fun handle(command: ReplenishStock): StockReplenished { + alter { + inStock += command.quantity + } + return stockReplenished { + stock = command.stock + quantity = command.quantity + } + } + + @React + internal fun on(event: BulkOrderPlaced): EitherOf2 { + val violations = tryAlter { + inStock -= event.quantity + } + return if (violations.isEmpty()) { + EitherOf2.withA( + stockReserved { + stock = id() + quantity = event.quantity + } + ) + } else { + EitherOf2.withB(noReaction()) + } + } +} + +/** + * The repository of [StockKeeper] process managers. + */ +class StockKeeperRepo : ProcessManagerRepository() diff --git a/server/src/testFixtures/proto/spine/test/tryalter/commands.proto b/server/src/testFixtures/proto/spine/test/tryalter/commands.proto new file mode 100644 index 00000000000..2e56497d7e1 --- /dev/null +++ b/server/src/testFixtures/proto/spine/test/tryalter/commands.proto @@ -0,0 +1,46 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +syntax = "proto3"; + +package spine.test.tryalter.command; + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.test.tryalter.command"; +option java_outer_classname = "TryAlterCommandsProto"; +option java_multiple_files = true; + +// A command to add items to the stock. +message ReplenishStock { + + // The ID of the stock. + string stock = 1; + + // How many items to add. + int32 quantity = 2; +} diff --git a/server/src/testFixtures/proto/spine/test/tryalter/events.proto b/server/src/testFixtures/proto/spine/test/tryalter/events.proto new file mode 100644 index 00000000000..1bcfa790cf5 --- /dev/null +++ b/server/src/testFixtures/proto/spine/test/tryalter/events.proto @@ -0,0 +1,66 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +syntax = "proto3"; + +package spine.test.tryalter.event; + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.test.tryalter.event"; +option java_outer_classname = "TryAlterEventsProto"; +option java_multiple_files = true; + +// New items were added to the stock. +message StockReplenished { + + // The ID of the stock. + string stock = 1; + + // How many items were added. + int32 quantity = 2; +} + +// A bulk order was placed, requesting items from the stock. +message BulkOrderPlaced { + + // The ID of the stock. + string stock = 1; + + // How many items are requested. + int32 quantity = 2; +} + +// Items were reserved in the stock for an order. +message StockReserved { + + // The ID of the stock. + string stock = 1; + + // How many items were reserved. + int32 quantity = 2; +} diff --git a/server/src/testFixtures/proto/spine/test/tryalter/stock.proto b/server/src/testFixtures/proto/spine/test/tryalter/stock.proto new file mode 100644 index 00000000000..67ced367d38 --- /dev/null +++ b/server/src/testFixtures/proto/spine/test/tryalter/stock.proto @@ -0,0 +1,56 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +syntax = "proto3"; + +package spine.test.tryalter; + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.test.tryalter"; +option java_outer_classname = "StockProto"; +option java_multiple_files = true; + +// A stock of goods maintained by the `StockKeeper` process manager. +message Stock { + option (entity).kind = PROCESS_MANAGER; + + // The ID of the stock. + string id = 1; + + // The number of items in the stock. + // + // Can never go negative. + // + int32 in_stock = 2 [(min).value = "0"]; + + // The name of the vendor supplying the stock. + // + // Once assigned, cannot be changed. + // + string vendor = 3 [(set_once) = true]; +} diff --git a/version.gradle.kts b/version.gradle.kts index 61fb30b8aa1..400e8f3bb4a 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -27,4 +27,4 @@ /** * The version of this library. */ -extra.set("versionToPublish", "2.0.0-SNAPSHOT.400") +extra.set("versionToPublish", "2.0.0-SNAPSHOT.401")