implements packer PR-02 workspace registry foundation

This commit is contained in:
bQUARKz 2026-03-11 17:38:28 +00:00
parent f0cc439b84
commit 42e7331d62
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
10 changed files with 499 additions and 0 deletions

View File

@ -0,0 +1,118 @@
package p.packer.foundation;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import p.packer.api.PackerProjectContext;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public final class FileSystemPackerRegistryRepository implements PackerRegistryRepository {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int REGISTRY_SCHEMA_VERSION = 1;
@Override
public PackerRegistryState load(PackerProjectContext project) {
final Path registryPath = PackerWorkspacePaths.registryPath(project);
if (!Files.isRegularFile(registryPath)) {
return emptyState();
}
try {
final RegistryDocument document = MAPPER.readValue(registryPath.toFile(), RegistryDocument.class);
final int schemaVersion = document.schemaVersion <= 0 ? REGISTRY_SCHEMA_VERSION : document.schemaVersion;
final List<PackerRegistryEntry> entries = new ArrayList<>();
if (document.assets != null) {
for (RegistryAssetDocument asset : document.assets) {
if (asset == null) {
continue;
}
entries.add(new PackerRegistryEntry(asset.assetId, asset.assetUuid, normalizeRoot(asset.root)));
}
}
validateEntries(entries);
final int nextAssetId = document.nextAssetId > 0
? document.nextAssetId
: entries.stream().mapToInt(PackerRegistryEntry::assetId).max().orElse(0) + 1;
return new PackerRegistryState(
schemaVersion,
nextAssetId,
entries.stream().sorted(Comparator.comparingInt(PackerRegistryEntry::assetId)).toList());
} catch (IOException exception) {
throw new PackerRegistryException("Unable to load registry: " + registryPath, exception);
}
}
@Override
public void save(PackerProjectContext project, PackerRegistryState state) {
final Path registryDirectory = PackerWorkspacePaths.registryDirectory(project);
final Path registryPath = PackerWorkspacePaths.registryPath(project);
validateEntries(state.assets());
try {
Files.createDirectories(registryDirectory);
final RegistryDocument document = new RegistryDocument();
document.schemaVersion = state.schemaVersion();
document.nextAssetId = state.nextAssetId();
document.assets = state.assets().stream()
.map(entry -> new RegistryAssetDocument(entry.assetId(), entry.assetUuid(), entry.root()))
.toList();
MAPPER.writerWithDefaultPrettyPrinter().writeValue(registryPath.toFile(), document);
} catch (IOException exception) {
throw new PackerRegistryException("Unable to save registry: " + registryPath, exception);
}
}
private PackerRegistryState emptyState() {
return new PackerRegistryState(REGISTRY_SCHEMA_VERSION, 1, List.of());
}
private void validateEntries(List<PackerRegistryEntry> entries) {
final Set<Integer> assetIds = new HashSet<>();
final Set<String> assetUuids = new HashSet<>();
final Set<String> roots = new HashSet<>();
for (PackerRegistryEntry entry : entries) {
if (!assetIds.add(entry.assetId())) {
throw new PackerRegistryException("Duplicate asset_id in registry: " + entry.assetId());
}
if (!assetUuids.add(entry.assetUuid())) {
throw new PackerRegistryException("Duplicate asset_uuid in registry: " + entry.assetUuid());
}
if (!roots.add(entry.root())) {
throw new PackerRegistryException("Duplicate asset root in registry: " + entry.root());
}
}
}
private String normalizeRoot(String root) {
if (root == null || root.isBlank()) {
throw new PackerRegistryException("Registry asset root must not be blank");
}
return root.trim().replace('\\', '/');
}
@JsonIgnoreProperties(ignoreUnknown = true)
private static final class RegistryDocument {
@JsonProperty("schema_version")
public int schemaVersion;
@JsonProperty("next_asset_id")
public int nextAssetId;
@JsonProperty("assets")
public List<RegistryAssetDocument> assets = List.of();
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record RegistryAssetDocument(
@JsonProperty("asset_id") int assetId,
@JsonProperty("asset_uuid") String assetUuid,
@JsonProperty("root") String root) {
}
}

View File

@ -0,0 +1,36 @@
package p.packer.foundation;
import p.packer.api.PackerProjectContext;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
public final class PackerIdentityAllocator {
public PackerRegistryEntry allocate(
PackerProjectContext project,
PackerRegistryState state,
Path assetRoot) {
Objects.requireNonNull(project, "project");
Objects.requireNonNull(state, "state");
final Path normalizedAssetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(project, normalizedAssetRoot);
final boolean alreadyRegistered = state.assets().stream().anyMatch(entry -> entry.root().equals(relativeRoot));
if (alreadyRegistered) {
throw new PackerRegistryException("Asset root is already registered: " + relativeRoot);
}
return new PackerRegistryEntry(state.nextAssetId(), UUID.randomUUID().toString(), relativeRoot);
}
public PackerRegistryState append(PackerRegistryState state, PackerRegistryEntry entry) {
Objects.requireNonNull(state, "state");
Objects.requireNonNull(entry, "entry");
final List<PackerRegistryEntry> updated = state.assets().stream()
.collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new));
updated.add(entry);
updated.sort(Comparator.comparingInt(PackerRegistryEntry::assetId));
return new PackerRegistryState(state.schemaVersion(), entry.assetId() + 1, updated);
}
}

