WorkspaceProjectionService.java
package com.taxonomy.workspace.service;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.workspace.model.UserWorkspace;
import com.taxonomy.workspace.model.WorkspaceProjection;
import com.taxonomy.workspace.repository.UserWorkspaceRepository;
import com.taxonomy.workspace.repository.WorkspaceProjectionRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/**
* Manages per-user workspace projection state with persistent storage.
*
* <p>A projection is a materialized view of the architecture DSL at a specific
* commit. This service wraps the existing in-memory projection tracking in
* {@link UserWorkspaceState} with a persistent {@link WorkspaceProjection}
* entity, ensuring projection metadata survives application restarts.
*
* <p>The service provides methods to record materializations, check staleness
* relative to the current Git HEAD, and retrieve projection details for API
* responses.
*/
@Service
public class WorkspaceProjectionService {
private static final Logger log = LoggerFactory.getLogger(WorkspaceProjectionService.class);
private final WorkspaceProjectionRepository projectionRepository;
private final WorkspaceManager workspaceManager;
private final DslGitRepositoryFactory repositoryFactory;
private final UserWorkspaceRepository workspaceRepository;
public WorkspaceProjectionService(WorkspaceProjectionRepository projectionRepository,
WorkspaceManager workspaceManager,
DslGitRepositoryFactory repositoryFactory,
UserWorkspaceRepository workspaceRepository) {
this.projectionRepository = projectionRepository;
this.workspaceManager = workspaceManager;
this.repositoryFactory = repositoryFactory;
this.workspaceRepository = workspaceRepository;
}
/**
* Resolve the Git repository for the given workspace context.
*
* @param ctx the workspace context (use {@link WorkspaceContext#SHARED}
* for the system repository)
* @return the resolved DslGitRepository
*/
private DslGitRepository resolveRepository(WorkspaceContext ctx) {
return repositoryFactory.resolveRepository(ctx);
}
/**
* Get or create the persistent projection record for a user.
*
* <p>If no projection exists, a new record is created with default values
* and linked to the user's workspace. If persistence fails, the error
* is logged and an unsaved instance is returned as a best-effort fallback.
*
* @param username the authenticated user's username
* @return the projection entity (never null)
*/
public WorkspaceProjection getOrCreateProjection(String username) {
return projectionRepository.findByUsername(username)
.orElseGet(() -> createProjection(username));
}
private WorkspaceProjection createProjection(String username) {
log.info("Creating projection record for user '{}'", username);
WorkspaceProjection projection = new WorkspaceProjection();
projection.setUsername(username);
// Link to existing UserWorkspace if available; otherwise generate a new ID
String wsId = workspaceRepository.findByUsernameAndSharedFalse(username)
.map(UserWorkspace::getWorkspaceId)
.orElseGet(() -> UUID.randomUUID().toString());
projection.setWorkspaceId(wsId);
projection.setStale(false);
projection.setCreatedAt(Instant.now());
try {
return projectionRepository.save(projection);
} catch (Exception e) {
// Non-fatal: in-memory state still works; subsequent calls will
// retry persistence via the orElseGet path.
log.warn("Could not persist projection for user '{}': {}",
username, e.getMessage());
return projection;
}
}
/**
* Record that a materialization completed successfully for a user.
*
* <p>Updates both the persistent projection record and the in-memory
* workspace state so that staleness checks are consistent.
*
* @param username the user whose projection was materialized
* @param commitId the commit SHA that was materialized
* @param branch the branch that was materialized
*/
public void recordProjection(String username, String commitId, String branch) {
try {
WorkspaceProjection projection = getOrCreateProjection(username);
projection.setProjectionCommitId(commitId);
projection.setProjectionBranch(branch);
projection.setProjectionTimestamp(Instant.now());
projection.setStale(false);
projection.setUpdatedAt(Instant.now());
projectionRepository.save(projection);
log.info("User '{}': recorded projection: branch='{}', commit='{}'",
username, branch, abbreviateSha(commitId));
} catch (Exception e) {
log.warn("Could not record projection for user '{}': {}", username, e.getMessage());
}
// Also update in-memory state
UserWorkspaceState ws = workspaceManager.getOrCreateWorkspace(username);
ws.recordProjection(commitId, branch);
}
/**
* Record that a search index rebuild completed for a user.
*
* <p>Updates both the persistent projection record and the in-memory
* workspace state.
*
* @param username the user whose index was rebuilt
* @param commitId the commit SHA the index was built from
*/
public void recordIndexBuild(String username, String commitId) {
try {
WorkspaceProjection projection = getOrCreateProjection(username);
projection.setIndexCommitId(commitId);
projection.setIndexTimestamp(Instant.now());
projection.setUpdatedAt(Instant.now());
projectionRepository.save(projection);
log.info("User '{}': recorded index build: commit='{}'",
username, abbreviateSha(commitId));
} catch (Exception e) {
log.warn("Could not record index build for user '{}': {}", username, e.getMessage());
}
// Also update in-memory state
UserWorkspaceState ws = workspaceManager.getOrCreateWorkspace(username);
ws.recordIndexBuild(commitId);
}
/**
* Check if the projection is stale relative to the HEAD of the given branch.
*
* <p>Uses the system repository (SHARED context). Use
* {@link #isProjectionStale(String, String, WorkspaceContext)} for workspace-aware resolution.
*
* @param username the user to check
* @param branch the branch to check against
* @return true if the projection needs to be rebuilt
*/
public boolean isProjectionStale(String username, String branch) {
return isProjectionStale(username, branch, WorkspaceContext.SHARED);
}
/**
* Check if the projection is stale relative to the HEAD of the given branch.
*
* <p>A projection is stale when its recorded commit differs from the
* current HEAD of the branch, meaning the user is viewing outdated data.
* Both in-memory and persisted projection state are checked.
*
* @param username the user to check
* @param branch the branch to check against
* @param ctx the workspace context for repository resolution
* @return true if the projection needs to be rebuilt
*/
public boolean isProjectionStale(String username, String branch, WorkspaceContext ctx) {
UserWorkspaceState ws = workspaceManager.getOrCreateWorkspace(username);
String projCommit = ws.getLastProjectionCommit();
// Also check persistent record for a more recent commit
WorkspaceProjection projection = null;
try {
projection = getOrCreateProjection(username);
String persistedCommit = projection.getProjectionCommitId();
if (persistedCommit != null) {
projCommit = persistedCommit;
}
} catch (Exception e) {
log.warn("Could not check persistent projection for user '{}': {}",
username, e.getMessage());
}
if (projCommit == null) {
return false;
}
// Compare against actual branch HEAD
try {
String headCommit = resolveRepository(ctx).getHeadCommit(branch);
if (headCommit == null) {
return false;
}
boolean stale = !headCommit.equals(projCommit);
// Persist the computed stale flag back to the entity
if (projection != null && projection.isStale() != stale) {
projection.setStale(stale);
projection.setUpdatedAt(Instant.now());
projectionRepository.save(projection);
}
return stale;
} catch (IOException e) {
log.warn("Could not resolve HEAD for branch '{}': {}", branch, e.getMessage());
return false;
}
}
/**
* Return a map with projection details suitable for API responses.
*
* <p>Combines persistent and in-memory state to provide a comprehensive
* view of the user's projection status.
*
* @param username the user whose projection info to retrieve
* @return a map containing projection metadata
*/
public Map<String, Object> getProjectionInfo(String username) {
Map<String, Object> info = new LinkedHashMap<>();
info.put("username", username);
UserWorkspaceState ws = workspaceManager.getOrCreateWorkspace(username);
info.put("lastProjectionCommit", ws.getLastProjectionCommit());
info.put("lastProjectionBranch", ws.getLastProjectionBranch());
info.put("lastProjectionTimestamp", ws.getLastProjectionTimestamp());
info.put("lastIndexCommit", ws.getLastIndexCommit());
info.put("lastIndexTimestamp", ws.getLastIndexTimestamp());
try {
WorkspaceProjection projection = getOrCreateProjection(username);
info.put("persistedProjectionCommit", projection.getProjectionCommitId());
info.put("persistedProjectionBranch", projection.getProjectionBranch());
info.put("persistedProjectionTimestamp", projection.getProjectionTimestamp());
info.put("persistedIndexCommit", projection.getIndexCommitId());
info.put("persistedIndexTimestamp", projection.getIndexTimestamp());
info.put("stale", projection.isStale());
} catch (Exception e) {
log.warn("Could not read persistent projection for user '{}': {}",
username, e.getMessage());
info.put("persistenceError", e.getMessage());
}
return info;
}
// ── Internal helpers ────────────────────────────────────────────
private String abbreviateSha(String commitId) {
if (commitId == null) return "null";
return commitId.substring(0, Math.min(7, commitId.length()));
}
}