Implement apix-registry with IoT sunset/decommission lifecycle and full BDD suite
- REST API: register, patch, O-level, replacements, history, search endpoints - IoT lifecycle validations: future sunset, lock-before-release, sunset-passed-before-decommission - DB schema: Liquibase changesets 001–008 (services, versions, replacements, sunset-at column) - @ColumnTransformer(write="?::jsonb") on bsm_payload fields to avoid JDBC varchar→jsonb rejection - Jandex plugin on apix-common + quarkus.index-dependency so @NotBlank validators resolve at runtime - quarkus-logging-json extension added; quarkus.log.console.json=false is now a recognised key - Fix requireSunsetBeforeLockRelease: Boolean.TRUE.equals instead of !Boolean.FALSE.equals (null guard) - BDD suite: 27 scenarios / 213 steps across 5 feature files (sunset-lock, decommission, replacement, discovery, anonymity) - Test infrastructure: JDBC TRUNCATE in @Before for DB isolation, Arc.container() for clock control — no test endpoints in production code - sunsetAt truncated to microseconds in BDD steps to match Postgres timestamptz precision - Cucumber step fixes: singular/plural candidate(s), lastResponse propagation in replacementsReturnsNCandidates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
package org.botstandards.apix.registry.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import org.botstandards.apix.common.OLevel;
|
||||
import org.botstandards.apix.common.ServiceStage;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record ReplacementsResponse(
|
||||
Boolean locked,
|
||||
Instant sunsetAt,
|
||||
List<Candidate> candidates
|
||||
) {
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record Candidate(
|
||||
UUID id,
|
||||
String name,
|
||||
String endpoint,
|
||||
OLevel oLevel,
|
||||
ServiceStage serviceStage
|
||||
) {}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package org.botstandards.apix.registry.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import org.botstandards.apix.common.BsmPayload;
|
||||
import org.botstandards.apix.common.OrgType;
|
||||
import org.botstandards.apix.common.ServiceStage;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ServicePatchRequest(
|
||||
String name,
|
||||
String description,
|
||||
String endpoint,
|
||||
List<String> capabilities,
|
||||
String registrantEmail,
|
||||
String registrantName,
|
||||
String registrantJurisdiction,
|
||||
OrgType registrantOrgType,
|
||||
String registrantLei,
|
||||
String openApiSpecUrl,
|
||||
String mcpSpecUrl,
|
||||
String policyUrl,
|
||||
String securityContactUrl,
|
||||
BsmPayload.Pricing pricing,
|
||||
String bsmVersion,
|
||||
ServiceStage serviceStage,
|
||||
Boolean locked,
|
||||
Instant sunsetAt,
|
||||
String migrationGuideUrl,
|
||||
List<UUID> replacesServiceIds
|
||||
) {}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.botstandards.apix.registry.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import org.botstandards.apix.common.*;
|
||||
import org.botstandards.apix.registry.entity.ServiceEntity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record ServiceResponse(
|
||||
UUID id,
|
||||
String name,
|
||||
String description,
|
||||
String endpoint,
|
||||
List<String> capabilities,
|
||||
String registrantEmail,
|
||||
String registrantName,
|
||||
String registrantJurisdiction,
|
||||
OrgType registrantOrgType,
|
||||
String registrantLei,
|
||||
String openApiSpecUrl,
|
||||
String mcpSpecUrl,
|
||||
String policyUrl,
|
||||
String securityContactUrl,
|
||||
BsmPayload.Pricing pricing,
|
||||
String bsmVersion,
|
||||
OLevel oLevel,
|
||||
LivenessStatus livenessStatus,
|
||||
ServiceStage serviceStage,
|
||||
RegistryStatus registryStatus,
|
||||
Boolean locked,
|
||||
Instant sunsetAt,
|
||||
String migrationGuideUrl,
|
||||
List<UUID> replacesServiceIds,
|
||||
Instant registeredAt,
|
||||
Instant lastUpdatedAt
|
||||
) {
|
||||
public static ServiceResponse from(ServiceEntity e) {
|
||||
BsmPayload b = e.bsmPayload;
|
||||
return new ServiceResponse(
|
||||
e.id,
|
||||
b.name(),
|
||||
b.description(),
|
||||
b.endpoint(),
|
||||
b.capabilities(),
|
||||
b.registrantEmail(),
|
||||
b.registrantName(),
|
||||
b.registrantJurisdiction(),
|
||||
e.registrantOrgType,
|
||||
b.registrantLei(),
|
||||
b.openApiSpecUrl(),
|
||||
b.mcpSpecUrl(),
|
||||
b.policyUrl(),
|
||||
b.securityContactUrl(),
|
||||
b.pricing(),
|
||||
b.bsmVersion(),
|
||||
e.olevel,
|
||||
e.livenessStatus,
|
||||
e.serviceStage,
|
||||
e.registryStatus,
|
||||
e.locked,
|
||||
e.sunsetAt,
|
||||
e.migrationGuideUrl,
|
||||
b.replacesServiceIds(),
|
||||
e.registeredAt,
|
||||
e.lastUpdatedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package org.botstandards.apix.registry.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record VersionHistoryEntry(
|
||||
UUID id,
|
||||
String type,
|
||||
String previousValue,
|
||||
String newValue,
|
||||
String createdAt
|
||||
) {}
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.botstandards.apix.registry.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import org.botstandards.apix.common.*;
|
||||
import org.botstandards.apix.registry.persistence.BsmPayloadConverter;
|
||||
import org.hibernate.annotations.ColumnTransformer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "services")
|
||||
public class ServiceEntity {
|
||||
|
||||
@Id
|
||||
@Column(columnDefinition = "uuid")
|
||||
public UUID id;
|
||||
|
||||
@Column(name = "endpoint_url", nullable = false, unique = true)
|
||||
public String endpointUrl;
|
||||
|
||||
@Convert(converter = BsmPayloadConverter.class)
|
||||
@Column(name = "bsm_payload", columnDefinition = "jsonb", nullable = false)
|
||||
@ColumnTransformer(write = "?::jsonb")
|
||||
public BsmPayload bsmPayload;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
public OLevel olevel = OLevel.UNVERIFIED;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "liveness_status", nullable = false)
|
||||
public LivenessStatus livenessStatus = LivenessStatus.PENDING;
|
||||
|
||||
@Column(name = "registered_at", nullable = false)
|
||||
public Instant registeredAt;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "registrant_org_type", nullable = false)
|
||||
public OrgType registrantOrgType = OrgType.INDIVIDUAL;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "service_stage", nullable = false)
|
||||
public ServiceStage serviceStage = ServiceStage.DEVELOPMENT;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "registry_status", nullable = false)
|
||||
public RegistryStatus registryStatus = RegistryStatus.ACTIVE;
|
||||
|
||||
@Column(nullable = false)
|
||||
public int version = 1;
|
||||
|
||||
@Column(name = "last_updated_at")
|
||||
public Instant lastUpdatedAt;
|
||||
|
||||
@Column(name = "locked")
|
||||
public Boolean locked;
|
||||
|
||||
@Column(name = "sunset_at")
|
||||
public Instant sunsetAt;
|
||||
|
||||
@Column(name = "migration_guide_url")
|
||||
public String migrationGuideUrl;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package org.botstandards.apix.registry.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "service_replacements")
|
||||
public class ServiceReplacementEntity {
|
||||
|
||||
@Id
|
||||
@Column(columnDefinition = "uuid")
|
||||
public UUID id;
|
||||
|
||||
@Column(name = "deprecated_service_id", nullable = false)
|
||||
public UUID deprecatedServiceId;
|
||||
|
||||
@Column(name = "replacement_service_id", nullable = false)
|
||||
public UUID replacementServiceId;
|
||||
|
||||
@Column(name = "declared_at", nullable = false)
|
||||
public Instant declaredAt;
|
||||
|
||||
@Column(name = "compatibility_notes")
|
||||
public String compatibilityNotes;
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package org.botstandards.apix.registry.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import org.botstandards.apix.common.*;
|
||||
import org.botstandards.apix.registry.persistence.BsmPayloadConverter;
|
||||
import org.hibernate.annotations.ColumnTransformer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "service_versions")
|
||||
public class ServiceVersionEntity {
|
||||
|
||||
@Id
|
||||
@Column(columnDefinition = "uuid")
|
||||
public UUID id;
|
||||
|
||||
@Column(name = "service_id", nullable = false)
|
||||
public UUID serviceId;
|
||||
|
||||
@Column(nullable = false)
|
||||
public int version;
|
||||
|
||||
@Column(name = "recorded_at", nullable = false)
|
||||
public Instant recordedAt;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "change_type", nullable = false)
|
||||
public ChangeType changeType;
|
||||
|
||||
@Convert(converter = BsmPayloadConverter.class)
|
||||
@Column(name = "bsm_payload", columnDefinition = "jsonb", nullable = false)
|
||||
@ColumnTransformer(write = "?::jsonb")
|
||||
public BsmPayload bsmPayload;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "registrant_org_type", nullable = false)
|
||||
public OrgType registrantOrgType;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
public OLevel olevel;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "service_stage", nullable = false)
|
||||
public ServiceStage serviceStage;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "registry_status", nullable = false)
|
||||
public RegistryStatus registryStatus;
|
||||
|
||||
@Column(name = "note")
|
||||
public String note;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package org.botstandards.apix.registry.persistence;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
import org.botstandards.apix.common.BsmPayload;
|
||||
|
||||
@Converter(autoApply = true)
|
||||
public class BsmPayloadConverter implements AttributeConverter<BsmPayload, String> {
|
||||
|
||||
private static final JsonMapper MAPPER = JsonMapper.builder()
|
||||
.addModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(BsmPayload payload) {
|
||||
if (payload == null) return null;
|
||||
try {
|
||||
return MAPPER.writeValueAsString(payload);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("Cannot serialize BsmPayload", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BsmPayload convertToEntityAttribute(String json) {
|
||||
if (json == null) return null;
|
||||
try {
|
||||
return MAPPER.readValue(json, BsmPayload.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("Cannot deserialize BsmPayload", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package org.botstandards.apix.registry.resource;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import org.botstandards.apix.common.BsmPayload;
|
||||
import org.botstandards.apix.common.OLevel;
|
||||
import org.botstandards.apix.registry.dto.ReplacementsResponse;
|
||||
import org.botstandards.apix.registry.dto.ServicePatchRequest;
|
||||
import org.botstandards.apix.registry.dto.ServiceResponse;
|
||||
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
|
||||
import org.botstandards.apix.registry.service.RegistryService;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Path("/services")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class ServiceResource {
|
||||
|
||||
@Inject
|
||||
RegistryService registryService;
|
||||
|
||||
@ConfigProperty(name = "apix.api-key")
|
||||
String apiKey;
|
||||
|
||||
@POST
|
||||
public Response register(@Valid BsmPayload payload, @HeaderParam("X-Api-Key") String key) {
|
||||
requireKey(key);
|
||||
var service = registryService.register(payload);
|
||||
return Response.created(URI.create("/services/" + service.id))
|
||||
.entity(Map.of("id", service.id.toString()))
|
||||
.build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public ServiceResponse getById(@PathParam("id") UUID id) {
|
||||
return ServiceResponse.from(registryService.requireById(id));
|
||||
}
|
||||
|
||||
@PATCH
|
||||
@Path("/{id}")
|
||||
public ServiceResponse patch(@PathParam("id") UUID id,
|
||||
ServicePatchRequest req,
|
||||
@HeaderParam("X-Api-Key") String key) {
|
||||
requireKey(key);
|
||||
return ServiceResponse.from(registryService.patch(id, req));
|
||||
}
|
||||
|
||||
@GET
|
||||
public List<ServiceResponse> search(@QueryParam("capability") String capability,
|
||||
@QueryParam("stage") String stage) {
|
||||
if (capability == null || capability.isBlank()) {
|
||||
throw new BadRequestException("capability query parameter is required");
|
||||
}
|
||||
return registryService.search(capability, stage).stream()
|
||||
.map(ServiceResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PATCH
|
||||
@Path("/{id}/olevel")
|
||||
public ServiceResponse setOLevel(@PathParam("id") UUID id,
|
||||
@QueryParam("level") String level,
|
||||
@HeaderParam("X-Api-Key") String key) {
|
||||
requireKey(key);
|
||||
OLevel oLevel = OLevel.valueOf(level.toUpperCase());
|
||||
return ServiceResponse.from(registryService.setOLevel(id, oLevel));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}/replacements")
|
||||
public Response getReplacements(@PathParam("id") UUID id,
|
||||
@QueryParam("minOLevel") String minOLevel) {
|
||||
ReplacementsResponse body = registryService.getReplacements(id, minOLevel);
|
||||
return Response.ok(body)
|
||||
.header("Cache-Control", "public, max-age=60")
|
||||
.build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}/history")
|
||||
public List<VersionHistoryEntry> getHistory(@PathParam("id") UUID id) {
|
||||
return registryService.getHistory(id);
|
||||
}
|
||||
|
||||
private void requireKey(String provided) {
|
||||
if (!apiKey.equals(provided)) {
|
||||
throw new NotAuthorizedException(
|
||||
Response.status(401)
|
||||
.entity(Map.of("message", "Invalid or missing API key"))
|
||||
.build());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.botstandards.apix.registry.service;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@ApplicationScoped
|
||||
public class ClockService {
|
||||
|
||||
private volatile Instant override = null;
|
||||
|
||||
public Instant now() {
|
||||
return override != null ? override : Instant.now();
|
||||
}
|
||||
|
||||
public void advance(Instant instant) {
|
||||
this.override = instant;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
this.override = null;
|
||||
}
|
||||
}
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
package org.botstandards.apix.registry.service;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.botstandards.apix.common.*;
|
||||
import org.botstandards.apix.registry.dto.ReplacementsResponse;
|
||||
import org.botstandards.apix.registry.dto.ServicePatchRequest;
|
||||
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
|
||||
import org.botstandards.apix.registry.entity.ServiceEntity;
|
||||
import org.botstandards.apix.registry.entity.ServiceReplacementEntity;
|
||||
import org.botstandards.apix.registry.entity.ServiceVersionEntity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
@ApplicationScoped
|
||||
public class RegistryService {
|
||||
|
||||
@Inject
|
||||
EntityManager em;
|
||||
|
||||
@Inject
|
||||
ClockService clockService;
|
||||
|
||||
@Transactional
|
||||
public ServiceEntity register(BsmPayload payload) {
|
||||
long existing = ((Number) em.createNativeQuery(
|
||||
"SELECT COUNT(*) FROM services WHERE endpoint_url = :url")
|
||||
.setParameter("url", payload.endpoint())
|
||||
.getSingleResult()).longValue();
|
||||
if (existing > 0) {
|
||||
throw new WebApplicationException(
|
||||
Response.status(409)
|
||||
.entity(Map.of("message", "A service with this endpoint is already registered"))
|
||||
.build());
|
||||
}
|
||||
|
||||
Instant now = Instant.now();
|
||||
ServiceEntity service = new ServiceEntity();
|
||||
service.id = UUID.randomUUID();
|
||||
service.endpointUrl = payload.endpoint();
|
||||
service.bsmPayload = payload;
|
||||
service.olevel = OLevel.UNVERIFIED;
|
||||
service.livenessStatus = LivenessStatus.PENDING;
|
||||
service.registeredAt = now;
|
||||
service.registrantOrgType = payload.registrantOrgType() != null ? payload.registrantOrgType() : OrgType.INDIVIDUAL;
|
||||
service.serviceStage = payload.serviceStage() != null ? payload.serviceStage() : ServiceStage.DEVELOPMENT;
|
||||
service.registryStatus = RegistryStatus.ACTIVE;
|
||||
service.version = 1;
|
||||
service.locked = payload.locked();
|
||||
service.sunsetAt = payload.sunsetAt();
|
||||
service.migrationGuideUrl = payload.migrationGuideUrl();
|
||||
|
||||
em.persist(service);
|
||||
em.persist(snapshot(service, ChangeType.REGISTERED, now));
|
||||
|
||||
if (payload.replacesServiceIds() != null) {
|
||||
for (UUID deprecatedId : payload.replacesServiceIds()) {
|
||||
upsertReplacement(deprecatedId, service.id, now);
|
||||
}
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
public ServiceEntity requireById(UUID id) {
|
||||
ServiceEntity e = em.find(ServiceEntity.class, id);
|
||||
if (e == null) throw new NotFoundException("Service not found: " + id);
|
||||
return e;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ServiceEntity setOLevel(UUID id, OLevel level) {
|
||||
ServiceEntity e = requireById(id);
|
||||
e.olevel = level;
|
||||
e.lastUpdatedAt = Instant.now();
|
||||
return e;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ServiceEntity patch(UUID id, ServicePatchRequest req) {
|
||||
ServiceEntity service = requireById(id);
|
||||
|
||||
// ── IoT validations ───────────────────────────────────────────────────
|
||||
Instant now = clockService.now();
|
||||
requireFutureSunset(req.sunsetAt(), now);
|
||||
requireSunsetBeforeLockRelease(req.locked(), service.locked,
|
||||
req.sunsetAt() != null ? req.sunsetAt() : service.sunsetAt);
|
||||
requireSunsetPassedBeforeDecommission(req.serviceStage(), service.sunsetAt, now);
|
||||
|
||||
if (req.replacesServiceIds() != null) {
|
||||
for (UUID deprecatedId : req.replacesServiceIds()) {
|
||||
validateReplacementTarget(deprecatedId);
|
||||
}
|
||||
}
|
||||
|
||||
ServiceStage stageBefore = service.serviceStage;
|
||||
Boolean lockedBefore = service.locked;
|
||||
|
||||
applyPatch(service, req);
|
||||
|
||||
ChangeType changeType = detectChangeType(stageBefore, lockedBefore, req);
|
||||
service.version++;
|
||||
service.lastUpdatedAt = Instant.now();
|
||||
|
||||
em.persist(snapshot(service, changeType, service.lastUpdatedAt));
|
||||
|
||||
if (req.replacesServiceIds() != null) {
|
||||
syncReplacements(service.id, req.replacesServiceIds(), service.lastUpdatedAt);
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<ServiceEntity> search(String capability, String stage) {
|
||||
ServiceStage targetStage = stage != null
|
||||
? ServiceStage.valueOf(stage.toUpperCase())
|
||||
: ServiceStage.PRODUCTION;
|
||||
|
||||
return em.createNativeQuery(
|
||||
"SELECT s.* FROM services s " +
|
||||
"WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " +
|
||||
"AND s.registry_status = 'ACTIVE' " +
|
||||
"AND s.service_stage = :stage",
|
||||
ServiceEntity.class)
|
||||
.setParameter("cap", capability)
|
||||
.setParameter("stage", targetStage.name())
|
||||
.getResultList();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr) {
|
||||
ServiceEntity deprecated = requireById(deprecatedId);
|
||||
|
||||
// Locked services that are not yet DECOMMISSIONED block replacement discovery
|
||||
if (Boolean.TRUE.equals(deprecated.locked) && deprecated.serviceStage != ServiceStage.DECOMMISSIONED) {
|
||||
return new ReplacementsResponse(deprecated.locked, deprecated.sunsetAt, List.of());
|
||||
}
|
||||
|
||||
List<ServiceEntity> candidates = em.createNativeQuery(
|
||||
"SELECT s.* FROM services s " +
|
||||
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id " +
|
||||
"WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'",
|
||||
ServiceEntity.class)
|
||||
.setParameter("depId", deprecatedId)
|
||||
.getResultList();
|
||||
|
||||
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
|
||||
|
||||
return new ReplacementsResponse(
|
||||
deprecated.locked,
|
||||
deprecated.sunsetAt,
|
||||
candidates.stream()
|
||||
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal())
|
||||
.sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed())
|
||||
.map(c -> new ReplacementsResponse.Candidate(
|
||||
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage))
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
|
||||
public List<VersionHistoryEntry> getHistory(UUID id) {
|
||||
requireById(id);
|
||||
|
||||
List<ServiceVersionEntity> versions = em.createQuery(
|
||||
"FROM ServiceVersionEntity v WHERE v.serviceId = :id ORDER BY v.version ASC",
|
||||
ServiceVersionEntity.class)
|
||||
.setParameter("id", id)
|
||||
.getResultList();
|
||||
|
||||
List<VersionHistoryEntry> result = new ArrayList<>();
|
||||
for (int i = 0; i < versions.size(); i++) {
|
||||
result.add(toHistoryEntry(versions.get(i), i > 0 ? versions.get(i - 1) : null));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Package-private static helpers — unit-testable without DB ─────────────
|
||||
|
||||
static ChangeType detectChangeType(ServiceStage stageBefore, Boolean lockedBefore, ServicePatchRequest req) {
|
||||
if (req.locked() != null && Boolean.FALSE.equals(req.locked()) && Boolean.TRUE.equals(lockedBefore)) {
|
||||
return ChangeType.LOCK_RELEASED;
|
||||
}
|
||||
if (req.serviceStage() != null && req.serviceStage() != stageBefore) {
|
||||
if (req.sunsetAt() != null && req.serviceStage() == ServiceStage.DEPRECATED) {
|
||||
return ChangeType.SUNSET_DECLARED;
|
||||
}
|
||||
return ChangeType.STAGE_CHANGED;
|
||||
}
|
||||
if (req.replacesServiceIds() != null) {
|
||||
return ChangeType.REPLACEMENT_DECLARED;
|
||||
}
|
||||
return ChangeType.BSM_UPDATED;
|
||||
}
|
||||
|
||||
// sunsetAt must be strictly after now — exclusive boundary means now==sunsetAt is already "past"
|
||||
static void requireFutureSunset(Instant sunsetAt, Instant now) {
|
||||
if (sunsetAt != null && !sunsetAt.isAfter(now)) {
|
||||
throw unprocessable("sunset_at must be a future moment");
|
||||
}
|
||||
}
|
||||
|
||||
static void requireSunsetBeforeLockRelease(Boolean newLocked, Boolean existingLocked, Instant effectiveSunsetAt) {
|
||||
if (Boolean.FALSE.equals(newLocked) && Boolean.TRUE.equals(existingLocked) && effectiveSunsetAt == null) {
|
||||
throw unprocessable("sunset_at required before lock release");
|
||||
}
|
||||
}
|
||||
|
||||
// Exclusive boundary: now >= sunsetAt means the sunset moment has arrived
|
||||
static void requireSunsetPassedBeforeDecommission(ServiceStage newStage, Instant sunsetAt, Instant now) {
|
||||
if (newStage == ServiceStage.DECOMMISSIONED) {
|
||||
if (sunsetAt == null || now.isBefore(sunsetAt)) {
|
||||
throw unprocessable("sunset_at has not passed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private void validateReplacementTarget(UUID deprecatedId) {
|
||||
ServiceEntity target = em.find(ServiceEntity.class, deprecatedId);
|
||||
if (target == null) throw unprocessable("target service not found");
|
||||
if (target.serviceStage != ServiceStage.DEPRECATED && target.serviceStage != ServiceStage.DECOMMISSIONED) {
|
||||
throw unprocessable("target service is not deprecated");
|
||||
}
|
||||
if (target.serviceStage == ServiceStage.DEPRECATED && Boolean.TRUE.equals(target.locked)) {
|
||||
throw unprocessable("target service lock has not been released");
|
||||
}
|
||||
}
|
||||
|
||||
private void applyPatch(ServiceEntity e, ServicePatchRequest r) {
|
||||
BsmPayload old = e.bsmPayload;
|
||||
e.bsmPayload = new BsmPayload(
|
||||
r.name() != null ? r.name() : old.name(),
|
||||
r.description() != null ? r.description() : old.description(),
|
||||
r.endpoint() != null ? r.endpoint() : old.endpoint(),
|
||||
r.capabilities() != null ? r.capabilities() : old.capabilities(),
|
||||
r.registrantEmail() != null ? r.registrantEmail() : old.registrantEmail(),
|
||||
r.registrantName() != null ? r.registrantName() : old.registrantName(),
|
||||
r.registrantJurisdiction() != null ? r.registrantJurisdiction() : old.registrantJurisdiction(),
|
||||
r.registrantOrgType() != null ? r.registrantOrgType() : old.registrantOrgType(),
|
||||
r.registrantLei() != null ? r.registrantLei() : old.registrantLei(),
|
||||
r.openApiSpecUrl() != null ? r.openApiSpecUrl() : old.openApiSpecUrl(),
|
||||
r.mcpSpecUrl() != null ? r.mcpSpecUrl() : old.mcpSpecUrl(),
|
||||
r.policyUrl() != null ? r.policyUrl() : old.policyUrl(),
|
||||
r.securityContactUrl() != null ? r.securityContactUrl() : old.securityContactUrl(),
|
||||
r.pricing() != null ? r.pricing() : old.pricing(),
|
||||
r.bsmVersion() != null ? r.bsmVersion() : old.bsmVersion(),
|
||||
r.serviceStage() != null ? r.serviceStage() : old.serviceStage(),
|
||||
r.locked() != null ? r.locked() : old.locked(),
|
||||
r.sunsetAt() != null ? r.sunsetAt() : old.sunsetAt(),
|
||||
r.migrationGuideUrl() != null ? r.migrationGuideUrl() : old.migrationGuideUrl(),
|
||||
r.replacesServiceIds() != null ? r.replacesServiceIds() : old.replacesServiceIds()
|
||||
);
|
||||
if (r.endpoint() != null) e.endpointUrl = r.endpoint();
|
||||
if (r.registrantOrgType() != null) e.registrantOrgType = r.registrantOrgType();
|
||||
if (r.serviceStage() != null) e.serviceStage = r.serviceStage();
|
||||
if (r.locked() != null) e.locked = r.locked();
|
||||
if (r.sunsetAt() != null) e.sunsetAt = r.sunsetAt();
|
||||
if (r.migrationGuideUrl() != null) e.migrationGuideUrl = r.migrationGuideUrl();
|
||||
}
|
||||
|
||||
private ServiceVersionEntity snapshot(ServiceEntity e, ChangeType changeType, Instant at) {
|
||||
ServiceVersionEntity v = new ServiceVersionEntity();
|
||||
v.id = UUID.randomUUID();
|
||||
v.serviceId = e.id;
|
||||
v.version = e.version;
|
||||
v.recordedAt = at;
|
||||
v.changeType = changeType;
|
||||
v.bsmPayload = e.bsmPayload;
|
||||
v.registrantOrgType = e.registrantOrgType;
|
||||
v.olevel = e.olevel;
|
||||
v.serviceStage = e.serviceStage;
|
||||
v.registryStatus = e.registryStatus;
|
||||
return v;
|
||||
}
|
||||
|
||||
private void syncReplacements(UUID providerId, List<UUID> deprecatedIds, Instant now) {
|
||||
em.createNativeQuery("DELETE FROM service_replacements WHERE replacement_service_id = :id")
|
||||
.setParameter("id", providerId)
|
||||
.executeUpdate();
|
||||
for (UUID deprecatedId : deprecatedIds) {
|
||||
upsertReplacement(deprecatedId, providerId, now);
|
||||
}
|
||||
}
|
||||
|
||||
private void upsertReplacement(UUID deprecatedId, UUID replacementId, Instant now) {
|
||||
long count = ((Number) em.createNativeQuery(
|
||||
"SELECT COUNT(*) FROM service_replacements " +
|
||||
"WHERE deprecated_service_id = :dep AND replacement_service_id = :rep")
|
||||
.setParameter("dep", deprecatedId)
|
||||
.setParameter("rep", replacementId)
|
||||
.getSingleResult()).longValue();
|
||||
if (count == 0) {
|
||||
// Record event in the deprecated service's timeline
|
||||
ServiceEntity deprecated = em.find(ServiceEntity.class, deprecatedId);
|
||||
if (deprecated != null) {
|
||||
deprecated.version++;
|
||||
deprecated.lastUpdatedAt = now;
|
||||
em.persist(snapshot(deprecated, ChangeType.REPLACEMENT_DECLARED, now));
|
||||
}
|
||||
ServiceReplacementEntity r = new ServiceReplacementEntity();
|
||||
r.id = UUID.randomUUID();
|
||||
r.deprecatedServiceId = deprecatedId;
|
||||
r.replacementServiceId = replacementId;
|
||||
r.declaredAt = now;
|
||||
em.persist(r);
|
||||
}
|
||||
}
|
||||
|
||||
private VersionHistoryEntry toHistoryEntry(ServiceVersionEntity cur, ServiceVersionEntity prev) {
|
||||
String previousValue = null;
|
||||
String newValue = null;
|
||||
switch (cur.changeType) {
|
||||
case STAGE_CHANGED, SUNSET_DECLARED -> {
|
||||
previousValue = prev != null ? prev.serviceStage.name() : null;
|
||||
newValue = cur.serviceStage.name();
|
||||
}
|
||||
case LOCK_RELEASED -> {
|
||||
previousValue = prev != null && prev.bsmPayload.locked() != null
|
||||
? prev.bsmPayload.locked().toString() : null;
|
||||
newValue = cur.bsmPayload.locked() != null ? cur.bsmPayload.locked().toString() : null;
|
||||
}
|
||||
default -> {}
|
||||
}
|
||||
return new VersionHistoryEntry(cur.id, cur.changeType.name(), previousValue, newValue,
|
||||
cur.recordedAt.toString());
|
||||
}
|
||||
|
||||
private static WebApplicationException unprocessable(String message) {
|
||||
return new WebApplicationException(
|
||||
Response.status(422).entity(Map.of("message", message)).build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# ── Jandex — include apix-common in the index so bean validation constraints work ──
|
||||
quarkus.index-dependency.apix-common.group-id=org.botstandards
|
||||
quarkus.index-dependency.apix-common.artifact-id=apix-common
|
||||
|
||||
# ── Datasource ────────────────────────────────────────────────────────────────
|
||||
quarkus.datasource.db-kind=postgresql
|
||||
quarkus.datasource.jdbc.url=${QUARKUS_DATASOURCE_JDBC_URL:jdbc:postgresql://localhost:5432/apix}
|
||||
quarkus.datasource.username=${QUARKUS_DATASOURCE_USERNAME:apix}
|
||||
quarkus.datasource.password=${QUARKUS_DATASOURCE_PASSWORD:apix}
|
||||
|
||||
# ── ORM ───────────────────────────────────────────────────────────────────────
|
||||
# Liquibase owns schema creation; Hibernate must not touch DDL
|
||||
quarkus.hibernate-orm.database.generation=none
|
||||
|
||||
# ── Liquibase ─────────────────────────────────────────────────────────────────
|
||||
quarkus.liquibase.migrate-at-start=true
|
||||
quarkus.liquibase.change-log=db/changelog/db.changelog-master.xml
|
||||
|
||||
# ── HTTP ──────────────────────────────────────────────────────────────────────
|
||||
quarkus.http.port=8180
|
||||
|
||||
# ── Security — API key for write endpoints ───────────────────────────────────
|
||||
apix.api-key=${APIX_API_KEY:dev-insecure-key-change-in-prod}
|
||||
|
||||
# ── Verification ──────────────────────────────────────────────────────────────
|
||||
apix.gleif.api-url=${GLEIF_API_URL:https://api.gleif.org/api/v1}
|
||||
apix.opencorporates.api-key=${OPENCORPORATES_API_KEY:}
|
||||
apix.sanctions.cache-path=${SANCTIONS_CACHE_PATH:./sanctions-cache}
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
quarkus.log.level=${LOG_LEVEL:DEBUG}
|
||||
quarkus.log.console.json=false
|
||||
|
||||
# ── Health ────────────────────────────────────────────────────────────────────
|
||||
quarkus.smallrye-health.root-path=/q/health
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<changeSet id="001" author="apix">
|
||||
<createTable tableName="services">
|
||||
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="endpoint_url" type="text">
|
||||
<constraints nullable="false" unique="true" uniqueConstraintName="uq_services_endpoint_url"/>
|
||||
</column>
|
||||
<!-- Full BSM document stored as JSONB for flexible querying -->
|
||||
<column name="bsm_payload" type="jsonb">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="olevel" type="varchar(50)" defaultValue="UNVERIFIED">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="slevel" type="varchar(50)"/>
|
||||
<column name="liveness_status" type="varchar(50)" defaultValue="PENDING">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="registered_at" type="timestamptz" defaultValueComputed="now()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<!-- GIN index on bsm_payload for capability search (@> operator) -->
|
||||
<sql>CREATE INDEX idx_services_bsm_payload_gin ON services USING gin (bsm_payload);</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<changeSet id="002" author="apix">
|
||||
<addColumn tableName="services">
|
||||
<column name="verification_status" type="varchar(50)"/>
|
||||
<column name="olevel_checked_at" type="timestamptz"/>
|
||||
<!-- null = not yet screened; false = hit; true = clear -->
|
||||
<column name="sanctions_cleared" type="boolean"/>
|
||||
<column name="gleif_lei" type="text"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<changeSet id="003" author="apix">
|
||||
<addColumn tableName="services">
|
||||
<column name="last_checked_at" type="timestamptz"/>
|
||||
<column name="uptime_30d_percent" type="numeric(5,2)"/>
|
||||
<column name="avg_response_ms" type="integer"/>
|
||||
<column name="consecutive_failures" type="integer" defaultValueNumeric="0"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<changeSet id="004" author="apix">
|
||||
<addColumn tableName="services">
|
||||
<!-- Registrant type — top-level column, not buried in JSONB, for direct filtering -->
|
||||
<column name="registrant_org_type" type="varchar(50)" defaultValue="INDIVIDUAL">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Registrant-declared lifecycle stage — controls search visibility -->
|
||||
<column name="service_stage" type="varchar(50)" defaultValue="DEVELOPMENT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- BSF-controlled administrative state — always applied before stage filter -->
|
||||
<column name="registry_status" type="varchar(50)" defaultValue="ACTIVE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Monotonically increasing per-service counter; links to service_versions -->
|
||||
<column name="version" type="integer" defaultValueNumeric="1">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Timestamp of last non-liveness update (liveness writes go to 003 columns) -->
|
||||
<column name="last_updated_at" type="timestamptz"/>
|
||||
</addColumn>
|
||||
|
||||
<!-- service_stage is the primary search filter dimension after capability -->
|
||||
<createIndex tableName="services" indexName="idx_services_service_stage">
|
||||
<column name="service_stage"/>
|
||||
</createIndex>
|
||||
<!-- registry_status is applied to every public query; index keeps it cheap -->
|
||||
<createIndex tableName="services" indexName="idx_services_registry_status">
|
||||
<column name="registry_status"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<changeSet id="005" author="apix">
|
||||
<!-- Append-only snapshot table. Rows are never updated or deleted.
|
||||
Each row captures the complete service state at the moment of a meaningful change.
|
||||
Diffs are computed from adjacent versions at read time, not stored. -->
|
||||
<createTable tableName="service_versions">
|
||||
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="service_id" type="uuid">
|
||||
<constraints nullable="false"
|
||||
foreignKeyName="fk_sv_service_id"
|
||||
references="services(id)"/>
|
||||
</column>
|
||||
<column name="version" type="integer">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="recorded_at" type="timestamptz" defaultValueComputed="now()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- REGISTERED | BSM_UPDATED | ORG_TYPE_CHANGED | OLEVEL_CHANGED |
|
||||
STAGE_CHANGED | OWNERSHIP_TRANSFERRED | REGISTRY_STATUS_CHANGED -->
|
||||
<column name="change_type" type="varchar(50)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Full BSM at this version — enables complete before/after comparison -->
|
||||
<column name="bsm_payload" type="jsonb">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="registrant_org_type" type="varchar(50)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="olevel" type="varchar(50)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="service_stage" type="varchar(50)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="registry_status" type="varchar(50)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Optional human-readable context for the change, e.g.
|
||||
"Incorporated as GmbH; transitioning from individual registration" -->
|
||||
<column name="note" type="text"/>
|
||||
</createTable>
|
||||
|
||||
<addUniqueConstraint
|
||||
tableName="service_versions"
|
||||
columnNames="service_id, version"
|
||||
constraintName="uq_sv_service_version"/>
|
||||
|
||||
<!-- Primary access pattern: all versions for a given service, ordered by version -->
|
||||
<createIndex tableName="service_versions" indexName="idx_sv_service_id">
|
||||
<column name="service_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<changeSet id="006" author="apix">
|
||||
<addColumn tableName="services">
|
||||
<!-- null = lock concept not applicable (non-IoT service)
|
||||
true = device locked to this template owner; migration blocked
|
||||
false = lock released; device owner may migrate freely
|
||||
Default null so existing records are not incorrectly treated as locked. -->
|
||||
<column name="locked" type="boolean"/>
|
||||
|
||||
<!-- ISO date when the service goes permanently offline.
|
||||
Set together with service_stage = DEPRECATED. -->
|
||||
<column name="sunset_date" type="date"/>
|
||||
|
||||
<!-- Provider-hosted migration documentation URL. -->
|
||||
<column name="migration_guide_url" type="text"/>
|
||||
</addColumn>
|
||||
|
||||
<!-- Targeted index: only rows with a sunset date (small, DEPRECATED subset) -->
|
||||
<sql>CREATE INDEX idx_services_sunset_date ON services (sunset_date)
|
||||
WHERE sunset_date IS NOT NULL;</sql>
|
||||
|
||||
<!-- Targeted index: only rows where locked is explicitly set -->
|
||||
<sql>CREATE INDEX idx_services_locked ON services (locked)
|
||||
WHERE locked IS NOT NULL;</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<changeSet id="007" author="apix">
|
||||
<!-- Declared compatibility index.
|
||||
Replacement providers list deprecated service IDs in BSM.replacesServiceIds[].
|
||||
The registry extracts those declarations here for efficient lookup.
|
||||
These rows are derived from BSM content and re-synced on every BSM update —
|
||||
never edit directly. -->
|
||||
<createTable tableName="service_replacements">
|
||||
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<!-- The deprecated service being replaced -->
|
||||
<column name="deprecated_service_id" type="uuid">
|
||||
<constraints nullable="false"
|
||||
foreignKeyName="fk_sr_deprecated"
|
||||
references="services(id)"/>
|
||||
</column>
|
||||
<!-- The service declaring it can replace the deprecated one -->
|
||||
<column name="replacement_service_id" type="uuid">
|
||||
<constraints nullable="false"
|
||||
foreignKeyName="fk_sr_replacement"
|
||||
references="services(id)"/>
|
||||
</column>
|
||||
<column name="declared_at" type="timestamptz" defaultValueComputed="now()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Optional human-readable compatibility notes from the replacement provider -->
|
||||
<column name="compatibility_notes" type="text"/>
|
||||
</createTable>
|
||||
|
||||
<!-- A replacement service can declare compatibility with each deprecated service once -->
|
||||
<addUniqueConstraint
|
||||
tableName="service_replacements"
|
||||
columnNames="deprecated_service_id, replacement_service_id"
|
||||
constraintName="uq_sr_deprecated_replacement"/>
|
||||
|
||||
<!-- Primary query path: GET /services/{deprecatedId}/replacements -->
|
||||
<createIndex tableName="service_replacements" indexName="idx_sr_deprecated_service_id">
|
||||
<column name="deprecated_service_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<changeSet id="008" author="apix">
|
||||
<!-- Drop old date-only index before renaming the column -->
|
||||
<sql>DROP INDEX IF EXISTS idx_services_sunset_date;</sql>
|
||||
|
||||
<!-- Rename and retype: date → timestamptz (exclusive boundary, UTC).
|
||||
Existing date values are cast to midnight UTC of that date. -->
|
||||
<sql>ALTER TABLE services RENAME COLUMN sunset_date TO sunset_at;</sql>
|
||||
<sql>ALTER TABLE services
|
||||
ALTER COLUMN sunset_at TYPE timestamptz
|
||||
USING sunset_at::timestamptz;</sql>
|
||||
|
||||
<!-- Recreate targeted index under new column name -->
|
||||
<sql>CREATE INDEX idx_services_sunset_at ON services (sunset_at)
|
||||
WHERE sunset_at IS NOT NULL;</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<include file="changes/001-initial-schema.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/002-verification-columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/003-liveness-metrics.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/004-org-stage-status.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/005-version-history.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/006-sunset-lock.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/007-service-replacements.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/008-sunset-at.xml" relativeToChangelogFile="true"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user