RelationDecisionProjectionCheckpoint.java

package com.taxonomy.relations.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;

import java.time.Instant;

/**
 * Completion token for one fully rebuilt repository/workspace/branch relation projection.
 *
 * <p>A checkpoint is valid only while its authoritative commit remains the exact
 * selected branch head. Incremental command projections invalidate the checkpoint;
 * user-facing reads may consume decision rows only after a complete rebuild has
 * atomically replaced the branch rows and checkpoint in one database transaction.</p>
 */
@Entity
@Table(name = "relation_decision_projection_checkpoint",
        indexes = @Index(
                name = "idx_rel_projection_checkpoint_repository",
                columnList = "repository_id"),
        uniqueConstraints = @UniqueConstraint(
                name = "uq_rel_projection_checkpoint_scope",
                columnNames = {
                        "repository_id",
                        "workspace_scope_key",
                        "branch"
                }))
public class RelationDecisionProjectionCheckpoint {

    public static final String CENTRAL_SCOPE_KEY = "__shared__";

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "repository_id", nullable = false, length = 255)
    private String repositoryId;

    @Column(name = "workspace_id", length = 255)
    private String workspaceId;

    @Column(name = "workspace_scope_key", nullable = false, length = 255)
    private String workspaceScopeKey = CENTRAL_SCOPE_KEY;

    @Column(name = "branch", nullable = false, length = 255)
    private String branch;

    @Column(name = "authoritative_commit_id", nullable = false, length = 40)
    private String authoritativeCommitId;

    @Column(name = "relation_count", nullable = false)
    private int relationCount;

    @Column(name = "completed_at", nullable = false)
    private Instant completedAt;

    @Version
    @Column(name = "version", nullable = false)
    private long version;

    @PrePersist
    void onCreate() {
        synchronize();
        if (completedAt == null) {
            completedAt = Instant.now();
        }
    }

    @PreUpdate
    void onUpdate() {
        synchronize();
        completedAt = Instant.now();
    }

    private void synchronize() {
        repositoryId = requireText(repositoryId, "repositoryId");
        workspaceId = normalizeOptional(workspaceId);
        workspaceScopeKey = scopeKeyFor(workspaceId);
        branch = requireText(branch, "branch");
        authoritativeCommitId = requireCommitId(authoritativeCommitId);
        if (relationCount < 0) {
            throw new IllegalArgumentException("relationCount must not be negative");
        }
    }

    public static String scopeKeyFor(String workspaceId) {
        String normalized = normalizeOptional(workspaceId);
        return normalized == null ? CENTRAL_SCOPE_KEY : normalized;
    }

    public Long getId() {
        return id;
    }

    public String getRepositoryId() {
        return repositoryId;
    }

    public void setRepositoryId(String repositoryId) {
        this.repositoryId = requireText(repositoryId, "repositoryId");
    }

    public String getWorkspaceId() {
        return workspaceId;
    }

    public void setWorkspaceId(String workspaceId) {
        this.workspaceId = normalizeOptional(workspaceId);
        this.workspaceScopeKey = scopeKeyFor(this.workspaceId);
    }

    public String getWorkspaceScopeKey() {
        return workspaceScopeKey;
    }

    public String getBranch() {
        return branch;
    }

    public void setBranch(String branch) {
        this.branch = requireText(branch, "branch");
    }

    public String getAuthoritativeCommitId() {
        return authoritativeCommitId;
    }

    public void setAuthoritativeCommitId(String authoritativeCommitId) {
        this.authoritativeCommitId = requireCommitId(authoritativeCommitId);
    }

    public int getRelationCount() {
        return relationCount;
    }

    public void setRelationCount(int relationCount) {
        if (relationCount < 0) {
            throw new IllegalArgumentException("relationCount must not be negative");
        }
        this.relationCount = relationCount;
    }

    public Instant getCompletedAt() {
        return completedAt;
    }

    public long getVersion() {
        return version;
    }

    private static String requireCommitId(String value) {
        String normalized = requireText(value, "authoritativeCommitId");
        if (normalized.length() != 40
                || !normalized.chars().allMatch(RelationDecisionProjectionCheckpoint::isHex)) {
            throw new IllegalArgumentException(
                    "authoritativeCommitId must be a full Git object ID");
        }
        return normalized.toLowerCase(java.util.Locale.ROOT);
    }

    private static boolean isHex(int value) {
        return value >= '0' && value <= '9'
                || value >= 'a' && value <= 'f'
                || value >= 'A' && value <= 'F';
    }

    private static String normalizeOptional(String value) {
        return value == null || value.isBlank() ? null : value.strip();
    }

    private static String requireText(String value, String field) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(field + " must not be blank");
        }
        return value.strip();
    }
}