From d773e8684baff75d3e6ef852cae57678b7a50ae2 Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Thu, 19 Mar 2026 07:20:29 +0000 Subject: [PATCH] invalidate asset cache on metadata changes and refresh asset details --- .../packer/models/PackerAssetCacheEntry.java | 4 + .../FileSystemPackerCacheRepository.java | 4 +- .../PackerContractFingerprint.java | 34 ++++++ .../PackerRuntimeAssetMaterializer.java | 19 +++- .../FileSystemPackerCacheRepositoryTest.java | 12 +- .../PackerAbstractBankWalkerTest.java | 8 +- .../PackerRuntimeAssetMaterializerTest.java | 104 ++++++++++++++++++ .../assets/details/AssetDetailsControl.java | 8 ++ 8 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerContractFingerprint.java create mode 100644 prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/PackerRuntimeAssetMaterializerTest.java diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetCacheEntry.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetCacheEntry.java index 87967559..259f7831 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetCacheEntry.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetCacheEntry.java @@ -4,12 +4,16 @@ import java.util.*; public record PackerAssetCacheEntry( int assetId, + String contractFingerprint, List files) { public PackerAssetCacheEntry { if (assetId <= 0) { throw new IllegalArgumentException("assetId must be positive"); } + contractFingerprint = contractFingerprint == null || contractFingerprint.isBlank() + ? null + : contractFingerprint.trim(); files = List.copyOf(Objects.requireNonNull(files, "files")); } diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/FileSystemPackerCacheRepository.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/FileSystemPackerCacheRepository.java index 7521418c..f5bbf715 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/FileSystemPackerCacheRepository.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/FileSystemPackerCacheRepository.java @@ -59,7 +59,7 @@ public final class FileSystemPackerCacheRepository { } validateAssetId(asset.assetId); validateDuplicateRelativePaths(asset.assetId, files); - assets.add(new PackerAssetCacheEntry(asset.assetId, files)); + assets.add(new PackerAssetCacheEntry(asset.assetId, asset.contractFingerprint, files)); } } validateDuplicateAssetIds(assets); @@ -84,6 +84,7 @@ public final class FileSystemPackerCacheRepository { document.assets = safeState.assets().stream() .map(asset -> new CacheAssetDocument( asset.assetId(), + asset.contractFingerprint(), asset.files().stream() .map(file -> new CacheFileDocument( file.relativePath(), @@ -150,6 +151,7 @@ public final class FileSystemPackerCacheRepository { @JsonIgnoreProperties(ignoreUnknown = true) private record CacheAssetDocument( @JsonProperty("asset_id") int assetId, + @JsonProperty("contract_fingerprint") String contractFingerprint, @JsonProperty("files") List files) { } diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerContractFingerprint.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerContractFingerprint.java new file mode 100644 index 00000000..5ac3967e --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerContractFingerprint.java @@ -0,0 +1,34 @@ +package p.packer.repositories; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +public final class PackerContractFingerprint { + private PackerContractFingerprint() { + } + + public static String metadataFingerprint(Map metadata) { + final var normalized = new TreeMap(String.CASE_INSENSITIVE_ORDER); + Objects.requireNonNull(metadata, "metadata").forEach((key, value) -> { + if (key == null || key.isBlank()) { + return; + } + normalized.put(key.trim(), value == null ? "" : value); + }); + final String canonical = normalized.entrySet().stream() + .map(entry -> entry.getKey() + "=" + entry.getValue()) + .reduce((left, right) -> left + "\n" + right) + .orElse(""); + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 contract fingerprint is unavailable", exception); + } + } +} diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerRuntimeAssetMaterializer.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerRuntimeAssetMaterializer.java index 8003fbb4..7f0f40cf 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerRuntimeAssetMaterializer.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerRuntimeAssetMaterializer.java @@ -25,6 +25,9 @@ public final class PackerRuntimeAssetMaterializer { final Optional safeRegistryEntry = Objects.requireNonNull(registryEntry, "registryEntry"); final PackerAssetDeclarationParseResult safeParseResult = Objects.requireNonNull(parseResult, "parseResult"); final Optional safePriorAssetCache = Objects.requireNonNull(priorAssetCache, "priorAssetCache"); + final String currentContractFingerprint = safeParseResult.valid() + ? PackerContractFingerprint.metadataFingerprint(safeParseResult.declaration().outputMetadata()) + : null; if (safeRegistryEntry.isEmpty() || !safeParseResult.valid()) { return new PackerRuntimeAssetMaterialization( @@ -42,7 +45,7 @@ public final class PackerRuntimeAssetMaterializer { final PackerWalkResult walkResult = assetWalker.walk( normalizedAssetRoot, safeParseResult.declaration(), - safePriorAssetCache); + reusablePriorAssetCache(safePriorAssetCache, currentContractFingerprint)); final List buildCandidateFiles = walkResult.probeResults().stream() .map(probeResult -> toRuntimeWalkFile(normalizedAssetRoot, probeResult)) .sorted(Comparator.comparing(PackerRuntimeWalkFile::relativePath, String.CASE_INSENSITIVE_ORDER)) @@ -63,6 +66,7 @@ public final class PackerRuntimeAssetMaterializer { walkResult.diagnostics()); final PackerAssetCacheEntry assetCacheEntry = new PackerAssetCacheEntry( safeRegistryEntry.get().assetId(), + currentContractFingerprint, buildCandidateFiles.stream() .map(file -> new PackerFileCacheEntry( file.relativePath(), @@ -75,6 +79,19 @@ public final class PackerRuntimeAssetMaterializer { return new PackerRuntimeAssetMaterialization(runtimeAsset, Optional.of(assetCacheEntry)); } + private Optional reusablePriorAssetCache( + Optional priorAssetCache, + String currentContractFingerprint) { + if (priorAssetCache.isEmpty()) { + return Optional.empty(); + } + final String cachedContractFingerprint = priorAssetCache.get().contractFingerprint(); + if (cachedContractFingerprint == null || !cachedContractFingerprint.equals(currentContractFingerprint)) { + return Optional.empty(); + } + return priorAssetCache; + } + private List listAvailableFiles(Path assetRoot) { try (var paths = Files.list(assetRoot) .filter(Files::isRegularFile) diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/FileSystemPackerCacheRepositoryTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/FileSystemPackerCacheRepositoryTest.java index 22d5beca..fb00cbf7 100644 --- a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/FileSystemPackerCacheRepositoryTest.java +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/FileSystemPackerCacheRepositoryTest.java @@ -39,7 +39,7 @@ final class FileSystemPackerCacheRepositoryTest { final var state = new PackerWorkspaceCacheState( PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION, List.of( - new PackerAssetCacheEntry(3, List.of( + new PackerAssetCacheEntry(3, "contract-3", List.of( new PackerFileCacheEntry( "tiles/ui.png", "image/png", @@ -54,7 +54,7 @@ final class FileSystemPackerCacheRepositoryTest { 84L, "def456", Map.of()))), - new PackerAssetCacheEntry(1, List.of( + new PackerAssetCacheEntry(1, "contract-1", List.of( new PackerFileCacheEntry( "audio/click.wav", "audio/wav", @@ -67,6 +67,7 @@ final class FileSystemPackerCacheRepositoryTest { final var loaded = repository.load(project); assertEquals(List.of(1, 3), loaded.assets().stream().map(PackerAssetCacheEntry::assetId).toList()); + assertEquals("contract-3", loaded.findAsset(3).orElseThrow().contractFingerprint()); assertTrue(loaded.findAsset(3).flatMap(asset -> asset.findFile("tiles/ui.png")).isPresent()); assertEquals(128L, loaded.findAsset(3).orElseThrow().findFile("tiles/ui.png").orElseThrow().size()); } @@ -105,8 +106,8 @@ final class FileSystemPackerCacheRepositoryTest { final var state = new PackerWorkspaceCacheState( PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION, List.of( - new PackerAssetCacheEntry(7, List.of()), - new PackerAssetCacheEntry(9, List.of()))); + new PackerAssetCacheEntry(7, "contract-7", List.of()), + new PackerAssetCacheEntry(9, "contract-9", List.of()))); assertTrue(state.findAsset(7).isPresent()); assertTrue(state.findAsset(8).isEmpty()); @@ -118,7 +119,7 @@ final class FileSystemPackerCacheRepositoryTest { final var project = project(tempDir.resolve("project")); final var state = new PackerWorkspaceCacheState( PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION, - List.of(new PackerAssetCacheEntry(1, List.of( + List.of(new PackerAssetCacheEntry(1, "contract-1", List.of( new PackerFileCacheEntry( "tiles/ui.png", "image/png", @@ -133,6 +134,7 @@ final class FileSystemPackerCacheRepositoryTest { assertFalse(json.contains("diagnostic")); assertFalse(json.contains("diagnostics")); assertTrue(json.contains("\"asset_id\"")); + assertTrue(json.contains("\"contract_fingerprint\"")); assertTrue(json.contains("\"metadata\"")); } diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/PackerAbstractBankWalkerTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/PackerAbstractBankWalkerTest.java index b7d78a11..89cef6f3 100644 --- a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/PackerAbstractBankWalkerTest.java +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/PackerAbstractBankWalkerTest.java @@ -28,7 +28,7 @@ final class PackerAbstractBankWalkerTest { final var assetRoot = Files.createDirectories(tempDir.resolve("asset")); final var filePath = writeText(assetRoot.resolve("entry.txt"), "hello world"); final long lastModified = Files.getLastModifiedTime(filePath).toMillis(); - final var priorCache = new PackerAssetCacheEntry(1, List.of( + final var priorCache = new PackerAssetCacheEntry(1, "contract", List.of( new PackerFileCacheEntry("entry.txt", "text/plain", 1L, lastModified, "cached", Map.of("source", "cache")))); final var result = walker.walk(assetRoot, "requirements", java.util.Optional.of(priorCache)); @@ -44,7 +44,7 @@ final class PackerAbstractBankWalkerTest { final var assetRoot = Files.createDirectories(tempDir.resolve("asset")); final var filePath = writeText(assetRoot.resolve("entry.txt"), "hello world"); final long currentLastModified = Files.getLastModifiedTime(filePath).toMillis(); - final var priorCache = new PackerAssetCacheEntry(1, List.of( + final var priorCache = new PackerAssetCacheEntry(1, "contract", List.of( new PackerFileCacheEntry( "entry.txt", "text/plain", @@ -67,7 +67,7 @@ final class PackerAbstractBankWalkerTest { final var filePath = writeText(assetRoot.resolve("entry.txt"), "hello world"); final long lastModified = Files.getLastModifiedTime(filePath).toMillis(); final String fingerprint = sha256(Files.readAllBytes(filePath)); - final var priorCache = new PackerAssetCacheEntry(1, List.of( + final var priorCache = new PackerAssetCacheEntry(1, "contract", List.of( new PackerFileCacheEntry( "entry.txt", "text/plain", @@ -89,7 +89,7 @@ final class PackerAbstractBankWalkerTest { final var assetRoot = Files.createDirectories(tempDir.resolve("asset")); final var filePath = writeText(assetRoot.resolve("entry.txt"), "hello world"); final long lastModified = Files.getLastModifiedTime(filePath).toMillis(); - final var priorCache = new PackerAssetCacheEntry(1, List.of( + final var priorCache = new PackerAssetCacheEntry(1, "contract", List.of( new PackerFileCacheEntry( "entry.txt", "text/plain", diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/PackerRuntimeAssetMaterializerTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/PackerRuntimeAssetMaterializerTest.java new file mode 100644 index 00000000..a14b11cc --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/PackerRuntimeAssetMaterializerTest.java @@ -0,0 +1,104 @@ +package p.packer.repositories; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.packer.messages.assets.AssetFamilyCatalog; +import p.packer.messages.assets.OutputCodecCatalog; +import p.packer.messages.assets.OutputFormatCatalog; +import p.packer.models.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +final class PackerRuntimeAssetMaterializerTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TempDir + Path tempDir; + + @Test + void reusesPriorAssetCacheOnlyWhenContractFingerprintMatches() throws Exception { + final var walker = new TrackingWalker(); + final var materializer = new PackerRuntimeAssetMaterializer(walker); + final Path assetRoot = Files.createDirectories(tempDir.resolve("asset")); + final Path manifestPath = assetRoot.resolve("asset.json"); + Files.writeString(manifestPath, "{}"); + + final PackerAssetDeclaration declaration = declaration(Map.of("tile_size", "32x32")); + final PackerAssetDeclarationParseResult parseResult = new PackerAssetDeclarationParseResult(declaration, List.of()); + final String fingerprint = PackerContractFingerprint.metadataFingerprint(declaration.outputMetadata()); + final Optional priorAssetCache = Optional.of(new PackerAssetCacheEntry(1, fingerprint, List.of())); + + materializer.materialize( + assetRoot, + manifestPath, + Optional.of(new PackerRegistryEntry(1, "uuid", "asset", true)), + parseResult, + priorAssetCache); + + assertTrue(walker.lastPriorAssetCache().isPresent()); + } + + @Test + void invalidatesPriorAssetCacheWhenContractFingerprintChanges() throws Exception { + final var walker = new TrackingWalker(); + final var materializer = new PackerRuntimeAssetMaterializer(walker); + final Path assetRoot = Files.createDirectories(tempDir.resolve("asset")); + final Path manifestPath = assetRoot.resolve("asset.json"); + Files.writeString(manifestPath, "{}"); + + materializer.materialize( + assetRoot, + manifestPath, + Optional.of(new PackerRegistryEntry(1, "uuid", "asset", true)), + new PackerAssetDeclarationParseResult(declaration(Map.of("tile_size", "32x32")), List.of()), + Optional.of(new PackerAssetCacheEntry( + 1, + PackerContractFingerprint.metadataFingerprint(Map.of("tile_size", "16x16")), + List.of()))); + + assertTrue(walker.lastPriorAssetCache().isEmpty()); + } + + private static PackerAssetDeclaration declaration(Map metadata) { + return new PackerAssetDeclaration( + 1, + "uuid", + "asset", + AssetFamilyCatalog.TILE_BANK, + Map.of(), + List.of(), + OutputFormatCatalog.TILES_INDEXED_V1, + OutputCodecCatalog.NONE, + metadata, + true); + } + + private static final class TrackingWalker extends PackerAssetWalker { + private Optional lastPriorAssetCache = Optional.empty(); + + private TrackingWalker() { + super(MAPPER); + } + + @Override + public PackerWalkResult walk( + Path assetRoot, + PackerAssetDeclaration declaration, + Optional priorAssetCache) { + lastPriorAssetCache = Objects.requireNonNull(priorAssetCache, "priorAssetCache"); + return PackerWalkResult.EMPTY; + } + + private Optional lastPriorAssetCache() { + return lastPriorAssetCache; + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java index 877f6b36..15059a5a 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java @@ -104,6 +104,14 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware @Override public void registerEventSubscriptions() { eventBindings.listen(workspaceBus, StudioAssetsRefreshRequestedEvent.class).handle(event -> { + if (event.preferredAssetReference() != null) { + loadSelection(event.preferredAssetReference()); + return; + } + if (event.deepSync() && viewState.selectedAssetReference() != null) { + loadSelection(viewState.selectedAssetReference()); + return; + } if (event.preferredAssetReference() == null) { clearSelection(); }