HypothesisReviewStateStore.java
package com.taxonomy.versioning.service;
import com.taxonomy.model.HypothesisStatus;
import com.taxonomy.model.RelationType;
import com.taxonomy.relations.model.RelationHypothesis;
import com.taxonomy.relations.repository.RelationHypothesisRepository;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.RepositoryScope;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.Objects;
/**
* Isolates hypothesis bookkeeping from the non-transactional Git authority
* boundary. Every lookup and transition is scoped to one exact repository and
* workspace; a database transaction never encloses the JGit command itself.
*/
@Service
public class HypothesisReviewStateStore {
private final RelationHypothesisRepository repository;
private final EntityManager entityManager;
public HypothesisReviewStateStore(
RelationHypothesisRepository repository,
EntityManager entityManager) {
this.repository = Objects.requireNonNull(repository, "repository");
this.entityManager = Objects.requireNonNull(entityManager, "entityManager");
}
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
public HypothesisSnapshot require(
Long hypothesisId,
RepositoryContext context) {
RepositoryContext tenant = requireWritableContext(context);
RelationHypothesis hypothesis = repository
.findByIdInRepositoryWorkspace(
tenant.repositoryId(),
Objects.requireNonNull(hypothesisId, "hypothesisId"),
tenant.workspaceId())
.orElseThrow(() -> notFound(hypothesisId));
return HypothesisSnapshot.from(hypothesis);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public RelationHypothesis transition(
Long hypothesisId,
RepositoryContext context,
HypothesisStatus expectedStatus,
HypothesisStatus targetStatus) {
RepositoryContext tenant = requireWritableContext(context);
RelationHypothesis hypothesis = repository
.findByIdInRepositoryWorkspace(
tenant.repositoryId(),
Objects.requireNonNull(hypothesisId, "hypothesisId"),
tenant.workspaceId())
.orElseThrow(() -> notFound(hypothesisId));
entityManager.lock(hypothesis, LockModeType.PESSIMISTIC_WRITE);
if (hypothesis.getStatus() != expectedStatus) {
throw new HypothesisReviewConflictException(
hypothesisId,
expectedStatus,
hypothesis.getStatus());
}
hypothesis.setStatus(Objects.requireNonNull(targetStatus, "targetStatus"));
return repository.saveAndFlush(hypothesis);
}
static RepositoryContext requireWritableContext(RepositoryContext context) {
RepositoryContext tenant = Objects.requireNonNull(context, "context");
if (tenant.scope() == RepositoryScope.CENTRAL_READ) {
throw new IllegalStateException(
"Hypothesis review requires a workspace, fork, or explicit central write context");
}
return tenant;
}
private static IllegalArgumentException notFound(Long hypothesisId) {
return new IllegalArgumentException("Hypothesis not found: " + hypothesisId);
}
public record HypothesisSnapshot(
Long id,
String sourceCode,
String targetCode,
RelationType relationType,
Double confidence,
HypothesisStatus status) {
public HypothesisSnapshot {
id = Objects.requireNonNull(id, "id");
sourceCode = requireText(sourceCode, "sourceCode");
targetCode = requireText(targetCode, "targetCode");
relationType = Objects.requireNonNull(relationType, "relationType");
status = Objects.requireNonNull(status, "status");
}
static HypothesisSnapshot from(RelationHypothesis hypothesis) {
return new HypothesisSnapshot(
hypothesis.getId(),
hypothesis.getSourceNodeId(),
hypothesis.getTargetNodeId(),
hypothesis.getRelationType(),
hypothesis.getConfidence(),
hypothesis.getStatus());
}
}
public static final class HypothesisReviewConflictException
extends IllegalStateException {
private final Long hypothesisId;
private final HypothesisStatus expectedStatus;
private final HypothesisStatus actualStatus;
public HypothesisReviewConflictException(
Long hypothesisId,
HypothesisStatus expectedStatus,
HypothesisStatus actualStatus) {
super("Hypothesis " + hypothesisId + " changed from "
+ expectedStatus + " to " + actualStatus
+ " before review bookkeeping completed");
this.hypothesisId = hypothesisId;
this.expectedStatus = expectedStatus;
this.actualStatus = actualStatus;
}
public Long getHypothesisId() {
return hypothesisId;
}
public HypothesisStatus getExpectedStatus() {
return expectedStatus;
}
public HypothesisStatus getActualStatus() {
return actualStatus;
}
}
private static String requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value.strip();
}
}