View File

@ -0,0 +1,20 @@
package p.packer.foundation;
import java.util.Objects;
public record PackerRegistryEntry(
int assetId,
String assetUuid,
String root) {
public PackerRegistryEntry {
assetUuid = Objects.requireNonNull(assetUuid, "assetUuid").trim();
root = Objects.requireNonNull(root, "root").trim();
if (assetId <= 0) {
throw new IllegalArgumentException("assetId must be positive");
}
if (assetUuid.isBlank() || root.isBlank()) {
throw new IllegalArgumentException("assetUuid and root must not be blank");
}
}
}

View File

@ -0,0 +1,11 @@
package p.packer.foundation;
public final class PackerRegistryException extends RuntimeException {
public PackerRegistryException(String message) {
super(message);
}
public PackerRegistryException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,38 @@
package p.packer.foundation;
import p.packer.api.PackerProjectContext;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import java.util.Optional;
public final class PackerRegistryLookup {
public Optional<PackerRegistryEntry> findByAssetId(PackerRegistryState state, int assetId) {
Objects.requireNonNull(state, "state");
return state.assets().stream().filter(entry -> entry.assetId() == assetId).findFirst();
}
public Optional<PackerRegistryEntry> findByAssetUuid(PackerRegistryState state, String assetUuid) {
Objects.requireNonNull(state, "state");
final String normalized = Objects.requireNonNull(assetUuid, "assetUuid").trim();
return state.assets().stream().filter(entry -> entry.assetUuid().equals(normalized)).findFirst();
}
public Optional<PackerRegistryEntry> findByRoot(PackerProjectContext project, PackerRegistryState state, Path assetRoot) {
Objects.requireNonNull(project, "project");
Objects.requireNonNull(state, "state");
final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(project, Objects.requireNonNull(assetRoot, "assetRoot"));
return state.assets().stream().filter(entry -> entry.root().equals(relativeRoot)).findFirst();
}
public Path resolveExistingRoot(PackerProjectContext project, PackerRegistryEntry entry) {
Objects.requireNonNull(project, "project");
Objects.requireNonNull(entry, "entry");
final Path assetRoot = PackerWorkspacePaths.assetRoot(project, entry.root());
if (!Files.isDirectory(assetRoot)) {
throw new PackerRegistryException("Registered asset root does not exist: " + entry.root());
}
return assetRoot;
}
}

View File

@ -0,0 +1,9 @@
package p.packer.foundation;
import p.packer.api.PackerProjectContext;
public interface PackerRegistryRepository {
PackerRegistryState load(PackerProjectContext project);
void save(PackerProjectContext project, PackerRegistryState state);
}

View File

@ -0,0 +1,30 @@
package p.packer.foundation;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
public record PackerRegistryState(
int schemaVersion,
int nextAssetId,
List<PackerRegistryEntry> assets) {
public PackerRegistryState {
if (schemaVersion <= 0) {
throw new IllegalArgumentException("schemaVersion must be positive");
}
if (nextAssetId <= 0) {
throw new IllegalArgumentException("nextAssetId must be positive");
}
assets = List.copyOf(Objects.requireNonNull(assets, "assets"));
}
public PackerRegistryState withAssets(List<PackerRegistryEntry> entries, int nextId) {
return new PackerRegistryState(
schemaVersion,
nextId,
entries.stream()
.sorted(Comparator.comparingInt(PackerRegistryEntry::assetId))
.toList());
}
}

View File

@ -0,0 +1,66 @@
package p.packer.foundation;
import p.packer.api.PackerOperationStatus;
import p.packer.api.PackerProjectContext;
import p.packer.api.workspace.InitWorkspaceRequest;
import p.packer.api.workspace.InitWorkspaceResult;
import java.nio.file.Files;
import java.util.List;
import java.util.Objects;
public final class PackerWorkspaceFoundation {
private final PackerRegistryRepository registryRepository;
private final PackerIdentityAllocator identityAllocator;
private final PackerRegistryLookup registryLookup;
public PackerWorkspaceFoundation() {
this(new FileSystemPackerRegistryRepository(), new PackerIdentityAllocator(), new PackerRegistryLookup());
}
public PackerWorkspaceFoundation(
PackerRegistryRepository registryRepository,
PackerIdentityAllocator identityAllocator,
PackerRegistryLookup registryLookup) {
this.registryRepository = Objects.requireNonNull(registryRepository, "registryRepository");
this.identityAllocator = Objects.requireNonNull(identityAllocator, "identityAllocator");
this.registryLookup = Objects.requireNonNull(registryLookup, "registryLookup");
}
public InitWorkspaceResult initWorkspace(InitWorkspaceRequest request) {
final PackerProjectContext project = Objects.requireNonNull(request, "request").project();
try {
Files.createDirectories(PackerWorkspacePaths.assetsRoot(project));
Files.createDirectories(PackerWorkspacePaths.registryDirectory(project));
final PackerRegistryState registryState = registryRepository.load(project);
registryRepository.save(project, registryState);
return new InitWorkspaceResult(
PackerOperationStatus.SUCCESS,
"Workspace initialized for packer control.",
PackerWorkspacePaths.registryPath(project),
List.of());
} catch (Exception exception) {
throw new PackerRegistryException("Unable to initialize workspace for " + project.projectId(), exception);
}
}
public PackerRegistryState loadRegistry(PackerProjectContext project) {
return registryRepository.load(project);
}
public void saveRegistry(PackerProjectContext project, PackerRegistryState state) {
registryRepository.save(project, state);
}
public PackerRegistryEntry allocateIdentity(PackerProjectContext project, PackerRegistryState state, java.nio.file.Path assetRoot) {
return identityAllocator.allocate(project, state, assetRoot);
}
public PackerRegistryState appendAllocatedEntry(PackerRegistryState state, PackerRegistryEntry entry) {
return identityAllocator.append(state, entry);
}
public PackerRegistryLookup lookup() {
return registryLookup;
}
}

View File

@ -0,0 +1,35 @@
package p.packer.foundation;
import p.packer.api.PackerProjectContext;
import java.nio.file.Path;
import java.util.Objects;
public final class PackerWorkspacePaths {
private static final String ASSETS_DIR = "assets";
private static final String PROMETEU_DIR = ".prometeu";
private static final String REGISTRY_FILE = "index.json";
private PackerWorkspacePaths() {
}
public static Path assetsRoot(PackerProjectContext project) {
return Objects.requireNonNull(project, "project").rootPath().resolve(ASSETS_DIR).toAbsolutePath().normalize();
}
public static Path registryDirectory(PackerProjectContext project) {
return assetsRoot(project).resolve(PROMETEU_DIR).toAbsolutePath().normalize();
}
public static Path registryPath(PackerProjectContext project) {
return registryDirectory(project).resolve(REGISTRY_FILE).toAbsolutePath().normalize();
}
public static Path assetRoot(PackerProjectContext project, String relativeRoot) {
return assetsRoot(project).resolve(relativeRoot).toAbsolutePath().normalize();
}
public static String relativeAssetRoot(PackerProjectContext project, Path assetRoot) {
return assetsRoot(project).relativize(assetRoot.toAbsolutePath().normalize()).toString().replace('\\', '/');
}
}

View File

@ -0,0 +1,136 @@
package p.packer.foundation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.api.PackerProjectContext;
import p.packer.api.workspace.InitWorkspaceRequest;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
final class PackerWorkspaceFoundationTest {
@TempDir
Path tempDir;
@Test
void initWorkspaceCreatesAssetsControlStructureAndRegistry() throws Exception {
final PackerWorkspaceFoundation foundation = new PackerWorkspaceFoundation();
final PackerProjectContext project = project(tempDir.resolve("main"));
final var result = foundation.initWorkspace(new InitWorkspaceRequest(project));
assertEquals(Path.of("assets/.prometeu/index.json"), project.rootPath().relativize(result.registryPath()));
assertTrue(Files.isDirectory(project.rootPath().resolve("assets")));
assertTrue(Files.isDirectory(project.rootPath().resolve("assets/.prometeu")));
assertTrue(Files.isRegularFile(project.rootPath().resolve("assets/.prometeu/index.json")));
final String registryJson = Files.readString(project.rootPath().resolve("assets/.prometeu/index.json"));
assertTrue(registryJson.contains("\"schema_version\""));
assertTrue(registryJson.contains("\"next_asset_id\""));
}
@Test
void registryRoundTripPreservesAllocatorAndEntries() throws Exception {
final Path projectRoot = tempDir.resolve("main");
final PackerWorkspaceFoundation foundation = new PackerWorkspaceFoundation();
final PackerProjectContext project = project(projectRoot);
foundation.initWorkspace(new InitWorkspaceRequest(project));
final PackerRegistryState state = new PackerRegistryState(
1,
3,
java.util.List.of(
new PackerRegistryEntry(1, "uuid-1", "ui/atlas"),
new PackerRegistryEntry(2, "uuid-2", "audio/ui_sounds")));
foundation.saveRegistry(project, state);
final PackerRegistryState reloaded = foundation.loadRegistry(project);
assertEquals(1, reloaded.schemaVersion());
assertEquals(3, reloaded.nextAssetId());
assertEquals(2, reloaded.assets().size());
assertEquals("audio/ui_sounds", reloaded.assets().get(1).root());
}
@Test
void allocatorIsMonotonicAndPersistedAcrossSaveLoad() throws Exception {
final Path projectRoot = tempDir.resolve("main");
final PackerWorkspaceFoundation foundation = new PackerWorkspaceFoundation();
final PackerProjectContext project = project(projectRoot);
foundation.initWorkspace(new InitWorkspaceRequest(project));
Files.createDirectories(projectRoot.resolve("assets/ui/atlas"));
final PackerRegistryEntry entry = foundation.allocateIdentity(project, foundation.loadRegistry(project), projectRoot.resolve("assets/ui/atlas"));
final PackerRegistryState updated = foundation.appendAllocatedEntry(foundation.loadRegistry(project), entry);
foundation.saveRegistry(project, updated);
final PackerRegistryState reloaded = foundation.loadRegistry(project);
assertEquals(2, reloaded.nextAssetId());
assertEquals(1, reloaded.assets().size());
assertEquals(1, reloaded.assets().getFirst().assetId());
assertEquals("ui/atlas", reloaded.assets().getFirst().root());
}
@Test
void duplicateRootsFailClearly() throws Exception {
final Path projectRoot = tempDir.resolve("main");
Files.createDirectories(projectRoot.resolve("assets/.prometeu"));
Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """
{
"schema_version": 1,
"next_asset_id": 3,
"assets": [
{ "asset_id": 1, "asset_uuid": "uuid-1", "root": "ui/atlas" },
{ "asset_id": 2, "asset_uuid": "uuid-2", "root": "ui/atlas" }
]
}
""");
final FileSystemPackerRegistryRepository repository = new FileSystemPackerRegistryRepository();
final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> repository.load(project(projectRoot)));
assertTrue(exception.getMessage().contains("Duplicate asset root"));
}
@Test
void malformedRegistryFailsClearly() throws Exception {
final Path projectRoot = tempDir.resolve("main");
Files.createDirectories(projectRoot.resolve("assets/.prometeu"));
Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), "{ nope ");
final FileSystemPackerRegistryRepository repository = new FileSystemPackerRegistryRepository();
final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> repository.load(project(projectRoot)));
assertTrue(exception.getMessage().contains("Unable to load registry"));
}
@Test
void lookupResolvesByIdUuidAndRootAndFailsOnMissingRoot() throws Exception {
final Path projectRoot = tempDir.resolve("main");
final PackerWorkspaceFoundation foundation = new PackerWorkspaceFoundation();
final PackerProjectContext project = project(projectRoot);
foundation.initWorkspace(new InitWorkspaceRequest(project));
Files.createDirectories(projectRoot.resolve("assets/ui/atlas"));
final PackerRegistryState state = new PackerRegistryState(
1,
2,
java.util.List.of(new PackerRegistryEntry(1, "uuid-1", "ui/atlas")));
final PackerRegistryLookup lookup = foundation.lookup();
assertTrue(lookup.findByAssetId(state, 1).isPresent());
assertTrue(lookup.findByAssetUuid(state, "uuid-1").isPresent());
assertTrue(lookup.findByRoot(project, state, projectRoot.resolve("assets/ui/atlas")).isPresent());
assertEquals(projectRoot.resolve("assets/ui/atlas").toAbsolutePath().normalize(), lookup.resolveExistingRoot(project, state.assets().getFirst()));
Files.delete(projectRoot.resolve("assets/ui/atlas"));
final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> lookup.resolveExistingRoot(project, state.assets().getFirst()));
assertTrue(exception.getMessage().contains("does not exist"));
}
private PackerProjectContext project(Path root) {
return new PackerProjectContext("main", root);
}
}