You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/08/06 08:06:57 UTC
(causeway) 08/15: CAUSEWAY-3910: reconcile replayable command projection
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/causeway.git
commit 576f7b49a5f0edc9ebeea7742ff2e86f9eb2337e
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Aug 6 06:47:53 2026 +0100
CAUSEWAY-3910: reconcile replayable command projection
---
.../adoc/modules/commandlog/pages/about.adoc | 26 +++
.../applib/CausewayModuleExtCommandLogApplib.java | 21 +-
.../commandlog/applib/dom/CommandLogEntry.java | 2 +
.../applib/dom/CommandLogEntryRepository.java | 8 +
.../dom/CommandLogEntryRepositoryAbstract.java | 24 ++
.../applib/dom/replay/CommandExportManager.java | 4 +
.../applib/dom/replay/CommandReplayManager.java | 2 +
.../applib/dom/replay/ReplayContext.java | 18 +-
.../ReplayableCommand.columnOrder.fallback.txt | 5 +-
.../applib/dom/replay/ReplayableCommand.java | 184 ++++++++++++---
.../replay/ReplayableCommand.layout.fallback.xml | 59 ++---
.../dom/replay/ReplayableCommandEligibility.java | 71 ++++++
...ableCommandParticipant.columnOrder.fallback.txt | 8 +
.../dom/replay/ReplayableCommandParticipant.java | 250 +++++++++++++++++++++
...eplayableCommandParticipant.layout.fallback.xml | 38 ++++
.../applib/dom/replay/ReplayableCommand_next.java | 48 ++++
.../dom/replay/ReplayableCommand_previous.java | 48 ++++
...CommandLogEntryRepositoryAdjacentQueryTest.java | 84 +++++++
.../dom/replay/CommandManagerEligibilityTest.java | 128 +++++++++++
.../replay/ReplayableCommandEligibilityTest.java | 88 ++++++++
.../replay/ReplayableCommandNavigationTest.java | 145 ++++++++++++
.../replay/ReplayableCommandParticipantTest.java | 247 ++++++++++++++++++++
.../replay/ReplayableCommandPresentationTest.java | 92 ++++++++
.../commandlog/jpa/dom/CommandLogEntry.java | 14 ++
.../CommandLogEntryAdjacentQuery_IntegTest.java | 89 ++++++++
.../tasks.md | 42 ++--
26 files changed, 1638 insertions(+), 107 deletions(-)
diff --git a/extensions/core/commandlog/adoc/modules/commandlog/pages/about.adoc b/extensions/core/commandlog/adoc/modules/commandlog/pages/about.adoc
index 2048d3672f9..9e037a45b17 100644
--- a/extensions/core/commandlog/adoc/modules/commandlog/pages/about.adoc
+++ b/extensions/core/commandlog/adoc/modules/commandlog/pages/about.adoc
@@ -132,6 +132,32 @@ When the repository is available, the commandlog menu provides prototyping actio
Each mapping can also be inspected and deleted individually.
An application-defined `CommandReplayMappingListener` bean remains authoritative and causes both built-in listeners to back off.
+=== Replayable Command Projections
+
+The export and replay managers wrap command-log entries as `ReplayableCommand` view models.
+Each command reports whether it has a recorded result and derives a Participants collection from its command data:
+
+* `TARGET` rows represent recorded target bookmarks;
+* `PARAMETER` rows represent reference-valued action parameters and include the parameter name (scalar parameters are omitted); and
+* `RESULT` represents the recorded result bookmark, when present.
+
+Participants show the recorded bookmark even when the referenced object is unavailable locally.
+Their actual bookmark comes from the configured replay-mapping service.
+For target and parameter rows an explicit mapping is visible in any replay state; after a successful (`OK`) replay, an unmapped bookmark falls back to the recorded value.
+A result row exposes its actual bookmark only after successful replay, using an explicit mapping when available and otherwise the recorded value.
+The optional target, argument, and result object links resolve only the actual bookmark and remain empty if that object is not available locally; bookmark audit data remains visible.
+
+Participant view-model mementos contain identity rather than bookmark state: the owning interaction id followed by `--target`, `--parameter--<name>`, or `--result`.
+Opening such a memento re-derives the participant from the current command and current replay mappings.
+
+General command projections include state-changing commands, safe commands with a recorded result, and commands whose semantics cannot be resolved.
+They omit resultless safe actions without deleting or changing the underlying command-log entries.
+The pending-or-failed replay collection is intentionally exempt so that all imported replay work remains reviewable.
+The safe Previous and Next actions navigate foreground commands in command-log order, skipping entries that are ineligible under this general rule.
+
+For now, export and replay continue to use the separate `CommandExportManager` and `CommandReplayManager` view models.
+A future change may unify their sequence-management surface; the participant projection and eligibility policy do not depend on that redesign.
+
[#menubar-layout-xml]
=== menubar.layout.xml
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java
index ef234f03384..5b9d3649ed2 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java
@@ -22,6 +22,8 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+import org.apache.causeway.applib.services.bookmark.BookmarkService;
import org.apache.causeway.applib.services.clock.ClockService;
import org.apache.causeway.applib.services.command.CommandExecutorService;
import org.apache.causeway.applib.services.iactn.InteractionService;
@@ -43,14 +45,15 @@
import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandExportManager;
import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandReplayManager;
import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayContext;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommandParticipant;
import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_delete;
-import org.apache.causeway.extensions.commandlog.applib.dom.replay.ResultRemappingService;
import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_excludeFromReplay;
import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_makeExportable;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_next;
import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_openCommandLogEntry;
-import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_openTarget;
-import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_openTargetTR;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_previous;
import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_replayOrRetry;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ResultRemappingService;
import org.apache.causeway.extensions.commandlog.applib.fakescheduler.FakeScheduler;
import org.apache.causeway.extensions.commandlog.applib.job.BackgroundCommandsJobControl;
import org.apache.causeway.extensions.commandlog.applib.job.RunBackgroundCommandsJob;
@@ -67,6 +70,7 @@
// viewmodels
CommandReplayManager.class,
+ ReplayableCommandParticipant.class,
// mixins
HasInteractionId_commandLogEntry.class,
@@ -78,8 +82,8 @@
CommandReplayResultMapping_delete.class,
ReplayableCommand_makeExportable.class,
ReplayableCommand_openCommandLogEntry.class,
- ReplayableCommand_openTarget.class,
- ReplayableCommand_openTargetTR.class,
+ ReplayableCommand_previous.class,
+ ReplayableCommand_next.class,
ReplayableCommand_replayOrRetry.class,
ReplayableCommand_excludeFromReplay.class,
ReplayableCommand_delete.class,
@@ -165,9 +169,12 @@ public static void honorSystemEnvironment() {
final CommandLogEntryRepository commandLogEntryRepository,
final CommandExecutorService commandExecutorService,
final ClockService clockService,
- final ResultRemappingService resultRemappingService) {
+ final ResultRemappingService resultRemappingService,
+ final BookmarkService bookmarkService,
+ final ApplicationFeatureRepository applicationFeatureRepository) {
return new ReplayContext(repositoryService, interactionService, transactionService,
- commandLogEntryRepository, commandExecutorService, clockService, resultRemappingService);
+ commandLogEntryRepository, commandExecutorService, clockService, resultRemappingService,
+ bookmarkService, applicationFeatureRepository);
}
}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java
index 3a1cf5d632b..bab9b2908ea 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java
@@ -145,6 +145,8 @@ public static class Nq {
* primary before the production database was restored to the secondary.
*/
public static final String FIND_MOST_RECENT_COMPLETED = LOGICAL_TYPE_NAME + ".findMostRecentCompleted";
+ public static final String FIND_FOREGROUND_BY_TIMESTAMP_AFTER = LOGICAL_TYPE_NAME + ".findForegroundByTimestampAfter";
+ public static final String FIND_FOREGROUND_BY_TIMESTAMP_BEFORE = LOGICAL_TYPE_NAME + ".findForegroundByTimestampBefore";
public static final String FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATE = LOGICAL_TYPE_NAME + ".findForegroundByTimestampAfterAndReplayState";
public static final String FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATES = LOGICAL_TYPE_NAME + ".findForegroundByTimestampAfterAndReplayStates";
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java
index 3cc377148c1..0864421465f 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java
@@ -121,6 +121,14 @@ List<CommandLogEntry> findByTargetAndFromAndTo(
*/
List<CommandLogEntry> findSince(UUID interactionId, Integer batchSize);
+ default List<CommandLogEntry> findForegroundSinceTimestamp(final Timestamp since) {
+ return findForegroundSinceTimestamp(since, null);
+ }
+
+ List<CommandLogEntry> findForegroundSinceTimestamp(Timestamp since, @Nullable Integer limitIfAny);
+
+ List<CommandLogEntry> findForegroundBeforeTimestamp(Timestamp before, @Nullable Integer limitIfAny);
+
List<CommandLogEntry> findForegroundSinceTimestampAndCanBeExported(Timestamp since);
List<CommandLogEntry> findForegroundSinceTimestampAndHasBeenExported(Timestamp since);
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java
index d4656396785..9a0a03c7496 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java
@@ -394,6 +394,30 @@ private List<CommandLogEntry> findSince(
: commandJdos;
}
+ @Override
+ public List<CommandLogEntry> findForegroundSinceTimestamp(
+ final Timestamp since,
+ final @Nullable Integer limitIfAny) {
+ var query = Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER)
+ .withParameter("from", since);
+ if (limitIfAny != null) {
+ query = query.withRange(QueryRange.limit(limitIfAny));
+ }
+ return _Casts.uncheckedCast(repositoryService().allMatches(query));
+ }
+
+ @Override
+ public List<CommandLogEntry> findForegroundBeforeTimestamp(
+ final Timestamp before,
+ final @Nullable Integer limitIfAny) {
+ var query = Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_BEFORE)
+ .withParameter("to", before);
+ if (limitIfAny != null) {
+ query = query.withRange(QueryRange.limit(limitIfAny));
+ }
+ return _Casts.uncheckedCast(repositoryService().allMatches(query));
+ }
+
@Override
public List<CommandLogEntry> findForegroundSinceTimestampAndCanBeExported(final Timestamp since) {
return findForegroundSinceTimestampWithState(since, ReplayState.UNDEFINED);
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java
index bfe33fc6c0b..b1402719ab8 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java
@@ -142,6 +142,8 @@ public class DomainEvent extends ActionDomainEvent<nextHour> { }
describedAs = "Commands that can be exported")
public List<ReplayableCommand> getNotYetExported() {
return commandLogEntryRepository().findForegroundSinceTimestampAndCanBeExported(baseline).stream()
+ .filter(entry -> ReplayableCommandEligibility.isEligible(
+ entry, replayContext.applicationFeatureRepository()))
.map(entry->new ReplayableCommand(
entry.getInteractionId(),
replayContext))
@@ -233,6 +235,8 @@ public class DomainEvent extends ActionDomainEvent<exportSelected> { }
@CollectionLayout(describedAs = "Commands that have been exported")
public List<ReplayableCommand> getExported() {
return commandLogEntryRepository().findForegroundSinceTimestampAndHasBeenExported(baseline).stream()
+ .filter(entry -> ReplayableCommandEligibility.isEligible(
+ entry, replayContext.applicationFeatureRepository()))
.map(entry->new ReplayableCommand(
entry.getInteractionId(),
replayContext))
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java
index d48d19a0522..7fee1988904 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java
@@ -320,6 +320,8 @@ public class DomainEvent extends ActionDomainEvent<deleteSelectedPendingOrFailed
+ "or marked to be excluded from replay (replayState=EXCLUDE)")
public List<ReplayableCommand> getSucceededOrExcluded() {
return commandLogEntryRepository().findSinceAndWithReplayOkOrExcluded(baseline).stream()
+ .filter(entry -> ReplayableCommandEligibility.isEligible(
+ entry, replayContext.applicationFeatureRepository()))
.map(entry->new ReplayableCommand(
entry.getInteractionId(),
replayContext))
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java
index 897b614f30e..d1978c1ea13 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java
@@ -23,6 +23,8 @@
import org.jspecify.annotations.Nullable;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+import org.apache.causeway.applib.services.bookmark.BookmarkService;
import org.apache.causeway.applib.services.clock.ClockService;
import org.apache.causeway.applib.services.command.CommandExecutorService;
import org.apache.causeway.applib.services.iactn.InteractionService;
@@ -41,7 +43,21 @@ public record ReplayContext(
CommandLogEntryRepository commandLogEntryRepository,
CommandExecutorService commandExecutorService,
ClockService clockService,
- ResultRemappingService resultRemappingService) {
+ ResultRemappingService resultRemappingService,
+ @Nullable BookmarkService bookmarkService,
+ @Nullable ApplicationFeatureRepository applicationFeatureRepository) {
+
+ public ReplayContext(
+ final RepositoryService repositoryService,
+ final InteractionService interactionService,
+ final TransactionService transactionService,
+ final CommandLogEntryRepository commandLogEntryRepository,
+ final CommandExecutorService commandExecutorService,
+ final ClockService clockService,
+ final ResultRemappingService resultRemappingService) {
+ this(repositoryService, interactionService, transactionService, commandLogEntryRepository,
+ commandExecutorService, clockService, resultRemappingService, null, null);
+ }
public Optional<CommandLogEntry> lookupCommandLogEntry(final @Nullable UUID interactionId) {
return interactionId!=null
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt
index bde91a6f91d..1b87450bbbc 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt
@@ -1,6 +1,5 @@
#interactionId
timestamp
-targetType
-targetId
member
-replayState
\ No newline at end of file
+replayState
+hasResult
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java
index ea291deadd2..9d7edb611bb 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java
@@ -18,10 +18,13 @@
*/
package org.apache.causeway.extensions.commandlog.applib.dom.replay;
+import java.sql.Timestamp;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.chrono.ChronoZonedDateTime;
+import java.util.ArrayList;
import java.util.Comparator;
+import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -31,23 +34,20 @@
import org.springframework.transaction.annotation.Propagation;
import org.apache.causeway.applib.ViewModel;
-import org.apache.causeway.applib.annotation.Action;
-import org.apache.causeway.applib.annotation.ActionLayout;
+import org.apache.causeway.applib.annotation.Collection;
+import org.apache.causeway.applib.annotation.CollectionLayout;
import org.apache.causeway.applib.annotation.DomainObject;
import org.apache.causeway.applib.annotation.DomainObjectLayout;
import org.apache.causeway.applib.annotation.Introspection;
import org.apache.causeway.applib.annotation.LabelPosition;
-import org.apache.causeway.applib.annotation.MemberSupport;
import org.apache.causeway.applib.annotation.ObjectSupport;
import org.apache.causeway.applib.annotation.Programmatic;
import org.apache.causeway.applib.annotation.Property;
import org.apache.causeway.applib.annotation.PropertyLayout;
-import org.apache.causeway.applib.annotation.SemanticsOf;
import org.apache.causeway.applib.annotation.Where;
import org.apache.causeway.applib.fa.FontAwesomeLayers;
import org.apache.causeway.applib.jaxb.JavaTimeXMLGregorianCalendarMarshalling;
import org.apache.causeway.applib.services.bookmark.Bookmark;
-import org.apache.causeway.applib.services.bookmark.BookmarkService;
import org.apache.causeway.applib.services.command.CommandRecordingSuppressed;
import org.apache.causeway.applib.services.command.CommandExecutorService.InteractionContextPolicy;
import org.apache.causeway.commons.functional.Try;
@@ -59,9 +59,12 @@
import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
import org.apache.causeway.extensions.commandlog.applib.dom.ReplayState;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommandParticipant.Role;
+import org.apache.causeway.schema.cmd.v2.ActionDto;
import org.apache.causeway.schema.cmd.v2.CommandDto;
import org.apache.causeway.schema.cmd.v2.MemberDto;
import org.apache.causeway.schema.common.v2.OidDto;
+import org.apache.causeway.schema.common.v2.ValueType;
import org.apache.causeway.valuetypes.asciidoc.applib.value.AsciiDoc;
import org.apache.causeway.valuetypes.asciidoc.builder.AsciiDocBuilder;
import org.apache.causeway.valuetypes.asciidoc.builder.AsciiDocFactory;
@@ -173,11 +176,7 @@ public Optional<ZonedDateTime> getTimestampIfAny() {
.map(JavaTimeXMLGregorianCalendarMarshalling::toZonedDateTime);
}
- @Property
- @PropertyLayout(
- sequence = "2.1",
- fieldSetId = "details",
- describedAs = "Target Type of the original (replayable) Command")
+ @Programmatic
public String getTargetType() {
return commandRecord()
.map(CommandRecord::commandDto)
@@ -186,11 +185,7 @@ public String getTargetType() {
.orElse(null);
}
- @Property
- @PropertyLayout(
- sequence = "2.2",
- fieldSetId = "details",
- describedAs = "Target ID of the original (replayable) Command")
+ @Programmatic
public String getTargetId() {
return commandRecord()
.map(CommandRecord::commandDto)
@@ -200,26 +195,6 @@ public String getTargetId() {
.orElse(null);
}
- @Action(semantics = SemanticsOf.SAFE)
- @ActionLayout(
- cssClassFa = "fa-bullseye"
- )
- public class openTarget {
- @Inject BookmarkService bookmarkService;
-
- @MemberSupport public Object act() {
- return commandLogEntry()
- .map(CommandLogEntry::getTarget)
- .flatMap(bookmark -> bookmarkService.lookup(bookmark))
- .orElse(null);
- }
- @MemberSupport public String disableAct() {
- return commandLogEntry().isPresent()
- ? null
- : "Unknown target";
- }
- }
-
@Property
@PropertyLayout(
sequence = "3.1",
@@ -249,6 +224,109 @@ public ReplayState getReplayState() {
.orElse(null);
}
+ @Property
+ @PropertyLayout(
+ sequence = "5",
+ fieldSetId = "details",
+ describedAs = "Whether this command stores a recorded result bookmark")
+ public boolean getHasResult() {
+ return commandLogEntry()
+ .map(CommandLogEntry::getResult)
+ .isPresent();
+ }
+
+ @Collection
+ @CollectionLayout(sequence = "6", named = "Participants")
+ public List<ReplayableCommandParticipant> getParticipants() {
+ return commandLogEntry()
+ .map(this::participantsFor)
+ .orElseGet(List::of);
+ }
+
+ private List<ReplayableCommandParticipant> participantsFor(final CommandLogEntry commandLogEntry) {
+ var participants = new ArrayList<ReplayableCommandParticipant>();
+ var commandDto = commandLogEntry.getCommandDto();
+ addTargetParticipants(participants, commandLogEntry, commandDto);
+ addReferenceParameterParticipants(participants, commandLogEntry, commandDto);
+ addResultParticipant(participants, commandLogEntry);
+ return participants;
+ }
+
+ private void addTargetParticipants(
+ final List<ReplayableCommandParticipant> participants,
+ final CommandLogEntry commandLogEntry,
+ final CommandDto commandDto) {
+ Optional.ofNullable(commandDto)
+ .map(CommandDto::getTargets)
+ .stream()
+ .flatMap(targets -> targets.getOid().stream())
+ .filter(java.util.Objects::nonNull)
+ .map(Bookmark::forOidDto)
+ .forEach(bookmark -> participants.add(participant(
+ commandLogEntry, Role.TARGET, null, bookmark)));
+ }
+
+ private void addReferenceParameterParticipants(
+ final List<ReplayableCommandParticipant> participants,
+ final CommandLogEntry commandLogEntry,
+ final CommandDto commandDto) {
+ if (commandDto == null || !(commandDto.getMember() instanceof ActionDto actionDto)) {
+ return;
+ }
+ Optional.ofNullable(actionDto.getParameters())
+ .stream()
+ .flatMap(parameters -> parameters.getParameter().stream())
+ .filter(parameter -> parameter != null
+ && parameter.getType() == ValueType.REFERENCE
+ && parameter.getReference() != null)
+ .forEach(parameter -> participants.add(participant(
+ commandLogEntry,
+ Role.PARAMETER,
+ parameter.getName(),
+ Bookmark.forOidDto(parameter.getReference()))));
+ }
+
+ private void addResultParticipant(
+ final List<ReplayableCommandParticipant> participants,
+ final CommandLogEntry commandLogEntry) {
+ if (commandLogEntry.getResult() != null) {
+ participants.add(participant(
+ commandLogEntry, Role.RESULT, null, commandLogEntry.getResult()));
+ }
+ }
+
+ private ReplayableCommandParticipant participant(
+ final CommandLogEntry commandLogEntry,
+ final Role role,
+ final String parameterName,
+ final Bookmark recordedBookmark) {
+ var owningInteractionId = commandLogEntry.getInteractionId() != null
+ ? commandLogEntry.getInteractionId()
+ : interactionId;
+ var actualBookmark = actualBookmarkFor(
+ role, recordedBookmark, commandLogEntry.getReplayState()).orElse(null);
+ return new ReplayableCommandParticipant(
+ owningInteractionId,
+ role,
+ parameterName,
+ recordedBookmark,
+ actualBookmark,
+ replayContext);
+ }
+
+ private Optional<Bookmark> actualBookmarkFor(
+ final Role role,
+ final Bookmark recordedBookmark,
+ final ReplayState replayState) {
+ if (role == Role.RESULT && replayState != ReplayState.OK) {
+ return Optional.empty();
+ }
+ return replayContext.resultRemappingService().lookup(recordedBookmark)
+ .or(() -> replayState == ReplayState.OK
+ ? Optional.of(recordedBookmark)
+ : Optional.empty());
+ }
+
@Property
@PropertyLayout(
sequence = "9",
@@ -269,6 +347,40 @@ public AsciiDoc getDto() {
// -- ACTIONS
+ ReplayableCommand previous() {
+ return adjacent(false).orElse(this);
+ }
+
+ String disablePrevious() {
+ return adjacent(false).isEmpty() ? "No previous replayable command" : null;
+ }
+
+ ReplayableCommand next() {
+ return adjacent(true).orElse(this);
+ }
+
+ String disableNext() {
+ return adjacent(true).isEmpty() ? "No next replayable command" : null;
+ }
+
+ private Optional<ReplayableCommand> adjacent(final boolean forward) {
+ return getTimestampIfAny()
+ .map(ChronoZonedDateTime::toInstant)
+ .map(Timestamp::from)
+ .stream()
+ .flatMap(timestamp -> (forward
+ ? replayContext.commandLogEntryRepository()
+ .findForegroundSinceTimestamp(timestamp)
+ : replayContext.commandLogEntryRepository()
+ .findForegroundBeforeTimestamp(timestamp, null))
+ .stream())
+ .filter(entry -> !interactionId.equals(entry.getInteractionId()))
+ .filter(entry -> ReplayableCommandEligibility.isEligible(
+ entry, replayContext.applicationFeatureRepository()))
+ .findFirst()
+ .map(entry -> new ReplayableCommand(entry.getInteractionId(), replayContext));
+ }
+
ReplayableCommand makeExportable() {
if(disableMakeExportable()!=null)
@@ -431,4 +543,4 @@ private void onReplaySuccess(final Bookmark actualResult) {
});
}
-}
\ No newline at end of file
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml
index 4b3f8a8658d..e03489ab878 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml
@@ -1,50 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?><bs:grid xmlns:bs="https://causeway.apache.org/applib/layout/grid/bootstrap3" xmlns:lnk="https://causeway.apache.org/applib/layout/links" xmlns:cpt="https://causeway.apache.org/applib/layout/component" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd https://causeway.apache.org/applib/layout/links https://caus [...]
<bs:row>
- <bs:col span="12" unreferencedActions="true">
+ <bs:col span="12">
<cpt:domainObject/>
</bs:col>
</bs:row>
<bs:row>
<bs:col span="6">
- <bs:tabGroup>
- <bs:tab name="Identity">
- <bs:row>
- <bs:col span="12">
- <cpt:fieldSet name="Id" id="id">
- <cpt:action id="delete" position="PANEL"/>
- <cpt:property id="interactionId"/>
- <cpt:property id="timestamp"/>
- </cpt:fieldSet>
- </bs:col>
- </bs:row>
- </bs:tab>
- <bs:tab name="Other">
- <bs:row>
- <bs:col span="12">
- <cpt:fieldSet name="Other" id="other" unreferencedProperties="true"/>
- </bs:col>
- </bs:row>
- </bs:tab>
- <bs:tab name="Metadata">
- <bs:row>
- <bs:col span="12">
- <cpt:fieldSet name="Metadata" id="metadata">
- <cpt:property id="logicalTypeName"/>
- <cpt:property id="interactionId"/>
- </cpt:fieldSet>
- </bs:col>
- </bs:row>
- </bs:tab>
- </bs:tabGroup>
- <cpt:fieldSet name="Target" id="target">
- <cpt:action id="openTarget" position="PANEL"/>
- <cpt:property id="targetType"/>
- <cpt:property id="targetId"/>
+ <cpt:fieldSet name="Details" id="details">
+ <cpt:action id="previous" position="PANEL"/>
+ <cpt:action id="next" position="PANEL"/>
+ <cpt:property id="interactionId"/>
+ <cpt:property id="timestamp"/>
<cpt:property id="member"/>
- </cpt:fieldSet>
- <cpt:fieldSet name="Control" id="control">
- <cpt:action id="replayOrRetry" position="PANEL"/>
- <cpt:property id="replayState"/>
+ <cpt:property id="hasResult"/>
</cpt:fieldSet>
</bs:col>
<bs:col span="6">
@@ -54,6 +22,19 @@
</cpt:fieldSet>
</bs:col>
</bs:row>
+ <bs:row>
+ <bs:col span="12">
+ <cpt:collection id="participants"/>
+ </bs:col>
+ </bs:row>
+ <bs:row>
+ <bs:col span="12" unreferencedActions="true">
+ <cpt:fieldSet name="Control" id="control" unreferencedProperties="true">
+ <cpt:action id="replayOrRetry" position="PANEL"/>
+ <cpt:property id="replayState"/>
+ </cpt:fieldSet>
+ </bs:col>
+ </bs:row>
<bs:row>
<bs:col span="12">
<bs:tabGroup unreferencedCollections="true"/>
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibility.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibility.java
new file mode 100644
index 00000000000..28c827f3261
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibility.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom.replay;
+
+import java.util.Optional;
+
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureId;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+import org.apache.causeway.schema.cmd.v2.ActionDto;
+
+import lombok.experimental.UtilityClass;
+
+/**
+ * Determines whether a command-log entry is useful in general replayable-command projections.
+ *
+ * @since 4.0 {@index}
+ */
+@UtilityClass
+public class ReplayableCommandEligibility {
+
+ public boolean isEligible(
+ final @Nullable CommandLogEntry entry,
+ final @Nullable ApplicationFeatureRepository applicationFeatureRepository) {
+ if (entry == null) {
+ return false;
+ }
+ return !isSafeAction(entry, applicationFeatureRepository)
+ || entry.getResult() != null;
+ }
+
+ private boolean isSafeAction(
+ final CommandLogEntry entry,
+ final @Nullable ApplicationFeatureRepository applicationFeatureRepository) {
+ if (applicationFeatureRepository == null
+ || entry.getCommandDto() == null
+ || !(entry.getCommandDto().getMember() instanceof ActionDto)
+ || entry.getLogicalMemberIdentifier() == null) {
+ return false;
+ }
+ try {
+ return Optional.of(ApplicationFeatureId.newMember(entry.getLogicalMemberIdentifier()))
+ .map(applicationFeatureRepository::findFeature)
+ .flatMap(feature -> feature != null
+ ? feature.getActionSemantics()
+ : Optional.empty())
+ .map(semantics -> semantics.isSafeInNature())
+ .orElse(false);
+ } catch (RuntimeException ex) {
+ return false;
+ }
+ }
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.columnOrder.fallback.txt
new file mode 100644
index 00000000000..27c74899e1d
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.columnOrder.fallback.txt
@@ -0,0 +1,8 @@
+#replayableCommand
+role
+parameterName
+#recordedBookmark
+target
+argument
+result
+#actualBookmark
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.java
new file mode 100644
index 00000000000..64e8e9ff912
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.java
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom.replay;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.ViewModel;
+import org.apache.causeway.applib.annotation.DomainObject;
+import org.apache.causeway.applib.annotation.DomainObjectLayout;
+import org.apache.causeway.applib.annotation.Introspection;
+import org.apache.causeway.applib.annotation.MemberSupport;
+import org.apache.causeway.applib.annotation.ObjectSupport;
+import org.apache.causeway.applib.annotation.Optionality;
+import org.apache.causeway.applib.annotation.Programmatic;
+import org.apache.causeway.applib.annotation.Property;
+import org.apache.causeway.applib.annotation.PropertyLayout;
+import org.apache.causeway.applib.annotation.Where;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.command.CommandRecordingSuppressed;
+import org.apache.causeway.applib.util.TitleBuffer;
+import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
+
+/**
+ * Derived view-model row describing one bookmark-bearing participant in a replayable command.
+ *
+ * @since 4.0 {@index}
+ */
+@Named(ReplayableCommandParticipant.LOGICAL_TYPE_NAME)
+@DomainObject(introspection = Introspection.ANNOTATION_REQUIRED)
+@DomainObjectLayout
+public final class ReplayableCommandParticipant
+implements ViewModel, CommandRecordingSuppressed {
+
+ public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE
+ + ".ReplayableCommandParticipant";
+
+ public enum Role {
+ TARGET,
+ PARAMETER,
+ RESULT
+ }
+
+ private final @Nullable UUID owningInteractionId;
+ private final @Nullable Role role;
+ private final @Nullable String parameterName;
+ private final @Nullable Bookmark recordedBookmark;
+ private final @Nullable Bookmark actualBookmark;
+ private final ReplayContext replayContext;
+
+ ReplayableCommandParticipant(
+ final UUID owningInteractionId,
+ final Role role,
+ final @Nullable String parameterName,
+ final Bookmark recordedBookmark,
+ final @Nullable Bookmark actualBookmark,
+ final ReplayContext replayContext) {
+ this.owningInteractionId = owningInteractionId;
+ this.role = role;
+ this.parameterName = parameterName;
+ this.recordedBookmark = recordedBookmark;
+ this.actualBookmark = actualBookmark;
+ this.replayContext = replayContext;
+ }
+
+ @Inject
+ public ReplayableCommandParticipant(
+ final String memento,
+ final ReplayContext replayContext) {
+ var parsed = Memento.parse(memento);
+ var derived = parsed.participantFrom(replayContext);
+ this.owningInteractionId = parsed.owningInteractionId;
+ this.role = parsed.role;
+ this.parameterName = parsed.parameterName;
+ this.recordedBookmark = derived
+ .map(ReplayableCommandParticipant::getRecordedBookmark)
+ .orElse(null);
+ this.actualBookmark = derived
+ .map(ReplayableCommandParticipant::getActualBookmark)
+ .orElse(null);
+ this.replayContext = replayContext;
+ }
+
+ @ObjectSupport
+ public String title() {
+ var title = new TitleBuffer()
+ .append("Replay participant")
+ .append(" - ")
+ .append(getRole());
+ if (parameterName != null) {
+ title.append(" ").append(parameterName);
+ }
+ return title
+ .append(" ")
+ .append(getRecordedBookmark())
+ .append(" → ")
+ .append(getActualBookmark())
+ .toString();
+ }
+
+ @Override
+ public String viewModelMemento() {
+ if (owningInteractionId == null || role == null) {
+ return "";
+ }
+ return switch (role) {
+ case TARGET -> owningInteractionId + "--target";
+ case PARAMETER -> owningInteractionId + "--parameter--"
+ + (parameterName != null ? parameterName : "");
+ case RESULT -> owningInteractionId + "--result";
+ };
+ }
+
+ @Property(optionality = Optionality.OPTIONAL)
+ @PropertyLayout(sequence = "0")
+ public @Nullable ReplayableCommand getReplayableCommand() {
+ return owningInteractionId != null
+ ? new ReplayableCommand(owningInteractionId, replayContext)
+ : null;
+ }
+
+ @Property(optionality = Optionality.OPTIONAL)
+ @PropertyLayout(sequence = "0.1", hidden = Where.OBJECT_FORMS)
+ public @Nullable UUID getOwningInteractionId() {
+ return owningInteractionId;
+ }
+
+ @Property(optionality = Optionality.OPTIONAL)
+ @PropertyLayout(sequence = "1")
+ public @Nullable Role getRole() {
+ return role;
+ }
+
+ @Property(optionality = Optionality.OPTIONAL)
+ @PropertyLayout(sequence = "2")
+ public @Nullable String getParameterName() {
+ return parameterName;
+ }
+
+ @Property(optionality = Optionality.OPTIONAL)
+ @PropertyLayout(sequence = "3")
+ public @Nullable Bookmark getRecordedBookmark() {
+ return recordedBookmark;
+ }
+
+ @Property(optionality = Optionality.OPTIONAL)
+ @PropertyLayout(sequence = "4")
+ public @Nullable Object getTarget() {
+ return role == Role.TARGET ? lookupActualObject().orElse(null) : null;
+ }
+
+ @MemberSupport
+ public boolean hideTarget() {
+ return role != Role.TARGET;
+ }
+
+ @Property(optionality = Optionality.OPTIONAL)
+ @PropertyLayout(sequence = "5")
+ public @Nullable Object getArgument() {
+ return role == Role.PARAMETER ? lookupActualObject().orElse(null) : null;
+ }
+
+ @MemberSupport
+ public boolean hideArgument() {
+ return role != Role.PARAMETER;
+ }
+
+ @Property(optionality = Optionality.OPTIONAL)
+ @PropertyLayout(sequence = "6")
+ public @Nullable Object getResult() {
+ return role == Role.RESULT ? lookupActualObject().orElse(null) : null;
+ }
+
+ @MemberSupport
+ public boolean hideResult() {
+ return role != Role.RESULT;
+ }
+
+ @Property(optionality = Optionality.OPTIONAL)
+ @PropertyLayout(sequence = "7")
+ public @Nullable Bookmark getActualBookmark() {
+ return actualBookmark;
+ }
+
+ @Programmatic
+ Optional<Object> lookupActualObject() {
+ return actualBookmark != null && replayContext.bookmarkService() != null
+ ? replayContext.bookmarkService().lookup(actualBookmark)
+ : Optional.empty();
+ }
+
+ private record Memento(
+ @Nullable UUID owningInteractionId,
+ @Nullable Role role,
+ @Nullable String parameterName) {
+
+ private static Memento parse(final String memento) {
+ var parts = memento.split("--", 3);
+ var interactionId = parts.length > 0 && !parts[0].isEmpty()
+ ? UUID.fromString(parts[0])
+ : null;
+ var role = parts.length > 1 && !parts[1].isEmpty()
+ ? Role.valueOf(parts[1].toUpperCase())
+ : null;
+ var parameterName = role == Role.PARAMETER && parts.length > 2 && !parts[2].isEmpty()
+ ? parts[2]
+ : null;
+ return new Memento(interactionId, role, parameterName);
+ }
+
+ private Optional<ReplayableCommandParticipant> participantFrom(final ReplayContext replayContext) {
+ if (owningInteractionId == null || role == null) {
+ return Optional.empty();
+ }
+ return new ReplayableCommand(owningInteractionId, replayContext)
+ .getParticipants()
+ .stream()
+ .filter(this::matches)
+ .findFirst();
+ }
+
+ private boolean matches(final ReplayableCommandParticipant participant) {
+ return participant.getRole() == role
+ && (role != Role.PARAMETER
+ || Objects.equals(participant.getParameterName(), parameterName));
+ }
+ }
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.layout.fallback.xml
new file mode 100644
index 00000000000..ac799dd5692
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.layout.fallback.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?><bs:grid xmlns:bs="https://causeway.apache.org/applib/layout/grid/bootstrap3" xmlns:lnk="https://causeway.apache.org/applib/layout/links" xmlns:cpt="https://causeway.apache.org/applib/layout/component" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd https://causeway.apache.org/applib/layout/links https://caus [...]
+ <bs:row>
+ <bs:col span="12" unreferencedActions="true">
+ <cpt:domainObject/>
+ </bs:col>
+ </bs:row>
+ <bs:row>
+ <bs:col span="4">
+ <cpt:fieldSet name="General" id="general">
+ <cpt:property id="replayableCommand"/>
+ <cpt:property id="role"/>
+ <cpt:property id="parameterName"/>
+ </cpt:fieldSet>
+ <cpt:fieldSet name="Metadata" id="metadata">
+ <cpt:property id="logicalTypeName"/>
+ </cpt:fieldSet>
+ <cpt:fieldSet name="Other" id="other" unreferencedProperties="true"/>
+ </bs:col>
+ <bs:col span="4">
+ <cpt:fieldSet name="Recorded" id="recorded">
+ <cpt:property id="recordedBookmark"/>
+ <cpt:property id="target"/>
+ <cpt:property id="argument"/>
+ </cpt:fieldSet>
+ </bs:col>
+ <bs:col span="4">
+ <cpt:fieldSet name="Actual" id="actual">
+ <cpt:property id="actualBookmark"/>
+ <cpt:property id="result"/>
+ </cpt:fieldSet>
+ </bs:col>
+ </bs:row>
+ <bs:row>
+ <bs:col span="12">
+ <bs:tabGroup unreferencedCollections="true"/>
+ </bs:col>
+ </bs:row>
+</bs:grid>
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_next.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_next.java
new file mode 100644
index 00000000000..e8367da7def
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_next.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom.replay;
+
+import lombok.RequiredArgsConstructor;
+
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.ActionLayout;
+import org.apache.causeway.applib.annotation.MemberSupport;
+import org.apache.causeway.applib.annotation.Publishing;
+import org.apache.causeway.applib.annotation.SemanticsOf;
+
+@Action(
+ semantics = SemanticsOf.SAFE,
+ commandPublishing = Publishing.DISABLED,
+ executionPublishing = Publishing.DISABLED)
+@ActionLayout(sequence = "0.3", describedAs = "Opens the next replayable foreground command")
+@RequiredArgsConstructor
+public class ReplayableCommand_next {
+
+ private final ReplayableCommand replayableCommand;
+
+ @MemberSupport
+ public ReplayableCommand act() {
+ return replayableCommand.next();
+ }
+
+ @MemberSupport
+ public String disableAct() {
+ return replayableCommand.disableNext();
+ }
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_previous.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_previous.java
new file mode 100644
index 00000000000..b35d40e455c
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_previous.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom.replay;
+
+import lombok.RequiredArgsConstructor;
+
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.ActionLayout;
+import org.apache.causeway.applib.annotation.MemberSupport;
+import org.apache.causeway.applib.annotation.Publishing;
+import org.apache.causeway.applib.annotation.SemanticsOf;
+
+@Action(
+ semantics = SemanticsOf.SAFE,
+ commandPublishing = Publishing.DISABLED,
+ executionPublishing = Publishing.DISABLED)
+@ActionLayout(sequence = "0.2", describedAs = "Opens the previous replayable foreground command")
+@RequiredArgsConstructor
+public class ReplayableCommand_previous {
+
+ private final ReplayableCommand replayableCommand;
+
+ @MemberSupport
+ public ReplayableCommand act() {
+ return replayableCommand.previous();
+ }
+
+ @MemberSupport
+ public String disableAct() {
+ return replayableCommand.disablePrevious();
+ }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAdjacentQueryTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAdjacentQueryTest.java
new file mode 100644
index 00000000000..a13dc369787
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAdjacentQueryTest.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom;
+
+import java.sql.Timestamp;
+import java.util.List;
+
+import jakarta.inject.Provider;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.apache.causeway.applib.query.NamedQuery;
+import org.apache.causeway.applib.query.Query;
+import org.apache.causeway.applib.services.repository.RepositoryService;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class CommandLogEntryRepositoryAdjacentQueryTest {
+
+ private final RepositoryService repositoryService = mock(RepositoryService.class);
+ private final Repository repository = new Repository();
+
+ @BeforeEach
+ void setUp() {
+ repository.repositoryServiceProvider = providerOf(repositoryService);
+ when(repositoryService.allMatches(any(Query.class))).thenReturn(List.of());
+ }
+
+ @Test
+ void selectsOrderedForegroundQueriesAndOptionalLimit() {
+ var timestamp = Timestamp.valueOf("2026-08-05 12:00:00");
+
+ repository.findForegroundSinceTimestamp(timestamp, 7);
+ var after = captureLastQuery();
+ assertThat(after.getName()).isEqualTo(CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER);
+ assertThat(after.getParametersByName()).containsEntry("from", timestamp);
+ assertThat(after.getRange().getLimit()).isEqualTo(7);
+
+ repository.findForegroundBeforeTimestamp(timestamp, null);
+ var before = captureLastQuery();
+ assertThat(before.getName()).isEqualTo(CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_BEFORE);
+ assertThat(before.getParametersByName()).containsEntry("to", timestamp);
+ assertThat(before.getRange().isUnconstrained()).isTrue();
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private NamedQuery<CommandLogEntry> captureLastQuery() {
+ var captor = ArgumentCaptor.forClass(Query.class);
+ verify(repositoryService, org.mockito.Mockito.atLeastOnce()).allMatches(captor.capture());
+ var queries = captor.getAllValues();
+ return (NamedQuery<CommandLogEntry>) queries.get(queries.size() - 1);
+ }
+
+ private static Provider<RepositoryService> providerOf(final RepositoryService service) {
+ return () -> service;
+ }
+
+ private static final class Repository
+ extends CommandLogEntryRepositoryAbstract<CommandLogEntry> {
+ private Repository() { super(CommandLogEntry.class); }
+ }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerEligibilityTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerEligibilityTest.java
new file mode 100644
index 00000000000..15b288f842e
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerEligibilityTest.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom.replay;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeature;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
+import org.apache.causeway.schema.cmd.v2.ActionDto;
+import org.apache.causeway.schema.cmd.v2.CommandDto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class CommandManagerEligibilityTest {
+
+ @Test
+ void generalManagerCollectionsOmitResultlessSafeEntryWithoutMutation() {
+ var fixture = fixture();
+ when(fixture.repository().findForegroundSinceTimestampAndCanBeExported(fixture.baseline()))
+ .thenReturn(fixture.entries());
+ when(fixture.repository().findForegroundSinceTimestampAndHasBeenExported(fixture.baseline()))
+ .thenReturn(fixture.entries());
+ when(fixture.repository().findSinceAndWithReplayOkOrExcluded(fixture.baseline()))
+ .thenReturn(fixture.entries());
+
+ var exportManager = new CommandExportManager(fixture.baseline(), fixture.context());
+ var replayManager = new CommandReplayManager(fixture.baseline(), fixture.context());
+
+ assertThat(exportManager.getNotYetExported()).extracting(ReplayableCommand::interactionId)
+ .containsExactly(fixture.resultBearing().getInteractionId());
+ assertThat(exportManager.getExported()).extracting(ReplayableCommand::interactionId)
+ .containsExactly(fixture.resultBearing().getInteractionId());
+ assertThat(replayManager.getSucceededOrExcluded()).extracting(ReplayableCommand::interactionId)
+ .containsExactly(fixture.resultBearing().getInteractionId());
+ verifyUnchanged(fixture.resultless());
+ verifyUnchanged(fixture.resultBearing());
+ }
+
+ @Test
+ void pendingOrFailedRetainsEveryRepositoryResult() {
+ var fixture = fixture();
+ when(fixture.repository().findForegroundSinceTimestampAndWithReplayPendingOrFailed(
+ fixture.baseline())).thenReturn(fixture.entries());
+
+ var commands = new CommandReplayManager(fixture.baseline(), fixture.context())
+ .getPendingOrFailed();
+
+ assertThat(commands).extracting(ReplayableCommand::interactionId)
+ .containsExactly(
+ fixture.resultless().getInteractionId(),
+ fixture.resultBearing().getInteractionId());
+ verifyUnchanged(fixture.resultless());
+ verifyUnchanged(fixture.resultBearing());
+ }
+
+ private static void verifyUnchanged(final CommandLogEntry entry) {
+ verify(entry, never()).setReplayState(any());
+ verify(entry, never()).setResult(any());
+ }
+
+ private static Fixture fixture() {
+ var baseline = Timestamp.from(Instant.parse("2026-08-05T10:00:00Z"));
+ var resultless = entry(null);
+ var resultBearing = entry(Bookmark.forLogicalTypeNameAndIdentifier("demo.Customer", "1"));
+ var repository = mock(CommandLogEntryRepository.class);
+ var featureRepository = mock(ApplicationFeatureRepository.class);
+ var feature = mock(ApplicationFeature.class);
+ when(featureRepository.findFeature(any())).thenReturn(feature);
+ when(feature.getActionSemantics()).thenReturn(Optional.of(SemanticsOf.SAFE));
+ var context = new ReplayContext(
+ null, null, null, repository, null, null,
+ new ResultRemappingService(List.of()), null, featureRepository);
+ return new Fixture(
+ baseline, context, repository, resultless, resultBearing,
+ List.of(resultless, resultBearing));
+ }
+
+ private static CommandLogEntry entry(final Bookmark result) {
+ var dto = new CommandDto();
+ dto.setMember(new ActionDto());
+ var entry = mock(CommandLogEntry.class);
+ when(entry.getInteractionId()).thenReturn(UUID.randomUUID());
+ when(entry.getCommandDto()).thenReturn(dto);
+ when(entry.getLogicalMemberIdentifier()).thenReturn("demo.Customer#find");
+ when(entry.getResult()).thenReturn(result);
+ return entry;
+ }
+
+ private record Fixture(
+ Timestamp baseline,
+ ReplayContext context,
+ CommandLogEntryRepository repository,
+ CommandLogEntry resultless,
+ CommandLogEntry resultBearing,
+ List<CommandLogEntry> entries) {
+ }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibilityTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibilityTest.java
new file mode 100644
index 00000000000..3a0e94605ff
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibilityTest.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom.replay;
+
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeature;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+import org.apache.causeway.schema.cmd.v2.ActionDto;
+import org.apache.causeway.schema.cmd.v2.CommandDto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ReplayableCommandEligibilityTest {
+
+ private final ApplicationFeatureRepository featureRepository = mock(ApplicationFeatureRepository.class);
+ private final ApplicationFeature feature = mock(ApplicationFeature.class);
+
+ @Test
+ void stateChangingActionIsEligibleWithoutResult() {
+ var entry = actionEntry(null);
+ actionSemantics(SemanticsOf.NON_IDEMPOTENT);
+
+ assertThat(ReplayableCommandEligibility.isEligible(entry, featureRepository)).isTrue();
+ }
+
+ @Test
+ void safeActionRequiresRecordedResult() {
+ actionSemantics(SemanticsOf.SAFE);
+
+ assertThat(ReplayableCommandEligibility.isEligible(actionEntry(null), featureRepository)).isFalse();
+ assertThat(ReplayableCommandEligibility.isEligible(
+ actionEntry(Bookmark.forLogicalTypeNameAndIdentifier("demo.Customer", "1")),
+ featureRepository)).isTrue();
+ }
+
+ @Test
+ void propertyAndUnclassifiableEntriesAreRetainedConservatively() {
+ var propertyEntry = mock(CommandLogEntry.class);
+ when(propertyEntry.getCommandDto()).thenReturn(new CommandDto());
+ var unknownAction = actionEntry(null);
+ when(featureRepository.findFeature(any())).thenThrow(new IllegalArgumentException("unknown"));
+
+ assertThat(ReplayableCommandEligibility.isEligible(propertyEntry, featureRepository)).isTrue();
+ assertThat(ReplayableCommandEligibility.isEligible(unknownAction, featureRepository)).isTrue();
+ assertThat(ReplayableCommandEligibility.isEligible(unknownAction, null)).isTrue();
+ assertThat(ReplayableCommandEligibility.isEligible(null, featureRepository)).isFalse();
+ }
+
+ private void actionSemantics(final SemanticsOf semantics) {
+ when(featureRepository.findFeature(any())).thenReturn(feature);
+ when(feature.getActionSemantics()).thenReturn(Optional.of(semantics));
+ }
+
+ private static CommandLogEntry actionEntry(final Bookmark result) {
+ var dto = new CommandDto();
+ dto.setMember(new ActionDto());
+ var entry = mock(CommandLogEntry.class);
+ when(entry.getCommandDto()).thenReturn(dto);
+ when(entry.getLogicalMemberIdentifier()).thenReturn("demo.Customer#find");
+ when(entry.getResult()).thenReturn(result);
+ return entry;
+ }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandNavigationTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandNavigationTest.java
new file mode 100644
index 00000000000..d0a91858274
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandNavigationTest.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom.replay;
+
+import java.sql.Timestamp;
+import java.time.ZonedDateTime;
+import java.util.GregorianCalendar;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import javax.xml.datatype.DatatypeFactory;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.Publishing;
+import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeature;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.ReplayState;
+import org.apache.causeway.schema.cmd.v2.ActionDto;
+import org.apache.causeway.schema.cmd.v2.CommandDto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ReplayableCommandNavigationTest {
+
+ @Test
+ void nextAndPreviousSkipIneligibleEntriesAndPreserveContext() throws Exception {
+ var timestamp = ZonedDateTime.parse("2026-08-05T10:15:30Z");
+ var current = entry(timestamp, false);
+ var ineligible = entry(timestamp.plusSeconds(1), true);
+ var adjacent = entry(timestamp.plusSeconds(2), false);
+ var fixture = fixture(current);
+ when(fixture.repository().findForegroundSinceTimestamp(Timestamp.from(timestamp.toInstant())))
+ .thenReturn(List.of(current, ineligible, adjacent));
+ when(fixture.repository().findForegroundBeforeTimestamp(
+ Timestamp.from(timestamp.toInstant()), null))
+ .thenReturn(List.of(ineligible, adjacent));
+
+ var next = fixture.command().next();
+ var previous = fixture.command().previous();
+
+ assertThat(next.interactionId()).isEqualTo(adjacent.getInteractionId());
+ assertThat(previous.interactionId()).isEqualTo(adjacent.getInteractionId());
+ assertThat(next.replayContext()).isSameAs(fixture.context());
+ assertThat(previous.replayContext()).isSameAs(fixture.context());
+ assertThat(fixture.command().disableNext()).isNull();
+ assertThat(fixture.command().disablePrevious()).isNull();
+ verify(current, never()).setReplayState(any());
+ verify(ineligible, never()).setReplayState(any());
+ verify(adjacent, never()).setReplayState(any());
+ }
+
+ @Test
+ void boundariesDisableSafelyAndReturnCurrentCommand() throws Exception {
+ var timestamp = ZonedDateTime.parse("2026-08-05T10:15:30Z");
+ var current = entry(timestamp, false);
+ var fixture = fixture(current);
+ when(fixture.repository().findForegroundSinceTimestamp(any())).thenReturn(List.of(current));
+ when(fixture.repository().findForegroundBeforeTimestamp(any(), isNull())).thenReturn(List.of());
+
+ assertThat(fixture.command().disablePrevious()).isNotNull();
+ assertThat(fixture.command().disableNext()).isNotNull();
+ assertThat(fixture.command().previous()).isSameAs(fixture.command());
+ assertThat(fixture.command().next()).isSameAs(fixture.command());
+ }
+
+ @Test
+ void navigationMixinsAreSafeAndSuppressPublishing() {
+ assertSafeSuppressed(ReplayableCommand_previous.class);
+ assertSafeSuppressed(ReplayableCommand_next.class);
+ }
+
+ private static void assertSafeSuppressed(final Class<?> mixinType) {
+ var action = mixinType.getAnnotation(Action.class);
+ assertThat(action.semantics()).isEqualTo(SemanticsOf.SAFE);
+ assertThat(action.commandPublishing()).isEqualTo(Publishing.DISABLED);
+ assertThat(action.executionPublishing()).isEqualTo(Publishing.DISABLED);
+ }
+
+ private static Fixture fixture(final CommandLogEntry current) {
+ var repository = mock(CommandLogEntryRepository.class);
+ when(repository.findByInteractionId(current.getInteractionId())).thenReturn(Optional.of(current));
+ var featureRepository = mock(ApplicationFeatureRepository.class);
+ var feature = mock(ApplicationFeature.class);
+ when(featureRepository.findFeature(any())).thenReturn(feature);
+ when(feature.getActionSemantics()).thenReturn(Optional.of(SemanticsOf.SAFE));
+ var context = new ReplayContext(
+ null, null, null, repository, null, null,
+ new ResultRemappingService(List.of()), null, featureRepository);
+ return new Fixture(
+ new ReplayableCommand(current.getInteractionId(), context),
+ context,
+ repository);
+ }
+
+ private static CommandLogEntry entry(
+ final ZonedDateTime timestamp,
+ final boolean safeActionWithoutResult) throws Exception {
+ var dto = new CommandDto();
+ dto.setTimestamp(DatatypeFactory.newInstance()
+ .newXMLGregorianCalendar(GregorianCalendar.from(timestamp)));
+ if (safeActionWithoutResult) {
+ dto.setMember(new ActionDto());
+ }
+ var entry = mock(CommandLogEntry.class);
+ when(entry.getInteractionId()).thenReturn(UUID.randomUUID());
+ when(entry.getCommandDto()).thenReturn(dto);
+ when(entry.getReplayState()).thenReturn(ReplayState.PENDING);
+ when(entry.getLogicalMemberIdentifier()).thenReturn("demo.Customer#find");
+ return entry;
+ }
+
+ private record Fixture(
+ ReplayableCommand command,
+ ReplayContext context,
+ CommandLogEntryRepository repository) {
+ }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipantTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipantTest.java
new file mode 100644
index 00000000000..0179885e195
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipantTest.java
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom.replay;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.bookmark.BookmarkService;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.ReplayState;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommandParticipant.Role;
+import org.apache.causeway.extensions.commandlog.applib.spi.CommandReplayMappingListener;
+import org.apache.causeway.schema.cmd.v2.ActionDto;
+import org.apache.causeway.schema.cmd.v2.CommandDto;
+import org.apache.causeway.schema.cmd.v2.ParamDto;
+import org.apache.causeway.schema.cmd.v2.ParamsDto;
+import org.apache.causeway.schema.common.v2.OidDto;
+import org.apache.causeway.schema.common.v2.OidsDto;
+import org.apache.causeway.schema.common.v2.ValueType;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ReplayableCommandParticipantTest {
+
+ @Test
+ void derivesTargetReferenceParameterAndResultButNotScalarParameter() {
+ var fixture = fixture(ReplayState.PENDING, null);
+
+ var participants = fixture.command().getParticipants();
+
+ assertThat(fixture.command().getHasResult()).isTrue();
+ assertThat(participants).extracting(ReplayableCommandParticipant::getRole)
+ .containsExactly(Role.TARGET, Role.PARAMETER, Role.RESULT);
+ assertThat(participants.get(1).getParameterName()).isEqualTo("customer");
+ assertThat(participants).extracting(ReplayableCommandParticipant::getRecordedBookmark)
+ .containsExactly(fixture.target(), fixture.parameter(), fixture.result());
+ verify(fixture.bookmarkService(), never()).lookup(fixture.result());
+ }
+
+ @Test
+ void mappedTargetAndParameterAppearWhilePendingButUnmappedParticipantsWaitForSuccess() {
+ var mappedTarget = bookmark("Customer", "2");
+ var fixture = fixture(ReplayState.PENDING, mapping(recorded ->
+ recorded.equals(bookmark("Order", "1")) ? Optional.of(mappedTarget) : Optional.empty()));
+
+ var participants = fixture.command().getParticipants();
+
+ assertThat(participant(participants, Role.TARGET).getActualBookmark()).isEqualTo(mappedTarget);
+ assertThat(participant(participants, Role.PARAMETER).getActualBookmark()).isNull();
+ assertThat(participant(participants, Role.RESULT).getActualBookmark()).isNull();
+ }
+
+ @Test
+ void successfulReplayUsesMappingAndRecordedBookmarkFallback() {
+ var mappedResult = bookmark("Invoice", "2");
+ var fixture = fixture(ReplayState.OK, mapping(recorded ->
+ recorded.equals(bookmark("Invoice", "1")) ? Optional.of(mappedResult) : Optional.empty()));
+
+ var participants = fixture.command().getParticipants();
+
+ assertThat(participant(participants, Role.TARGET).getActualBookmark()).isEqualTo(fixture.target());
+ assertThat(participant(participants, Role.PARAMETER).getActualBookmark()).isEqualTo(fixture.parameter());
+ assertThat(participant(participants, Role.RESULT).getActualBookmark()).isEqualTo(mappedResult);
+ }
+
+ @Test
+ void roleSpecificObjectLinksResolveActualBookmarkBestEffort() {
+ var mappedTarget = bookmark("Order", "2");
+ var targetObject = new Object();
+ var fixture = fixture(ReplayState.PENDING, mapping(recorded ->
+ recorded.equals(bookmark("Order", "1")) ? Optional.of(mappedTarget) : Optional.empty()));
+ when(fixture.bookmarkService().lookup(mappedTarget)).thenReturn(Optional.of(targetObject));
+
+ var target = participant(fixture.command().getParticipants(), Role.TARGET);
+ var parameter = participant(fixture.command().getParticipants(), Role.PARAMETER);
+
+ assertThat(target.getTarget()).isSameAs(targetObject);
+ assertThat(target.getArgument()).isNull();
+ assertThat(target.getResult()).isNull();
+ assertThat(target.hideTarget()).isFalse();
+ assertThat(target.hideArgument()).isTrue();
+ assertThat(parameter.getArgument()).isNull();
+ assertThat(parameter.getRecordedBookmark()).isEqualTo(fixture.parameter());
+ }
+
+ @Test
+ void readableMementosRehydrateFromCurrentCommandAndMappingState() {
+ var actualParameter = bookmark("Customer", "2");
+ var mapping = new MutableMapping();
+ var fixture = fixture(ReplayState.OK, mapping);
+ var parameter = participant(fixture.command().getParticipants(), Role.PARAMETER);
+ var expectedMemento = fixture.interactionId() + "--parameter--customer";
+
+ assertThat(parameter.viewModelMemento()).isEqualTo(expectedMemento);
+ assertThat(parameter.viewModelMemento()).doesNotContain(fixture.parameter().stringify());
+
+ mapping.actual = actualParameter;
+ var rehydrated = new ReplayableCommandParticipant(expectedMemento, fixture.context());
+
+ assertThat(rehydrated.getReplayableCommand().interactionId()).isEqualTo(fixture.interactionId());
+ assertThat(rehydrated.getRecordedBookmark()).isEqualTo(fixture.parameter());
+ assertThat(rehydrated.getActualBookmark()).isEqualTo(actualParameter);
+ assertThat(rehydrated.title()).contains("PARAMETER", "customer", "→");
+ }
+
+ @Test
+ void reportsNoResultWhenEntryHasNoRecordedResult() {
+ var fixture = fixture(ReplayState.PENDING, null);
+ when(fixture.entry().getResult()).thenReturn(null);
+
+ assertThat(fixture.command().getHasResult()).isFalse();
+ assertThat(fixture.command().getParticipants())
+ .extracting(ReplayableCommandParticipant::getRole)
+ .doesNotContain(Role.RESULT);
+ }
+
+ private static ReplayableCommandParticipant participant(
+ final List<ReplayableCommandParticipant> participants,
+ final Role role) {
+ return participants.stream()
+ .filter(candidate -> candidate.getRole() == role)
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private static Fixture fixture(
+ final ReplayState replayState,
+ final CommandReplayMappingListener listener) {
+ var interactionId = UUID.randomUUID();
+ var target = bookmark("Order", "1");
+ var parameter = bookmark("Customer", "1");
+ var result = bookmark("Invoice", "1");
+ var dto = commandDto(target, parameter);
+ var entry = mock(CommandLogEntry.class);
+ when(entry.getInteractionId()).thenReturn(interactionId);
+ when(entry.getCommandDto()).thenReturn(dto);
+ when(entry.getReplayState()).thenReturn(replayState);
+ when(entry.getResult()).thenReturn(result);
+ var repository = mock(CommandLogEntryRepository.class);
+ when(repository.findByInteractionId(interactionId)).thenReturn(Optional.of(entry));
+ var bookmarkService = mock(BookmarkService.class);
+ var remappingService = new ResultRemappingService(
+ listener != null ? List.of(listener) : List.of());
+ var context = new ReplayContext(
+ null, null, null, repository, null, null, remappingService, bookmarkService, null);
+ return new Fixture(
+ interactionId,
+ new ReplayableCommand(interactionId, context),
+ context,
+ entry,
+ bookmarkService,
+ target,
+ parameter,
+ result);
+ }
+
+ private static CommandDto commandDto(final Bookmark target, final Bookmark reference) {
+ var dto = new CommandDto();
+ var targets = new OidsDto();
+ targets.getOid().add(oid(target));
+ dto.setTargets(targets);
+ var action = new ActionDto();
+ action.setLogicalMemberIdentifier("demo.Order#place");
+ var params = new ParamsDto();
+ var referenceParam = new ParamDto();
+ referenceParam.setName("customer");
+ referenceParam.setType(ValueType.REFERENCE);
+ referenceParam.setReference(oid(reference));
+ params.getParameter().add(referenceParam);
+ var scalarParam = new ParamDto();
+ scalarParam.setName("quantity");
+ scalarParam.setType(ValueType.INT);
+ params.getParameter().add(scalarParam);
+ action.setParameters(params);
+ dto.setMember(action);
+ return dto;
+ }
+
+ private static OidDto oid(final Bookmark bookmark) {
+ var oid = new OidDto();
+ oid.setType(bookmark.logicalTypeName());
+ oid.setId(bookmark.identifier());
+ return oid;
+ }
+
+ private static Bookmark bookmark(final String type, final String id) {
+ return Bookmark.forLogicalTypeNameAndIdentifier("demo." + type, id);
+ }
+
+ private static CommandReplayMappingListener mapping(
+ final Function<Bookmark, Optional<Bookmark>> lookup) {
+ return new CommandReplayMappingListener() {
+ @Override
+ public Optional<Bookmark> lookup(final Bookmark recordedBookmark) {
+ return lookup.apply(recordedBookmark);
+ }
+ };
+ }
+
+ private record Fixture(
+ UUID interactionId,
+ ReplayableCommand command,
+ ReplayContext context,
+ CommandLogEntry entry,
+ BookmarkService bookmarkService,
+ Bookmark target,
+ Bookmark parameter,
+ Bookmark result) {
+ }
+
+ private static final class MutableMapping implements CommandReplayMappingListener {
+ private Bookmark actual;
+
+ @Override
+ public Optional<Bookmark> lookup(final Bookmark recordedBookmark) {
+ return actual != null && recordedBookmark.equals(bookmark("Customer", "1"))
+ ? Optional.of(actual)
+ : Optional.empty();
+ }
+ }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandPresentationTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandPresentationTest.java
new file mode 100644
index 00000000000..8e20a79f952
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandPresentationTest.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom.replay;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.Import;
+
+import org.apache.causeway.applib.annotation.Programmatic;
+import org.apache.causeway.applib.annotation.PropertyLayout;
+import org.apache.causeway.applib.annotation.Where;
+import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ReplayableCommandPresentationTest {
+
+ @Test
+ void replayableCommandLayoutShowsParticipantsBeforeControlsWithoutTargetSummary() throws Exception {
+ var layout = resource("ReplayableCommand.layout.fallback.xml");
+
+ assertThat(layout).contains("id=\"hasResult\"", "id=\"participants\"", "id=\"control\"");
+ assertThat(layout.indexOf("id=\"participants\"")).isLessThan(layout.indexOf("id=\"control\""));
+ assertThat(layout).doesNotContain("id=\"targetType\"", "id=\"targetId\"", "id=\"openTarget\"");
+ assertThat(ReplayableCommand.class.getMethod("getTargetType").isAnnotationPresent(Programmatic.class))
+ .isTrue();
+ assertThat(ReplayableCommand.class.getMethod("getTargetId").isAnnotationPresent(Programmatic.class))
+ .isTrue();
+ }
+
+ @Test
+ void participantLayoutAndColumnOrderFollowRecordedActualContract() throws Exception {
+ var layout = resource("ReplayableCommandParticipant.layout.fallback.xml");
+ var columns = resource("ReplayableCommandParticipant.columnOrder.fallback.txt")
+ .lines().toList();
+
+ assertThat(layout).containsSubsequence(
+ "span=\"4\"", "span=\"4\"", "span=\"4\"");
+ assertThat(layout).contains(
+ "id=\"replayableCommand\"", "id=\"role\"", "id=\"parameterName\"",
+ "id=\"logicalTypeName\"", "id=\"recordedBookmark\"", "id=\"target\"",
+ "id=\"argument\"", "id=\"actualBookmark\"", "id=\"result\"");
+ assertThat(layout).doesNotContain("id=\"owningInteractionId\"");
+ assertThat(columns).containsExactly(
+ "#replayableCommand", "role", "parameterName", "#recordedBookmark",
+ "target", "argument", "result", "#actualBookmark");
+ var owningIdLayout = ReplayableCommandParticipant.class
+ .getMethod("getOwningInteractionId")
+ .getAnnotation(PropertyLayout.class);
+ assertThat(owningIdLayout.hidden()).isEqualTo(Where.OBJECT_FORMS);
+ }
+
+ @Test
+ void moduleRegistersParticipantAndNavigationButNotLegacyTargetMixins() {
+ var imports = List.of(CausewayModuleExtCommandLogApplib.class
+ .getAnnotation(Import.class).value());
+
+ assertThat(imports).contains(
+ ReplayableCommandParticipant.class,
+ ReplayableCommand_previous.class,
+ ReplayableCommand_next.class);
+ assertThat(imports).doesNotContain(
+ ReplayableCommand_openTarget.class,
+ ReplayableCommand_openTargetTR.class);
+ }
+
+ private static String resource(final String name) throws IOException {
+ try (var stream = ReplayableCommand.class.getResourceAsStream(name)) {
+ assertThat(stream).as(name).isNotNull();
+ return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+}
diff --git a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java
index 65c156e70e0..8ef25d85203 100644
--- a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java
+++ b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java
@@ -208,6 +208,20 @@
+ " WHERE cl.startedAt is not null "
+ " AND cl.completedAt is not null "
+ " ORDER BY cl.timestamp DESC"), // programmatic LIMIT 1
+ @NamedQuery(
+ name = Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER,
+ query = "SELECT cl "
+ + " FROM CommandLogEntry cl "
+ + " WHERE cl.executeIn = org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn.FOREGROUND "
+ + " AND cl.timestamp >= :from "
+ + " ORDER BY cl.timestamp ASC"),
+ @NamedQuery(
+ name = Nq.FIND_FOREGROUND_BY_TIMESTAMP_BEFORE,
+ query = "SELECT cl "
+ + " FROM CommandLogEntry cl "
+ + " WHERE cl.executeIn = org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn.FOREGROUND "
+ + " AND cl.timestamp < :to "
+ + " ORDER BY cl.timestamp DESC"),
@NamedQuery(
name = Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATE,
query = "SELECT cl "
diff --git a/extensions/core/commandlog/persistence-jpa/src/test/java/org/apache/causeway/extensions/commandlog/jpa/integtests/CommandLogEntryAdjacentQuery_IntegTest.java b/extensions/core/commandlog/persistence-jpa/src/test/java/org/apache/causeway/extensions/commandlog/jpa/integtests/CommandLogEntryAdjacentQuery_IntegTest.java
new file mode 100644
index 00000000000..8fbc9c758ee
--- /dev/null
+++ b/extensions/core/commandlog/persistence-jpa/src/test/java/org/apache/causeway/extensions/commandlog/jpa/integtests/CommandLogEntryAdjacentQuery_IntegTest.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.extensions.commandlog.jpa.integtests;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.UUID;
+
+import jakarta.inject.Inject;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.iactn.InteractionService;
+import org.apache.causeway.applib.services.repository.RepositoryService;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn;
+import org.apache.causeway.extensions.commandlog.applib.dom.ReplayState;
+import org.apache.causeway.extensions.commandlog.jpa.dom.CommandLogEntry;
+import org.apache.causeway.testing.integtestsupport.applib.CausewayIntegrationTestAbstract;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(classes = AppManifest.class)
+@ActiveProfiles("test")
+class CommandLogEntryAdjacentQuery_IntegTest extends CausewayIntegrationTestAbstract {
+
+ @Inject CommandLogEntryRepository repository;
+ @Inject InteractionService interactionService;
+ @Inject RepositoryService repositoryService;
+
+ @BeforeEach
+ void setUp() {
+ interactionService.nextInteraction();
+ repositoryService.removeAll(CommandLogEntry.class);
+ }
+
+ @Test
+ void foregroundQueriesUseExpectedDirectionBoundaryAndLimit() {
+ var first = persist("2026-08-05T10:00:00Z", ExecuteIn.FOREGROUND);
+ persist("2026-08-05T10:30:00Z", ExecuteIn.BACKGROUND);
+ var second = persist("2026-08-05T11:00:00Z", ExecuteIn.FOREGROUND);
+ var third = persist("2026-08-05T12:00:00Z", ExecuteIn.FOREGROUND);
+
+ assertThat(repository.findForegroundSinceTimestamp(timestamp("2026-08-05T10:30:00Z")))
+ .containsExactly(second, third);
+ assertThat(repository.findForegroundSinceTimestamp(timestamp("2026-08-05T10:00:00Z"), 1))
+ .containsExactly(first);
+ assertThat(repository.findForegroundBeforeTimestamp(timestamp("2026-08-05T12:00:00Z"), null))
+ .containsExactly(second, first);
+ }
+
+ private CommandLogEntry persist(final String instant, final ExecuteIn executeIn) {
+ var entry = new CommandLogEntry();
+ entry.setInteractionId(UUID.randomUUID());
+ entry.setUsername("tester");
+ entry.setTimestamp(timestamp(instant));
+ entry.setTarget(Bookmark.forLogicalTypeNameAndIdentifier("demo.Customer", "1"));
+ entry.setExecuteIn(executeIn);
+ entry.setLogicalMemberIdentifier("demo.Customer#update");
+ entry.setReplayState(ReplayState.UNDEFINED);
+ repository.persist(entry);
+ return entry;
+ }
+
+ private static Timestamp timestamp(final String instant) {
+ return Timestamp.from(Instant.parse(instant));
+ }
+}
diff --git a/openspec/changes/reconcile-replayable-command-projection/tasks.md b/openspec/changes/reconcile-replayable-command-projection/tasks.md
index 65ae697ab24..fcabc82f448 100644
--- a/openspec/changes/reconcile-replayable-command-projection/tasks.md
+++ b/openspec/changes/reconcile-replayable-command-projection/tasks.md
@@ -1,35 +1,35 @@
## 1. Eligibility and Ordered Lookup Foundations
-- [ ] 1.1 Add a reusable replayable-command eligibility policy that retains state-changing and unclassifiable entries while excluding safe actions without recorded results.
-- [ ] 1.2 Extend `ReplayContext` compatibly with `BookmarkService` and `ApplicationFeatureRepository`, updating module wiring and existing fixtures without changing replay transaction behavior or depending on core metamodel internals.
-- [ ] 1.3 Add persistence-neutral foreground-before and foreground-since repository operations and reusable query behavior, with the corresponding Jakarta Persistence named queries and no schema changes.
-- [ ] 1.4 Add focused applib and JPA tests for state-changing, result-bearing safe, resultless safe, and unclassifiable eligibility plus ordered foreground repository lookup.
+- [x] 1.1 Add a reusable replayable-command eligibility policy that retains state-changing and unclassifiable entries while excluding safe actions without recorded results.
+- [x] 1.2 Extend `ReplayContext` compatibly with `BookmarkService` and `ApplicationFeatureRepository`, updating module wiring and existing fixtures without changing replay transaction behavior or depending on core metamodel internals.
+- [x] 1.3 Add persistence-neutral foreground-before and foreground-since repository operations and reusable query behavior, with the corresponding Jakarta Persistence named queries and no schema changes.
+- [x] 1.4 Add focused applib and JPA tests for state-changing, result-bearing safe, resultless safe, and unclassifiable eligibility plus ordered foreground repository lookup.
## 2. Participant Projection
-- [ ] 2.1 Add the documented public `ReplayableCommandParticipant` view model, roles, role-specific optional object properties, human-readable title, and identity-only memento parsing and serialization.
-- [ ] 2.2 Enrich `ReplayableCommand` with non-persisted `hasResult` and derived target, reference-parameter, and result participants while omitting scalar parameters.
-- [ ] 2.3 Resolve target and parameter actual bookmarks through `ResultRemappingService`, using explicit mappings in any state and recorded-bookmark fallback only after replay state `OK`.
-- [ ] 2.4 Gate result actual bookmarks on replay state `OK`, then use the configured mapping or recorded-bookmark fallback, without depending directly on persistent mapping storage.
-- [ ] 2.5 Rehydrate participant bookmarks and object links from the owning command and current mapping state, resolving local objects best-effort through `BookmarkService`.
-- [ ] 2.6 Add focused tests for participant derivation, mapping and replay-state combinations, object resolution, result presence, titles, readable mementos, and rehydration.
+- [x] 2.1 Add the documented public `ReplayableCommandParticipant` view model, roles, role-specific optional object properties, human-readable title, and identity-only memento parsing and serialization.
+- [x] 2.2 Enrich `ReplayableCommand` with non-persisted `hasResult` and derived target, reference-parameter, and result participants while omitting scalar parameters.
+- [x] 2.3 Resolve target and parameter actual bookmarks through `ResultRemappingService`, using explicit mappings in any state and recorded-bookmark fallback only after replay state `OK`.
+- [x] 2.4 Gate result actual bookmarks on replay state `OK`, then use the configured mapping or recorded-bookmark fallback, without depending directly on persistent mapping storage.
+- [x] 2.5 Rehydrate participant bookmarks and object links from the owning command and current mapping state, resolving local objects best-effort through `BookmarkService`.
+- [x] 2.6 Add focused tests for participant derivation, mapping and replay-state combinations, object resolution, result presence, titles, readable mementos, and rehydration.
## 3. Navigation and Presentation
-- [ ] 3.1 Add previous/next replayable-command behavior that scans foreground ordering, skips ineligible entries, preserves `ReplayContext`, and disables safely at boundaries.
-- [ ] 3.2 Add and register safe command-recording-suppressed previous/next mixins with focused action and non-mutation tests.
-- [ ] 3.3 Add participant fallback layout and table column order, and update replayable-command layout/columns to show `hasResult` and Participants before controls while removing redundant target presentation.
-- [ ] 3.4 Register the participant view model and new mixins in the commandlog applib module and add focused metadata/layout/order tests.
+- [x] 3.1 Add previous/next replayable-command behavior that scans foreground ordering, skips ineligible entries, preserves `ReplayContext`, and disables safely at boundaries.
+- [x] 3.2 Add and register safe command-recording-suppressed previous/next mixins with focused action and non-mutation tests.
+- [x] 3.3 Add participant fallback layout and table column order, and update replayable-command layout/columns to show `hasResult` and Participants before controls while removing redundant target presentation.
+- [x] 3.4 Register the participant view model and new mixins in the commandlog applib module and add focused metadata/layout/order tests.
## 4. Existing Manager Adapters
-- [ ] 4.1 Apply the shared eligibility policy to existing export-manager collections without changing their logical type, baseline, memento, export actions, or replay-state filters.
-- [ ] 4.2 Apply the shared eligibility policy to the existing completed-or-excluded replay collection while leaving pending-or-failed repository results unfiltered.
-- [ ] 4.3 Add regression tests proving resultless safe commands are omitted from general existing-manager projections, retained in pending-or-failed work, and never deleted or mutated.
+- [x] 4.1 Apply the shared eligibility policy to existing export-manager collections without changing their logical type, baseline, memento, export actions, or replay-state filters.
+- [x] 4.2 Apply the shared eligibility policy to the existing completed-or-excluded replay collection while leaving pending-or-failed repository results unfiltered.
+- [x] 4.3 Add regression tests proving resultless safe commands are omitted from general existing-manager projections, retained in pending-or-failed work, and never deleted or mutated.
## 5. Documentation and Verification
-- [ ] 5.1 Document result presence, participant roles, recorded/actual bookmark semantics, object availability, mementos, eligibility, adjacent navigation, and the temporary use of separate managers until P2.
-- [ ] 5.2 Run focused commandlog applib and JPA tests with JDK 21, including existing replay mapping, persistent mapping, export manager, and replay manager regressions.
-- [ ] 5.3 Run the affected aggregate Maven build, IDE compilation and inspections, strict OpenSpec validation, and repository checks.
-- [ ] 5.4 Confirm the implementation does not introduce the P2 unified manager or R1/R2, E1/W1, B1/B2 behavior and does not restore commandlog JDO.
+- [x] 5.1 Document result presence, participant roles, recorded/actual bookmark semantics, object availability, mementos, eligibility, adjacent navigation, and the temporary use of separate managers until P2.
+- [x] 5.2 Run focused commandlog applib and JPA tests with JDK 21, including existing replay mapping, persistent mapping, export manager, and replay manager regressions.
+- [x] 5.3 Run the affected aggregate Maven build, IDE compilation and inspections, strict OpenSpec validation, and repository checks.
+- [x] 5.4 Confirm the implementation does not introduce the P2 unified manager or R1/R2, E1/W1, B1/B2 behavior and does not restore commandlog JDO.