diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java
index 97564c4..cb127d3 100644
--- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexService.java
@@ -322,8 +322,66 @@ public class LineageIndexService {
return new LineageMeta(true, m.lastRefreshedAt(), m.lastStatus(), m.lastDurationMs());
}
+ /**
+ * Batch outgoing FK summary: для каждого visible dict — сколько outgoing
+ * FK refs в schema + список target dict names.
+ *
+ *
Используется admin-UI catalog list (`→` column) — раньше frontend
+ * парсил schemaJson каждого dict для подсчёта, что требовало включения
+ * schemaJson в list response. Этот endpoint позволяет catalog list
+ * вернуть только metadata + один graph batch.
+ *
+ * @param allowedScopes scopes caller видит. Source dicts вне scope skip.
+ * @param dictFilter optional whitelist names (null = все visible).
+ * @return map (dictName → OutgoingFkInfo). Empty dicts тоже включены
+ * (fkCount=0) чтобы frontend мог дисплейнуть "—" единообразно.
+ */
+ @Transactional(readOnly = true)
+ public Map findOutgoingFkSummary(
+ Set allowedScopes, Set dictFilter) {
+ List all = definitionRepository.findAll();
+ Map out = new java.util.LinkedHashMap<>();
+ for (DictionaryDefinition d : all) {
+ if (!allowedScopes.contains(d.getScope())) continue;
+ if (dictFilter != null && !dictFilter.contains(d.getName())) continue;
+
+ JsonNode schema = d.getSchemaJson();
+ List targets = new ArrayList<>();
+ if (schema != null && schema.isObject()) {
+ JsonNode properties = schema.get("properties");
+ if (properties != null && properties.isObject()) {
+ Iterator> it = properties.fields();
+ while (it.hasNext()) {
+ Map.Entry entry = it.next();
+ JsonNode refNode = entry.getValue().get("x-references");
+ if (refNode == null || !refNode.isTextual()) continue;
+ try {
+ ReferenceValidator.ParsedRef parsed =
+ ReferenceValidator.parseRef(refNode.asText(), entry.getValue());
+ // Dedup target dict names (один target может быть указан несколько
+ // раз через разные fields — UI показывает unique targets list).
+ if (!targets.contains(parsed.dictionaryName())) {
+ targets.add(parsed.dictionaryName());
+ }
+ } catch (IllegalArgumentException e) {
+ log.warn("Schema {} field {} malformed x-references: {}",
+ d.getName(), entry.getKey(), e.getMessage());
+ }
+ }
+ }
+ }
+ // Sort target list для stable response (test stability + cache keys).
+ targets.sort(String::compareTo);
+ out.put(d.getName(), new OutgoingFkInfo(targets.size(), targets));
+ }
+ return out;
+ }
+
// === DTOs ===
+ /** Per-dict outgoing FK summary. */
+ public record OutgoingFkInfo(int fkCount, List targets) {}
+
/** Schema-level reverse FK pointer: source dict → target dict через field. */
public record SchemaDependent(
String sourceDict,
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/LineageController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/LineageController.java
index 9f6107c..d10c22d 100644
--- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/LineageController.java
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/LineageController.java
@@ -9,7 +9,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import java.util.Arrays;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
/**
* Lineage / dependents read-side. Phase 1 dict-relationships-v2 epic.
@@ -91,4 +95,47 @@ public class LineageController {
throw OrdinisException.badRequest("invalid_request", msg);
}
}
+
+ /**
+ * Batch outgoing FK summary. Для admin-UI catalog list:
+ * frontend нужен сколько outgoing FK refs у каждого dict и список target
+ * names — раньше парсил schemaJson per-row на клиенте, теперь один
+ * batch endpoint.
+ *
+ * Query: {@code GET /api/v1/dictionaries/graph/outgoing?dicts=a,b,c}
+ *
+ *
+ * - {@code dicts} — optional CSV filter. Null/empty = все visible.
+ *
+ *
+ * Response: {@code { "outgoing": { "": { "fkCount", "targets" } } }}.
+ * Dicts вне scope caller'а skip — same hide pattern что и {@code GET /dictionaries}.
+ *
+ * @return wrapped в outer object для будущего extension (например meta).
+ */
+ @GetMapping("/dictionaries/graph/outgoing")
+ public OutgoingGraphResponse outgoingGraph(
+ @RequestParam(required = false) String dicts) {
+ Set filter = parseDictsFilter(dicts);
+ Map outgoing =
+ lineage.findOutgoingFkSummary(scopeContext.currentScopes(), filter);
+ return new OutgoingGraphResponse(outgoing);
+ }
+
+ /** Parse CSV "a,b,c" → Set. Returns null если param empty (means "all"). */
+ private static Set parseDictsFilter(String dictsCsv) {
+ if (dictsCsv == null || dictsCsv.isBlank()) return null;
+ return Arrays.stream(dictsCsv.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .collect(Collectors.toSet());
+ }
+
+ /**
+ * Wrapper для batch outgoing graph response. Inner map не серилизуется
+ * напрямую (consumer'у удобнее видеть {@code outgoing: { ... }} ключ —
+ * легче добавить sibling fields позже без breaking change).
+ */
+ public record OutgoingGraphResponse(
+ Map outgoing) {}
}
diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexServiceTest.java
index 37b9c02..ee62847 100644
--- a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexServiceTest.java
+++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/LineageIndexServiceTest.java
@@ -342,6 +342,94 @@ class LineageIndexServiceTest {
assertThat(page3.items().get(0).businessKey()).isEqualTo("K-0"); // oldest
}
+ // === Batch outgoing FK summary ===
+
+ @Test
+ void outgoingFk_emptyDicts_returnsEmpty() {
+ when(defRepo.findAll()).thenReturn(List.of());
+ var result = service.findOutgoingFkSummary(Set.of(DataScope.PUBLIC), null);
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void outgoingFk_basicCounts_returnsCountsAndTargets() {
+ var noRefs = mockDefWithSchema("operators", DataScope.PUBLIC,
+ "{\"properties\":{\"name\":{\"type\":\"string\"}}}");
+ var threeRefs = mockDefWithSchema("satellites", DataScope.PUBLIC, """
+ {"properties":{
+ "operator":{"type":"string","x-references":"operators.code"},
+ "launchSite":{"type":"string","x-references":"launch_sites.code"},
+ "mission":{"type":"string","x-references":"missions.code"}
+ }}""");
+ when(defRepo.findAll()).thenReturn(List.of(noRefs, threeRefs));
+
+ var result = service.findOutgoingFkSummary(Set.of(DataScope.PUBLIC), null);
+
+ assertThat(result).hasSize(2);
+ assertThat(result.get("operators").fkCount()).isEqualTo(0);
+ assertThat(result.get("operators").targets()).isEmpty();
+ assertThat(result.get("satellites").fkCount()).isEqualTo(3);
+ // Sorted alphabetically для stable test + cacheability.
+ assertThat(result.get("satellites").targets())
+ .containsExactly("launch_sites", "missions", "operators");
+ }
+
+ @Test
+ void outgoingFk_scopeHidesSource_skipped() {
+ var publicDict = mockDef("pub", DataScope.PUBLIC);
+ var internalDict = mockDef("internal", DataScope.INTERNAL);
+ when(defRepo.findAll()).thenReturn(List.of(publicDict, internalDict));
+
+ var result = service.findOutgoingFkSummary(Set.of(DataScope.PUBLIC), null);
+
+ assertThat(result).containsOnlyKeys("pub");
+ }
+
+ @Test
+ void outgoingFk_filterWhitelist_respectsFilter() {
+ var a = mockDef("a", DataScope.PUBLIC);
+ var b = mockDef("b", DataScope.PUBLIC);
+ var c = mockDef("c", DataScope.PUBLIC);
+ when(defRepo.findAll()).thenReturn(List.of(a, b, c));
+
+ var result = service.findOutgoingFkSummary(
+ Set.of(DataScope.PUBLIC), Set.of("a", "c"));
+
+ assertThat(result).containsOnlyKeys("a", "c");
+ }
+
+ @Test
+ void outgoingFk_dedupRepeatedTarget_returnsUnique() {
+ // Source с двумя fields ссылающимися на same target dict — target должен
+ // появиться в targets list один раз, но fkCount считает unique targets.
+ var src = mockDefWithSchema("doc", DataScope.PUBLIC, """
+ {"properties":{
+ "primaryAuthor":{"type":"string","x-references":"users.id"},
+ "reviewerAuthor":{"type":"string","x-references":"users.id"}
+ }}""");
+ when(defRepo.findAll()).thenReturn(List.of(src));
+
+ var result = service.findOutgoingFkSummary(Set.of(DataScope.PUBLIC), null);
+
+ assertThat(result.get("doc").fkCount()).isEqualTo(1);
+ assertThat(result.get("doc").targets()).containsExactly("users");
+ }
+
+ @Test
+ void outgoingFk_malformedRef_skippedNotFatal() {
+ var src = mockDefWithSchema("buggy", DataScope.PUBLIC, """
+ {"properties":{
+ "good":{"type":"string","x-references":"ok.code"},
+ "bad":{"type":"string","x-references":"no_dot_format"}
+ }}""");
+ when(defRepo.findAll()).thenReturn(List.of(src));
+
+ var result = service.findOutgoingFkSummary(Set.of(DataScope.PUBLIC), null);
+
+ assertThat(result.get("buggy").fkCount()).isEqualTo(1);
+ assertThat(result.get("buggy").targets()).containsExactly("ok");
+ }
+
// === Helpers ===
private static DictionaryDefinition mockDef(String name, DataScope scope) {