com.diffplug.spotless
spotless-maven-plugin
diff --git a/version-gate-control-postgres/pom.xml b/version-gate-control-postgres/pom.xml
new file mode 100644
index 0000000..1f1573a
--- /dev/null
+++ b/version-gate-control-postgres/pom.xml
@@ -0,0 +1,23 @@
+
+
+ 4.0.0
+
+
+ io.github.kbarseghyan
+ version-gate
+ 0.1.0-SNAPSHOT
+
+
+ version-gate-control-postgres
+ version-gate-control-postgres
+ Official PostgreSQL control-store adapter boundary for Version Gate.
+
+
+
+ ${project.groupId}
+ version-gate-spi
+
+
+
diff --git a/version-gate-control-postgres/src/main/java/io/github/kbarseghyan/versiongate/control/postgres/package-info.java b/version-gate-control-postgres/src/main/java/io/github/kbarseghyan/versiongate/control/postgres/package-info.java
new file mode 100644
index 0000000..009cf38
--- /dev/null
+++ b/version-gate-control-postgres/src/main/java/io/github/kbarseghyan/versiongate/control/postgres/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * PostgreSQL control-store adapter boundary.
+ *
+ * The module is intentionally a placeholder during the multi-module refactor. A concrete
+ * implementation will provide a durable {@code ControlStore} without changing the SPI or core.
+ */
+package io.github.kbarseghyan.versiongate.control.postgres;
diff --git a/version-gate-core/pom.xml b/version-gate-core/pom.xml
new file mode 100644
index 0000000..3d7af5c
--- /dev/null
+++ b/version-gate-core/pom.xml
@@ -0,0 +1,39 @@
+
+
+ 4.0.0
+
+
+ io.github.kbarseghyan
+ version-gate
+ 0.1.0-SNAPSHOT
+
+
+ version-gate-core
+ version-gate-core
+ Framework-free Version Gate lifecycle and application use cases.
+
+
+
+ ${project.groupId}
+ version-gate-spi
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/BuildLifecycle.java b/version-gate-core/src/main/java/io/github/kbarseghyan/versiongate/application/BuildLifecycle.java
similarity index 93%
rename from src/main/java/io/github/kbarseghyan/versiongate/domain/BuildLifecycle.java
rename to version-gate-core/src/main/java/io/github/kbarseghyan/versiongate/application/BuildLifecycle.java
index 81dc85d..040d0e9 100644
--- a/src/main/java/io/github/kbarseghyan/versiongate/domain/BuildLifecycle.java
+++ b/version-gate-core/src/main/java/io/github/kbarseghyan/versiongate/application/BuildLifecycle.java
@@ -1,12 +1,16 @@
-package io.github.kbarseghyan.versiongate.domain;
+package io.github.kbarseghyan.versiongate.application;
import io.github.kbarseghyan.versiongate.api.ErrorCode;
import io.github.kbarseghyan.versiongate.api.VersionGateException;
+import io.github.kbarseghyan.versiongate.domain.Build;
+import io.github.kbarseghyan.versiongate.domain.BuildStatus;
+import io.github.kbarseghyan.versiongate.domain.Resource;
+import io.github.kbarseghyan.versiongate.domain.SnapshotPolicy;
import java.time.Instant;
import java.util.Objects;
-/** Pure lifecycle and fencing rules shared by application logic and adapter contract tests. */
-public final class BuildLifecycle {
+/** Pure lifecycle and fencing rules used by the application layer. */
+final class BuildLifecycle {
private BuildLifecycle() {}
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/application/VersionGateService.java b/version-gate-core/src/main/java/io/github/kbarseghyan/versiongate/application/VersionGateService.java
similarity index 98%
rename from src/main/java/io/github/kbarseghyan/versiongate/application/VersionGateService.java
rename to version-gate-core/src/main/java/io/github/kbarseghyan/versiongate/application/VersionGateService.java
index 806a20c..9500ea2 100644
--- a/src/main/java/io/github/kbarseghyan/versiongate/application/VersionGateService.java
+++ b/version-gate-core/src/main/java/io/github/kbarseghyan/versiongate/application/VersionGateService.java
@@ -3,7 +3,6 @@
import io.github.kbarseghyan.versiongate.api.ErrorCode;
import io.github.kbarseghyan.versiongate.api.VersionGateException;
import io.github.kbarseghyan.versiongate.domain.Build;
-import io.github.kbarseghyan.versiongate.domain.BuildLifecycle;
import io.github.kbarseghyan.versiongate.domain.BuildStatus;
import io.github.kbarseghyan.versiongate.domain.DomainValidation;
import io.github.kbarseghyan.versiongate.domain.Participant;
@@ -156,7 +155,10 @@ public Resource getResource(String resourceId) {
/**
* Atomically starts a uniquely fenced candidate build.
*
- * @param command target resource, version, owner, and initial lease
+ *
The authoritative control store allocates both the coordinator version and fencing token.
+ * Clients supply only ownership metadata and the requested initial lease.
+ *
+ * @param command target resource, owner, and initial lease
* @return newly created build
*/
public Build beginBuild(BeginBuildCommand command) {
@@ -164,15 +166,11 @@ public Build beginBuild(BeginBuildCommand command) {
validateInput(
() -> {
DomainValidation.requireIdentifier(command.resourceId(), "resourceId");
- if (command.targetVersion() < 0) {
- throw new IllegalArgumentException("targetVersion must not be negative");
- }
DomainValidation.requireNonBlank(
command.owner(), "owner", DomainValidation.TEXT_MAX_LENGTH);
});
validateLease(command.leaseDuration());
- return controlStore.beginBuild(
- command.resourceId(), command.targetVersion(), command.owner(), command.leaseDuration());
+ return controlStore.beginBuild(command.resourceId(), command.owner(), command.leaseDuration());
}
/**
@@ -872,12 +870,10 @@ public record RegisterResourceCommand(
* Command to start one fenced candidate build.
*
* @param resourceId registered resource identifier
- * @param targetVersion non-negative version to produce
* @param owner caller-defined owner for diagnostics
* @param leaseDuration requested positive initial lease
*/
- public record BeginBuildCommand(
- String resourceId, long targetVersion, String owner, Duration leaseDuration) {}
+ public record BeginBuildCommand(String resourceId, String owner, Duration leaseDuration) {}
/**
* Command to renew one fenced build lease.
diff --git a/src/test/java/io/github/kbarseghyan/versiongate/domain/BuildLifecycleTest.java b/version-gate-core/src/test/java/io/github/kbarseghyan/versiongate/application/BuildLifecycleTest.java
similarity index 95%
rename from src/test/java/io/github/kbarseghyan/versiongate/domain/BuildLifecycleTest.java
rename to version-gate-core/src/test/java/io/github/kbarseghyan/versiongate/application/BuildLifecycleTest.java
index 87c5a5f..6b19162 100644
--- a/src/test/java/io/github/kbarseghyan/versiongate/domain/BuildLifecycleTest.java
+++ b/version-gate-core/src/test/java/io/github/kbarseghyan/versiongate/application/BuildLifecycleTest.java
@@ -1,4 +1,4 @@
-package io.github.kbarseghyan.versiongate.domain;
+package io.github.kbarseghyan.versiongate.application;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
@@ -6,6 +6,12 @@
import io.github.kbarseghyan.versiongate.api.ErrorCode;
import io.github.kbarseghyan.versiongate.api.VersionGateException;
+import io.github.kbarseghyan.versiongate.domain.Build;
+import io.github.kbarseghyan.versiongate.domain.BuildStatus;
+import io.github.kbarseghyan.versiongate.domain.DomainValidation;
+import io.github.kbarseghyan.versiongate.domain.Participant;
+import io.github.kbarseghyan.versiongate.domain.Resource;
+import io.github.kbarseghyan.versiongate.domain.SnapshotPolicy;
import java.net.URI;
import java.time.Instant;
import java.util.List;
diff --git a/src/test/java/io/github/kbarseghyan/versiongate/application/VersionGateServiceTest.java b/version-gate-core/src/test/java/io/github/kbarseghyan/versiongate/application/VersionGateServiceTest.java
similarity index 99%
rename from src/test/java/io/github/kbarseghyan/versiongate/application/VersionGateServiceTest.java
rename to version-gate-core/src/test/java/io/github/kbarseghyan/versiongate/application/VersionGateServiceTest.java
index 18f27b0..02093d6 100644
--- a/src/test/java/io/github/kbarseghyan/versiongate/application/VersionGateServiceTest.java
+++ b/version-gate-core/src/test/java/io/github/kbarseghyan/versiongate/application/VersionGateServiceTest.java
@@ -149,7 +149,7 @@ void registrationAndOwnerLimitsAreRejectedBeforeControlStorage() {
() ->
service.beginBuild(
new VersionGateService.BeginBuildCommand(
- "catalog", 1, "x".repeat(256), Duration.ofMinutes(5))));
+ "catalog", "x".repeat(256), Duration.ofMinutes(5))));
verifyNoInteractions(controlStore);
verifyNoInteractions(snapshotStore);
diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/version-gate-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
similarity index 100%
rename from src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
rename to version-gate-core/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
diff --git a/version-gate-server/pom.xml b/version-gate-server/pom.xml
new file mode 100644
index 0000000..bb84352
--- /dev/null
+++ b/version-gate-server/pom.xml
@@ -0,0 +1,82 @@
+
+
+ 4.0.0
+
+
+ io.github.kbarseghyan
+ version-gate
+ 0.1.0-SNAPSHOT
+
+
+ version-gate-server
+ version-gate-server
+ Executable Spring Boot assembly for Version Gate.
+
+
+
+ ${project.groupId}
+ version-gate-spi
+
+
+ ${project.groupId}
+ version-gate-core
+
+
+ ${project.groupId}
+ version-gate-control-postgres
+ runtime
+
+
+ ${project.groupId}
+ version-gate-snapshot-s3
+ runtime
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ ${springdoc.version}
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ repackage
+
+
+
+
+
+
+
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/VersionGateApplication.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/VersionGateApplication.java
similarity index 64%
rename from src/main/java/io/github/kbarseghyan/versiongate/VersionGateApplication.java
rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/VersionGateApplication.java
index 594992b..7601e1c 100644
--- a/src/main/java/io/github/kbarseghyan/versiongate/VersionGateApplication.java
+++ b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/VersionGateApplication.java
@@ -5,19 +5,20 @@
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
/**
- * Optional bootstrap for a classpath that also contains contract-compliant storage adapters.
+ * Bootstrap for the assembled Version Gate server.
*
- *
The core artifact alone intentionally fails startup because it provides no storage beans.
+ *
Startup intentionally fails when the selected adapter modules provide no compatible storage
+ * beans.
*/
@SpringBootConfiguration
@EnableAutoConfiguration
public class VersionGateApplication {
- /** Creates the optional Spring Boot bootstrap configuration. */
+ /** Creates the Spring Boot bootstrap configuration. */
public VersionGateApplication() {}
/**
- * Starts an optional assembled distribution.
+ * Starts the assembled distribution.
*
* @param args Spring Boot command-line arguments
*/
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpApiException.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpApiException.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpApiException.java
rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpApiException.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGateway.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGateway.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGateway.java
rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGateway.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateController.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateController.java
similarity index 99%
rename from src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateController.java
rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateController.java
index 9b50b94..ce7ce69 100644
--- a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateController.java
+++ b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateController.java
@@ -506,13 +506,12 @@ Participant toDomain() {
}
record BeginBuildRequest(
- @NotNull @PositiveOrZero Long targetVersion,
@NotBlank @Size(max = DomainValidation.TEXT_MAX_LENGTH) String owner,
@NotNull @Positive Long leaseSeconds) {
VersionGateService.BeginBuildCommand toCommand(String resourceId) {
return new VersionGateService.BeginBuildCommand(
- resourceId, targetVersion, owner, Duration.ofSeconds(leaseSeconds));
+ resourceId, owner, Duration.ofSeconds(leaseSeconds));
}
}
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateExceptionHandler.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateExceptionHandler.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateExceptionHandler.java
rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateExceptionHandler.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateHttpConfiguration.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateHttpConfiguration.java
similarity index 94%
rename from src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateHttpConfiguration.java
rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateHttpConfiguration.java
index 1d97f63..8c62a94 100644
--- a/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateHttpConfiguration.java
+++ b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateHttpConfiguration.java
@@ -13,7 +13,7 @@
/**
* Registers the core HTTP API and the default participant callback implementation.
*
- *
A consuming application may replace the callback behavior with its own {@code
+ *
An assembled distribution may replace the callback behavior with its own {@code
* ParticipantGateway} bean.
*/
@Configuration(proxyBeanMethods = false)
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/configuration/ExpiredBuildSweeper.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/ExpiredBuildSweeper.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/configuration/ExpiredBuildSweeper.java
rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/ExpiredBuildSweeper.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfiguration.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfiguration.java
similarity index 85%
rename from src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfiguration.java
rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfiguration.java
index 081f103..e8424bc 100644
--- a/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfiguration.java
+++ b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfiguration.java
@@ -4,7 +4,7 @@
import org.springframework.context.annotation.Import;
/**
- * Spring Boot entry point that makes the core composable from separate adapter artifacts.
+ * Spring Boot entry point that composes the core and selected adapter artifacts.
*
*
The auto-configuration intentionally fails fast when the application supplies neither a
* control-store nor a snapshot-store adapter.
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateConfiguration.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateConfiguration.java
similarity index 87%
rename from src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateConfiguration.java
rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateConfiguration.java
index 1bddec1..96af021 100644
--- a/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateConfiguration.java
+++ b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateConfiguration.java
@@ -15,10 +15,10 @@
import org.springframework.scheduling.annotation.EnableScheduling;
/**
- * Composes the core around externally supplied storage-port beans.
+ * Composes the server around storage-port beans supplied by selected adapters.
*
- *
A consuming application must provide exactly one compatible {@code ControlStore} and {@code
- * SnapshotStore}. No production storage implementation is supplied by the core artifact.
+ *
The assembled server must provide exactly one compatible {@code ControlStore} and {@code
+ * SnapshotStore}. Placeholder adapter modules intentionally provide neither.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(VersionGateProperties.class)
@@ -26,7 +26,7 @@
@Import(VersionGateHttpConfiguration.class)
public class VersionGateConfiguration {
- /** Creates the core bean configuration. */
+ /** Creates the server bean configuration. */
public VersionGateConfiguration() {}
@Bean
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateProperties.java b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateProperties.java
similarity index 96%
rename from src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateProperties.java
rename to version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateProperties.java
index 96b5dee..658e69c 100644
--- a/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateProperties.java
+++ b/version-gate-server/src/main/java/io/github/kbarseghyan/versiongate/configuration/VersionGateProperties.java
@@ -9,7 +9,7 @@
import org.springframework.util.unit.DataSize;
/**
- * Core configuration contract; storage adapters define their own independent namespaces.
+ * Server configuration contract; storage adapters define their own independent namespaces.
*
* @param maximumLease largest lease duration accepted by application use cases
* @param maximumComponentSize largest accepted component representation
@@ -26,7 +26,7 @@ public record VersionGateProperties(
@DefaultValue ParticipantProperties participant) {
/**
- * Creates validated core configuration.
+ * Creates validated server configuration.
*
* @param maximumLease largest lease duration accepted by application use cases
* @param maximumComponentSize largest accepted component representation
diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/version-gate-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
similarity index 100%
rename from src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
rename to version-gate-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
diff --git a/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGatewayTest.java b/version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGatewayTest.java
similarity index 100%
rename from src/test/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGatewayTest.java
rename to version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/HttpParticipantGatewayTest.java
diff --git a/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateControllerTest.java b/version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateControllerTest.java
similarity index 99%
rename from src/test/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateControllerTest.java
rename to version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateControllerTest.java
index 76cbe68..949458d 100644
--- a/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateControllerTest.java
+++ b/version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/adapter/http/VersionGateControllerTest.java
@@ -152,7 +152,6 @@ void mapsLeaseSecondsForBeginAndRenew() throws Exception {
.content(
"""
{
- "targetVersion": 5,
"owner": "catalog-publisher",
"leaseSeconds": 300
}
@@ -161,7 +160,7 @@ void mapsLeaseSecondsForBeginAndRenew() throws Exception {
.andExpect(
header()
.string(HttpHeaders.LOCATION, "http://localhost/resources/catalog/builds/current"))
- .andExpect(jsonPath("$.targetVersion").value(5));
+ .andExpect(jsonPath("$.targetVersion").value(1));
assertThat(controlStore.lastLeaseDuration).isEqualTo(Duration.ofSeconds(300));
@@ -374,7 +373,6 @@ void rejectsOverlengthRegistrationAndBuildFieldsBeforeControlStorage() throws Ex
.content(
"""
{
- "targetVersion": 5,
"owner": "%s",
"leaseSeconds": 300
}
@@ -624,14 +622,13 @@ public Optional findResource(String resourceId) {
}
@Override
- public Build beginBuild(
- String resourceId, long targetVersion, String owner, Duration leaseDuration) {
+ public Build beginBuild(String resourceId, String owner, Duration leaseDuration) {
lastLeaseDuration = leaseDuration;
build =
new Build(
BUILD_ID,
resourceId,
- targetVersion,
+ 1,
resource.activeVersion(),
BuildStatus.BUILDING,
owner,
diff --git a/src/test/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfigurationTest.java b/version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfigurationTest.java
similarity index 100%
rename from src/test/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfigurationTest.java
rename to version-gate-server/src/test/java/io/github/kbarseghyan/versiongate/configuration/VersionGateAutoConfigurationTest.java
diff --git a/version-gate-server/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/version-gate-server/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000..fdbd0b1
--- /dev/null
+++ b/version-gate-server/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-subclass
diff --git a/version-gate-snapshot-s3/pom.xml b/version-gate-snapshot-s3/pom.xml
new file mode 100644
index 0000000..753242a
--- /dev/null
+++ b/version-gate-snapshot-s3/pom.xml
@@ -0,0 +1,23 @@
+
+
+ 4.0.0
+
+
+ io.github.kbarseghyan
+ version-gate
+ 0.1.0-SNAPSHOT
+
+
+ version-gate-snapshot-s3
+ version-gate-snapshot-s3
+ Official S3-compatible snapshot-store adapter boundary for Version Gate.
+
+
+
+ ${project.groupId}
+ version-gate-spi
+
+
+
diff --git a/version-gate-snapshot-s3/src/main/java/io/github/kbarseghyan/versiongate/snapshot/s3/package-info.java b/version-gate-snapshot-s3/src/main/java/io/github/kbarseghyan/versiongate/snapshot/s3/package-info.java
new file mode 100644
index 0000000..150e886
--- /dev/null
+++ b/version-gate-snapshot-s3/src/main/java/io/github/kbarseghyan/versiongate/snapshot/s3/package-info.java
@@ -0,0 +1,8 @@
+/**
+ * S3-compatible snapshot-store adapter boundary.
+ *
+ * The module is intentionally a placeholder during the multi-module refactor. A concrete
+ * implementation will provide an immutable streaming {@code SnapshotStore} without changing the SPI
+ * or core.
+ */
+package io.github.kbarseghyan.versiongate.snapshot.s3;
diff --git a/version-gate-spi/pom.xml b/version-gate-spi/pom.xml
new file mode 100644
index 0000000..8c887a7
--- /dev/null
+++ b/version-gate-spi/pom.xml
@@ -0,0 +1,16 @@
+
+
+ 4.0.0
+
+
+ io.github.kbarseghyan
+ version-gate
+ 0.1.0-SNAPSHOT
+
+
+ version-gate-spi
+ version-gate-spi
+ Framework-free domain and infrastructure contracts for Version Gate.
+
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/api/ErrorCode.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/api/ErrorCode.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/api/ErrorCode.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/api/ErrorCode.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/api/VersionGateException.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/api/VersionGateException.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/api/VersionGateException.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/api/VersionGateException.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/Build.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/Build.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/domain/Build.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/Build.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/BuildStatus.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/BuildStatus.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/domain/BuildStatus.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/BuildStatus.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/DomainValidation.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/DomainValidation.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/domain/DomainValidation.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/DomainValidation.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/Participant.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/Participant.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/domain/Participant.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/Participant.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantState.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantState.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantState.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantState.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantStatus.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantStatus.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantStatus.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/ParticipantStatus.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/Resource.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/Resource.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/domain/Resource.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/Resource.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotComponent.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotComponent.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotComponent.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotComponent.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotPolicy.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotPolicy.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotPolicy.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/SnapshotPolicy.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/domain/VersionManifest.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/VersionManifest.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/domain/VersionManifest.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/domain/VersionManifest.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/port/ControlStore.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/ControlStore.java
similarity index 94%
rename from src/main/java/io/github/kbarseghyan/versiongate/port/ControlStore.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/ControlStore.java
index 98b1b44..c87bda1 100644
--- a/src/main/java/io/github/kbarseghyan/versiongate/port/ControlStore.java
+++ b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/ControlStore.java
@@ -18,7 +18,7 @@
/**
* Authoritative storage contract for Version Gate control state.
*
- *
Implementations are production adapters supplied outside this repository. They must be
+ *
Implementations are supplied by official or third-party adapter modules. They must be
* thread-safe, durable across process restarts, and enforce the documented atomicity rather than
* relying on application-layer prechecks. In particular, an adapter owns the authoritative clock:
* it must evaluate lease expiry inside the same critical section as each mutation and only after
@@ -66,20 +66,20 @@ Resource registerResource(
Optional findResource(String resourceId);
/**
- * Atomically creates a fenced candidate build only when the resource has no non-terminal build.
+ * Atomically allocates a coordinator version and fencing token and creates a candidate build.
*
* The adapter may abandon an expired candidate in the same critical section. Fencing tokens
- * must never be reused for a resource, the target version must be unique, and it must be greater
- * than the active version when one exists. For a coordinated resource, the same atomic operation
- * initializes exactly one {@code PENDING} participant state for every registered participant.
+ * and coordinator versions must be monotonically increasing and never reused for a resource.
+ * Failed and abandoned builds keep their allocated versions; gaps are valid. For a coordinated
+ * resource, the same atomic operation initializes exactly one {@code PENDING} participant state
+ * for every registered participant.
*
* @param resourceId registered resource identifier
- * @param targetVersion non-negative version to produce
* @param owner caller-defined build owner
* @param leaseDuration positive initial lease duration
- * @return newly created fenced build
+ * @return newly created build containing the allocated version and fencing token
*/
- Build beginBuild(String resourceId, long targetVersion, String owner, Duration leaseDuration);
+ Build beginBuild(String resourceId, String owner, Duration leaseDuration);
/**
* Atomically renews a live build after validating its fence, status, and unexpired lease.
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/port/ParticipantGateway.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/ParticipantGateway.java
similarity index 100%
rename from src/main/java/io/github/kbarseghyan/versiongate/port/ParticipantGateway.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/ParticipantGateway.java
diff --git a/src/main/java/io/github/kbarseghyan/versiongate/port/SnapshotStore.java b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/SnapshotStore.java
similarity index 98%
rename from src/main/java/io/github/kbarseghyan/versiongate/port/SnapshotStore.java
rename to version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/SnapshotStore.java
index 7e8c351..e5ec892 100644
--- a/src/main/java/io/github/kbarseghyan/versiongate/port/SnapshotStore.java
+++ b/version-gate-spi/src/main/java/io/github/kbarseghyan/versiongate/port/SnapshotStore.java
@@ -8,7 +8,7 @@
/**
* Storage-neutral contract for immutable snapshot payloads.
*
- *
Implementations live in separately versioned adapter libraries. They must stream payloads
+ *
Implementations live in official or third-party adapter modules. They must stream payloads
* without materializing the entire content in memory, enforce immutable logical keys, and validate
* SHA-256 over the exact stored representation (after any content encoding). Backend SDK types,
* checksums, and exceptions must not cross this boundary.
diff --git a/version-gate-testkit/pom.xml b/version-gate-testkit/pom.xml
new file mode 100644
index 0000000..f2b6e3b
--- /dev/null
+++ b/version-gate-testkit/pom.xml
@@ -0,0 +1,43 @@
+
+
+ 4.0.0
+
+
+ io.github.kbarseghyan
+ version-gate
+ 0.1.0-SNAPSHOT
+
+
+ version-gate-testkit
+ version-gate-testkit
+ Reusable semantic contract tests and deterministic stores for Version Gate adapters.
+
+
+
+ ${project.groupId}
+ version-gate-spi
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+
+
+
+ ${project.groupId}
+ version-gate-core
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
diff --git a/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/ControlStoreContract.java b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/ControlStoreContract.java
new file mode 100644
index 0000000..1ef9754
--- /dev/null
+++ b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/ControlStoreContract.java
@@ -0,0 +1,94 @@
+package io.github.kbarseghyan.versiongate.testkit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.github.kbarseghyan.versiongate.api.ErrorCode;
+import io.github.kbarseghyan.versiongate.api.VersionGateException;
+import io.github.kbarseghyan.versiongate.domain.Build;
+import io.github.kbarseghyan.versiongate.domain.BuildStatus;
+import io.github.kbarseghyan.versiongate.domain.SnapshotPolicy;
+import io.github.kbarseghyan.versiongate.port.ControlStore;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Reusable minimum semantic contract for {@link ControlStore} implementations.
+ *
+ *
Adapter tests extend this class and construct a fresh store backed by the supplied
+ * deterministic clock. Adapters may add stronger integration tests for isolation, crash recovery,
+ * migrations, and their storage-authoritative clock.
+ */
+public abstract class ControlStoreContract {
+
+ private static final Instant NOW = Instant.parse("2030-01-02T03:04:05Z");
+ private static final Duration LEASE = Duration.ofMinutes(5);
+
+ private MutableClock clock;
+ private ControlStore store;
+
+ /**
+ * Creates an empty control store for one contract-test invocation.
+ *
+ * @param clock deterministic authoritative clock for the test store
+ * @return empty store instance
+ */
+ protected abstract ControlStore createControlStore(Clock clock);
+
+ @BeforeEach
+ final void initializeContractStore() {
+ clock = new MutableClock(NOW);
+ store = createControlStore(clock);
+ }
+
+ @Test
+ final void allocatesMonotonicCoordinatorVersionsAndFencingTokens() {
+ registerClientResource();
+
+ Build first = store.beginBuild("catalog", "first-owner", LEASE);
+ Build abandoned = store.abortBuild(first.buildId(), first.fencingToken());
+ Build second = store.beginBuild("catalog", "second-owner", LEASE);
+
+ assertEquals(BuildStatus.ABANDONED, abandoned.status());
+ assertTrue(first.targetVersion() >= 0);
+ assertTrue(second.targetVersion() > first.targetVersion());
+ assertTrue(second.fencingToken() > first.fencingToken());
+ assertNull(first.baseActiveVersion());
+ assertNull(second.baseActiveVersion());
+ }
+
+ @Test
+ final void rejectsASecondLiveBuildWithoutAllocatingClientChosenState() {
+ registerClientResource();
+ Build first = store.beginBuild("catalog", "first-owner", LEASE);
+
+ VersionGateException failure =
+ assertThrows(
+ VersionGateException.class, () -> store.beginBuild("catalog", "second-owner", LEASE));
+
+ assertEquals(ErrorCode.BUILD_ALREADY_EXISTS, failure.code());
+ assertEquals(first, store.findCurrentBuild("catalog").orElseThrow());
+ }
+
+ @Test
+ final void expiresTheCurrentBuildUsingTheAdapterClock() {
+ registerClientResource();
+ Build build = store.beginBuild("catalog", "owner", Duration.ofSeconds(10));
+
+ clock.advance(Duration.ofSeconds(10));
+
+ assertTrue(store.findCurrentBuild("catalog").isEmpty());
+ assertEquals(BuildStatus.ABANDONED, store.findBuild(build.buildId()).orElseThrow().status());
+ }
+
+ private void registerClientResource() {
+ store.registerResource("catalog", SnapshotPolicy.CLIENT_MANAGED, Set.of("products"), List.of());
+ }
+}
diff --git a/src/test/java/io/github/kbarseghyan/versiongate/support/InMemoryControlStore.java b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/InMemoryControlStore.java
similarity index 95%
rename from src/test/java/io/github/kbarseghyan/versiongate/support/InMemoryControlStore.java
rename to version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/InMemoryControlStore.java
index 2dd9b5e..51961a3 100644
--- a/src/test/java/io/github/kbarseghyan/versiongate/support/InMemoryControlStore.java
+++ b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/InMemoryControlStore.java
@@ -1,4 +1,4 @@
-package io.github.kbarseghyan.versiongate.support;
+package io.github.kbarseghyan.versiongate.testkit;
import io.github.kbarseghyan.versiongate.api.ErrorCode;
import io.github.kbarseghyan.versiongate.api.VersionGateException;
@@ -36,6 +36,7 @@ public final class InMemoryControlStore implements ControlStore {
private final Map builds = new HashMap<>();
private final Map currentBuilds = new HashMap<>();
private final Map versionBuilds = new HashMap<>();
+ private final Map lastAllocatedVersions = new HashMap<>();
private final Map lastFencingTokens = new HashMap<>();
private final Map components = new HashMap<>();
private final Map manifests = new HashMap<>();
@@ -43,6 +44,11 @@ public final class InMemoryControlStore implements ControlStore {
private long nextBuildSequence = 1;
+ /**
+ * Creates an empty deterministic store.
+ *
+ * @param clock authoritative clock used for lease decisions
+ */
public InMemoryControlStore(Clock clock) {
this.clock = Objects.requireNonNull(clock);
}
@@ -71,8 +77,7 @@ public synchronized Optional findResource(String resourceId) {
}
@Override
- public synchronized Build beginBuild(
- String resourceId, long targetVersion, String owner, Duration leaseDuration) {
+ public synchronized Build beginBuild(String resourceId, String owner, Duration leaseDuration) {
Resource resource = requireResource(resourceId);
Instant now = now();
UUID currentId = currentBuilds.get(resourceId);
@@ -89,23 +94,31 @@ public synchronized Build beginBuild(
currentBuilds.remove(resourceId);
}
}
+ if (leaseDuration == null || leaseDuration.isZero() || leaseDuration.isNegative()) {
+ throw error(ErrorCode.VALIDATION_FAILED, "lease duration must be positive");
+ }
+
+ long targetVersion;
+ try {
+ targetVersion = Math.addExact(lastAllocatedVersions.getOrDefault(resourceId, 0L), 1L);
+ } catch (ArithmeticException exhausted) {
+ throw error(
+ ErrorCode.STORAGE_FAILURE,
+ "Coordinator version space is exhausted for resource " + resourceId);
+ }
ResourceVersion versionKey = new ResourceVersion(resourceId, targetVersion);
if (versionBuilds.containsKey(versionKey)) {
throw error(
- ErrorCode.VERSION_ALREADY_EXISTS,
- "Version " + targetVersion + " already exists for " + resourceId);
+ ErrorCode.STORAGE_FAILURE,
+ "Coordinator version allocation is inconsistent for resource " + resourceId);
}
- if (resource.activeVersion() != null && targetVersion <= resource.activeVersion()) {
+ long fencingToken;
+ try {
+ fencingToken = Math.addExact(lastFencingTokens.getOrDefault(resourceId, 0L), 1L);
+ } catch (ArithmeticException exhausted) {
throw error(
- ErrorCode.VERSION_ALREADY_EXISTS,
- "Target version must be greater than the active version");
+ ErrorCode.STORAGE_FAILURE, "Fencing-token space is exhausted for resource " + resourceId);
}
- if (leaseDuration == null || leaseDuration.isZero() || leaseDuration.isNegative()) {
- throw error(ErrorCode.VALIDATION_FAILED, "lease duration must be positive");
- }
-
- long fencingToken = lastFencingTokens.getOrDefault(resourceId, 0L) + 1;
- lastFencingTokens.put(resourceId, fencingToken);
UUID buildId = new UUID(0L, nextBuildSequence++);
Build build =
new Build(
@@ -119,6 +132,8 @@ public synchronized Build beginBuild(
now.plus(leaseDuration),
now,
now);
+ lastAllocatedVersions.put(resourceId, targetVersion);
+ lastFencingTokens.put(resourceId, fencingToken);
builds.put(buildId, build);
currentBuilds.put(resourceId, buildId);
versionBuilds.put(versionKey, buildId);
diff --git a/src/test/java/io/github/kbarseghyan/versiongate/support/InMemorySnapshotStore.java b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/InMemorySnapshotStore.java
similarity index 97%
rename from src/test/java/io/github/kbarseghyan/versiongate/support/InMemorySnapshotStore.java
rename to version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/InMemorySnapshotStore.java
index f8f488b..9e9c61e 100644
--- a/src/test/java/io/github/kbarseghyan/versiongate/support/InMemorySnapshotStore.java
+++ b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/InMemorySnapshotStore.java
@@ -1,4 +1,4 @@
-package io.github.kbarseghyan.versiongate.support;
+package io.github.kbarseghyan.versiongate.testkit;
import io.github.kbarseghyan.versiongate.api.ErrorCode;
import io.github.kbarseghyan.versiongate.api.VersionGateException;
@@ -63,6 +63,7 @@ public synchronized void delete(ObjectReference objectReference) {
payloads.remove(objectReference.objectKey());
}
+ /** Returns the number of immutable objects currently retained by this test store. */
public synchronized int size() {
return payloads.size();
}
diff --git a/src/test/java/io/github/kbarseghyan/versiongate/support/MutableClock.java b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/MutableClock.java
similarity index 76%
rename from src/test/java/io/github/kbarseghyan/versiongate/support/MutableClock.java
rename to version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/MutableClock.java
index 168539e..d08be69 100644
--- a/src/test/java/io/github/kbarseghyan/versiongate/support/MutableClock.java
+++ b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/MutableClock.java
@@ -1,4 +1,4 @@
-package io.github.kbarseghyan.versiongate.support;
+package io.github.kbarseghyan.versiongate.testkit;
import java.time.Clock;
import java.time.Duration;
@@ -14,6 +14,11 @@ public final class MutableClock extends Clock {
private final AtomicReference instant;
private final ZoneId zone;
+ /**
+ * Creates a UTC clock at the supplied instant.
+ *
+ * @param initialInstant initial clock value
+ */
public MutableClock(Instant initialInstant) {
this(new AtomicReference<>(Objects.requireNonNull(initialInstant)), ZoneOffset.UTC);
}
@@ -38,10 +43,21 @@ public Instant instant() {
return instant.get();
}
+ /**
+ * Sets the current instant.
+ *
+ * @param newInstant replacement clock value
+ */
public void set(Instant newInstant) {
instant.set(Objects.requireNonNull(newInstant));
}
+ /**
+ * Advances the clock atomically.
+ *
+ * @param duration amount to add
+ * @return resulting instant
+ */
public Instant advance(Duration duration) {
Objects.requireNonNull(duration);
return instant.updateAndGet(current -> current.plus(duration));
diff --git a/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/SnapshotStoreContract.java b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/SnapshotStoreContract.java
new file mode 100644
index 0000000..8d3170b
--- /dev/null
+++ b/version-gate-testkit/src/main/java/io/github/kbarseghyan/versiongate/testkit/SnapshotStoreContract.java
@@ -0,0 +1,93 @@
+package io.github.kbarseghyan.versiongate.testkit;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.github.kbarseghyan.versiongate.api.ErrorCode;
+import io.github.kbarseghyan.versiongate.api.VersionGateException;
+import io.github.kbarseghyan.versiongate.port.SnapshotStore;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Reusable minimum semantic contract for {@link SnapshotStore} implementations.
+ *
+ * Adapter tests extend this class and construct an empty store for each invocation. Provider
+ * adapters should add integration coverage for multipart cleanup, crash recovery, and provider
+ * integrity guarantees.
+ */
+public abstract class SnapshotStoreContract {
+
+ private SnapshotStore store;
+
+ /**
+ * Creates an empty snapshot store for one contract-test invocation.
+ *
+ * @return empty store instance
+ */
+ protected abstract SnapshotStore createSnapshotStore();
+
+ @BeforeEach
+ final void initializeContractStore() {
+ store = createSnapshotStore();
+ }
+
+ @Test
+ final void immutableReplayReturnsTheOriginalObjectAndDifferentBytesConflict() {
+ byte[] bytes = "snapshot".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ String key = "snapshots/catalog/1/products";
+ SnapshotStore.StoredObject first = store.uploadImmutable(upload(key, bytes));
+ SnapshotStore.StoredObject replay = store.uploadImmutable(upload(key, bytes));
+
+ assertFalse(first.alreadyExisted());
+ assertTrue(replay.alreadyExisted());
+ assertEquals(first.reference(), replay.reference());
+
+ byte[] changed = bytes.clone();
+ changed[0]++;
+ VersionGateException failure =
+ assertThrows(VersionGateException.class, () -> store.uploadImmutable(upload(key, changed)));
+ assertEquals(ErrorCode.COMPONENT_CONFLICT, failure.code());
+ }
+
+ @Test
+ final void storedContentCanBeVerifiedAndStreamedWithoutChangingItsIdentity() throws IOException {
+ byte[] bytes = "snapshot".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ SnapshotStore.StoredObject stored =
+ store.uploadImmutable(upload("snapshots/catalog/1/products", bytes));
+
+ store.verify(stored.reference());
+ try (SnapshotStore.ObjectContent content = store.open(stored.reference())) {
+ assertArrayEquals(bytes, content.inputStream().readAllBytes());
+ assertEquals(bytes.length, content.contentLength());
+ assertEquals(sha256(bytes), content.sha256());
+ }
+ }
+
+ private static SnapshotStore.Upload upload(String key, byte[] bytes) {
+ return new SnapshotStore.Upload(
+ key,
+ new ByteArrayInputStream(bytes),
+ bytes.length,
+ "application/octet-stream",
+ Optional.empty(),
+ Optional.of(sha256(bytes)));
+ }
+
+ private static String sha256(byte[] bytes) {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
+ } catch (NoSuchAlgorithmException impossible) {
+ throw new IllegalStateException("SHA-256 is unavailable", impossible);
+ }
+ }
+}
diff --git a/src/test/java/io/github/kbarseghyan/versiongate/support/CoreSpiSemanticsTest.java b/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/CoreSpiSemanticsTest.java
similarity index 94%
rename from src/test/java/io/github/kbarseghyan/versiongate/support/CoreSpiSemanticsTest.java
rename to version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/CoreSpiSemanticsTest.java
index 6ce45a1..6ae0aa8 100644
--- a/src/test/java/io/github/kbarseghyan/versiongate/support/CoreSpiSemanticsTest.java
+++ b/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/CoreSpiSemanticsTest.java
@@ -1,4 +1,4 @@
-package io.github.kbarseghyan.versiongate.support;
+package io.github.kbarseghyan.versiongate.testkit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -71,7 +71,7 @@ void simultaneousBeginsYieldExactlyOneBuildAndNeverReuseItsFence() throws Except
List> attempts = new ArrayList<>();
try {
for (int index = 0; index < contenderCount; index++) {
- long targetVersion = index + 1L;
+ int contender = index;
attempts.add(
executor.submit(
() -> {
@@ -79,9 +79,7 @@ void simultaneousBeginsYieldExactlyOneBuildAndNeverReuseItsFence() throws Except
start.await();
try {
return new BeginAttempt(
- controlStore.beginBuild(
- "catalog", targetVersion, "owner-" + targetVersion, LEASE),
- null);
+ controlStore.beginBuild("catalog", "owner-" + contender, LEASE), null);
} catch (VersionGateException exception) {
return new BeginAttempt(null, exception.code());
}
@@ -106,8 +104,9 @@ void simultaneousBeginsYieldExactlyOneBuildAndNeverReuseItsFence() throws Except
assertThat(abandoned.status()).isEqualTo(BuildStatus.ABANDONED);
assertThat(controlStore.abortBuild(winner.buildId(), winner.fencingToken()))
.isSameAs(abandoned);
- Build next = controlStore.beginBuild("catalog", 100, "next-owner", LEASE);
+ Build next = controlStore.beginBuild("catalog", "next-owner", LEASE);
assertThat(next.fencingToken()).isEqualTo(winner.fencingToken() + 1);
+ assertThat(next.targetVersion()).isGreaterThan(winner.targetVersion());
} finally {
executor.shutdownNow();
}
@@ -116,7 +115,7 @@ void simultaneousBeginsYieldExactlyOneBuildAndNeverReuseItsFence() throws Except
@Test
void staleAndExpiredMutationsFailWhileFailureTerminalizationRemainsAvailable() {
registerClientResource("catalog", Set.of("products"));
- Build build = controlStore.beginBuild("catalog", 1, "owner", Duration.ofSeconds(10));
+ Build build = controlStore.beginBuild("catalog", "owner", Duration.ofSeconds(10));
assertCode(
ErrorCode.STALE_FENCING_TOKEN,
@@ -151,7 +150,7 @@ void coordinatedRenewalRejectsAtomicallyOnceQuiescenceBegins() {
SnapshotPolicy.COORDINATED_QUIESCE,
Set.of("orders"),
List.of(new Participant("database", URI.create("https://database.example"))));
- Build build = controlStore.beginBuild("orders", 1, "owner", LEASE);
+ Build build = controlStore.beginBuild("orders", "owner", LEASE);
Build quiescing =
controlStore.startSnapshotPhase(
build.buildId(), build.fencingToken(), BuildStatus.QUIESCING);
@@ -167,7 +166,7 @@ void coordinatedRenewalRejectsAtomicallyOnceQuiescenceBegins() {
@Test
void completionRechecksRequiredComponentsAndKeepsItsManifestStable() {
registerClientResource("catalog", Set.of("prices", "products"));
- Build build = snapshottingBuild("catalog", 1);
+ Build build = snapshottingBuild("catalog");
SnapshotComponent products = component(build, "products", "a".repeat(64), 3);
controlStore.registerSnapshotComponent(build.buildId(), build.fencingToken(), products);
@@ -212,7 +211,7 @@ void completionRechecksRequiredComponentsAndKeepsItsManifestStable() {
@Test
void activationAtomicallyPublishesOneStableVersionAndPreservesHistory() {
registerClientResource("catalog", Set.of("products"));
- Build first = snapshottingBuild("catalog", 1);
+ Build first = snapshottingBuild("catalog");
controlStore.registerSnapshotComponent(
first.buildId(), first.fencingToken(), component(first, "products", "a".repeat(64), 3));
VersionManifest firstManifest =
@@ -227,7 +226,7 @@ void activationAtomicallyPublishesOneStableVersionAndPreservesHistory() {
assertThat(controlStore.findActiveVersionManifest("catalog")).contains(firstManifest);
assertThat(controlStore.findVersionManifest("catalog", 1)).contains(firstManifest);
- Build second = snapshottingBuild("catalog", 2);
+ Build second = snapshottingBuild("catalog");
assertThat(second.baseActiveVersion()).isEqualTo(1);
controlStore.registerSnapshotComponent(
second.buildId(), second.fencingToken(), component(second, "products", "b".repeat(64), 4));
@@ -249,7 +248,7 @@ void participantProgressCanRetryAfterFailureButTerminalStatesNeverRegress() {
SnapshotPolicy.COORDINATED_QUIESCE,
Set.of("orders"),
List.of(new Participant("database", URI.create("https://database.example"))));
- Build build = controlStore.beginBuild("orders", 1, "owner", LEASE);
+ Build build = controlStore.beginBuild("orders", "owner", LEASE);
controlStore.updateParticipantState(
build.buildId(), "database", ParticipantStatus.QUIESCED, null);
@@ -277,8 +276,8 @@ void participantProgressCanRetryAfterFailureButTerminalStatesNeverRegress() {
void sweeperAbandonsOnlyExpiredBuildsUsingTheInjectedClock() {
registerClientResource("catalog", Set.of("products"));
registerClientResource("orders", Set.of("orders"));
- Build expired = controlStore.beginBuild("catalog", 1, "owner", Duration.ofSeconds(10));
- Build live = controlStore.beginBuild("orders", 1, "owner", Duration.ofSeconds(30));
+ Build expired = controlStore.beginBuild("catalog", "owner", Duration.ofSeconds(10));
+ Build live = controlStore.beginBuild("orders", "owner", Duration.ofSeconds(30));
clock.advance(Duration.ofSeconds(10));
assertThat(controlStore.abandonExpiredBuilds()).isOne();
@@ -393,7 +392,7 @@ void applicationFlowUsesTheReusableStoresWithoutBypassingStreamingOrVisibility()
new VersionGateService.RegisterResourceCommand(
"catalog", SnapshotPolicy.CLIENT_MANAGED, Set.of("products"), List.of()));
Build build =
- service.beginBuild(new VersionGateService.BeginBuildCommand("catalog", 1, "owner", LEASE));
+ service.beginBuild(new VersionGateService.BeginBuildCommand("catalog", "owner", LEASE));
service.startSnapshotPhase(
new VersionGateService.BuildTokenCommand(build.buildId(), build.fencingToken()));
byte[] bytes = "streamed-snapshot".getBytes(StandardCharsets.UTF_8);
@@ -427,8 +426,8 @@ private void registerClientResource(String resourceId, Set components) {
controlStore.registerResource(resourceId, SnapshotPolicy.CLIENT_MANAGED, components, List.of());
}
- private Build snapshottingBuild(String resourceId, long version) {
- Build build = controlStore.beginBuild(resourceId, version, "owner", LEASE);
+ private Build snapshottingBuild(String resourceId) {
+ Build build = controlStore.beginBuild(resourceId, "owner", LEASE);
return controlStore.startSnapshotPhase(
build.buildId(), build.fencingToken(), BuildStatus.SNAPSHOTTING);
}
diff --git a/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/InMemoryControlStoreContractTest.java b/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/InMemoryControlStoreContractTest.java
new file mode 100644
index 0000000..daaed7c
--- /dev/null
+++ b/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/InMemoryControlStoreContractTest.java
@@ -0,0 +1,12 @@
+package io.github.kbarseghyan.versiongate.testkit;
+
+import io.github.kbarseghyan.versiongate.port.ControlStore;
+import java.time.Clock;
+
+final class InMemoryControlStoreContractTest extends ControlStoreContract {
+
+ @Override
+ protected ControlStore createControlStore(Clock clock) {
+ return new InMemoryControlStore(clock);
+ }
+}
diff --git a/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/InMemorySnapshotStoreContractTest.java b/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/InMemorySnapshotStoreContractTest.java
new file mode 100644
index 0000000..b49f8c9
--- /dev/null
+++ b/version-gate-testkit/src/test/java/io/github/kbarseghyan/versiongate/testkit/InMemorySnapshotStoreContractTest.java
@@ -0,0 +1,11 @@
+package io.github.kbarseghyan.versiongate.testkit;
+
+import io.github.kbarseghyan.versiongate.port.SnapshotStore;
+
+final class InMemorySnapshotStoreContractTest extends SnapshotStoreContract {
+
+ @Override
+ protected SnapshotStore createSnapshotStore() {
+ return new InMemorySnapshotStore();
+ }
+}