From 246d56bcb054ab978a7ad00753a6350c4b544638 Mon Sep 17 00:00:00 2001 From: andi-huber Date: Mon, 27 Apr 2026 09:34:59 +0200 Subject: [PATCH 1/4] CAUSEWAY-3989: forwardport of replay managers v2->v4 --- .../CausewayModuleExtCommandLogApplib.java | 34 +- .../commandlog/applib/app/CommandLogMenu.java | 36 +- .../applib/dom/CommandLogEntry.java | 4 +- .../applib/dom/CommandLogEntryRepository.java | 24 +- .../CommandLogEntryRepositoryAbstract.java | 73 ++-- .../commandlog/applib/dom/ReplayState.java | 35 +- ...tManager#exported.columnOrder.fallback.txt | 6 + ...er#notYetExported.columnOrder.fallback.txt | 6 + .../dom/replay/CommandExportManager.java | 297 ++++++++++---- .../CommandExportManager.layout.fallback.xml | 12 +- ...r#pendingOrFailed.columnOrder.fallback.txt | 6 + ...cceededOrExcluded.columnOrder.fallback.txt | 6 + .../dom/replay/CommandReplayManager.java | 371 ++++++++++++++---- .../CommandReplayManager.layout.fallback.xml | 12 +- .../applib/dom/replay/ReplayContext.java | 6 +- ...ReplayableCommand.columnOrder.fallback.txt | 6 + .../applib/dom/replay/ReplayableCommand.java | 224 +++++------ .../ReplayableCommand.layout.fallback.xml | 36 +- .../dom/replay/ReplayableCommand_delete.java | 49 +++ .../ReplayableCommand_excludeFromReplay.java | 54 +++ .../ReplayableCommand_makeExportable.java | 55 +++ ...ReplayableCommand_openCommandLogEntry.java | 54 +++ .../ReplayableCommand_replayOrRetry.java | 57 +++ .../dom/replay/TimestampMarshallUtil.java | 48 +++ .../CommandLog_IntegTestAbstract.java | 5 +- .../commandlog/jpa/dom/CommandLogEntry.java | 14 +- 26 files changed, 1178 insertions(+), 352 deletions(-) create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#exported.columnOrder.fallback.txt create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#notYetExported.columnOrder.fallback.txt create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#pendingOrFailed.columnOrder.fallback.txt create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#succeededOrExcluded.columnOrder.fallback.txt create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_delete.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_excludeFromReplay.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_makeExportable.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_openCommandLogEntry.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/TimestampMarshallUtil.java 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 e6a467b836a..8cdf6f7877e 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,9 +22,11 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.apache.causeway.applib.services.clock.ClockService; import org.apache.causeway.applib.services.command.CommandExecutorService; import org.apache.causeway.applib.services.iactn.InteractionService; import org.apache.causeway.applib.services.repository.RepositoryService; +import org.apache.causeway.applib.services.xactn.TransactionService; import org.apache.causeway.core.config.util.SpringProfileUtil; import org.apache.causeway.extensions.commandlog.applib.app.CommandLogMenu; import org.apache.causeway.extensions.commandlog.applib.contributions.HasInteractionId_commandLogEntry; @@ -36,8 +38,14 @@ import org.apache.causeway.extensions.commandlog.applib.dom.mixins.CommandLogEntry_childCommands; import org.apache.causeway.extensions.commandlog.applib.dom.mixins.CommandLogEntry_openResultObject; import org.apache.causeway.extensions.commandlog.applib.dom.mixins.CommandLogEntry_siblingCommands; +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.ReplayableCommand_delete; +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_openCommandLogEntry; +import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_replayOrRetry; 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; @@ -59,6 +67,24 @@ CommandLogEntry_childCommands.class, CommandLogEntry_openResultObject.class, CommandLogEntry_siblingCommands.class, + ReplayableCommand_makeExportable.class, + ReplayableCommand_openCommandLogEntry.class, + ReplayableCommand_replayOrRetry.class, + ReplayableCommand_excludeFromReplay.class, + ReplayableCommand_delete.class, + CommandExportManager.changeSince.class, + CommandExportManager.previousHour.class, + CommandExportManager.nextHour.class, + CommandExportManager.exportSelected.class, + CommandExportManager.makeSelectedExportable.class, + CommandReplayManager.changeSince.class, + CommandExportManager.previousHour.class, + CommandReplayManager.nextHour.class, + CommandReplayManager.importCommands.class, + CommandReplayManager.replayOrRetrySelected.class, + CommandReplayManager.excludeSelectedFromReplay.class, + CommandReplayManager.deleteSelectedSucceededOrExcluded.class, + CommandReplayManager.deleteSelectedPendingOrFailed.class, // @Component's RunBackgroundCommandsJob.class, @@ -119,10 +145,12 @@ public static void honorSystemEnvironment() { @Bean ReplayContext replayContext( final RepositoryService repositoryService, final InteractionService interactionService, + final TransactionService transactionService, final CommandLogEntryRepository commandLogEntryRepository, - final CommandExecutorService commandExecutorService) { - return new ReplayContext(repositoryService, interactionService, - commandLogEntryRepository, commandExecutorService); + final CommandExecutorService commandExecutorService, + final ClockService clockService) { + return new ReplayContext(repositoryService, interactionService, transactionService, + commandLogEntryRepository, commandExecutorService, clockService); } } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java index 0e33e4e7c57..8dafea89645 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java @@ -18,8 +18,10 @@ */ package org.apache.causeway.extensions.commandlog.applib.app; +import java.sql.Timestamp; import java.time.LocalDate; import java.time.ZoneId; +import java.time.temporal.ChronoUnit; import java.util.List; import jakarta.inject.Inject; @@ -32,6 +34,7 @@ import org.apache.causeway.applib.annotation.DomainService; import org.apache.causeway.applib.annotation.DomainServiceLayout; import org.apache.causeway.applib.annotation.MemberSupport; +import org.apache.causeway.applib.annotation.ParameterLayout; import org.apache.causeway.applib.annotation.PriorityPrecedence; import org.apache.causeway.applib.annotation.Publishing; import org.apache.causeway.applib.annotation.RestrictTo; @@ -156,8 +159,19 @@ public class DomainEvent extends ActionDomainEvent { } public class exportManager { public class DomainEvent extends ActionDomainEvent { } - @MemberSupport public CommandExportManager act() { - return new CommandExportManager(null, replayContext); + @MemberSupport public CommandExportManager act( + @ParameterLayout( + describedAs = "Limits the commands shown; " + + "only commands since this timestamp are available for export. " + + "Set to a time immediately before the commands to be replayed.") + final java.sql.Timestamp since + ) { + return new CommandExportManager(since, replayContext); + } + + @MemberSupport public java.sql.Timestamp defaultSince() { + final var now = clockService.getClock().nowAsJavaSqlTimestamp(); + return truncatedTo(now, ChronoUnit.HOURS); } } @@ -172,11 +186,25 @@ public class DomainEvent extends ActionDomainEvent { } public class replayManager { public class DomainEvent extends ActionDomainEvent { } - @MemberSupport public CommandReplayManager act() { - return new CommandReplayManager(null, replayContext); + @MemberSupport public CommandReplayManager act( + @ParameterLayout( + describedAs = "Limits the commands shown; " + + "only commands since this timestamp are available for replay. " + + "Set to a time immediately before the commands to be replayed.") + final java.sql.Timestamp since + ) { + return new CommandReplayManager(since, replayContext); + } + + @MemberSupport public java.sql.Timestamp defaultSince() { + final var now = clockService.getClock().nowAsJavaSqlTimestamp(); + return truncatedTo(now, ChronoUnit.HOURS); } } + private static Timestamp truncatedTo(final Timestamp now, final ChronoUnit chronoUnit) { + return Timestamp.from(now.toInstant().truncatedTo(chronoUnit)); + } private LocalDate now() { return clockService.getClock().nowAsLocalDate(ZoneId.systemDefault()); 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 efae57536c3..99a167025f0 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 @@ -144,7 +144,9 @@ 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_BY_REPLAY_STATE = LOGICAL_TYPE_NAME + ".findNotYetReplayed"; + 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"; + public static final String FIND_BACKGROUND_AND_NOT_YET_STARTED = LOGICAL_TYPE_NAME + ".findBackgroundAndNotYetStarted"; public static final String FIND_RECENT_BACKGROUND_BY_TARGET = LOGICAL_TYPE_NAME + ".findRecentBackgroundByTarget"; } 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 d067f446e76..3cc377148c1 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 @@ -18,6 +18,7 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom; +import java.sql.Timestamp; import java.time.LocalDate; import java.util.Collections; import java.util.List; @@ -120,6 +121,20 @@ List findByTargetAndFromAndTo( */ List findSince(UUID interactionId, Integer batchSize); + List findForegroundSinceTimestampAndCanBeExported(Timestamp since); + + List findForegroundSinceTimestampAndHasBeenExported(Timestamp since); + + /** + * Command Replay feature: Can replay or retry. + */ + List findForegroundSinceTimestampAndWithReplayPendingOrFailed(Timestamp since); + + /** + * Command Replay feature: Cannot replay or retry. + */ + List findSinceAndWithReplayOkOrExcluded(Timestamp since); + /** * Returns any persisted commands that have not yet started. * @@ -158,15 +173,6 @@ List findByTargetAndFromAndTo( */ Optional findMostRecentCompleted(); - /** - * Command Replay feature: Can replay or retry. - */ - List findReplayPendingOrFailed(); - /** - * Command Replay feature: Cannot replay or retry. - */ - List findReplaySucceededOrExcluded(); - CommandLogEntry saveForReplay(CommandDto dto); default List saveForReplay(@Nullable final List commandDtoList) { 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 d7e8cb391a9..d4656396785 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 @@ -332,44 +332,13 @@ public Optional findMostRecentCompleted() { Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_MOST_RECENT_COMPLETED)) ); } - @Override - public List findReplayPendingOrFailed() { - return _Casts.uncheckedCast( - repositoryService().allMatches( - Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_BY_REPLAY_STATE) - .withParameter("replayState1", ReplayState.PENDING) - .withParameter("replayState2", ReplayState.FAILED)) - ); - } - /** - * Command Replay feature: Cannot replay or retry. - */ - @Override - public List findReplaySucceededOrExcluded() { - return _Casts.uncheckedCast( - repositoryService().allMatches( - Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_BY_REPLAY_STATE) - .withParameter("replayState1", ReplayState.OK) - .withParameter("replayState2", ReplayState.EXCLUDED)) - ); - } @Override public C saveForReplay(final CommandDto commandToReplay) { - -//TODO why? -// if(commandToReplay.getMember().getInteractionType() == InteractionType.ACTION_INVOCATION) { -// final MapDto userData = commandToReplay.getUserData(); -// if (userData == null ) -// throw new IllegalStateException(String.format( -// "Can only persist action DTOs with additional userData; got: \n%s", -// CommandDtoUtils.dtoMapper().toString(commandToReplay))); -// } - final C entity = factoryService.detachedEntity(commandLogEntryClass); entity.init(commandToReplay, ReplayState.PENDING, 0); entity.setParentInteractionId(null); // n/a for replay - entity.setExecuteIn(null); // to be specified later depending on user action + entity.setExecuteIn(ExecuteIn.FOREGROUND); // only ever replay foreground commands persist(entity); @@ -425,6 +394,46 @@ private List findSince( : commandJdos; } + @Override + public List findForegroundSinceTimestampAndCanBeExported(final Timestamp since) { + return findForegroundSinceTimestampWithState(since, ReplayState.UNDEFINED); + } + + @Override + public List findForegroundSinceTimestampAndHasBeenExported(final Timestamp since) { + return findForegroundSinceTimestampWithState(since, ReplayState.EXPORTED); + } + + @Override + public List findForegroundSinceTimestampAndWithReplayPendingOrFailed(final Timestamp since) { + return findForegroundSinceTimestampWithStates(since, ReplayState.PENDING, ReplayState.FAILED); + } + + /** + * Command Replay feature: Cannot replay or retry. + */ + @Override + public List findSinceAndWithReplayOkOrExcluded(final Timestamp since) { + return findForegroundSinceTimestampWithStates(since, ReplayState.OK, ReplayState.EXCLUDED); + } + + private List findForegroundSinceTimestampWithState(final Timestamp from, final ReplayState replayState) { + return _Casts.uncheckedCast( + repositoryService().allMatches( + Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATE) + .withParameter("from", from) + .withParameter("replayState", replayState))); + } + + private List findForegroundSinceTimestampWithStates(final Timestamp from, final ReplayState replayState1, final ReplayState replayState2) { + return _Casts.uncheckedCast( + repositoryService().allMatches( + Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATES) + .withParameter("from", from) + .withParameter("replayState1", replayState1) + .withParameter("replayState2", replayState2))); + } + private RepositoryService repositoryService() { return repositoryServiceProvider.get(); } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java index 932dfa7394f..5be703106dd 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java @@ -54,33 +54,40 @@ public enum ReplayState { public boolean isExported() { return this == EXPORTED; } public boolean isFailed() { return this == FAILED; } - public boolean canExport() { + public boolean isExportable() { return this == ReplayState.UNDEFINED; } - public boolean canReplayOrRetryOrMarkForExclusion() { + public boolean isPendingOrFailed() { return this == ReplayState.PENDING || this == ReplayState.FAILED; } + public boolean isOkOrExcluded() { + return this == ReplayState.OK + || this == ReplayState.EXCLUDED; + } + // -- NULL SAFE - public static boolean canExport(final @Nullable ReplayState replayState) { - return replayState!=null - ? replayState.canExport() - : true; + public static boolean isExportable(final @Nullable ReplayState replayState) { + return replayState == null + || replayState.isExportable(); + } + + public static boolean isExported(final @Nullable ReplayState replayState) { + return replayState != null + && replayState.isExported(); } - public static boolean canReplayOrRetryOrMarkForExclusion(final @Nullable ReplayState replayState) { - return replayState!=null - ? replayState.canReplayOrRetryOrMarkForExclusion() - : false; + public static boolean isPendingOrFailed(final @Nullable ReplayState replayState) { + return replayState != null + && replayState.isPendingOrFailed(); } - public static boolean isExported(final ReplayState replayState) { - return replayState!=null - ? replayState.isExported() - : false; + public static boolean isOkOrExcluded(final @Nullable ReplayState replayState) { + return replayState != null + && replayState.isOkOrExcluded(); } } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#exported.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#exported.columnOrder.fallback.txt new file mode 100644 index 00000000000..bde91a6f91d --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#exported.columnOrder.fallback.txt @@ -0,0 +1,6 @@ +#interactionId +timestamp +targetType +targetId +member +replayState \ No newline at end of file diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#notYetExported.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#notYetExported.columnOrder.fallback.txt new file mode 100644 index 00000000000..bde91a6f91d --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager#notYetExported.columnOrder.fallback.txt @@ -0,0 +1,6 @@ +#interactionId +timestamp +targetType +targetId +member +replayState \ No newline at end of file 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 878f545f81d..bf9e2dbe94f 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 @@ -18,8 +18,12 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom.replay; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.chrono.ChronoZonedDateTime; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; import jakarta.inject.Inject; import jakarta.inject.Named; @@ -32,7 +36,12 @@ 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.Property; +import org.apache.causeway.applib.annotation.PropertyLayout; +import org.apache.causeway.applib.annotation.Publishing; +import org.apache.causeway.applib.annotation.RestrictTo; import org.apache.causeway.applib.annotation.SemanticsOf; import org.apache.causeway.applib.util.schema.CommandDtoUtils; import org.apache.causeway.applib.value.Blob; @@ -43,135 +52,267 @@ import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository; import org.apache.causeway.extensions.commandlog.applib.dom.ReplayState; +import static org.apache.causeway.extensions.commandlog.applib.dom.replay.TimestampMarshallUtil.fromString; + +import lombok.Getter; + @DomainObject(introspection = Introspection.ANNOTATION_REQUIRED) @DomainObjectLayout(cssClassFa = "solid share-from-square") @Named(CommandExportManager.LOGICAL_TYPE_NAME) -public record CommandExportManager( - ReplayContext replayContext) implements ViewModel { +public final class CommandExportManager implements ViewModel { + + public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandExportManager"; - public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandExportManager"; + public static abstract class ActionDomainEvent + extends CausewayModuleExtCommandLogApplib.ActionDomainEvent { } + + private ReplayContext replayContext; @Inject public CommandExportManager( final String memento, final ReplayContext replayContext) { - this(replayContext); + this(fromString(memento, replayContext.clockService().getClock().nowAsJavaSqlTimestamp()), replayContext); + } + + public CommandExportManager( + final java.sql.Timestamp since, + final ReplayContext replayContext) { + this.since = since; + this.replayContext = replayContext; } @ObjectSupport public String title() { return "Command Export Manager"; } - @Action(semantics = SemanticsOf.IDEMPOTENT_ARE_YOU_SURE) + + @Property + @PropertyLayout(describedAs = "Only commands since this timestamp are available for export") + @Getter + private java.sql.Timestamp since; + + @Action( + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = previousHour.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "since", sequence = "1", + named = "Previous", + position = ActionLayout.Position.PANEL, + describedAs = "Move back one hour" + ) + public class previousHour { + public class DomainEvent extends ActionDomainEvent { } + + @MemberSupport public CommandExportManager act() { + return new CommandExportManager(addSeconds(since, -3600), replayContext); + } + } + + @Action( + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = nextHour.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "since", sequence = "3", + named = "Next", + position = ActionLayout.Position.PANEL, + describedAs = "Move forward one hour" + ) + public class nextHour { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport public CommandExportManager act() { + return new CommandExportManager(addSeconds(since, +3600), replayContext); + } + } + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = changeSince.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) @ActionLayout( - sequence = "0.1", - describedAs = "Deletes all commands, regardless of state (cannot be undone)") - public CommandExportManager deleteAll() { - commandLogEntryRepository().removeAll(); - return this; + associateWith = "since", sequence = "2", + named = "Change", + position = ActionLayout.Position.PANEL + ) + public class changeSince { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport public CommandExportManager act(final java.sql.Timestamp since) { + return new CommandExportManager(since, replayContext); + } + @MemberSupport public java.sql.Timestamp defaultSince() { + return CommandExportManager.this.since; + } } + private static Timestamp addSeconds(final Timestamp since, final int secondsToAdd) { + return Timestamp.from(since.toInstant().plusSeconds(secondsToAdd)); + } + + // -- NOT YET EXPORTED @Collection @CollectionLayout( - describedAs = "Commands that can be exported") + describedAs = "Commands that can be exported" + ) public List getNotYetExported() { - return commandLogEntryRepository().findAll().stream() - .filter(entry->ReplayState.canExport(entry.getReplayState())) + return commandLogEntryRepository().findForegroundSinceTimestampAndCanBeExported(since).stream() .map(entry->new ReplayableCommand( entry.getInteractionId(), replayContext)) - .toList(); + .collect(Collectors.toList()); } - @Action(choicesFrom = "notYetExported", semantics = SemanticsOf.NON_IDEMPOTENT) - @ActionLayout(associateWith = "notYetExported", - sequence = "1.1", - cssClassFa = "solid share-from-square", - cssClass = "btn-primary", - describedAs = "Exports selected Commands as zipped DTOs for import later. " - + "(You need to refresh the page to see changed states.)") - public Blob exportSelected( - final List selected) { - - var selectedCommandLogEntries = selected.stream() - .map(ReplayableCommand::commandLogEntry) - .filter(Optional::isPresent) - .map(Optional::get) - .sorted() - .toList(); - - var yaml = CommandDtoUtils.toMultiDocYaml( - selectedCommandLogEntries.stream() - .filter(entry->!ReplayState.isExported(entry.getReplayState())) - .map(CommandLogEntry::getCommandDto) - .toList()); - - var blob = Clob.of("commands.yaml", CommonMimeType.YAML, yaml) - .toBlobUtf8() - .zip(); - - // do this last once we have successfully created the Clob - selectedCommandLogEntries.forEach(c->c.setReplayState(ReplayState.EXPORTED)); - - return blob; - } + @Action( + restrictTo = RestrictTo.PROTOTYPING, + choicesFrom = "notYetExported", + semantics = SemanticsOf.NON_IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = exportSelected.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "notYetExported", sequence = "1.1", + cssClassFa = "solid share-from-square", + cssClass = "btn-primary", + describedAs = "Exports selected Commands as zipped DTOs for import later. " + + "Refresh the page to see changed states." + ) + public class exportSelected { + public class DomainEvent extends ActionDomainEvent { } - @Action(choicesFrom = "notYetExported") - @ActionLayout(associateWith = "notYetExported", sequence = "1.2", - describedAs = "Deletes selected Commands (cannot be undone)") - public CommandExportManager deleteSelected(final List selected) { - selected.stream() - .forEach(ReplayableCommand::delete); // filtered on its own responsibility - return this; + @MemberSupport public Blob act( + final List selected, + final String filenamePrefix, + final boolean filenameTimestamp + ) { + + var selectedCommandLogEntries = selected.stream() + .map(ReplayableCommand::commandLogEntry) + .filter(Optional::isPresent) + .map(Optional::get) + .sorted() + .collect(Collectors.toList()); + + var yaml = CommandDtoUtils.toYaml( + selectedCommandLogEntries.stream() + .filter(entry->!ReplayState.isExported(entry.getReplayState())) // shouldn't be necessary unless a race condition + .map(CommandLogEntry::getCommandDto) + .collect(Collectors.toList())); + + final var replayableCommand = selected.get(0); // validate ensures there is at least one command + final var timestamp = filenameTimestamp + ? replayableCommand.getTimestampIfAny() + .map(ChronoZonedDateTime::toInstant) + .map(Instant::toString) + .map(x -> "." + x.replaceAll("[^A-Za-z0-9._-]", "_")) // make safe within filename + .orElse("") + : ""; + final var filename = filenamePrefix + timestamp; + + var blob = Clob.of(filename, CommonMimeType.YAML, yaml) + .toBlobUtf8(); + + // do this last once we have successfully created the Clob + selectedCommandLogEntries.forEach(c->c.setReplayState(ReplayState.EXPORTED)); + + return blob; + } + + @MemberSupport public String disableAct() { + return getNotYetExported().isEmpty() ? "No commands in collection" : null; + } + + @MemberSupport public String defaultFilenamePrefix() { + return "commands"; + } + + @MemberSupport public boolean defaultFilenameTimestamp() { + return true; + } + + @MemberSupport public String validateSelected(final List selected) { + return selected != null && selected.isEmpty() ? "Select at least one command to export" : null; + } + + // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet + @MemberSupport + public List choicesSelected() { + return getNotYetExported(); + } } + // -- EXPORTED @Collection - @CollectionLayout( - describedAs = "Commands that were exported") + @CollectionLayout(describedAs = "Commands that have been exported") public List getExported() { - return commandLogEntryRepository().findAll().stream() - .filter(entry->ReplayState.isExported(entry.getReplayState())) + return commandLogEntryRepository().findForegroundSinceTimestampAndHasBeenExported(since).stream() .map(entry->new ReplayableCommand( entry.getInteractionId(), replayContext)) - .toList(); + .collect(Collectors.toList()); } - @Action(choicesFrom = "exported") - @ActionLayout(associateWith = "exported", sequence = "2.1", - describedAs = "Makes selected Commands exportable (again)") - public CommandExportManager makeSelectedExportable(final List selected) { - selected.stream() - .forEach(ReplayableCommand::makeExportable); // filtered on its own responsibility - return this; - } - @Action(choicesFrom = "exported") - @ActionLayout(associateWith = "exported", sequence = "2.2", - named = "Delete Selected", - describedAs = "Deletes selected Commands (cannot be undone)") - public CommandExportManager deleteSelected2(final List selected) { - selected.stream() - .forEach(ReplayableCommand::delete); // filtered on its own responsibility - return this; + @Action( + restrictTo = RestrictTo.PROTOTYPING, + choicesFrom = "exported", + commandPublishing = Publishing.DISABLED, + semantics = SemanticsOf.IDEMPOTENT, + domainEvent = makeSelectedExportable.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "exported", sequence = "2.1", + describedAs = "Makes selected Commands exportable (again)" + ) + public class makeSelectedExportable { + public class DomainEvent extends ActionDomainEvent { } + + @MemberSupport + public CommandExportManager act(final List selected) { + selected.forEach(ReplayableCommand::makeExportable); // filtered on its own responsibility + return CommandExportManager.this; + } + + @MemberSupport + public String disableAct() { + return getExported().isEmpty() ? "No commands in collection" : null; + } + + @MemberSupport + public String validateSelected(final List selected) { + return selected != null && selected.isEmpty() ? "Select at least one command" : null; + } + + // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet + @MemberSupport + public List choicesSelected() { + return getExported(); + } } + // -- VM STATE @Override public String viewModelMemento() { - // TODO could use to store filter state - return null; + return TimestampMarshallUtil.toString(this.since); } // -- HELPER - private CommandLogEntryRepository commandLogEntryRepository() { return replayContext.commandLogEntryRepository(); } - } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml index df20c9b73aa..ba0d2da0ef7 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.layout.fallback.xml @@ -1,9 +1,17 @@ - + - + + + + + + + + + diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#pendingOrFailed.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#pendingOrFailed.columnOrder.fallback.txt new file mode 100644 index 00000000000..bde91a6f91d --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#pendingOrFailed.columnOrder.fallback.txt @@ -0,0 +1,6 @@ +#interactionId +timestamp +targetType +targetId +member +replayState \ No newline at end of file diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#succeededOrExcluded.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#succeededOrExcluded.columnOrder.fallback.txt new file mode 100644 index 00000000000..bde91a6f91d --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager#succeededOrExcluded.columnOrder.fallback.txt @@ -0,0 +1,6 @@ +#interactionId +timestamp +targetType +targetId +member +replayState \ No newline at end of file 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 f734e487f96..d7fdb81a42d 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 @@ -18,7 +18,9 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom.replay; +import java.sql.Timestamp; import java.util.List; +import java.util.stream.Collectors; import jakarta.inject.Inject; import jakarta.inject.Named; @@ -31,144 +33,360 @@ 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.Parameter; +import org.apache.causeway.applib.annotation.Property; +import org.apache.causeway.applib.annotation.PropertyLayout; +import org.apache.causeway.applib.annotation.Publishing; +import org.apache.causeway.applib.annotation.RestrictTo; import org.apache.causeway.applib.annotation.SemanticsOf; import org.apache.causeway.applib.util.schema.CommandDtoUtils; import org.apache.causeway.applib.value.Blob; -import org.apache.causeway.applib.value.NamedWithMimeType.CommonMimeType; import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib; import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository; import org.apache.causeway.schema.cmd.v2.CommandDto; +import static org.apache.causeway.extensions.commandlog.applib.dom.replay.TimestampMarshallUtil.fromString; + +import lombok.Getter; + @DomainObject(introspection = Introspection.ANNOTATION_REQUIRED) @DomainObjectLayout(cssClassFa = "solid circle-play") @Named(CommandReplayManager.LOGICAL_TYPE_NAME) -public record CommandReplayManager( - ReplayContext replayContext) implements ViewModel { +public final class CommandReplayManager implements ViewModel { public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandReplayManager"; + public static abstract class ActionDomainEvent + extends CausewayModuleExtCommandLogApplib.ActionDomainEvent { } + + private ReplayContext replayContext; + @Inject public CommandReplayManager( final String memento, final ReplayContext replayContext) { - this(replayContext); + this(fromString(memento, replayContext.clockService().getClock().nowAsJavaSqlTimestamp()), replayContext); + } + + public CommandReplayManager( + final java.sql.Timestamp since, + final ReplayContext replayContext) { + this.since = since; + this.replayContext = replayContext; } @ObjectSupport public String title() { return "Command Replay Manager"; } - @Action + + @Property + @PropertyLayout(describedAs = "Only commands since this timestamp are available for export") + @Getter + private java.sql.Timestamp since; + + @Action( + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = previousHour.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) @ActionLayout( - sequence = "0.1", - cssClass = "btn-primary", - describedAs = "Imports commands from a zipped yaml, then persists them with replayState=PENDING.") - public CommandReplayManager importCommands( - @Parameter(fileAccept = ".zip") - final Blob zippedCommandsYaml) { + associateWith = "since", sequence = "1", + named = "Previous", + position = ActionLayout.Position.PANEL, + describedAs = "Move back one hour" + ) + public class previousHour { + public class DomainEvent extends ActionDomainEvent { } - var yamlDs = zippedCommandsYaml.unZip(CommonMimeType.YAML).asDataSource(); + @MemberSupport public CommandReplayManager act() { + return new CommandReplayManager(addSeconds(since, -3600), replayContext); + } + } - final List commandDtos = CommandDtoUtils.fromYaml(yamlDs); - commandDtos.forEach(commandLogEntryRepository()::saveForReplay); + @Action( + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = nextHour.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "since", sequence = "3", + named = "Next", + position = ActionLayout.Position.PANEL, + describedAs = "Move forward one hour" + ) + public class nextHour { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport public CommandReplayManager act() { + return new CommandReplayManager(addSeconds(since, +3600), replayContext); + } + } - return this; + @Action( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = changeSince.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "since", sequence = "2", + named = "Change", + position = ActionLayout.Position.PANEL + ) + public class changeSince { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport public CommandReplayManager act(final java.sql.Timestamp since) { + return new CommandReplayManager(since, replayContext); + } + @MemberSupport public java.sql.Timestamp defaultSince() { + return CommandReplayManager.this.since; + } } - @Action(semantics = SemanticsOf.IDEMPOTENT_ARE_YOU_SURE) + private static Timestamp addSeconds(final Timestamp since, final int secondsToAdd) { + return Timestamp.from(since.toInstant().plusSeconds(secondsToAdd)); + } + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = importCommands.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) @ActionLayout( - sequence = "0.2", - describedAs = "Deletes all commands, regardless of state (cannot be undone)") - public CommandReplayManager deleteAll() { - commandLogEntryRepository().removeAll(); - return this; + sequence = "0.1", + associateWith = "pendingOrFailed", + cssClass = "btn-primary", + describedAs = "Imports commands from a zipped yaml, then persists them with replayState=PENDING." + ) + public class importCommands { + public class DomainEvent extends ActionDomainEvent { } + public CommandReplayManager act( + @Parameter(fileAccept = ".yml") + final Blob commandsYaml) { + var yamlDs = commandsYaml.asDataSource(); + + final List commandDtos = CommandDtoUtils.fromYaml(yamlDs); + commandDtos.forEach(commandLogEntryRepository()::saveForReplay); + + return CommandReplayManager.this; + } } + // -- PENDING OR FAILED @Collection @CollectionLayout( - describedAs = "Imported Commands that can be either replayed (replayState=PENDING) or retried (when replayState=FAILED)") + describedAs = "Imported Commands that can be either replayed (replayState=PENDING) or retried (when replayState=FAILED)" + ) public List getPendingOrFailed() { - return commandLogEntryRepository().findReplayPendingOrFailed().stream() + return commandLogEntryRepository().findForegroundSinceTimestampAndWithReplayPendingOrFailed(since).stream() .map(entry->new ReplayableCommand( entry.getInteractionId(), replayContext)) - .toList(); - } - - @Action(choicesFrom = "pendingOrFailed") - @ActionLayout(associateWith = "pendingOrFailed", - sequence = "1.1", - cssClassFa = "solid circle-play", - cssClass = "btn-primary", - describedAs = "Executes the list of commands in sequence, after having sorted them by their timestamp. " - + "If any of the given commands fails, " - + "the surrounding transaction is rolled back and any successful commands are undone). " - + "The command, that caused the failure, gets marked as FAILED.") - public CommandReplayManager replayOrRetrySelected(final List selected) { - var replayables = selected.stream() - .sorted() - .toList(); - for(var replayableCommand : replayables) { - var tryReplayOrRetry = replayableCommand.tryReplayOrRetry(); // filtered on its own responsibility - if(tryReplayOrRetry.isFailure()) { - return this; // stop further execution - } - } - return this; - } - - @Action(choicesFrom = "pendingOrFailed") - @ActionLayout(associateWith = "pendingOrFailed", sequence = "1.2", - describedAs = "Marks selected Commands to be EXCLUDED from replay") - public CommandReplayManager excludeSelectedFromReplay(final List selected) { - selected.stream() - .forEach(ReplayableCommand::excludeFromReplay); // filtered on its own responsibility - return this; - } - - @Action(choicesFrom = "pendingOrFailed") - @ActionLayout(associateWith = "pendingOrFailed", sequence = "1.3", - describedAs = "Deletes selected Commands (cannot be undone)") - public CommandReplayManager deleteSelected(final List selected) { - selected.stream() - .forEach(ReplayableCommand::delete); // filtered on its own responsibility - return this; + .collect(Collectors.toList()); + } + + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + choicesFrom = "pendingOrFailed", + semantics = SemanticsOf.NON_IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = replayOrRetrySelected.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "pendingOrFailed", sequence = "1.1", + cssClassFa = "solid circle-play", + cssClass = "btn-primary", + describedAs = "Executes the list of commands in sequence, after having sorted them by their timestamp. " + + "If any of the given commands fails, " + + "the surrounding transaction is rolled back and any successful commands are undone). " + + "The command, that caused the failure, gets marked as FAILED." + ) + public class replayOrRetrySelected { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport public CommandReplayManager act(final List selected) { + var replayables = selected.stream() + .sorted() + .collect(Collectors.toList()); + for(var replayableCommand : replayables) { + var tryReplayOrRetry = replayableCommand.tryReplayOrRetry(); // filtered on its own responsibility + if(tryReplayOrRetry.isFailure()) + return CommandReplayManager.this; // stop further execution + } + return CommandReplayManager.this; + } + + + @MemberSupport + public String disableAct() { + return getPendingOrFailed().isEmpty() ? "No commands in collection" : null; + } + + @MemberSupport + public String validateSelected(final List selected) { + return selected != null && selected.isEmpty() ? "Select at least one command" : null; + } + + // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet + @MemberSupport + public List choicesSelected() { + return getPendingOrFailed(); + } } + + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + choicesFrom = "pendingOrFailed", + semantics = SemanticsOf.NON_IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = excludeSelectedFromReplay.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "pendingOrFailed", sequence = "1.2", + describedAs = "Marks selected Commands to be EXCLUDED from replay" + ) + public class excludeSelectedFromReplay { + public class DomainEvent extends ActionDomainEvent { } + @MemberSupport + public CommandReplayManager act(final List selected) { + selected.stream() + .forEach(ReplayableCommand::excludeFromReplay); // filtered on its own responsibility + return CommandReplayManager.this; + } + + @MemberSupport + public String disableAct() { + return getPendingOrFailed().isEmpty() ? "No commands in collection" : null; + } + + @MemberSupport + public String validateSelected(final List selected) { + return selected != null && selected.isEmpty() ? "Select at least one command" : null; + } + + // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet + @MemberSupport + public List choicesSelected() { + return getPendingOrFailed(); + } + + } + + + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + choicesFrom = "pendingOrFailed", + semantics = SemanticsOf.NON_IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = deleteSelectedPendingOrFailed.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "pendingOrFailed", sequence = "1.3", + describedAs = "Deletes selected Commands (cannot be undone)" + ) + public class deleteSelectedPendingOrFailed { + public class DomainEvent extends ActionDomainEvent { } + public CommandReplayManager act(final List selected) { + selected.stream() + .forEach(ReplayableCommand::deleteObj); // filtered on its own responsibility + return CommandReplayManager.this; + } + + @MemberSupport + public String disableAct() { + return getPendingOrFailed().isEmpty() ? "No commands in collection" : null; + } + + @MemberSupport + public String validateSelected(final List selected) { + return selected != null && selected.isEmpty() ? "Select at least one command" : null; + } + + // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet + @MemberSupport + public List choicesSelected() { + return getPendingOrFailed(); + } + + } + + + // -- OK OR EXCLUDE @Collection @CollectionLayout( describedAs = "Imported Commands that were either replayed with success (replayState=OK) " - + "or marked to be excluded from replay (replayState=EXCLUDE)") + + "or marked to be excluded from replay (replayState=EXCLUDE)" + ) public List getSucceededOrExcluded() { - return commandLogEntryRepository().findReplaySucceededOrExcluded().stream() + return commandLogEntryRepository().findSinceAndWithReplayOkOrExcluded(since).stream() .map(entry->new ReplayableCommand( entry.getInteractionId(), replayContext)) - .toList(); + .collect(Collectors.toList()); } - @Action(choicesFrom = "succeededOrExcluded") - @ActionLayout(associateWith = "succeededOrExcluded", + + @Action( + restrictTo = RestrictTo.PROTOTYPING, + choicesFrom = "succeededOrExcluded", + semantics = SemanticsOf.IDEMPOTENT, + domainEvent = deleteSelectedSucceededOrExcluded.DomainEvent.class, + executionPublishing = Publishing.DISABLED + ) + @ActionLayout( + associateWith = "succeededOrExcluded", named = "Delete Selected", - describedAs = "Deletes selected Commands (cannot be undone)") - public CommandReplayManager deleteSelected2(final List selected) { - selected.stream() - .forEach(ReplayableCommand::delete); // filtered on its own responsibility - return this; + describedAs = "Deletes selected Commands (cannot be undone)" + ) + public class deleteSelectedSucceededOrExcluded { + public class DomainEvent extends ActionDomainEvent { } + public CommandReplayManager act(final List selected) { + selected.stream() + .forEach(ReplayableCommand::deleteObj); // filtered on its own responsibility + return CommandReplayManager.this; + } + + @MemberSupport + public String disableAct() { + return getSucceededOrExcluded().isEmpty() ? "No commands in collection" : null; + } + + @MemberSupport + public String validateSelected(final List selected) { + return selected != null && selected.isEmpty() ? "Select at least one command" : null; + } + + // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet + @MemberSupport + public List choicesSelected() { + return getSucceededOrExcluded(); + } } + + // -- VM STATE @Override public String viewModelMemento() { - // TODO could use to store filter state - return null; + return TimestampMarshallUtil.toString(this.since); } // -- HELPER @@ -176,5 +394,4 @@ public String viewModelMemento() { private CommandLogEntryRepository commandLogEntryRepository() { return replayContext.commandLogEntryRepository(); } - } diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml index 30489ca2158..c7aed354e9f 100644 --- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.layout.fallback.xml @@ -1,9 +1,17 @@ - + - + + + + + + + + + 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 fb65cb4a511..937599bb12a 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 @@ -18,9 +18,11 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom.replay; +import org.apache.causeway.applib.services.clock.ClockService; import org.apache.causeway.applib.services.command.CommandExecutorService; import org.apache.causeway.applib.services.iactn.InteractionService; import org.apache.causeway.applib.services.repository.RepositoryService; +import org.apache.causeway.applib.services.xactn.TransactionService; import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository; /** @@ -29,6 +31,8 @@ public record ReplayContext( RepositoryService repositoryService, InteractionService interactionService, + TransactionService transactionService, CommandLogEntryRepository commandLogEntryRepository, - CommandExecutorService commandExecutorService) { + CommandExecutorService commandExecutorService, + ClockService clockService) { } 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 new file mode 100644 index 00000000000..bde91a6f91d --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt @@ -0,0 +1,6 @@ +#interactionId +timestamp +targetType +targetId +member +replayState \ 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.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java index 3428266fc05..88ed8da2de2 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,9 +18,10 @@ */ package org.apache.causeway.extensions.commandlog.applib.dom.replay; +import java.time.Instant; import java.time.ZonedDateTime; +import java.time.chrono.ChronoZonedDateTime; import java.util.Comparator; -import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.Executors; @@ -28,15 +29,15 @@ import jakarta.inject.Inject; import jakarta.inject.Named; +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.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.Where; @@ -51,7 +52,6 @@ import org.apache.causeway.commons.io.YamlUtils; 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.ExecuteIn; import org.apache.causeway.extensions.commandlog.applib.dom.ReplayState; import org.apache.causeway.schema.cmd.v2.CommandDto; import org.apache.causeway.schema.cmd.v2.MemberDto; @@ -60,38 +60,55 @@ import org.apache.causeway.valuetypes.asciidoc.builder.AsciiDocBuilder; import org.apache.causeway.valuetypes.asciidoc.builder.AsciiDocFactory; +import lombok.AllArgsConstructor; +import lombok.Value; +import lombok.experimental.Accessors; + /** * Viewmodel that wraps a {@link CommandLogEntry}. */ -@DomainObject(introspection = Introspection.ENCAPSULATION_ENABLED) -@DomainObjectLayout(cssClassFa = "terminal") +@DomainObject(introspection = Introspection.ANNOTATION_REQUIRED) +@DomainObjectLayout//(cssClassFa = "terminal") @Named(ReplayableCommand.LOGICAL_TYPE_NAME) -public record ReplayableCommand( - UUID interactionId, - ReplayContext replayContext, - ObjectReference recordRef) implements ViewModel, Comparable { +@AllArgsConstructor +public final class ReplayableCommand implements ViewModel, Comparable { + + public static abstract class ActionDomainEvent + extends CausewayModuleExtCommandLogApplib.ActionDomainEvent { } + + private final UUID interactionId; + @Programmatic + public UUID interactionId() { return interactionId; } + + private final ReplayContext replayContext; + @Programmatic + public ReplayContext replayContext() { return replayContext; } + + + private final ObjectReference recordRef; + @Programmatic + public ObjectReference recordRef() { return recordRef; } public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".ReplayableCommand"; // decoupled from the underlying entity - record CommandRecord( - CommandDto commandDto, - ReplayState replayState) { - CommandRecord { - Objects.requireNonNull(commandDto); - Objects.requireNonNull(replayState); - } + @Value @Accessors(fluent = true) + final static class CommandRecord { + + final CommandDto commandDto; + final ReplayState replayState; + boolean canReplayOrRetryOrMarkForExclusion() { - return replayState.canReplayOrRetryOrMarkForExclusion(); + return replayState.isPendingOrFailed(); } public String faQuickIcon() { - return switch(replayState) { + return switch (replayState) { case UNDEFINED -> "solid terminal .col-indigo"; - case EXPORTED -> "solid terminal .col-indigo, solid circle-arrow-right .ov-size-80 .ov-right-45 .ov-bottom-45 .col-dodgerblue"; - case PENDING -> "solid terminal .col-indigo, solid circle-pause .ov-size-80 .ov-right-45 .ov-bottom-45 .col-gold"; - case OK -> "solid terminal .col-indigo, solid circle-check .ov-size-80 .ov-right-45 .ov-bottom-45 .col-green"; - case FAILED -> "solid terminal .col-indigo, solid circle-exclamation .ov-size-80 .ov-right-45 .ov-bottom-45 .col-red"; - case EXCLUDED -> "solid terminal .col-indigo, solid circle-xmark .ov-size-80 .ov-right-45 .ov-bottom-45 .col-grey"; + case EXPORTED -> "solid terminal .col-indigo, solid circle-arrow-right .ov-size-80 .ov-right-45 .ov-bottom-45 .col-dodgerblue"; + case PENDING -> "solid terminal .col-indigo, solid circle-pause .ov-size-80 .ov-right-45 .ov-bottom-45 .col-gold"; + case OK -> "solid terminal .col-indigo, solid circle-check .ov-size-80 .ov-right-45 .ov-bottom-45 .col-green"; + case FAILED -> "solid terminal .col-indigo, solid circle-exclamation .ov-size-80 .ov-right-45 .ov-bottom-45 .col-red"; + case EXCLUDED -> "solid terminal .col-indigo, solid circle-xmark .ov-size-80 .ov-right-45 .ov-bottom-45 .col-grey"; }; } } @@ -110,7 +127,8 @@ public ReplayableCommand( } @ObjectSupport public String title() { - return "Replayable Command"; + final var timestamp = getTimestampIfAny().map(ChronoZonedDateTime::toInstant).map(Instant::toString).map(x -> " @ " + x).orElse(""); + return getTargetType() + ":" + getTargetId() + " #" + getMember() + timestamp; } @ObjectSupport public ObjectSupport.IconResource icon(final ObjectSupport.IconSize iconSize) { @@ -125,29 +143,34 @@ public ReplayableCommand( @PropertyLayout( sequence = "1.1", fieldSetId = "details", - describedAs = "UUID of the original (replayabel) Command") + describedAs = "UUID of the original (replayable) Command") public UUID getInteractionId() { - return interactionId(); + return interactionId; } @Property @PropertyLayout( sequence = "1.2", fieldSetId = "details", - describedAs = "Timestamp of the original (replayabel) Command") + describedAs = "Timestamp of the original (replayable) Command") public ZonedDateTime getTimestamp() { - return commandRecord() - .map(CommandRecord::commandDto) - .map(CommandDto::getTimestamp) - .map(JavaTimeXMLGregorianCalendarMarshalling::toZonedDateTime) + return getTimestampIfAny() .orElse(null); } + @Programmatic + public Optional getTimestampIfAny() { + return commandRecord() + .map(CommandRecord::commandDto) + .map(CommandDto::getTimestamp) + .map(JavaTimeXMLGregorianCalendarMarshalling::toZonedDateTime); + } + @Property @PropertyLayout( sequence = "2.1", fieldSetId = "details", - describedAs = "Target Type of the original (replayabel) Command") + describedAs = "Target Type of the original (replayable) Command") public String getTargetType() { return commandRecord() .map(CommandRecord::commandDto) @@ -160,7 +183,7 @@ public String getTargetType() { @PropertyLayout( sequence = "2.2", fieldSetId = "details", - describedAs = "Target ID of the original (replayabel) Command") + describedAs = "Target ID of the original (replayable) Command") public String getTargetId() { return commandRecord() .map(CommandRecord::commandDto) @@ -174,7 +197,7 @@ public String getTargetId() { @PropertyLayout( sequence = "3.1", fieldSetId = "details", - describedAs = "Replayabel Action or Property, that was executed as captured by the original Command") + describedAs = "Replayable Action or Property, that was executed as captured by the original Command") public String getMember() { return commandRecord() .map(CommandRecord::commandDto) @@ -189,7 +212,7 @@ public String getMember() { @PropertyLayout( sequence = "4", fieldSetId = "details", - describedAs = "Replay State of the original (replayabel) Command. " + describedAs = "Replay State of the original (replayable) Command. " + "When imported initially is PENDING. " + "Then after replay its either OK or FAILED. " + "Can be manually set to EXCLUDED, which marks it to be ignored for replay.") @@ -205,7 +228,7 @@ public ReplayState getReplayState() { fieldSetId = "dto", hidden = Where.ALL_TABLES, labelPosition = LabelPosition.NONE, - describedAs = "DTO of the original (replayabel) Command") + describedAs = "DTO of the original (replayable) Command") public AsciiDoc getDto() { return commandRecord() .map(CommandRecord::commandDto) @@ -219,96 +242,57 @@ public AsciiDoc getDto() { // -- ACTIONS - @Action - @ActionLayout( - sequence = "0.2", - describedAs = "Opens the associated Command Log Entry") - public CommandLogEntry openCommandLogEntry() { - return commandLogEntry() - .orElse(null); - } - - @Action - @ActionLayout( - sequence = "0.1", - cssClassFa = "solid circle-play", - cssClass = "btn-primary") - //hidden = Where.NOWHERE) // show in tables //TODO NPE bug - public ReplayableCommand replayOrRetry() { - tryReplayOrRetry(); - return this; - } - @MemberSupport private String disableReplayOrRetry() { - return commandRecord() - .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) - .orElse(false) - ? null - : "Cannot replay, if neither PENDING nor FAILED"; - } - @Action - @ActionLayout( - //hidden = Where.NOWHERE, // show in tables //TODO NPE bug - sequence = "2.1", - associateWith = "replayState", - describedAs = "Makes Command exportable (again)") - public ReplayableCommand makeExportable() { + ReplayableCommand makeExportable() { if(disableMakeExportable()!=null) - return this; // safe guard when called programmatically + return this; // safeguard when called programmatically commandLogEntry() - .filter(commandLogEntry->ReplayState.isExported(commandLogEntry.getReplayState())) - .ifPresent(commandLogEntry->{ - commandLogEntry.setReplayState(ReplayState.UNDEFINED); - invalidateCachedRecord(); - }); + .filter(commandLogEntry->ReplayState.isExported(commandLogEntry.getReplayState())) + .ifPresent(commandLogEntry->{ + commandLogEntry.setReplayState(ReplayState.UNDEFINED); + invalidateCachedRecord(); + }); return this; } - @MemberSupport private String disableMakeExportable() { + String disableMakeExportable() { return commandRecord() - .map(rec->ReplayState.isExported(rec.replayState())) - .orElse(false) + .map(rec->ReplayState.isExported(rec.replayState())) + .orElse(false) ? null : "Cannot make exportable, if not EXPORTED"; } - @Action - @ActionLayout( - //hidden = Where.NOWHERE, // show in tables //TODO NPE bug - sequence = "2.2", - associateWith = "replayState", - describedAs = "Marks Command to be EXCLUDED from replay.") - public ReplayableCommand excludeFromReplay() { + + ReplayableCommand excludeFromReplay() { if(disableExcludeFromReplay()!=null) - return this; // safe guard when called programmatically + return ReplayableCommand.this; // safeguard when called programmatically commandLogEntry() - .filter(ReplayableCommand::canReplayOrRetryOrMarkForExclusion) - .ifPresent(commandLogEntry->{ - commandLogEntry.setReplayState(ReplayState.EXCLUDED); - invalidateCachedRecord(); - }); - return this; + .filter(ReplayableCommand::canReplayOrRetryOrMarkForExclusion) + .ifPresent(commandLogEntry->{ + commandLogEntry.setReplayState(ReplayState.EXCLUDED); + invalidateCachedRecord(); + }); + return ReplayableCommand.this; } - @MemberSupport private String disableExcludeFromReplay() { + String disableExcludeFromReplay() { return commandRecord() - .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) - .orElse(false) + .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) + .orElse(false) ? null : "Cannot mark for exclusion, if neither PENDING nor FAILED"; } - @Action - @ActionLayout( - sequence = "0.3", - //hidden = Where.NOWHERE, // show in tables //TODO NPE bug - describedAs = "Deletes the associated Command Log Entry (cannot be undone)") - public void delete() { + + @Programmatic + void deleteObj() { commandLogEntry() - .ifPresent(commandLogEntry->{ - replayContext.repositoryService().remove(commandLogEntry); - invalidateCachedRecord(); - }); + .ifPresent(commandLogEntry->{ + replayContext.repositoryService().remove(commandLogEntry); + invalidateCachedRecord(); + }); } + // -- EXECUTION ORDER GOVERNED BY TIMESTAMP private static final Comparator TIMESTAMP_COMPARATOR = @@ -334,10 +318,14 @@ Try tryReplayOrRetry() { return commandLogEntry() .filter(ReplayableCommand::canReplayOrRetryOrMarkForExclusion) .map(commandLogEntry->{ - commandLogEntry.setExecuteIn(ExecuteIn.FOREGROUND); - var tryResultBookmark = replayContext.commandExecutorService().executeCommand( - InteractionContextPolicy.SWITCH_USER_AND_TIME, - commandLogEntry.getCommandDto()); + final var commandDto = commandLogEntry.getCommandDto(); + final var tryResultBookmark = replayContext.transactionService().callTransactional(Propagation.REQUIRES_NEW, + () -> { + final var bookmarkTry = replayContext.commandExecutorService().executeCommand( + InteractionContextPolicy.SWITCH_USER_AND_TIME, + commandDto); + return bookmarkTry.valueAsNullableElseFail(); + }); // handle the replay outcome tryResultBookmark.accept( @@ -347,10 +335,17 @@ Try tryReplayOrRetry() { invalidateCachedRecord(); return tryResultBookmark - .mapSuccessAsNullable(__->this); + .mapSuccessAsNullable(__ -> this); }) .orElseGet(()->Try.success(null)); } + String disableReplayOrRetry() { + return commandRecord() + .map(CommandRecord::canReplayOrRetryOrMarkForExclusion) + .orElse(false) + ? null + : "Cannot replay, if neither PENDING nor FAILED"; + } // -- HELPER @@ -374,7 +369,7 @@ Optional commandLogEntry() { } private static boolean canReplayOrRetryOrMarkForExclusion(final CommandLogEntry commandLogEntry) { - return ReplayState.canReplayOrRetryOrMarkForExclusion(commandLogEntry.getReplayState()); + return ReplayState.isPendingOrFailed(commandLogEntry.getReplayState()); } private void onReplayError(final UUID interactionId, final Throwable ex) { @@ -383,5 +378,4 @@ private void onReplayError(final UUID interactionId, final Throwable ex) { replayContext.commandLogEntryRepository().findByInteractionId(interactionId) .ifPresent(entry->entry.saveAnalysis(ex.toString())))); } - -} +} \ 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 0b6c5fee4d5..331644bba1a 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,14 +1,20 @@ - + - + + + - + + + + + @@ -22,19 +28,29 @@ - + + + + - - - - - + + + + + + + + + - + + + + diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_delete.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_delete.java new file mode 100644 index 00000000000..6289ab9647e --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_delete.java @@ -0,0 +1,49 @@ +/* + * 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( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.NON_IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = ReplayableCommand_delete.DomainEvent.class, + executionPublishing = Publishing.DISABLED +) +@ActionLayout( + sequence = "0.3", + //hidden = Where.NOWHERE, // show in tables //TODO NPE bug + describedAs = "Deletes the associated Command Log Entry (cannot be undone)" +) +@RequiredArgsConstructor +public class ReplayableCommand_delete { + + public static class DomainEvent extends ReplayableCommand.ActionDomainEvent { + } + + private final ReplayableCommand replayableCommand; + + @MemberSupport + public void act() { + replayableCommand.deleteObj(); + } +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_excludeFromReplay.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_excludeFromReplay.java new file mode 100644 index 00000000000..955c7ef4c2d --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_excludeFromReplay.java @@ -0,0 +1,54 @@ +/* + * 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( + restrictTo = RestrictTo.PROTOTYPING, + commandPublishing = Publishing.DISABLED, + domainEvent = ReplayableCommand_excludeFromReplay.DomainEvent.class, + executionPublishing = Publishing.DISABLED +) +@ActionLayout( + //hidden = Where.NOWHERE, // show in tables //TODO NPE bug + sequence = "2.2", + associateWith = "replayState", + describedAs = "Marks Command to be EXCLUDED from replay." +) +@RequiredArgsConstructor +public class ReplayableCommand_excludeFromReplay { + + public static class DomainEvent extends ReplayableCommand.ActionDomainEvent { + } + + private final ReplayableCommand replayableCommand; + + @MemberSupport + public ReplayableCommand act() { + return replayableCommand.excludeFromReplay(); + } + + @MemberSupport + private String disableAct() { + return replayableCommand.disableExcludeFromReplay(); + } +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_makeExportable.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_makeExportable.java new file mode 100644 index 00000000000..591746fa0c9 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_makeExportable.java @@ -0,0 +1,55 @@ +/* + * 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( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = ReplayableCommand_makeExportable.DomainEvent.class, + executionPublishing = Publishing.DISABLED +) +@ActionLayout( + //hidden = Where.NOWHERE, // show in tables //TODO NPE bug + associateWith = "replayState", + sequence = "2.1", + describedAs = "Makes Command exportable (again)" +) +@RequiredArgsConstructor +public class ReplayableCommand_makeExportable { + + public static class DomainEvent extends ReplayableCommand.ActionDomainEvent { + } + + private final ReplayableCommand replayableCommand; + + @MemberSupport + public ReplayableCommand act() { + return replayableCommand.makeExportable(); + } + + @MemberSupport + public String disableAct() { + return replayableCommand.disableMakeExportable(); + } +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_openCommandLogEntry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_openCommandLogEntry.java new file mode 100644 index 00000000000..600744eab9c --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_openCommandLogEntry.java @@ -0,0 +1,54 @@ +/* + * 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.*; +import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry; + +@Action( + semantics = SemanticsOf.SAFE, + commandPublishing = Publishing.DISABLED, + domainEvent = ReplayableCommand_openCommandLogEntry.DomainEvent.class, + executionPublishing = Publishing.DISABLED +) +@ActionLayout( + sequence = "0.2", + describedAs = "Opens the underlying Command Log Entry" +) +@RequiredArgsConstructor +public class ReplayableCommand_openCommandLogEntry { + + public static class DomainEvent extends ReplayableCommand.ActionDomainEvent { + } + + private final ReplayableCommand replayableCommand; + + @MemberSupport + public CommandLogEntry act() { + return replayableCommand.commandLogEntry() + .orElse(null); + } + + @MemberSupport + public String disableAct() { + return replayableCommand.commandLogEntry().isEmpty() ? "No corresponding CommandLogEntry" : null; + } +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java new file mode 100644 index 00000000000..e2847773951 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java @@ -0,0 +1,57 @@ +/* + * 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( + restrictTo = RestrictTo.PROTOTYPING, + semantics = SemanticsOf.NON_IDEMPOTENT, + commandPublishing = Publishing.DISABLED, + domainEvent = ReplayableCommand_replayOrRetry.DomainEvent.class, + executionPublishing = Publishing.DISABLED +) +@ActionLayout( + sequence = "0.1", + cssClassFa = "solid circle-play", + cssClass = "btn-primary" + //hidden = Where.NOWHERE // show in tables //TODO NPE bug +) +@RequiredArgsConstructor +public class ReplayableCommand_replayOrRetry { + + public static class DomainEvent extends ReplayableCommand.ActionDomainEvent { + } + + private final ReplayableCommand replayableCommand; + + + @MemberSupport + public ReplayableCommand act() { + replayableCommand.tryReplayOrRetry(); + return replayableCommand; + } + + @MemberSupport + public String disableAct() { + return replayableCommand.disableReplayOrRetry(); + } +} diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/TimestampMarshallUtil.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/TimestampMarshallUtil.java new file mode 100644 index 00000000000..ba8eaab85a4 --- /dev/null +++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/TimestampMarshallUtil.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.experimental.UtilityClass; + +import java.sql.Timestamp; +import java.time.Instant; + +@UtilityClass +class TimestampMarshallUtil { + private static final java.time.format.DateTimeFormatter VM_MEMENTO_FORMATTER = + java.time.format.DateTimeFormatter + .ofPattern("uuuu-MM-dd'T'HH-mm-ss.SSSX") + .withZone(java.time.ZoneOffset.UTC); + + static String toString(Timestamp ts) { + // Human-readable and URL-friendly (no ':' or '~'). + return VM_MEMENTO_FORMATTER.format(ts.toInstant()); // e.g. 2026-04-22T02-00-00.000Z + } + + static Timestamp fromString(String s, Timestamp fallback) { + if (s == null || s.isBlank()) { + return fallback; + } + try { + return Timestamp.from(Instant.from(VM_MEMENTO_FORMATTER.parse(s))); + } catch (Exception e) { + return fallback; + } + } +} diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java index 1c94cd85468..8c280960613 100644 --- a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java +++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java @@ -359,9 +359,10 @@ void test_all_the_repository_methods() { var username1 = commandTarget1User1.getUsername(); var from = commandTarget1User1.getStartedAt().toLocalDateTime().toLocalDate(); var to = from.plusDays(1); + final var timestamp = commandTarget1User1.getTimestamp(); // when - List notYetReplayed = commandLogEntryRepository.findReplayPendingOrFailed(); + List notYetReplayed = commandLogEntryRepository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(timestamp); // then Assertions.assertThat(notYetReplayed).isEmpty(); @@ -374,7 +375,7 @@ void test_all_the_repository_methods() { commandTarget1User1.setReplayState(ReplayState.PENDING); // when - List notYetReplayed2 = commandLogEntryRepository.findReplayPendingOrFailed(); + List notYetReplayed2 = commandLogEntryRepository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(timestamp); // then Assertions.assertThat(notYetReplayed2).hasSize(1); 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 0ac083a3198..65c156e70e0 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 @@ -209,10 +209,20 @@ + " AND cl.completedAt is not null " + " ORDER BY cl.timestamp DESC"), // programmatic LIMIT 1 @NamedQuery( - name = Nq.FIND_BY_REPLAY_STATE, + name = Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATE, query = "SELECT cl " + " FROM CommandLogEntry cl " - + " WHERE (cl.replayState = :replayState1 OR cl.replayState = :replayState2) " + + " WHERE cl.executeIn = org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn.FOREGROUND " + + " AND cl.timestamp >= :from " + + " AND cl.replayState = :replayState " + + " ORDER BY cl.timestamp ASC"), + @NamedQuery( + name = Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATES, + query = "SELECT cl " + + " FROM CommandLogEntry cl " + + " WHERE cl.executeIn = org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn.FOREGROUND " + + " AND cl.timestamp >= :from " + + " AND (cl.replayState = :replayState1 OR cl.replayState = :replayState2) " + " ORDER BY cl.timestamp ASC"), }) @Named(CommandLogEntry.LOGICAL_TYPE_NAME) From 2115092aee3dbc5317d5b2f7374872847fa1d580 Mon Sep 17 00:00:00 2001 From: andi-huber Date: Tue, 28 Apr 2026 04:21:21 +0200 Subject: [PATCH 2/4] CAUSEWAY-3989: reinstate managers as Java Records --- .../dom/replay/CommandExportManager.java | 97 +++------- .../dom/replay/CommandReplayManager.java | 175 ++++++------------ 2 files changed, 90 insertions(+), 182 deletions(-) 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 bf9e2dbe94f..e1cbbb50799 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 @@ -54,59 +54,44 @@ import static org.apache.causeway.extensions.commandlog.applib.dom.replay.TimestampMarshallUtil.fromString; -import lombok.Getter; - @DomainObject(introspection = Introspection.ANNOTATION_REQUIRED) @DomainObjectLayout(cssClassFa = "solid share-from-square") @Named(CommandExportManager.LOGICAL_TYPE_NAME) -public final class CommandExportManager implements ViewModel { +public record CommandExportManager( + @Property + @PropertyLayout(describedAs = "Only commands since this timestamp are available for export") + java.sql.Timestamp since, + ReplayContext replayContext) implements ViewModel { public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandExportManager"; public static abstract class ActionDomainEvent extends CausewayModuleExtCommandLogApplib.ActionDomainEvent { } - private ReplayContext replayContext; - @Inject public CommandExportManager( final String memento, final ReplayContext replayContext) { - this(fromString(memento, replayContext.clockService().getClock().nowAsJavaSqlTimestamp()), replayContext); - } - - public CommandExportManager( - final java.sql.Timestamp since, - final ReplayContext replayContext) { - this.since = since; - this.replayContext = replayContext; + this( fromString(memento, replayContext.clockService().getClock().nowAsJavaSqlTimestamp()), + replayContext); } @ObjectSupport public String title() { return "Command Export Manager"; } - - @Property - @PropertyLayout(describedAs = "Only commands since this timestamp are available for export") - @Getter - private java.sql.Timestamp since; - @Action( semantics = SemanticsOf.SAFE, commandPublishing = Publishing.DISABLED, domainEvent = previousHour.DomainEvent.class, - executionPublishing = Publishing.DISABLED - ) + executionPublishing = Publishing.DISABLED) @ActionLayout( associateWith = "since", sequence = "1", named = "Previous", position = ActionLayout.Position.PANEL, - describedAs = "Move back one hour" - ) + describedAs = "Move back one hour") public class previousHour { public class DomainEvent extends ActionDomainEvent { } - @MemberSupport public CommandExportManager act() { return new CommandExportManager(addSeconds(since, -3600), replayContext); } @@ -116,14 +101,12 @@ public class DomainEvent extends ActionDomainEvent { } semantics = SemanticsOf.SAFE, commandPublishing = Publishing.DISABLED, domainEvent = nextHour.DomainEvent.class, - executionPublishing = Publishing.DISABLED - ) + executionPublishing = Publishing.DISABLED) @ActionLayout( associateWith = "since", sequence = "3", named = "Next", position = ActionLayout.Position.PANEL, - describedAs = "Move forward one hour" - ) + describedAs = "Move forward one hour") public class nextHour { public class DomainEvent extends ActionDomainEvent { } @MemberSupport public CommandExportManager act() { @@ -136,13 +119,11 @@ public class DomainEvent extends ActionDomainEvent { } semantics = SemanticsOf.SAFE, commandPublishing = Publishing.DISABLED, domainEvent = changeSince.DomainEvent.class, - executionPublishing = Publishing.DISABLED - ) + executionPublishing = Publishing.DISABLED) @ActionLayout( associateWith = "since", sequence = "2", named = "Change", - position = ActionLayout.Position.PANEL - ) + position = ActionLayout.Position.PANEL) public class changeSince { public class DomainEvent extends ActionDomainEvent { } @MemberSupport public CommandExportManager act(final java.sql.Timestamp since) { @@ -153,17 +134,11 @@ public class DomainEvent extends ActionDomainEvent { } } } - private static Timestamp addSeconds(final Timestamp since, final int secondsToAdd) { - return Timestamp.from(since.toInstant().plusSeconds(secondsToAdd)); - } - - // -- NOT YET EXPORTED @Collection @CollectionLayout( - describedAs = "Commands that can be exported" - ) + describedAs = "Commands that can be exported") public List getNotYetExported() { return commandLogEntryRepository().findForegroundSinceTimestampAndCanBeExported(since).stream() .map(entry->new ReplayableCommand( @@ -178,18 +153,15 @@ public List getNotYetExported() { semantics = SemanticsOf.NON_IDEMPOTENT, commandPublishing = Publishing.DISABLED, domainEvent = exportSelected.DomainEvent.class, - executionPublishing = Publishing.DISABLED - ) + executionPublishing = Publishing.DISABLED) @ActionLayout( associateWith = "notYetExported", sequence = "1.1", cssClassFa = "solid share-from-square", cssClass = "btn-primary", describedAs = "Exports selected Commands as zipped DTOs for import later. " - + "Refresh the page to see changed states." - ) + + "Refresh the page to see changed states.") public class exportSelected { public class DomainEvent extends ActionDomainEvent { } - @MemberSupport public Blob act( final List selected, final String filenamePrefix, @@ -227,23 +199,18 @@ public class DomainEvent extends ActionDomainEvent { } return blob; } - @MemberSupport public String disableAct() { return getNotYetExported().isEmpty() ? "No commands in collection" : null; } - @MemberSupport public String defaultFilenamePrefix() { return "commands"; } - @MemberSupport public boolean defaultFilenameTimestamp() { return true; } - @MemberSupport public String validateSelected(final List selected) { return selected != null && selected.isEmpty() ? "Select at least one command to export" : null; } - // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet @MemberSupport public List choicesSelected() { @@ -251,7 +218,6 @@ public List choicesSelected() { } } - // -- EXPORTED @Collection @@ -264,38 +230,31 @@ public List getExported() { .collect(Collectors.toList()); } - @Action( restrictTo = RestrictTo.PROTOTYPING, choicesFrom = "exported", commandPublishing = Publishing.DISABLED, semantics = SemanticsOf.IDEMPOTENT, domainEvent = makeSelectedExportable.DomainEvent.class, - executionPublishing = Publishing.DISABLED - ) + executionPublishing = Publishing.DISABLED) @ActionLayout( associateWith = "exported", sequence = "2.1", - describedAs = "Makes selected Commands exportable (again)" - ) + describedAs = "Makes selected Commands exportable (again)") public class makeSelectedExportable { public class DomainEvent extends ActionDomainEvent { } - - @MemberSupport - public CommandExportManager act(final List selected) { + @MemberSupport public CommandExportManager act(final List selected) { selected.forEach(ReplayableCommand::makeExportable); // filtered on its own responsibility return CommandExportManager.this; } - - @MemberSupport - public String disableAct() { + @MemberSupport public String disableAct() { return getExported().isEmpty() ? "No commands in collection" : null; } - - @MemberSupport - public String validateSelected(final List selected) { - return selected != null && selected.isEmpty() ? "Select at least one command" : null; + @MemberSupport public String validateSelected(final List selected) { + return selected != null + && selected.isEmpty() + ? "Select at least one command" + : null; } - // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet @MemberSupport public List choicesSelected() { @@ -303,7 +262,6 @@ public List choicesSelected() { } } - // -- VM STATE @Override @@ -312,7 +270,12 @@ public String viewModelMemento() { } // -- HELPER + private CommandLogEntryRepository commandLogEntryRepository() { return replayContext.commandLogEntryRepository(); } + + private static Timestamp addSeconds(final Timestamp since, final int secondsToAdd) { + return Timestamp.from(since.toInstant().plusSeconds(secondsToAdd)); + } } 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 d7fdb81a42d..90e854a5fd6 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 @@ -49,59 +49,44 @@ import static org.apache.causeway.extensions.commandlog.applib.dom.replay.TimestampMarshallUtil.fromString; -import lombok.Getter; - @DomainObject(introspection = Introspection.ANNOTATION_REQUIRED) @DomainObjectLayout(cssClassFa = "solid circle-play") @Named(CommandReplayManager.LOGICAL_TYPE_NAME) -public final class CommandReplayManager implements ViewModel { +public record CommandReplayManager( + @Property + @PropertyLayout(describedAs = "Only commands since this timestamp are available for export") + java.sql.Timestamp since, + ReplayContext replayContext) implements ViewModel { public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandReplayManager"; public static abstract class ActionDomainEvent extends CausewayModuleExtCommandLogApplib.ActionDomainEvent { } - private ReplayContext replayContext; - @Inject public CommandReplayManager( final String memento, final ReplayContext replayContext) { - this(fromString(memento, replayContext.clockService().getClock().nowAsJavaSqlTimestamp()), replayContext); - } - - public CommandReplayManager( - final java.sql.Timestamp since, - final ReplayContext replayContext) { - this.since = since; - this.replayContext = replayContext; + this( fromString(memento, replayContext.clockService().getClock().nowAsJavaSqlTimestamp()), + replayContext); } @ObjectSupport public String title() { return "Command Replay Manager"; } - - @Property - @PropertyLayout(describedAs = "Only commands since this timestamp are available for export") - @Getter - private java.sql.Timestamp since; - @Action( semantics = SemanticsOf.SAFE, commandPublishing = Publishing.DISABLED, domainEvent = previousHour.DomainEvent.class, - executionPublishing = Publishing.DISABLED - ) + executionPublishing = Publishing.DISABLED) @ActionLayout( associateWith = "since", sequence = "1", named = "Previous", position = ActionLayout.Position.PANEL, - describedAs = "Move back one hour" - ) + describedAs = "Move back one hour") public class previousHour { public class DomainEvent extends ActionDomainEvent { } - @MemberSupport public CommandReplayManager act() { return new CommandReplayManager(addSeconds(since, -3600), replayContext); } @@ -111,14 +96,12 @@ public class DomainEvent extends ActionDomainEvent { } semantics = SemanticsOf.SAFE, commandPublishing = Publishing.DISABLED, domainEvent = nextHour.DomainEvent.class, - executionPublishing = Publishing.DISABLED - ) + executionPublishing = Publishing.DISABLED) @ActionLayout( associateWith = "since", sequence = "3", named = "Next", position = ActionLayout.Position.PANEL, - describedAs = "Move forward one hour" - ) + describedAs = "Move forward one hour") public class nextHour { public class DomainEvent extends ActionDomainEvent { } @MemberSupport public CommandReplayManager act() { @@ -148,27 +131,21 @@ public class DomainEvent extends ActionDomainEvent { } } } - private static Timestamp addSeconds(final Timestamp since, final int secondsToAdd) { - return Timestamp.from(since.toInstant().plusSeconds(secondsToAdd)); - } - @Action( restrictTo = RestrictTo.PROTOTYPING, semantics = SemanticsOf.IDEMPOTENT, commandPublishing = Publishing.DISABLED, domainEvent = importCommands.DomainEvent.class, - executionPublishing = Publishing.DISABLED - ) + executionPublishing = Publishing.DISABLED) @ActionLayout( sequence = "0.1", associateWith = "pendingOrFailed", cssClass = "btn-primary", - describedAs = "Imports commands from a zipped yaml, then persists them with replayState=PENDING." - ) + describedAs = "Imports commands from a zipped yaml, then persists them with replayState=PENDING.") public class importCommands { public class DomainEvent extends ActionDomainEvent { } public CommandReplayManager act( - @Parameter(fileAccept = ".yml") + @Parameter(fileAccept = ".yml,.yaml") final Blob commandsYaml) { var yamlDs = commandsYaml.asDataSource(); @@ -179,30 +156,29 @@ public CommandReplayManager act( } } - // -- PENDING OR FAILED @Collection @CollectionLayout( - describedAs = "Imported Commands that can be either replayed (replayState=PENDING) or retried (when replayState=FAILED)" - ) + describedAs = "Imported Commands that can be either replayed (replayState=PENDING) " + + "or retried (when replayState=FAILED)") public List getPendingOrFailed() { - return commandLogEntryRepository().findForegroundSinceTimestampAndWithReplayPendingOrFailed(since).stream() + return commandLogEntryRepository() + .findForegroundSinceTimestampAndWithReplayPendingOrFailed(since) + .stream() .map(entry->new ReplayableCommand( entry.getInteractionId(), replayContext)) .collect(Collectors.toList()); } - @Action( restrictTo = RestrictTo.PROTOTYPING, choicesFrom = "pendingOrFailed", semantics = SemanticsOf.NON_IDEMPOTENT, commandPublishing = Publishing.DISABLED, domainEvent = replayOrRetrySelected.DomainEvent.class, - executionPublishing = Publishing.DISABLED - ) + executionPublishing = Publishing.DISABLED) @ActionLayout( associateWith = "pendingOrFailed", sequence = "1.1", cssClassFa = "solid circle-play", @@ -210,8 +186,7 @@ public List getPendingOrFailed() { describedAs = "Executes the list of commands in sequence, after having sorted them by their timestamp. " + "If any of the given commands fails, " + "the surrounding transaction is rolled back and any successful commands are undone). " - + "The command, that caused the failure, gets marked as FAILED." - ) + + "The command, that caused the failure, gets marked as FAILED.") public class replayOrRetrySelected { public class DomainEvent extends ActionDomainEvent { } @MemberSupport public CommandReplayManager act(final List selected) { @@ -225,115 +200,89 @@ public class DomainEvent extends ActionDomainEvent { } } return CommandReplayManager.this; } - - - @MemberSupport - public String disableAct() { + @MemberSupport public String disableAct() { return getPendingOrFailed().isEmpty() ? "No commands in collection" : null; } - - @MemberSupport - public String validateSelected(final List selected) { + @MemberSupport public String validateSelected(final List selected) { return selected != null && selected.isEmpty() ? "Select at least one command" : null; } - // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet - @MemberSupport - public List choicesSelected() { + @MemberSupport public List choicesSelected() { return getPendingOrFailed(); } } - - @Action( restrictTo = RestrictTo.PROTOTYPING, choicesFrom = "pendingOrFailed", semantics = SemanticsOf.NON_IDEMPOTENT, commandPublishing = Publishing.DISABLED, domainEvent = excludeSelectedFromReplay.DomainEvent.class, - executionPublishing = Publishing.DISABLED - ) + executionPublishing = Publishing.DISABLED) @ActionLayout( associateWith = "pendingOrFailed", sequence = "1.2", - describedAs = "Marks selected Commands to be EXCLUDED from replay" - ) + describedAs = "Marks selected Commands to be EXCLUDED from replay") public class excludeSelectedFromReplay { - public class DomainEvent extends ActionDomainEvent { } - @MemberSupport - public CommandReplayManager act(final List selected) { + public class DomainEvent extends ActionDomainEvent {} + @MemberSupport public CommandReplayManager act(final List selected) { selected.stream() .forEach(ReplayableCommand::excludeFromReplay); // filtered on its own responsibility return CommandReplayManager.this; } - - @MemberSupport - public String disableAct() { + @MemberSupport public String disableAct() { return getPendingOrFailed().isEmpty() ? "No commands in collection" : null; } - - @MemberSupport - public String validateSelected(final List selected) { - return selected != null && selected.isEmpty() ? "Select at least one command" : null; + @MemberSupport public String validateSelected(final List selected) { + return selected != null + && selected.isEmpty() + ? "Select at least one command" + : null; } - // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet - @MemberSupport - public List choicesSelected() { + @MemberSupport public List choicesSelected() { return getPendingOrFailed(); } - } - - @Action( restrictTo = RestrictTo.PROTOTYPING, choicesFrom = "pendingOrFailed", semantics = SemanticsOf.NON_IDEMPOTENT, commandPublishing = Publishing.DISABLED, domainEvent = deleteSelectedPendingOrFailed.DomainEvent.class, - executionPublishing = Publishing.DISABLED - ) + executionPublishing = Publishing.DISABLED) @ActionLayout( associateWith = "pendingOrFailed", sequence = "1.3", - describedAs = "Deletes selected Commands (cannot be undone)" - ) + describedAs = "Deletes selected Commands (cannot be undone)") public class deleteSelectedPendingOrFailed { - public class DomainEvent extends ActionDomainEvent { } - public CommandReplayManager act(final List selected) { + public class DomainEvent extends ActionDomainEvent {} + @MemberSupport public CommandReplayManager act(final List selected) { selected.stream() .forEach(ReplayableCommand::deleteObj); // filtered on its own responsibility return CommandReplayManager.this; } - - @MemberSupport - public String disableAct() { + @MemberSupport public String disableAct() { return getPendingOrFailed().isEmpty() ? "No commands in collection" : null; } - - @MemberSupport - public String validateSelected(final List selected) { - return selected != null && selected.isEmpty() ? "Select at least one command" : null; + @MemberSupport public String validateSelected(final List selected) { + return selected != null + && selected.isEmpty() + ? "Select at least one command" + : null; } - // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet @MemberSupport public List choicesSelected() { return getPendingOrFailed(); } - } - - // -- OK OR EXCLUDE @Collection @CollectionLayout( describedAs = "Imported Commands that were either replayed with success (replayState=OK) " - + "or marked to be excluded from replay (replayState=EXCLUDE)" - ) + + "or marked to be excluded from replay (replayState=EXCLUDE)") public List getSucceededOrExcluded() { return commandLogEntryRepository().findSinceAndWithReplayOkOrExcluded(since).stream() .map(entry->new ReplayableCommand( @@ -342,46 +291,38 @@ public List getSucceededOrExcluded() { .collect(Collectors.toList()); } - @Action( restrictTo = RestrictTo.PROTOTYPING, choicesFrom = "succeededOrExcluded", semantics = SemanticsOf.IDEMPOTENT, domainEvent = deleteSelectedSucceededOrExcluded.DomainEvent.class, - executionPublishing = Publishing.DISABLED - ) + executionPublishing = Publishing.DISABLED) @ActionLayout( associateWith = "succeededOrExcluded", named = "Delete Selected", - describedAs = "Deletes selected Commands (cannot be undone)" - ) + describedAs = "Deletes selected Commands (cannot be undone)") public class deleteSelectedSucceededOrExcluded { public class DomainEvent extends ActionDomainEvent { } - public CommandReplayManager act(final List selected) { + @MemberSupport public CommandReplayManager act(final List selected) { selected.stream() .forEach(ReplayableCommand::deleteObj); // filtered on its own responsibility return CommandReplayManager.this; } - - @MemberSupport - public String disableAct() { + @MemberSupport public String disableAct() { return getSucceededOrExcluded().isEmpty() ? "No commands in collection" : null; } - - @MemberSupport - public String validateSelected(final List selected) { - return selected != null && selected.isEmpty() ? "Select at least one command" : null; + @MemberSupport public String validateSelected(final List selected) { + return selected != null + && selected.isEmpty() + ? "Select at least one command" + : null; } - // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet - @MemberSupport - public List choicesSelected() { + @MemberSupport public List choicesSelected() { return getSucceededOrExcluded(); } } - - // -- VM STATE @Override @@ -394,4 +335,8 @@ public String viewModelMemento() { private CommandLogEntryRepository commandLogEntryRepository() { return replayContext.commandLogEntryRepository(); } + + private static Timestamp addSeconds(final Timestamp since, final int secondsToAdd) { + return Timestamp.from(since.toInstant().plusSeconds(secondsToAdd)); + } } From 8bf4780ce34dad2cf8c3cc539d9ab3b0b33dd3f2 Mon Sep 17 00:00:00 2001 From: andi-huber Date: Tue, 28 Apr 2026 04:32:39 +0200 Subject: [PATCH 3/4] CAUSEWAY-3989: removes programmatic choices from managers --- .../dom/replay/CommandExportManager.java | 19 +++++++------------ .../dom/replay/CommandReplayManager.java | 18 +----------------- 2 files changed, 8 insertions(+), 29 deletions(-) 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 e1cbbb50799..302879a934f 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 @@ -209,12 +209,10 @@ public class DomainEvent extends ActionDomainEvent { } return true; } @MemberSupport public String validateSelected(final List selected) { - return selected != null && selected.isEmpty() ? "Select at least one command to export" : null; - } - // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet - @MemberSupport - public List choicesSelected() { - return getNotYetExported(); + return selected != null + && selected.isEmpty() + ? "Select at least one command to export" + : null; } } @@ -247,7 +245,9 @@ public class DomainEvent extends ActionDomainEvent { } return CommandExportManager.this; } @MemberSupport public String disableAct() { - return getExported().isEmpty() ? "No commands in collection" : null; + return getExported().isEmpty() + ? "No commands in collection" + : null; } @MemberSupport public String validateSelected(final List selected) { return selected != null @@ -255,11 +255,6 @@ public class DomainEvent extends ActionDomainEvent { } ? "Select at least one command" : null; } - // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet - @MemberSupport - public List choicesSelected() { - return getExported(); - } } // -- VM STATE 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 90e854a5fd6..7cc796e4181 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 @@ -206,10 +206,6 @@ public class DomainEvent extends ActionDomainEvent { } @MemberSupport public String validateSelected(final List selected) { return selected != null && selected.isEmpty() ? "Select at least one command" : null; } - // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet - @MemberSupport public List choicesSelected() { - return getPendingOrFailed(); - } } @Action( @@ -238,10 +234,6 @@ public class DomainEvent extends ActionDomainEvent {} ? "Select at least one command" : null; } - // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet - @MemberSupport public List choicesSelected() { - return getPendingOrFailed(); - } } @Action( @@ -253,6 +245,7 @@ public class DomainEvent extends ActionDomainEvent {} executionPublishing = Publishing.DISABLED) @ActionLayout( associateWith = "pendingOrFailed", sequence = "1.3", + named = "Delete Selected", describedAs = "Deletes selected Commands (cannot be undone)") public class deleteSelectedPendingOrFailed { public class DomainEvent extends ActionDomainEvent {} @@ -270,11 +263,6 @@ public class DomainEvent extends ActionDomainEvent choicesSelected() { - return getPendingOrFailed(); - } } // -- OK OR EXCLUDE @@ -317,10 +305,6 @@ public class DomainEvent extends ActionDomainEvent choicesSelected() { - return getSucceededOrExcluded(); - } } // -- VM STATE From 7cef277e29dce1b133e617120183a8a223d3ab31 Mon Sep 17 00:00:00 2001 From: andi-huber Date: Tue, 28 Apr 2026 05:05:49 +0200 Subject: [PATCH 4/4] CAUSEWAY-3989: yaml export format option (user choice) --- .../dom/replay/CommandExportManager.java | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) 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 302879a934f..9b9e8a30b90 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 @@ -38,13 +38,13 @@ 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.ParameterLayout; import org.apache.causeway.applib.annotation.Property; import org.apache.causeway.applib.annotation.PropertyLayout; import org.apache.causeway.applib.annotation.Publishing; import org.apache.causeway.applib.annotation.RestrictTo; import org.apache.causeway.applib.annotation.SemanticsOf; import org.apache.causeway.applib.util.schema.CommandDtoUtils; -import org.apache.causeway.applib.value.Blob; import org.apache.causeway.applib.value.Clob; import org.apache.causeway.applib.value.NamedWithMimeType.CommonMimeType; import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib; @@ -162,42 +162,49 @@ public List getNotYetExported() { + "Refresh the page to see changed states.") public class exportSelected { public class DomainEvent extends ActionDomainEvent { } - @MemberSupport public Blob act( + @MemberSupport public Clob act( final List selected, + @ParameterLayout(describedAs = "File name for the exported file." ) final String filenamePrefix, - final boolean filenameTimestamp - ) { + @ParameterLayout(describedAs = "Whether to add a timestamp suffix to the exported file's name." ) + final boolean filenameTimestamp, + @ParameterLayout(describedAs = "Whether to use the multi-doc YAML format to represent a collection of " + + "CommandDto entries. " + + "If unchecked uses the YAML list format. " + + "(Command Replay Manager's import understands both formats.)" ) + final boolean multiDocFormat) { var selectedCommandLogEntries = selected.stream() .map(ReplayableCommand::commandLogEntry) .filter(Optional::isPresent) .map(Optional::get) + .filter(entry->!ReplayState.isExported(entry.getReplayState())) // shouldn't be necessary unless a race condition .sorted() - .collect(Collectors.toList()); + .toList(); + var selectedCommandDtos = selectedCommandLogEntries.stream() + .map(CommandLogEntry::getCommandDto) + .toList(); - var yaml = CommandDtoUtils.toYaml( - selectedCommandLogEntries.stream() - .filter(entry->!ReplayState.isExported(entry.getReplayState())) // shouldn't be necessary unless a race condition - .map(CommandLogEntry::getCommandDto) - .collect(Collectors.toList())); + var yaml = multiDocFormat + ? CommandDtoUtils.toMultiDocYaml(selectedCommandDtos) + : CommandDtoUtils.toYaml(selectedCommandDtos); - final var replayableCommand = selected.get(0); // validate ensures there is at least one command - final var timestamp = filenameTimestamp + var replayableCommand = selected.get(0); // validate ensures there is at least one command + var timestamp = filenameTimestamp ? replayableCommand.getTimestampIfAny() .map(ChronoZonedDateTime::toInstant) .map(Instant::toString) .map(x -> "." + x.replaceAll("[^A-Za-z0-9._-]", "_")) // make safe within filename .orElse("") : ""; - final var filename = filenamePrefix + timestamp; + var filename = filenamePrefix + timestamp; - var blob = Clob.of(filename, CommonMimeType.YAML, yaml) - .toBlobUtf8(); + var clob = Clob.of(filename, CommonMimeType.YAML, yaml); // do this last once we have successfully created the Clob selectedCommandLogEntries.forEach(c->c.setReplayState(ReplayState.EXPORTED)); - return blob; + return clob; } @MemberSupport public String disableAct() { return getNotYetExported().isEmpty() ? "No commands in collection" : null; @@ -208,6 +215,9 @@ public class DomainEvent extends ActionDomainEvent { } @MemberSupport public boolean defaultFilenameTimestamp() { return true; } + @MemberSupport public boolean defaultMultiDocFormat() { + return true; + } @MemberSupport public String validateSelected(final List selected) { return selected != null && selected.isEmpty()