@oneuptime/common 14.0.5 → 14.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/MetricService.ts +1 -1
  10. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  11. package/Server/Services/UserNotificationRuleService.ts +55 -53
  12. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  13. package/Server/Types/Database/QueryHelper.ts +54 -1
  14. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  15. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  16. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  17. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  18. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  19. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  20. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  21. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  22. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  23. package/Server/Utils/Response.ts +31 -0
  24. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  25. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  26. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  27. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  28. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  29. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  30. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  31. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  32. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  33. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  34. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  35. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  36. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  37. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  38. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  39. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  40. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  41. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  42. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  43. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  44. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  45. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  46. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  47. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  48. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  49. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  50. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  51. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  52. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  53. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  54. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  55. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  56. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  57. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  58. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  59. package/Tests/Server/Services/DomainService.test.ts +525 -0
  60. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  61. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  62. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  63. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  64. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  65. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  66. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  67. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  68. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  69. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  70. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  71. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  72. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  73. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  74. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  75. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  76. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  77. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  78. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  79. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  80. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  81. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  82. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  83. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  84. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  85. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  86. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  87. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  88. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  89. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  90. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  91. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  92. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  93. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  94. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  95. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  96. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  97. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  98. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  99. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  100. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  101. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  102. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  103. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  104. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  105. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  106. package/Types/BaseDatabase/AggregationType.ts +7 -0
  107. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  108. package/Types/Incident/IncidentAlertLink.ts +19 -0
  109. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  110. package/Types/Rum/WebVitals.ts +13 -0
  111. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  112. package/Types/Topology/TopologyApi.ts +428 -0
  113. package/Types/Topology/TopologyTypeRules.ts +191 -0
  114. package/UI/Components/Input/Input.tsx +11 -0
  115. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  116. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  117. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  118. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  123. package/build/dist/Server/Services/AlertService.js +21 -33
  124. package/build/dist/Server/Services/AlertService.js.map +1 -1
  125. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  126. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  127. package/build/dist/Server/Services/IncidentService.js +96 -42
  128. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  129. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  130. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  131. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  132. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  133. package/build/dist/Server/Services/MetricService.js +1 -1
  134. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  135. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  136. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  137. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  138. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  139. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  140. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  141. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  142. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  143. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  144. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  145. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  146. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  147. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  148. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  149. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  150. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  151. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  152. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  153. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  154. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  156. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  158. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  159. package/build/dist/Server/Utils/Response.js +30 -0
  160. package/build/dist/Server/Utils/Response.js.map +1 -1
  161. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  162. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  163. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  164. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  165. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  166. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  167. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  168. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  169. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  170. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  171. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  172. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  173. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  174. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  175. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  176. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  177. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  178. package/build/dist/Types/Rum/WebVitals.js +12 -0
  179. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  180. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  181. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  182. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  183. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  184. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  185. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  186. package/build/dist/UI/Components/Input/Input.js +1 -1
  187. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  188. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  189. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  190. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  191. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  192. package/package.json +1 -1
@@ -0,0 +1,2624 @@
1
+ import Entities from "../../../../Models/DatabaseModels/Index";
2
+ import { PostgresStatementTimeoutMs } from "../../../../Server/EnvironmentConfig";
3
+ import PostgresAppInstance from "../../../../Server/Infrastructure/PostgresDatabase";
4
+ import TopologyQueries, {
5
+ TopologyStatementRunner,
6
+ } from "../../../../Server/Utils/Topology/TopologyQueries";
7
+ import { JSONObject } from "../../../../Types/JSON";
8
+ import ObjectID from "../../../../Types/ObjectID";
9
+ import EntityRelationshipType from "../../../../Types/Telemetry/EntityRelationshipType";
10
+ import EntitySource from "../../../../Types/Telemetry/EntitySource";
11
+ import EntityType from "../../../../Types/Telemetry/EntityType";
12
+ import {
13
+ TopologyApiLimits,
14
+ TopologyCollectionCursorJSON,
15
+ TopologyCollectionResponseJSON,
16
+ TopologyCollectionSearchResponseJSON,
17
+ TopologyConnectionRowJSON,
18
+ TopologyEntityAllTimeConnectionsResponseJSON,
19
+ TopologyEntityAllTimeResponseJSON,
20
+ TopologyEntityConnectionsResponseJSON,
21
+ TopologyEntityJSON,
22
+ TopologyEntityResponseJSON,
23
+ TopologyInfrastructureNodeJSON,
24
+ TopologyInfrastructureResponseJSON,
25
+ TopologyServiceMapEntityJSON,
26
+ TopologyServiceMapResponseJSON,
27
+ } from "../../../../Types/Topology/TopologyApi";
28
+ import { DataSource } from "typeorm";
29
+
30
+ /*
31
+ * The Topology API's SQL EXECUTED on Postgres, against the migrated
32
+ * InventoryItem / InventoryItemRelationship structures.
33
+ *
34
+ * The unit suites check the statements' text and the decoding with a faked
35
+ * runner; only a real planner and executor can say whether the nesting
36
+ * choice, the activity predicate, the winner among duplicate keys, the
37
+ * collection keyset and the drawer's sections actually return the rows the
38
+ * contract promises. Collation matters here in particular: ties are broken
39
+ * with COLLATE "C" (code-point order), which differs from the database's
40
+ * default collation exactly on mixed-case keys.
41
+ *
42
+ * Opt in with RUN_POSTGRES_TOPOLOGY_TESTS=true against a Postgres migrated to
43
+ * the current head (the Postgres Schema Drift workflow's database right after
44
+ * its drift check, or the docker-compose one on localhost:5400). Both tables'
45
+ * STRUCTURE (columns, defaults, indexes) is cloned into a unique schema that
46
+ * is the connection's whole search_path, and dropped afterwards; no real row
47
+ * is read or written. Credentials from DATABASE_USERNAME / DATABASE_PASSWORD,
48
+ * database from TOPOLOGY_TEST_DATABASE_NAME or DATABASE_NAME, endpoint from
49
+ * TOPOLOGY_TEST_DATABASE_HOST / _PORT (default localhost:5400).
50
+ */
51
+ const describePostgres: typeof describe =
52
+ process.env["RUN_POSTGRES_TOPOLOGY_TESTS"] === "true"
53
+ ? describe
54
+ : describe.skip;
55
+
56
+ const TABLES: Array<string> = ["InventoryItem", "InventoryItemRelationship"];
57
+
58
+ /*
59
+ * How pg_indexes spells the migration's index (under any name the clone
60
+ * gave it). Hoisted so the literal is not the object of a member
61
+ * expression, which wrap-regex and Prettier cannot agree on.
62
+ */
63
+ const ENTITY_KEY_INDEX_DEFINITION: RegExp = /\("projectId", "entityKey"\)$/;
64
+
65
+ const PROJECT_ID: ObjectID = ObjectID.generate();
66
+ const OTHER_PROJECT_ID: ObjectID = ObjectID.generate();
67
+
68
+ /* The (already floored) range start every request below uses. */
69
+ const RANGE_START: Date = new Date("2026-09-20T10:00:00.000Z");
70
+ const IN_RANGE: Date = new Date("2026-09-20T10:10:00.000Z");
71
+ const STALE: Date = new Date("2026-09-19T10:00:00.000Z");
72
+ const CREATED: Date = new Date("2026-09-01T00:00:00.000Z");
73
+
74
+ interface ItemFixture {
75
+ key: string;
76
+ type: string;
77
+ name?: string | null | undefined;
78
+ source?: string | undefined;
79
+ lastSeenAt?: Date | null | undefined;
80
+ firstSeenAt?: Date | null | undefined;
81
+ archived?: boolean | undefined;
82
+ deleted?: boolean | undefined;
83
+ projectId?: ObjectID | undefined;
84
+ createdAt?: Date | undefined;
85
+ id?: string | undefined;
86
+ descriptive?: JSONObject | null | undefined;
87
+ identifying?: JSONObject | null | undefined;
88
+ resourceType?: string | null | undefined;
89
+ resourceId?: string | null | undefined;
90
+ }
91
+
92
+ interface RelationshipFixture {
93
+ from: string;
94
+ to: string;
95
+ type: string;
96
+ source?: string | undefined;
97
+ lastSeenAt?: Date | null | undefined;
98
+ deleted?: boolean | undefined;
99
+ projectId?: ObjectID | undefined;
100
+ callCount?: number | null | undefined;
101
+ errorCount?: number | null | undefined;
102
+ avgDurationMs?: number | null | undefined;
103
+ createdAt?: Date | undefined;
104
+ id?: string | undefined;
105
+ }
106
+
107
+ function scope(): { projectId: ObjectID; rangeStart: Date } {
108
+ return { projectId: PROJECT_ID, rangeStart: RANGE_START };
109
+ }
110
+
111
+ function keysOf(entities: Array<{ key: string }>): Array<string> {
112
+ return entities.map((entity: { key: string }): string => {
113
+ return entity.key;
114
+ });
115
+ }
116
+
117
+ function otherKeysOf(rows: Array<TopologyConnectionRowJSON>): Array<string> {
118
+ return rows.map((row: TopologyConnectionRowJSON): string => {
119
+ return row.otherKey;
120
+ });
121
+ }
122
+
123
+ /*
124
+ * The whole suite runs twice: with the ("projectId", "entityKey") index that
125
+ * migration 1795200000000 adds, and without it — Helm runs migrations
126
+ * asynchronously, so the API serves traffic on both schemas. Plans differ;
127
+ * answers must not. The clone mirrors whatever public has, and each variant
128
+ * then adds or drops the index in its own schema.
129
+ */
130
+ const INDEX_VARIANTS: Array<[string, boolean]> = [
131
+ ["with", true],
132
+ ["without", false],
133
+ ];
134
+
135
+ describePostgres.each(INDEX_VARIANTS)(
136
+ "Topology API SQL against Postgres (%s the entity-key index)",
137
+ (_label: string, withEntityKeyIndex: boolean) => {
138
+ const schema: string = `topology_${ObjectID.generate()
139
+ .toString()
140
+ .replace(/-/g, "")}`;
141
+ let database: DataSource;
142
+
143
+ beforeAll(async () => {
144
+ database = new DataSource({
145
+ type: "postgres",
146
+ host: process.env["TOPOLOGY_TEST_DATABASE_HOST"] || "localhost",
147
+ port: Number(process.env["TOPOLOGY_TEST_DATABASE_PORT"] || "5400"),
148
+ username: process.env["DATABASE_USERNAME"] || "postgres",
149
+ password: process.env["DATABASE_PASSWORD"] || "password",
150
+ database:
151
+ process.env["TOPOLOGY_TEST_DATABASE_NAME"] ||
152
+ process.env["DATABASE_NAME"] ||
153
+ "oneuptimedb",
154
+ entities: Entities,
155
+ schema,
156
+ synchronize: false,
157
+ extra: { options: `-c search_path=${schema}` },
158
+ });
159
+ await database.initialize();
160
+ await database.query(`CREATE SCHEMA "${schema}"`);
161
+ for (const table of TABLES) {
162
+ await database.query(
163
+ `CREATE TABLE "${schema}"."${table}" (LIKE public."${table}" INCLUDING ALL)`,
164
+ );
165
+ }
166
+ const entityKeyIndexes: Array<{ indexname: string }> = (
167
+ await database.query(
168
+ `SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 AND tablename = 'InventoryItem'`,
169
+ [schema],
170
+ )
171
+ ).filter((index: { indexdef: string }): boolean => {
172
+ return ENTITY_KEY_INDEX_DEFINITION.test(index.indexdef);
173
+ });
174
+ if (withEntityKeyIndex && entityKeyIndexes.length === 0) {
175
+ await database.query(
176
+ `CREATE INDEX "topology_test_entity_key" ON "${schema}"."InventoryItem" ("projectId", "entityKey")`,
177
+ );
178
+ }
179
+ if (!withEntityKeyIndex) {
180
+ for (const index of entityKeyIndexes) {
181
+ await database.query(`DROP INDEX "${schema}"."${index.indexname}"`);
182
+ }
183
+ }
184
+ expect(
185
+ (await database.query("SELECT current_schema()"))[0].current_schema,
186
+ ).toBe(schema);
187
+ });
188
+
189
+ afterAll(async () => {
190
+ if (database?.isInitialized) {
191
+ await database.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
192
+ await database.destroy();
193
+ }
194
+ });
195
+
196
+ beforeEach(async () => {
197
+ jest.spyOn(PostgresAppInstance, "isConnected").mockReturnValue(true);
198
+ jest
199
+ .spyOn(PostgresAppInstance, "getDataSource")
200
+ .mockReturnValue(database);
201
+ for (const table of TABLES) {
202
+ await database.query(`DELETE FROM "${schema}"."${table}"`);
203
+ }
204
+ });
205
+
206
+ afterEach(() => {
207
+ jest.restoreAllMocks();
208
+ });
209
+
210
+ /* Inserts rows in one multi-row statement (one round trip per call). */
211
+ async function insertRows(
212
+ table: string,
213
+ columns: Array<string>,
214
+ rows: Array<Array<unknown>>,
215
+ ): Promise<void> {
216
+ if (rows.length === 0) {
217
+ return;
218
+ }
219
+ const params: Array<unknown> = [];
220
+ const tuples: Array<string> = rows.map((row: Array<unknown>): string => {
221
+ return `(${row
222
+ .map((value: unknown): string => {
223
+ params.push(value);
224
+ return `$${params.length}`;
225
+ })
226
+ .join(", ")})`;
227
+ });
228
+ await database.query(
229
+ `INSERT INTO "${schema}"."${table}" (${columns
230
+ .map((column: string): string => {
231
+ return `"${column}"`;
232
+ })
233
+ .join(", ")}) VALUES ${tuples.join(", ")}`,
234
+ params,
235
+ );
236
+ }
237
+
238
+ async function items(fixtures: Array<ItemFixture>): Promise<void> {
239
+ await insertRows(
240
+ "InventoryItem",
241
+ [
242
+ "_id",
243
+ "createdAt",
244
+ "updatedAt",
245
+ "deletedAt",
246
+ "version",
247
+ "projectId",
248
+ "entityType",
249
+ "entityKey",
250
+ "displayName",
251
+ "source",
252
+ "lastSeenAt",
253
+ "firstSeenAt",
254
+ "isArchived",
255
+ "descriptiveAttributes",
256
+ "identifyingAttributes",
257
+ "resourceType",
258
+ "resourceId",
259
+ ],
260
+ fixtures.map((fixture: ItemFixture): Array<unknown> => {
261
+ return [
262
+ fixture.id || ObjectID.generate().toString(),
263
+ fixture.createdAt || CREATED,
264
+ fixture.createdAt || CREATED,
265
+ fixture.deleted ? IN_RANGE : null,
266
+ 1,
267
+ (fixture.projectId || PROJECT_ID).toString(),
268
+ fixture.type,
269
+ fixture.key,
270
+ fixture.name === undefined ? fixture.key : fixture.name,
271
+ fixture.source === undefined
272
+ ? EntitySource.Discovered
273
+ : fixture.source,
274
+ fixture.lastSeenAt === undefined ? IN_RANGE : fixture.lastSeenAt,
275
+ fixture.firstSeenAt === undefined ? CREATED : fixture.firstSeenAt,
276
+ Boolean(fixture.archived),
277
+ fixture.descriptive === undefined
278
+ ? null
279
+ : JSON.stringify(fixture.descriptive),
280
+ fixture.identifying === undefined
281
+ ? null
282
+ : JSON.stringify(fixture.identifying),
283
+ fixture.resourceType || null,
284
+ fixture.resourceId || null,
285
+ ];
286
+ }),
287
+ );
288
+ }
289
+
290
+ async function item(fixture: ItemFixture): Promise<void> {
291
+ await items([fixture]);
292
+ }
293
+
294
+ async function relationships(
295
+ fixtures: Array<RelationshipFixture>,
296
+ ): Promise<void> {
297
+ await insertRows(
298
+ "InventoryItemRelationship",
299
+ [
300
+ "_id",
301
+ "createdAt",
302
+ "updatedAt",
303
+ "deletedAt",
304
+ "version",
305
+ "projectId",
306
+ "fromEntityKey",
307
+ "toEntityKey",
308
+ "relationshipType",
309
+ "source",
310
+ "lastSeenAt",
311
+ "callCount",
312
+ "errorCount",
313
+ "avgDurationMs",
314
+ ],
315
+ fixtures.map((fixture: RelationshipFixture): Array<unknown> => {
316
+ return [
317
+ fixture.id || ObjectID.generate().toString(),
318
+ fixture.createdAt || CREATED,
319
+ fixture.createdAt || CREATED,
320
+ fixture.deleted ? IN_RANGE : null,
321
+ 1,
322
+ (fixture.projectId || PROJECT_ID).toString(),
323
+ fixture.from,
324
+ fixture.to,
325
+ fixture.type,
326
+ fixture.source || EntitySource.Discovered,
327
+ fixture.lastSeenAt === undefined ? IN_RANGE : fixture.lastSeenAt,
328
+ fixture.callCount === undefined ? null : fixture.callCount,
329
+ fixture.errorCount === undefined ? null : fixture.errorCount,
330
+ fixture.avgDurationMs === undefined ? null : fixture.avgDurationMs,
331
+ ];
332
+ }),
333
+ );
334
+ }
335
+
336
+ async function relationship(fixture: RelationshipFixture): Promise<void> {
337
+ await relationships([fixture]);
338
+ }
339
+
340
+ /* `count` items of one type in one statement: key `${prefix}${n}`. */
341
+ async function bulkItems(data: {
342
+ type: string;
343
+ prefix: string;
344
+ count: number;
345
+ activeEvery?: number;
346
+ }): Promise<void> {
347
+ await database.query(
348
+ `INSERT INTO "${schema}"."InventoryItem"
349
+ ("_id", "createdAt", "updatedAt", "version", "projectId", "entityType",
350
+ "entityKey", "displayName", "source", "lastSeenAt", "isArchived")
351
+ SELECT gen_random_uuid(), $1, $1, 1, $2, $3, $4::text || g, $4::text || g, 'discovered',
352
+ CASE WHEN g % $6 = 0 THEN $7::timestamptz ELSE $8::timestamptz END, false
353
+ FROM generate_series(1, $5) AS g`,
354
+ [
355
+ CREATED,
356
+ PROJECT_ID.toString(),
357
+ data.type,
358
+ data.prefix,
359
+ data.count,
360
+ data.activeEvery || 1,
361
+ IN_RANGE,
362
+ STALE,
363
+ ],
364
+ );
365
+ }
366
+
367
+ // ------------------------------------------------------------ snapshot
368
+
369
+ describe("the read snapshot", () => {
370
+ test("is one READ ONLY, REPEATABLE READ transaction without parallel workers", async () => {
371
+ const settings: Array<string> =
372
+ await TopologyQueries.inReadOnlySnapshot(
373
+ async (runner: TopologyStatementRunner): Promise<Array<string>> => {
374
+ const rows: Array<JSONObject> = await runner.query({
375
+ sql: `SELECT current_setting('transaction_read_only') AS "readOnly",
376
+ current_setting('transaction_isolation') AS "isolation",
377
+ current_setting('max_parallel_workers_per_gather') AS "workers"`,
378
+ params: [],
379
+ });
380
+ return [
381
+ rows[0]!["readOnly"] as string,
382
+ rows[0]!["isolation"] as string,
383
+ rows[0]!["workers"] as string,
384
+ ];
385
+ },
386
+ );
387
+ expect(settings).toEqual(["on", "repeatable read", "0"]);
388
+ });
389
+
390
+ test("refuses writes", async () => {
391
+ await expect(
392
+ TopologyQueries.inReadOnlySnapshot(
393
+ async (runner: TopologyStatementRunner): Promise<void> => {
394
+ await runner.query({
395
+ sql: `DELETE FROM "InventoryItem"`,
396
+ params: [],
397
+ });
398
+ },
399
+ ),
400
+ ).rejects.toThrow(/read-only transaction/);
401
+ });
402
+
403
+ /*
404
+ * Behind PgBouncer the connection's startup statement_timeout is
405
+ * dropped; the transaction carries its own, so Postgres cancels a
406
+ * runaway read itself before the client-side query_timeout abandons it.
407
+ */
408
+ test("carries the configured statement timeout, for the transaction only", async () => {
409
+ const inside: string = await TopologyQueries.inReadOnlySnapshot(
410
+ async (runner: TopologyStatementRunner): Promise<string> => {
411
+ const rows: Array<JSONObject> = await runner.query({
412
+ sql: `SELECT setting FROM pg_settings WHERE name = 'statement_timeout'`,
413
+ params: [],
414
+ });
415
+ return rows[0]!["setting"] as string;
416
+ },
417
+ );
418
+ expect(inside).toBe(String(Math.floor(PostgresStatementTimeoutMs)));
419
+ const after: Array<{ setting: string }> = await database.query(
420
+ `SELECT setting FROM pg_settings WHERE name = 'statement_timeout'`,
421
+ );
422
+ expect(after[0]!.setting).not.toBe(inside);
423
+ });
424
+
425
+ test("the worker setting does not leak to the pooled connection", async () => {
426
+ await TopologyQueries.inReadOnlySnapshot(async (): Promise<void> => {
427
+ return undefined;
428
+ });
429
+ const rows: Array<{ workers: string }> = await database.query(
430
+ `SELECT current_setting('max_parallel_workers_per_gather') AS "workers"`,
431
+ );
432
+ expect(rows[0]!.workers).not.toBe("0");
433
+ });
434
+ });
435
+
436
+ // --------------------------------------------------------- service map
437
+
438
+ describe("service map", () => {
439
+ test("lists every live service, reporting or not, and nothing archived, deleted or foreign", async () => {
440
+ await items([
441
+ { key: "svc-live", type: EntityType.Service },
442
+ { key: "svc-stale", type: EntityType.Service, lastSeenAt: STALE },
443
+ { key: "svc-archived", type: EntityType.Service, archived: true },
444
+ { key: "svc-deleted", type: EntityType.Service, deleted: true },
445
+ {
446
+ key: "svc-foreign",
447
+ type: EntityType.Service,
448
+ projectId: OTHER_PROJECT_ID,
449
+ },
450
+ { key: "host-1", type: EntityType.Host },
451
+ ]);
452
+
453
+ const response: TopologyServiceMapResponseJSON =
454
+ await TopologyQueries.getServiceMap(scope());
455
+
456
+ expect(keysOf(response.entities)).toEqual(["svc-live", "svc-stale"]);
457
+ expect(response.rangeStart).toBe(RANGE_START.toISOString());
458
+ expect(response.formatVersion).toBe(1);
459
+ expect(response.entityTruncation).toBeNull();
460
+ expect(response.dependencyTruncation).toBeNull();
461
+ });
462
+
463
+ test("ships lean rows with epoch-millisecond timestamps, floored", async () => {
464
+ await item({
465
+ key: "svc-a",
466
+ type: EntityType.Service,
467
+ name: "Checkout",
468
+ });
469
+ await database.query(
470
+ `UPDATE "${schema}"."InventoryItem" SET "lastSeenAt" = '2026-09-20T10:10:00.123987Z'`,
471
+ );
472
+
473
+ const response: TopologyServiceMapResponseJSON =
474
+ await TopologyQueries.getServiceMap(scope());
475
+
476
+ expect(response.entities).toEqual([
477
+ {
478
+ key: "svc-a",
479
+ type: EntityType.Service,
480
+ name: "Checkout",
481
+ source: EntitySource.Discovered,
482
+ lastSeenAt: Date.parse("2026-09-20T10:10:00.123Z"),
483
+ },
484
+ ]);
485
+ });
486
+
487
+ test("dependencies: in-range depends-on from a live service, never a self-loop, in createdAt DESC / _id ASC order", async () => {
488
+ await items([
489
+ { key: "svc-a", type: EntityType.Service },
490
+ { key: "svc-b", type: EntityType.Service },
491
+ { key: "db-1", type: EntityType.Database },
492
+ { key: "svc-archived", type: EntityType.Service, archived: true },
493
+ { key: "remote-1", type: EntityType.RemoteService },
494
+ { key: "remote-2", type: EntityType.RemoteService },
495
+ ]);
496
+ const sameTime: Date = new Date("2026-09-10T00:00:00.000Z");
497
+ await relationships([
498
+ {
499
+ from: "svc-a",
500
+ to: "db-1",
501
+ type: EntityRelationshipType.DependsOn,
502
+ callCount: 10,
503
+ errorCount: 1,
504
+ avgDurationMs: 7,
505
+ createdAt: new Date("2026-09-05T00:00:00.000Z"),
506
+ },
507
+ {
508
+ from: "svc-a",
509
+ to: "svc-b",
510
+ type: EntityRelationshipType.DependsOn,
511
+ createdAt: sameTime,
512
+ id: "00000000-0000-4000-8000-000000000002",
513
+ },
514
+ {
515
+ from: "svc-b",
516
+ to: "ghost",
517
+ type: EntityRelationshipType.DependsOn,
518
+ createdAt: sameTime,
519
+ id: "00000000-0000-4000-8000-000000000001",
520
+ },
521
+ // Excluded, one reason each.
522
+ {
523
+ from: "svc-a",
524
+ to: "svc-a",
525
+ type: EntityRelationshipType.DependsOn,
526
+ },
527
+ {
528
+ from: "svc-a",
529
+ to: "remote-1",
530
+ type: EntityRelationshipType.DependsOn,
531
+ lastSeenAt: STALE,
532
+ },
533
+ {
534
+ from: "svc-b",
535
+ to: "remote-1",
536
+ type: EntityRelationshipType.DependsOn,
537
+ lastSeenAt: null,
538
+ },
539
+ {
540
+ from: "svc-a",
541
+ to: "remote-2",
542
+ type: EntityRelationshipType.DependsOn,
543
+ deleted: true,
544
+ },
545
+ {
546
+ from: "svc-archived",
547
+ to: "db-1",
548
+ type: EntityRelationshipType.DependsOn,
549
+ },
550
+ { from: "db-1", to: "svc-a", type: EntityRelationshipType.DependsOn },
551
+ { from: "svc-a", to: "db-1", type: EntityRelationshipType.RunsOn },
552
+ {
553
+ from: "svc-a",
554
+ to: "db-1",
555
+ type: EntityRelationshipType.DependsOn,
556
+ projectId: OTHER_PROJECT_ID,
557
+ },
558
+ ]);
559
+
560
+ const response: TopologyServiceMapResponseJSON =
561
+ await TopologyQueries.getServiceMap(scope());
562
+
563
+ expect(response.dependencies).toEqual([
564
+ {
565
+ from: "svc-b",
566
+ to: "ghost",
567
+ callCount: null,
568
+ errorCount: null,
569
+ avgDurationMs: null,
570
+ },
571
+ {
572
+ from: "svc-a",
573
+ to: "svc-b",
574
+ callCount: null,
575
+ errorCount: null,
576
+ avgDurationMs: null,
577
+ },
578
+ {
579
+ from: "svc-a",
580
+ to: "db-1",
581
+ callCount: 10,
582
+ errorCount: 1,
583
+ avgDurationMs: 7,
584
+ },
585
+ ]);
586
+ /*
587
+ * Callees are the live items the calls point at; `ghost` is not an
588
+ * item, and remote-1 is only reached by excluded rows.
589
+ */
590
+ expect(keysOf(response.entities)).toEqual(["svc-a", "svc-b", "db-1"]);
591
+ });
592
+
593
+ test("a callee is shipped whatever its type or activity, but not when archived", async () => {
594
+ await items([
595
+ { key: "svc-a", type: EntityType.Service },
596
+ { key: "db-old", type: EntityType.Database, lastSeenAt: STALE },
597
+ { key: "ext-1", type: EntityType.ExternalService, source: "manual" },
598
+ { key: "db-archived", type: EntityType.Database, archived: true },
599
+ ]);
600
+ for (const to of ["db-old", "ext-1", "db-archived"]) {
601
+ await relationship({
602
+ from: "svc-a",
603
+ to,
604
+ type: EntityRelationshipType.DependsOn,
605
+ });
606
+ }
607
+
608
+ const response: TopologyServiceMapResponseJSON =
609
+ await TopologyQueries.getServiceMap(scope());
610
+
611
+ expect(keysOf(response.entities)).toEqual(["svc-a", "db-old", "ext-1"]);
612
+ expect(response.dependencies).toHaveLength(3);
613
+ });
614
+
615
+ test("detail attributes: only string values of the known keys, per bag", async () => {
616
+ await item({
617
+ key: "svc-a",
618
+ type: EntityType.Service,
619
+ descriptive: {
620
+ "telemetry.sdk.language": "nodejs",
621
+ "db.system.name": 42,
622
+ "network.protocol.name": { nested: "http" },
623
+ "messaging.system": null,
624
+ "service.version": "1.2.3",
625
+ },
626
+ identifying: {
627
+ "db.system.name": "postgresql",
628
+ "service.name": "a",
629
+ },
630
+ });
631
+ await item({
632
+ key: "svc-b",
633
+ type: EntityType.Service,
634
+ descriptive: { "telemetry.sdk.language": true },
635
+ identifying: ["not", "an", "object"] as unknown as JSONObject,
636
+ });
637
+
638
+ const response: TopologyServiceMapResponseJSON =
639
+ await TopologyQueries.getServiceMap(scope());
640
+ const byKey: Map<string, TopologyServiceMapEntityJSON> = new Map(
641
+ response.entities.map(
642
+ (
643
+ entity: TopologyServiceMapEntityJSON,
644
+ ): [string, TopologyServiceMapEntityJSON] => {
645
+ return [entity.key, entity];
646
+ },
647
+ ),
648
+ );
649
+
650
+ expect(byKey.get("svc-a")!.descriptiveAttributes).toEqual({
651
+ "telemetry.sdk.language": "nodejs",
652
+ });
653
+ expect(byKey.get("svc-a")!.identifyingAttributes).toEqual({
654
+ "db.system.name": "postgresql",
655
+ });
656
+ expect(byKey.get("svc-b")!.descriptiveAttributes).toBeUndefined();
657
+ expect(byKey.get("svc-b")!.identifyingAttributes).toBeUndefined();
658
+ });
659
+
660
+ test("runs-on counts: distinct targets per type, active by the activity predicate, live targets only", async () => {
661
+ await items([
662
+ { key: "svc-a", type: EntityType.Service },
663
+ { key: "svc-b", type: EntityType.Service, lastSeenAt: STALE },
664
+ { key: "pod-1", type: EntityType.KubernetesPod },
665
+ { key: "pod-2", type: EntityType.KubernetesPod, lastSeenAt: STALE },
666
+ { key: "pod-3", type: EntityType.KubernetesPod, lastSeenAt: null },
667
+ {
668
+ key: "pod-archived",
669
+ type: EntityType.KubernetesPod,
670
+ archived: true,
671
+ },
672
+ {
673
+ key: "host-manual",
674
+ type: EntityType.Host,
675
+ source: EntitySource.Manual,
676
+ lastSeenAt: STALE,
677
+ },
678
+ { key: "host-2", type: EntityType.Host, lastSeenAt: STALE },
679
+ ]);
680
+ await relationships([
681
+ { from: "svc-a", to: "pod-1", type: EntityRelationshipType.RunsOn },
682
+ // The same target through both placement types counts once.
683
+ { from: "svc-a", to: "pod-1", type: EntityRelationshipType.HostedOn },
684
+ { from: "svc-a", to: "pod-2", type: EntityRelationshipType.RunsOn },
685
+ { from: "svc-a", to: "pod-3", type: EntityRelationshipType.RunsOn },
686
+ {
687
+ from: "svc-a",
688
+ to: "pod-archived",
689
+ type: EntityRelationshipType.RunsOn,
690
+ },
691
+ { from: "svc-a", to: "gone", type: EntityRelationshipType.RunsOn },
692
+ {
693
+ from: "svc-a",
694
+ to: "host-manual",
695
+ type: EntityRelationshipType.HostedOn,
696
+ },
697
+ {
698
+ from: "svc-a",
699
+ to: "host-2",
700
+ type: EntityRelationshipType.HostedOn,
701
+ lastSeenAt: STALE,
702
+ },
703
+ {
704
+ from: "svc-b",
705
+ to: "host-2",
706
+ type: EntityRelationshipType.HostedOn,
707
+ },
708
+ { from: "svc-a", to: "host-2", type: EntityRelationshipType.PartOf },
709
+ ]);
710
+
711
+ const response: TopologyServiceMapResponseJSON =
712
+ await TopologyQueries.getServiceMap(scope());
713
+
714
+ expect(response.runsOn).toEqual([
715
+ { service: "svc-a", type: EntityType.Host, active: 1, total: 1 },
716
+ {
717
+ service: "svc-a",
718
+ type: EntityType.KubernetesPod,
719
+ active: 2,
720
+ total: 3,
721
+ },
722
+ { service: "svc-b", type: EntityType.Host, active: 0, total: 1 },
723
+ ]);
724
+ });
725
+
726
+ test("two live rows sharing a key: the first by createdAt ASC, _id DESC represents it", async () => {
727
+ await items([
728
+ {
729
+ key: "shared-early-service",
730
+ type: EntityType.Service,
731
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
732
+ },
733
+ {
734
+ key: "shared-early-service",
735
+ type: EntityType.Host,
736
+ createdAt: new Date("2026-09-02T00:00:00.000Z"),
737
+ },
738
+ {
739
+ key: "shared-late-service",
740
+ type: EntityType.Service,
741
+ createdAt: new Date("2026-09-02T00:00:00.000Z"),
742
+ },
743
+ {
744
+ key: "shared-late-service",
745
+ type: EntityType.Host,
746
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
747
+ },
748
+ {
749
+ key: "shared-tie",
750
+ type: EntityType.Service,
751
+ id: "00000000-0000-4000-8000-00000000000f",
752
+ },
753
+ {
754
+ key: "shared-tie",
755
+ type: EntityType.Host,
756
+ id: "00000000-0000-4000-8000-000000000001",
757
+ },
758
+ // An archived twin never competes.
759
+ {
760
+ key: "shared-archived",
761
+ type: EntityType.Service,
762
+ createdAt: new Date("2026-09-02T00:00:00.000Z"),
763
+ },
764
+ {
765
+ key: "shared-archived",
766
+ type: EntityType.Host,
767
+ archived: true,
768
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
769
+ },
770
+ ]);
771
+
772
+ const response: TopologyServiceMapResponseJSON =
773
+ await TopologyQueries.getServiceMap(scope());
774
+
775
+ expect(keysOf(response.entities)).toEqual([
776
+ "shared-archived",
777
+ "shared-early-service",
778
+ "shared-tie",
779
+ ]);
780
+ });
781
+
782
+ test("an empty project answers with empty lists", async () => {
783
+ const response: TopologyServiceMapResponseJSON =
784
+ await TopologyQueries.getServiceMap(scope());
785
+ expect(response.entities).toEqual([]);
786
+ expect(response.dependencies).toEqual([]);
787
+ expect(response.runsOn).toEqual([]);
788
+ });
789
+
790
+ test("keys are bound as arrays whatever they contain", async () => {
791
+ /*
792
+ * Service, callee and target keys travel as text[] parameters; a
793
+ * key with quotes, commas, braces or backslashes must survive
794
+ * node-postgres's array literal and come back unchanged.
795
+ */
796
+ const service: string = 'svc "quoted", {braced} \\ back';
797
+ const callee: string = "db,with'comma}";
798
+ const target: string = 'pod{"x"}\\';
799
+ await items([
800
+ { key: service, type: EntityType.Service },
801
+ { key: callee, type: EntityType.Database },
802
+ { key: target, type: EntityType.KubernetesPod },
803
+ ]);
804
+ await relationships([
805
+ { from: service, to: callee, type: EntityRelationshipType.DependsOn },
806
+ { from: service, to: target, type: EntityRelationshipType.RunsOn },
807
+ ]);
808
+
809
+ const response: TopologyServiceMapResponseJSON =
810
+ await TopologyQueries.getServiceMap(scope());
811
+
812
+ expect(keysOf(response.entities)).toEqual([service, callee]);
813
+ expect(response.dependencies[0]).toMatchObject({
814
+ from: service,
815
+ to: callee,
816
+ });
817
+ expect(response.runsOn).toEqual([
818
+ {
819
+ service,
820
+ type: EntityType.KubernetesPod,
821
+ active: 1,
822
+ total: 1,
823
+ },
824
+ ]);
825
+
826
+ const infrastructure: TopologyInfrastructureResponseJSON =
827
+ await TopologyQueries.getInfrastructure(scope());
828
+ expect(infrastructure.placements).toEqual([[0, 0]]);
829
+ expect(infrastructure.nodes[0]!.key).toBe(target);
830
+ });
831
+
832
+ test("callees and runs-on targets resolve a duplicated key to its winning row", async () => {
833
+ await items([
834
+ { key: "svc-a", type: EntityType.Service },
835
+ // The database row wins (older); the host twin must not be counted.
836
+ {
837
+ key: "twin-callee",
838
+ type: EntityType.Database,
839
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
840
+ },
841
+ {
842
+ key: "twin-callee",
843
+ type: EntityType.Host,
844
+ createdAt: new Date("2026-09-02T00:00:00.000Z"),
845
+ },
846
+ // The pod row wins; it is stale, the losing host twin is not.
847
+ {
848
+ key: "twin-target",
849
+ type: EntityType.KubernetesPod,
850
+ lastSeenAt: STALE,
851
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
852
+ },
853
+ {
854
+ key: "twin-target",
855
+ type: EntityType.Host,
856
+ createdAt: new Date("2026-09-03T00:00:00.000Z"),
857
+ },
858
+ ]);
859
+ await relationships([
860
+ {
861
+ from: "svc-a",
862
+ to: "twin-callee",
863
+ type: EntityRelationshipType.DependsOn,
864
+ },
865
+ {
866
+ from: "svc-a",
867
+ to: "twin-target",
868
+ type: EntityRelationshipType.RunsOn,
869
+ },
870
+ ]);
871
+
872
+ const response: TopologyServiceMapResponseJSON =
873
+ await TopologyQueries.getServiceMap(scope());
874
+
875
+ expect(
876
+ response.entities.map(
877
+ (entity: TopologyServiceMapEntityJSON): string => {
878
+ return `${entity.key}:${entity.type}`;
879
+ },
880
+ ),
881
+ ).toEqual(["svc-a:service", `twin-callee:${EntityType.Database}`]);
882
+ expect(response.runsOn).toEqual([
883
+ {
884
+ service: "svc-a",
885
+ type: EntityType.KubernetesPod,
886
+ active: 0,
887
+ total: 1,
888
+ },
889
+ ]);
890
+ });
891
+ });
892
+
893
+ // ------------------------------------------------------ infrastructure
894
+
895
+ describe("infrastructure", () => {
896
+ async function seedCluster(): Promise<void> {
897
+ await items([
898
+ { key: "cluster", type: EntityType.KubernetesCluster },
899
+ { key: "ns-a", type: EntityType.KubernetesNamespace },
900
+ {
901
+ key: "ns-stale",
902
+ type: EntityType.KubernetesNamespace,
903
+ lastSeenAt: STALE,
904
+ },
905
+ {
906
+ key: "ns-archived",
907
+ type: EntityType.KubernetesNamespace,
908
+ archived: true,
909
+ },
910
+ { key: "node-B", type: EntityType.KubernetesNode },
911
+ { key: "node-a", type: EntityType.KubernetesNode },
912
+ { key: "dep-x", type: EntityType.KubernetesDeployment },
913
+ { key: "pod-1", type: EntityType.KubernetesPod },
914
+ { key: "pod-2", type: EntityType.KubernetesPod },
915
+ { key: "pod-3", type: EntityType.KubernetesPod },
916
+ { key: "pod-4", type: EntityType.KubernetesPod },
917
+ { key: "pod-5", type: EntityType.KubernetesPod, lastSeenAt: STALE },
918
+ { key: "pod-6", type: EntityType.KubernetesPod },
919
+ { key: "pod-7", type: EntityType.KubernetesPod },
920
+ { key: "pod-8", type: EntityType.KubernetesPod },
921
+ { key: "host-1", type: EntityType.Host },
922
+ { key: "ctr-1", type: EntityType.Container },
923
+ { key: "svc-a", type: EntityType.Service },
924
+ { key: "svc-b", type: EntityType.Service, lastSeenAt: STALE },
925
+ { key: "inst-1", type: EntityType.ServiceInstance },
926
+ { key: "proc-1", type: EntityType.Process },
927
+ { key: "db-1", type: EntityType.Database },
928
+ {
929
+ key: "pod-foreign",
930
+ type: EntityType.KubernetesPod,
931
+ projectId: OTHER_PROJECT_ID,
932
+ },
933
+ ]);
934
+ const partOf: string = EntityRelationshipType.PartOf;
935
+ const runsOn: string = EntityRelationshipType.RunsOn;
936
+ await relationships([
937
+ { from: "ns-a", to: "cluster", type: partOf },
938
+ { from: "node-B", to: "cluster", type: partOf },
939
+ { from: "node-a", to: "cluster", type: partOf },
940
+ { from: "dep-x", to: "ns-a", type: partOf },
941
+ // pod-1: part-of beats runs-on.
942
+ { from: "pod-1", to: "ns-a", type: partOf },
943
+ { from: "pod-1", to: "node-B", type: runsOn },
944
+ // pod-2: a full tie, broken by key in code-point order ("B" < "a").
945
+ { from: "pod-2", to: "node-a", type: runsOn },
946
+ { from: "pod-2", to: "node-B", type: runsOn },
947
+ // pod-3: same priority, the more specific container wins.
948
+ { from: "pod-3", to: "ns-a", type: partOf },
949
+ { from: "pod-3", to: "dep-x", type: partOf },
950
+ // pod-4: best container is inactive; best active is a node.
951
+ { from: "pod-4", to: "ns-stale", type: partOf },
952
+ { from: "pod-4", to: "node-a", type: runsOn },
953
+ // pod-5: only an inactive container.
954
+ { from: "pod-5", to: "ns-stale", type: partOf },
955
+ // pod-6: nothing qualifies.
956
+ { from: "pod-6", to: "missing-ns", type: partOf },
957
+ { from: "pod-6", to: "svc-a", type: partOf },
958
+ { from: "pod-6", to: "pod-6", type: partOf },
959
+ {
960
+ from: "pod-6",
961
+ to: "cluster",
962
+ type: EntityRelationshipType.DependsOn,
963
+ },
964
+ // pod-7: archived container.
965
+ { from: "pod-7", to: "ns-archived", type: partOf },
966
+ // pod-8: out-of-range, deleted and foreign relationships.
967
+ { from: "pod-8", to: "node-a", type: runsOn, lastSeenAt: STALE },
968
+ { from: "pod-8", to: "node-B", type: runsOn, deleted: true },
969
+ {
970
+ from: "pod-8",
971
+ to: "ns-a",
972
+ type: partOf,
973
+ projectId: OTHER_PROJECT_ID,
974
+ },
975
+ { from: "pod-8", to: "cluster", type: partOf, lastSeenAt: null },
976
+ // A host does not nest; a container nests in it.
977
+ { from: "host-1", to: "cluster", type: partOf },
978
+ { from: "ctr-1", to: "host-1", type: runsOn },
979
+ // Placements.
980
+ { from: "svc-a", to: "pod-1", type: runsOn },
981
+ { from: "svc-a", to: "pod-1", type: EntityRelationshipType.HostedOn },
982
+ { from: "svc-a", to: "pod-5", type: runsOn },
983
+ {
984
+ from: "svc-a",
985
+ to: "host-1",
986
+ type: EntityRelationshipType.HostedOn,
987
+ },
988
+ {
989
+ from: "svc-b",
990
+ to: "host-1",
991
+ type: EntityRelationshipType.HostedOn,
992
+ },
993
+ { from: "svc-a", to: "db-1", type: runsOn },
994
+ { from: "svc-a", to: "gone", type: runsOn },
995
+ { from: "svc-a", to: "pod-2", type: runsOn, lastSeenAt: STALE },
996
+ { from: "host-1", to: "node-a", type: runsOn },
997
+ { from: "svc-a", to: "pod-3", type: partOf },
998
+ ]);
999
+ }
1000
+
1001
+ function nodeByKey(
1002
+ response: TopologyInfrastructureResponseJSON,
1003
+ key: string,
1004
+ ): TopologyInfrastructureNodeJSON {
1005
+ const node: TopologyInfrastructureNodeJSON | undefined =
1006
+ response.nodes.find((candidate: TopologyInfrastructureNodeJSON) => {
1007
+ return candidate.key === key;
1008
+ });
1009
+ if (!node) {
1010
+ throw new Error(`no node ${key}`);
1011
+ }
1012
+ return node;
1013
+ }
1014
+
1015
+ function parentKey(
1016
+ response: TopologyInfrastructureResponseJSON,
1017
+ key: string,
1018
+ ): string | null {
1019
+ const node: TopologyInfrastructureNodeJSON = nodeByKey(response, key);
1020
+ return node.parent === undefined
1021
+ ? null
1022
+ : response.nodes[node.parent]!.key;
1023
+ }
1024
+
1025
+ test("ships only infrastructure nodes, ordered by type then key in code-point order", async () => {
1026
+ await seedCluster();
1027
+
1028
+ const response: TopologyInfrastructureResponseJSON =
1029
+ await TopologyQueries.getInfrastructure(scope());
1030
+
1031
+ expect(
1032
+ response.nodes.map((node: TopologyInfrastructureNodeJSON): string => {
1033
+ return `${node.type}:${node.key}`;
1034
+ }),
1035
+ ).toEqual([
1036
+ "container:ctr-1",
1037
+ "host:host-1",
1038
+ "k8s.cluster:cluster",
1039
+ "k8s.deployment:dep-x",
1040
+ "k8s.namespace:ns-a",
1041
+ "k8s.namespace:ns-stale",
1042
+ "k8s.node:node-B",
1043
+ "k8s.node:node-a",
1044
+ "k8s.pod:pod-1",
1045
+ "k8s.pod:pod-2",
1046
+ "k8s.pod:pod-3",
1047
+ "k8s.pod:pod-4",
1048
+ "k8s.pod:pod-5",
1049
+ "k8s.pod:pod-6",
1050
+ "k8s.pod:pod-7",
1051
+ "k8s.pod:pod-8",
1052
+ ]);
1053
+ expect(response.truncation).toBeNull();
1054
+ });
1055
+
1056
+ test.each([
1057
+ [
1058
+ "pod-1",
1059
+ "ns-a",
1060
+ EntityRelationshipType.PartOf,
1061
+ "part-of beats runs-on",
1062
+ ],
1063
+ [
1064
+ "pod-2",
1065
+ "node-B",
1066
+ EntityRelationshipType.RunsOn,
1067
+ "a full tie goes to the smaller key in code-point order",
1068
+ ],
1069
+ [
1070
+ "pod-3",
1071
+ "dep-x",
1072
+ EntityRelationshipType.PartOf,
1073
+ "the more specific container wins",
1074
+ ],
1075
+ [
1076
+ "ns-a",
1077
+ "cluster",
1078
+ EntityRelationshipType.PartOf,
1079
+ "namespaces nest in clusters",
1080
+ ],
1081
+ [
1082
+ "dep-x",
1083
+ "ns-a",
1084
+ EntityRelationshipType.PartOf,
1085
+ "deployments nest in namespaces",
1086
+ ],
1087
+ [
1088
+ "ctr-1",
1089
+ "host-1",
1090
+ EntityRelationshipType.RunsOn,
1091
+ "containers nest in hosts",
1092
+ ],
1093
+ [
1094
+ "pod-4",
1095
+ "ns-stale",
1096
+ EntityRelationshipType.PartOf,
1097
+ "an inactive container still wins overall",
1098
+ ],
1099
+ [
1100
+ "pod-5",
1101
+ "ns-stale",
1102
+ EntityRelationshipType.PartOf,
1103
+ "an inactive child still gets its container",
1104
+ ],
1105
+ ])(
1106
+ "%s nests in %s via %s (%s)",
1107
+ async (child: string, parent: string, via: string) => {
1108
+ await seedCluster();
1109
+ const response: TopologyInfrastructureResponseJSON =
1110
+ await TopologyQueries.getInfrastructure(scope());
1111
+ expect(parentKey(response, child)).toBe(parent);
1112
+ expect(nodeByKey(response, child).parentVia).toBe(via);
1113
+ },
1114
+ );
1115
+
1116
+ test.each([
1117
+ [
1118
+ "pod-6",
1119
+ "missing ends, services, self-loops and depends-on never contain",
1120
+ ],
1121
+ ["pod-7", "an archived container is not a node"],
1122
+ [
1123
+ "pod-8",
1124
+ "stale, deleted, NULL-lastSeenAt and foreign relationships are ignored",
1125
+ ],
1126
+ ["host-1", "a host is not a nestable type"],
1127
+ ["cluster", "nothing contains the cluster"],
1128
+ ])("%s has no container (%s)", async (key: string) => {
1129
+ await seedCluster();
1130
+ const response: TopologyInfrastructureResponseJSON =
1131
+ await TopologyQueries.getInfrastructure(scope());
1132
+ const node: TopologyInfrastructureNodeJSON = nodeByKey(response, key);
1133
+ expect(node.parent).toBeUndefined();
1134
+ expect(node.parentVia).toBeUndefined();
1135
+ expect(node.activeParent).toBeUndefined();
1136
+ });
1137
+
1138
+ test("the active fallback is sent only when the container did not report", async () => {
1139
+ await seedCluster();
1140
+ const response: TopologyInfrastructureResponseJSON =
1141
+ await TopologyQueries.getInfrastructure(scope());
1142
+
1143
+ const pod4: TopologyInfrastructureNodeJSON = nodeByKey(
1144
+ response,
1145
+ "pod-4",
1146
+ );
1147
+ expect(response.nodes[pod4.activeParent!]!.key).toBe("node-a");
1148
+ expect(pod4.activeParentVia).toBe(EntityRelationshipType.RunsOn);
1149
+
1150
+ const pod5: TopologyInfrastructureNodeJSON = nodeByKey(
1151
+ response,
1152
+ "pod-5",
1153
+ );
1154
+ expect(pod5.activeParent).toBe(-1);
1155
+ expect(pod5.activeParentVia).toBeUndefined();
1156
+
1157
+ const pod1: TopologyInfrastructureNodeJSON = nodeByKey(
1158
+ response,
1159
+ "pod-1",
1160
+ );
1161
+ expect(pod1.activeParent).toBeUndefined();
1162
+ });
1163
+
1164
+ test("a duplicated key is one node: its winning row", async () => {
1165
+ await items([
1166
+ {
1167
+ key: "twin",
1168
+ type: EntityType.KubernetesNode,
1169
+ createdAt: new Date("2026-09-02T00:00:00.000Z"),
1170
+ },
1171
+ {
1172
+ key: "twin",
1173
+ type: EntityType.Host,
1174
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
1175
+ },
1176
+ // A service twin that wins hides the node row entirely.
1177
+ {
1178
+ key: "svc-twin",
1179
+ type: EntityType.Service,
1180
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
1181
+ },
1182
+ {
1183
+ key: "svc-twin",
1184
+ type: EntityType.Host,
1185
+ createdAt: new Date("2026-09-02T00:00:00.000Z"),
1186
+ },
1187
+ { key: "solo", type: EntityType.Host },
1188
+ ]);
1189
+
1190
+ const response: TopologyInfrastructureResponseJSON =
1191
+ await TopologyQueries.getInfrastructure(scope());
1192
+
1193
+ expect(
1194
+ response.nodes.map((node: TopologyInfrastructureNodeJSON): string => {
1195
+ return `${node.type}:${node.key}`;
1196
+ }),
1197
+ ).toEqual(["host:solo", "host:twin"]);
1198
+ expect(response.services).toEqual([
1199
+ { key: "svc-twin", name: "svc-twin" },
1200
+ ]);
1201
+ expect(response.totals.resources).toBe(2);
1202
+ });
1203
+
1204
+ test("services, placements and totals", async () => {
1205
+ await seedCluster();
1206
+ const response: TopologyInfrastructureResponseJSON =
1207
+ await TopologyQueries.getInfrastructure(scope());
1208
+
1209
+ expect(response.services).toEqual([
1210
+ { key: "svc-a", name: "svc-a" },
1211
+ { key: "svc-b", name: "svc-b" },
1212
+ ]);
1213
+ const placements: Array<string> = response.placements.map(
1214
+ ([service, node]: [number, number]): string => {
1215
+ return `${response.services[service]!.key}->${response.nodes[node]!.key}`;
1216
+ },
1217
+ );
1218
+ /*
1219
+ * Distinct, onto nodes only (db-1 is not infrastructure, gone is not an
1220
+ * item), active or not (pod-5), in range only (pod-2), from services
1221
+ * only (host-1 -> node-a), placement types only (part-of pod-3).
1222
+ */
1223
+ expect(placements).toEqual([
1224
+ "svc-a->host-1",
1225
+ "svc-a->pod-1",
1226
+ "svc-a->pod-5",
1227
+ "svc-b->host-1",
1228
+ ]);
1229
+ expect(response.totals).toEqual({ resources: 16, activeResources: 14 });
1230
+ expect(response.collections).toEqual([]);
1231
+ });
1232
+
1233
+ /*
1234
+ * Issue #3972: the map draws the traffic between resources from the
1235
+ * calls between the services placed on them.
1236
+ */
1237
+ test("calls: in-range depends-on between two placed services, with traffic, by index", async () => {
1238
+ await items([
1239
+ { key: "pod-a", type: EntityType.KubernetesPod },
1240
+ { key: "pod-b", type: EntityType.KubernetesPod },
1241
+ { key: "pod-c", type: EntityType.KubernetesPod, lastSeenAt: STALE },
1242
+ { key: "svc-a", type: EntityType.Service },
1243
+ { key: "svc-b", type: EntityType.Service },
1244
+ { key: "svc-c", type: EntityType.Service },
1245
+ { key: "svc-idle", type: EntityType.Service },
1246
+ { key: "db-1", type: EntityType.Database },
1247
+ ]);
1248
+ const runsOn: string = EntityRelationshipType.RunsOn;
1249
+ const dependsOn: string = EntityRelationshipType.DependsOn;
1250
+ await relationships([
1251
+ { from: "svc-a", to: "pod-a", type: runsOn },
1252
+ { from: "svc-b", to: "pod-b", type: runsOn },
1253
+ // A pod that went quiet is still shipped, so svc-c is placed.
1254
+ { from: "svc-c", to: "pod-c", type: runsOn },
1255
+ // A placement out of range places nothing.
1256
+ { from: "svc-idle", to: "pod-a", type: runsOn, lastSeenAt: STALE },
1257
+ {
1258
+ from: "svc-a",
1259
+ to: "svc-b",
1260
+ type: dependsOn,
1261
+ callCount: 1200,
1262
+ errorCount: 6,
1263
+ avgDurationMs: 45,
1264
+ },
1265
+ { from: "svc-b", to: "svc-a", type: dependsOn, callCount: 12 },
1266
+ {
1267
+ from: "svc-a",
1268
+ to: "svc-c",
1269
+ type: dependsOn,
1270
+ callCount: 5,
1271
+ errorCount: 0,
1272
+ avgDurationMs: 3,
1273
+ },
1274
+ // Not between two placed services.
1275
+ { from: "svc-a", to: "svc-idle", type: dependsOn, callCount: 7 },
1276
+ { from: "svc-idle", to: "svc-a", type: dependsOn, callCount: 7 },
1277
+ { from: "svc-a", to: "db-1", type: dependsOn, callCount: 7 },
1278
+ { from: "svc-a", to: "svc-a", type: dependsOn, callCount: 7 },
1279
+ // Not in range / deleted / foreign.
1280
+ {
1281
+ from: "svc-b",
1282
+ to: "svc-c",
1283
+ type: dependsOn,
1284
+ callCount: 7,
1285
+ lastSeenAt: STALE,
1286
+ },
1287
+ {
1288
+ from: "svc-c",
1289
+ to: "svc-a",
1290
+ type: dependsOn,
1291
+ callCount: 7,
1292
+ deleted: true,
1293
+ },
1294
+ {
1295
+ from: "svc-c",
1296
+ to: "svc-b",
1297
+ type: dependsOn,
1298
+ callCount: 7,
1299
+ projectId: OTHER_PROJECT_ID,
1300
+ },
1301
+ ]);
1302
+
1303
+ const response: TopologyInfrastructureResponseJSON =
1304
+ await TopologyQueries.getInfrastructure(scope());
1305
+
1306
+ expect(
1307
+ response.services.map(
1308
+ (service: { key: string; name: string | null }): string => {
1309
+ return service.key;
1310
+ },
1311
+ ),
1312
+ ).toEqual(["svc-a", "svc-b", "svc-c", "svc-idle"]);
1313
+ expect(response.dependencies).toEqual([
1314
+ { from: 0, to: 1, callCount: 1200, errorCount: 6, avgDurationMs: 45 },
1315
+ { from: 0, to: 2, callCount: 5, errorCount: 0, avgDurationMs: 3 },
1316
+ {
1317
+ from: 1,
1318
+ to: 0,
1319
+ callCount: 12,
1320
+ errorCount: null,
1321
+ avgDurationMs: null,
1322
+ },
1323
+ ]);
1324
+ expect(response.dependencyTruncation).toBeNull();
1325
+ });
1326
+
1327
+ test("calls over the cap: the first in the Service Map's order, and the exact total", async () => {
1328
+ const cap: number = TopologyApiLimits.MaxServiceMapDependencies;
1329
+ TopologyApiLimits.MaxServiceMapDependencies = 1;
1330
+ try {
1331
+ await items([
1332
+ { key: "pod-a", type: EntityType.KubernetesPod },
1333
+ { key: "pod-b", type: EntityType.KubernetesPod },
1334
+ { key: "svc-a", type: EntityType.Service },
1335
+ { key: "svc-b", type: EntityType.Service },
1336
+ ]);
1337
+ await relationships([
1338
+ { from: "svc-a", to: "pod-a", type: EntityRelationshipType.RunsOn },
1339
+ { from: "svc-b", to: "pod-b", type: EntityRelationshipType.RunsOn },
1340
+ {
1341
+ from: "svc-a",
1342
+ to: "svc-b",
1343
+ type: EntityRelationshipType.DependsOn,
1344
+ callCount: 1,
1345
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
1346
+ },
1347
+ {
1348
+ from: "svc-b",
1349
+ to: "svc-a",
1350
+ type: EntityRelationshipType.DependsOn,
1351
+ callCount: 2,
1352
+ createdAt: new Date("2026-09-02T00:00:00.000Z"),
1353
+ },
1354
+ ]);
1355
+ const response: TopologyInfrastructureResponseJSON =
1356
+ await TopologyQueries.getInfrastructure(scope());
1357
+ // Newest first, as the Service Map reads them.
1358
+ expect(response.dependencies).toEqual([
1359
+ {
1360
+ from: 1,
1361
+ to: 0,
1362
+ callCount: 2,
1363
+ errorCount: null,
1364
+ avgDurationMs: null,
1365
+ },
1366
+ ]);
1367
+ expect(response.dependencyTruncation).toEqual({
1368
+ shown: 1,
1369
+ total: 2,
1370
+ });
1371
+ } finally {
1372
+ TopologyApiLimits.MaxServiceMapDependencies = cap;
1373
+ }
1374
+ });
1375
+
1376
+ test("a flat type above the inline budget becomes a collection with exact counts", async () => {
1377
+ await bulkItems({
1378
+ type: EntityType.NetworkDevice,
1379
+ prefix: "switch-",
1380
+ count: TopologyApiLimits.InlineFlatItemsPerType + 1,
1381
+ activeEvery: 2,
1382
+ });
1383
+ await bulkItems({
1384
+ type: EntityType.IoTDevice,
1385
+ prefix: "sensor-",
1386
+ count: TopologyApiLimits.InlineFlatItemsPerType,
1387
+ });
1388
+ await item({ key: "host-1", type: EntityType.Host, lastSeenAt: STALE });
1389
+
1390
+ const response: TopologyInfrastructureResponseJSON =
1391
+ await TopologyQueries.getInfrastructure(scope());
1392
+
1393
+ expect(response.collections).toEqual([
1394
+ {
1395
+ type: EntityType.NetworkDevice,
1396
+ total: 1001,
1397
+ active: 500,
1398
+ lastSeenAt: IN_RANGE.getTime(),
1399
+ activeLastSeenAt: IN_RANGE.getTime(),
1400
+ },
1401
+ ]);
1402
+ const types: Set<string> = new Set<string>(
1403
+ response.nodes.map((node: TopologyInfrastructureNodeJSON): string => {
1404
+ return node.type;
1405
+ }),
1406
+ );
1407
+ expect(types).toEqual(
1408
+ new Set<string>([EntityType.IoTDevice, EntityType.Host]),
1409
+ );
1410
+ expect(response.nodes).toHaveLength(1001);
1411
+ expect(response.totals).toEqual({
1412
+ resources: 1001 + 1000 + 1,
1413
+ activeResources: 500 + 1000,
1414
+ });
1415
+ });
1416
+
1417
+ test("a structural type is never collected however many there are", async () => {
1418
+ await bulkItems({
1419
+ type: EntityType.KubernetesPod,
1420
+ prefix: "pod-",
1421
+ count: TopologyApiLimits.InlineFlatItemsPerType + 5,
1422
+ });
1423
+ const response: TopologyInfrastructureResponseJSON =
1424
+ await TopologyQueries.getInfrastructure(scope());
1425
+ expect(response.collections).toEqual([]);
1426
+ expect(response.nodes).toHaveLength(1005);
1427
+ });
1428
+
1429
+ test("over the node cap: the first nodes by (type, key), exact totals, no parent outside the kept set", async () => {
1430
+ const cap: number = TopologyApiLimits.MaxInfrastructureNodes;
1431
+ TopologyApiLimits.MaxInfrastructureNodes = 3;
1432
+ try {
1433
+ await items([
1434
+ { key: "cluster", type: EntityType.KubernetesCluster },
1435
+ { key: "ns-a", type: EntityType.KubernetesNamespace },
1436
+ { key: "pod-1", type: EntityType.KubernetesPod },
1437
+ { key: "pod-2", type: EntityType.KubernetesPod, lastSeenAt: STALE },
1438
+ { key: "host-1", type: EntityType.Host },
1439
+ ]);
1440
+ await relationships([
1441
+ {
1442
+ from: "ns-a",
1443
+ to: "cluster",
1444
+ type: EntityRelationshipType.PartOf,
1445
+ },
1446
+ { from: "pod-1", to: "ns-a", type: EntityRelationshipType.PartOf },
1447
+ ]);
1448
+ const response: TopologyInfrastructureResponseJSON =
1449
+ await TopologyQueries.getInfrastructure(scope());
1450
+
1451
+ expect(keysOf(response.nodes)).toEqual(["host-1", "cluster", "ns-a"]);
1452
+ expect(response.truncation).toEqual({ shown: 3, total: 5 });
1453
+ expect(response.totals).toEqual({ resources: 5, activeResources: 4 });
1454
+ expect(parentKey(response, "ns-a")).toBe("cluster");
1455
+ } finally {
1456
+ TopologyApiLimits.MaxInfrastructureNodes = cap;
1457
+ }
1458
+ });
1459
+ });
1460
+
1461
+ // ---------------------------------------------------------- collections
1462
+
1463
+ describe("collections", () => {
1464
+ async function page(
1465
+ overrides: Partial<{
1466
+ includeInactive: boolean;
1467
+ nameTerms: Array<string>;
1468
+ cursor: TopologyCollectionCursorJSON | null;
1469
+ limit: number;
1470
+ }> = {},
1471
+ ): Promise<TopologyCollectionResponseJSON> {
1472
+ return await TopologyQueries.getCollectionPage({
1473
+ ...scope(),
1474
+ entityType: EntityType.NetworkDevice,
1475
+ includeInactive: true,
1476
+ nameTerms: [],
1477
+ cursor: null,
1478
+ limit: 2,
1479
+ ...overrides,
1480
+ });
1481
+ }
1482
+
1483
+ test("keyset pages cover every row exactly once, by name then key in code-point order", async () => {
1484
+ await items([
1485
+ { key: "k-3", type: EntityType.NetworkDevice, name: "beta" },
1486
+ { key: "k-B", type: EntityType.NetworkDevice, name: "alpha" },
1487
+ { key: "k-a", type: EntityType.NetworkDevice, name: "alpha" },
1488
+ { key: "k-1", type: EntityType.NetworkDevice, name: null },
1489
+ { key: "k-2", type: EntityType.NetworkDevice, name: "" },
1490
+ { key: "k-9", type: EntityType.NetworkDevice, name: "Gamma" },
1491
+ {
1492
+ key: "k-x",
1493
+ type: EntityType.NetworkDevice,
1494
+ name: "zeta",
1495
+ archived: true,
1496
+ },
1497
+ {
1498
+ key: "k-y",
1499
+ type: EntityType.NetworkDevice,
1500
+ name: "zeta",
1501
+ deleted: true,
1502
+ },
1503
+ {
1504
+ key: "k-z",
1505
+ type: EntityType.NetworkDevice,
1506
+ name: "zeta",
1507
+ projectId: OTHER_PROJECT_ID,
1508
+ },
1509
+ { key: "h-1", type: EntityType.Host, name: "zeta" },
1510
+ ]);
1511
+
1512
+ const seen: Array<string> = [];
1513
+ let cursor: TopologyCollectionCursorJSON | null = null;
1514
+ for (let guard: number = 0; guard < 10; guard++) {
1515
+ const response: TopologyCollectionResponseJSON = await page({
1516
+ cursor,
1517
+ });
1518
+ expect(response.total).toBe(6);
1519
+ expect(response.entityType).toBe(EntityType.NetworkDevice);
1520
+ seen.push(...keysOf(response.items));
1521
+ cursor = response.nextCursor;
1522
+ if (!cursor) {
1523
+ break;
1524
+ }
1525
+ }
1526
+ // Unnamed rows sort as "" first, then by key.
1527
+ expect(seen.slice(0, 2)).toEqual(["k-1", "k-2"]);
1528
+ expect(seen.slice(2, 4)).toEqual(["k-B", "k-a"]);
1529
+ expect(seen).toHaveLength(6);
1530
+ expect(new Set<string>(seen).size).toBe(6);
1531
+ expect(new Set<string>(seen)).toEqual(
1532
+ new Set<string>(["k-1", "k-2", "k-B", "k-a", "k-3", "k-9"]),
1533
+ );
1534
+ });
1535
+
1536
+ test("the last page has no cursor; the cursor carries name and key", async () => {
1537
+ await items([
1538
+ { key: "k-1", type: EntityType.NetworkDevice, name: "one" },
1539
+ { key: "k-2", type: EntityType.NetworkDevice, name: null },
1540
+ { key: "k-3", type: EntityType.NetworkDevice, name: "three" },
1541
+ ]);
1542
+ const first: TopologyCollectionResponseJSON = await page();
1543
+ expect(first.nextCursor).toEqual({ name: "one", key: "k-1" });
1544
+ const second: TopologyCollectionResponseJSON = await page({
1545
+ cursor: first.nextCursor,
1546
+ });
1547
+ expect(keysOf(second.items)).toEqual(["k-3"]);
1548
+ expect(second.nextCursor).toBeNull();
1549
+ expect(second.items[0]).toEqual({
1550
+ key: "k-3",
1551
+ type: EntityType.NetworkDevice,
1552
+ name: "three",
1553
+ source: EntitySource.Discovered,
1554
+ lastSeenAt: IN_RANGE.getTime(),
1555
+ } as TopologyEntityJSON);
1556
+ });
1557
+
1558
+ test("inactive items are left out unless asked for, by the activity predicate", async () => {
1559
+ await items([
1560
+ { key: "k-live", type: EntityType.NetworkDevice },
1561
+ { key: "k-stale", type: EntityType.NetworkDevice, lastSeenAt: STALE },
1562
+ { key: "k-never", type: EntityType.NetworkDevice, lastSeenAt: null },
1563
+ {
1564
+ key: "k-manual",
1565
+ type: EntityType.NetworkDevice,
1566
+ source: EntitySource.Manual,
1567
+ lastSeenAt: STALE,
1568
+ },
1569
+ {
1570
+ key: "k-inventory",
1571
+ type: EntityType.NetworkDevice,
1572
+ source: EntitySource.Inventory,
1573
+ lastSeenAt: STALE,
1574
+ },
1575
+ {
1576
+ key: "k-blank-source",
1577
+ type: EntityType.NetworkDevice,
1578
+ source: "",
1579
+ lastSeenAt: STALE,
1580
+ },
1581
+ {
1582
+ key: "k-boundary",
1583
+ type: EntityType.NetworkDevice,
1584
+ lastSeenAt: RANGE_START,
1585
+ },
1586
+ ]);
1587
+ const active: TopologyCollectionResponseJSON = await page({
1588
+ includeInactive: false,
1589
+ limit: 50,
1590
+ });
1591
+ expect(new Set<string>(keysOf(active.items))).toEqual(
1592
+ new Set<string>([
1593
+ "k-live",
1594
+ "k-never",
1595
+ "k-manual",
1596
+ "k-inventory",
1597
+ "k-boundary",
1598
+ ]),
1599
+ );
1600
+ expect(active.total).toBe(5);
1601
+ expect((await page({ includeInactive: true, limit: 50 })).total).toBe(
1602
+ 7,
1603
+ );
1604
+ });
1605
+
1606
+ test("every name term must match, case-insensitively and literally", async () => {
1607
+ await items([
1608
+ {
1609
+ key: "k-1",
1610
+ type: EntityType.NetworkDevice,
1611
+ name: "Core Switch 100%",
1612
+ },
1613
+ {
1614
+ key: "k-2",
1615
+ type: EntityType.NetworkDevice,
1616
+ name: "core switch 1000",
1617
+ },
1618
+ { key: "k-3", type: EntityType.NetworkDevice, name: "edge_router" },
1619
+ { key: "k-4", type: EntityType.NetworkDevice, name: "edgeXrouter" },
1620
+ { key: "k-5", type: EntityType.NetworkDevice, name: "back\\slash" },
1621
+ { key: "k-6", type: EntityType.NetworkDevice, name: "backslash" },
1622
+ { key: "k-7", type: EntityType.NetworkDevice, name: null },
1623
+ ]);
1624
+ const search: (terms: Array<string>) => Promise<Array<string>> = async (
1625
+ terms: Array<string>,
1626
+ ): Promise<Array<string>> => {
1627
+ return keysOf((await page({ nameTerms: terms, limit: 50 })).items);
1628
+ };
1629
+
1630
+ expect(await search(["core", "switch"])).toEqual(["k-1", "k-2"]);
1631
+ expect(await search(["100%"])).toEqual(["k-1"]);
1632
+ expect(await search(["edge_"])).toEqual(["k-3"]);
1633
+ expect(await search(["_"])).toEqual(["k-3"]);
1634
+ expect(await search(["\\"])).toEqual(["k-5"]);
1635
+ expect(await search(["%"])).toEqual(["k-1"]);
1636
+ expect(await search(["core", "edge"])).toEqual([]);
1637
+ });
1638
+
1639
+ test("search counts per collection, only where something matches", async () => {
1640
+ await items([
1641
+ { key: "n-1", type: EntityType.NetworkDevice, name: "core-1" },
1642
+ {
1643
+ key: "n-2",
1644
+ type: EntityType.NetworkDevice,
1645
+ name: "core-2",
1646
+ lastSeenAt: STALE,
1647
+ },
1648
+ { key: "n-3", type: EntityType.NetworkDevice, name: "edge-1" },
1649
+ { key: "i-1", type: EntityType.IoTDevice, name: "sensor" },
1650
+ ]);
1651
+ const response: TopologyCollectionSearchResponseJSON =
1652
+ await TopologyQueries.getCollectionSearch({
1653
+ ...scope(),
1654
+ includeInactive: true,
1655
+ types: [
1656
+ { entityType: EntityType.NetworkDevice, nameTerms: ["core"] },
1657
+ { entityType: EntityType.IoTDevice, nameTerms: ["core"] },
1658
+ { entityType: EntityType.CloudResource, nameTerms: [] },
1659
+ ],
1660
+ });
1661
+ expect(response.matches).toEqual([
1662
+ { entityType: EntityType.NetworkDevice, count: 2 },
1663
+ ]);
1664
+
1665
+ const activeOnly: TopologyCollectionSearchResponseJSON =
1666
+ await TopologyQueries.getCollectionSearch({
1667
+ ...scope(),
1668
+ includeInactive: false,
1669
+ types: [
1670
+ { entityType: EntityType.NetworkDevice, nameTerms: ["core"] },
1671
+ { entityType: EntityType.IoTDevice, nameTerms: [] },
1672
+ ],
1673
+ });
1674
+ expect(activeOnly.matches).toEqual([
1675
+ { entityType: EntityType.NetworkDevice, count: 1 },
1676
+ { entityType: EntityType.IoTDevice, count: 1 },
1677
+ ]);
1678
+ });
1679
+ });
1680
+
1681
+ // --------------------------------------------------------------- drawer
1682
+
1683
+ describe("entity drawer", () => {
1684
+ async function seedDrawer(): Promise<void> {
1685
+ await items([
1686
+ {
1687
+ key: "svc-a",
1688
+ type: EntityType.Service,
1689
+ name: "Checkout",
1690
+ descriptive: { "service.version": "1.2.3", nested: { a: 1 } },
1691
+ identifying: { "service.name": "checkout" },
1692
+ resourceType: "Service",
1693
+ resourceId: "5f8b9c0d-e1a2-4b3c-8d5e-6f7a8b9c0d1e",
1694
+ firstSeenAt: new Date("2026-08-01T00:00:00.000Z"),
1695
+ },
1696
+ { key: "db-1", type: EntityType.Database, name: "Orders DB" },
1697
+ {
1698
+ key: "api-2",
1699
+ type: EntityType.RemoteService,
1700
+ name: "Payments API",
1701
+ },
1702
+ { key: "svc-c", type: EntityType.Service, name: "Frontend" },
1703
+ {
1704
+ key: "pod-1",
1705
+ type: EntityType.KubernetesPod,
1706
+ name: "Pod checkout",
1707
+ },
1708
+ { key: "host-1", type: EntityType.Host, name: "Host One" },
1709
+ { key: "unnamed-1", type: EntityType.KubernetesNamespace, name: "" },
1710
+ { key: "archived-1", type: EntityType.KubernetesPod, archived: true },
1711
+ ]);
1712
+ const dependsOn: string = EntityRelationshipType.DependsOn;
1713
+ await relationships([
1714
+ { from: "svc-a", to: "db-1", type: dependsOn, callCount: 10 },
1715
+ { from: "svc-a", to: "api-2", type: dependsOn, callCount: null },
1716
+ { from: "svc-a", to: "ghost", type: dependsOn, callCount: 5 },
1717
+ { from: "svc-a", to: "svc-a", type: dependsOn, callCount: 1 },
1718
+ {
1719
+ from: "svc-c",
1720
+ to: "svc-a",
1721
+ type: dependsOn,
1722
+ callCount: 3,
1723
+ errorCount: 1,
1724
+ avgDurationMs: 12,
1725
+ },
1726
+ { from: "svc-a", to: "pod-1", type: EntityRelationshipType.RunsOn },
1727
+ {
1728
+ from: "svc-a",
1729
+ to: "host-1",
1730
+ type: EntityRelationshipType.HostedOn,
1731
+ },
1732
+ {
1733
+ from: "svc-a",
1734
+ to: "gone-pod",
1735
+ type: EntityRelationshipType.RunsOn,
1736
+ },
1737
+ {
1738
+ from: "svc-a",
1739
+ to: "archived-1",
1740
+ type: EntityRelationshipType.RunsOn,
1741
+ },
1742
+ {
1743
+ from: "svc-a",
1744
+ to: "unnamed-1",
1745
+ type: EntityRelationshipType.PartOf,
1746
+ },
1747
+ { from: "pod-1", to: "svc-a", type: EntityRelationshipType.RunsOn },
1748
+ // Not in range / deleted / foreign.
1749
+ { from: "svc-a", to: "db-2", type: dependsOn, lastSeenAt: STALE },
1750
+ { from: "svc-a", to: "db-3", type: dependsOn, deleted: true },
1751
+ {
1752
+ from: "svc-a",
1753
+ to: "db-4",
1754
+ type: dependsOn,
1755
+ projectId: OTHER_PROJECT_ID,
1756
+ },
1757
+ ]);
1758
+ }
1759
+
1760
+ async function entity(
1761
+ entityKey: string,
1762
+ entityType: string | null = null,
1763
+ ): Promise<TopologyEntityResponseJSON> {
1764
+ return await TopologyQueries.getEntity({
1765
+ ...scope(),
1766
+ entityKey,
1767
+ entityType,
1768
+ });
1769
+ }
1770
+
1771
+ test("the full row, timestamps as epoch milliseconds", async () => {
1772
+ await seedDrawer();
1773
+ const response: TopologyEntityResponseJSON = await entity("svc-a");
1774
+ expect(response.entity).toMatchObject({
1775
+ key: "svc-a",
1776
+ type: EntityType.Service,
1777
+ name: "Checkout",
1778
+ source: EntitySource.Discovered,
1779
+ lastSeenAt: IN_RANGE.getTime(),
1780
+ firstSeenAt: Date.parse("2026-08-01T00:00:00.000Z"),
1781
+ resourceType: "Service",
1782
+ resourceId: "5f8b9c0d-e1a2-4b3c-8d5e-6f7a8b9c0d1e",
1783
+ identifyingAttributes: { "service.name": "checkout" },
1784
+ descriptiveAttributes: {
1785
+ "service.version": "1.2.3",
1786
+ nested: { a: 1 },
1787
+ },
1788
+ });
1789
+ expect(response.entity!.id).toMatch(/^[0-9a-f-]{36}$/);
1790
+ expect(response.isScanLimited).toBe(false);
1791
+ });
1792
+
1793
+ test("sections, their order, exact totals and unknown ends", async () => {
1794
+ await seedDrawer();
1795
+ const response: TopologyEntityResponseJSON = await entity("svc-a");
1796
+
1797
+ // calls: callCount DESC NULLS LAST; the self-loop appears once, outbound.
1798
+ expect(otherKeysOf(response.sections.calls.rows)).toEqual([
1799
+ "db-1",
1800
+ "ghost",
1801
+ "svc-a",
1802
+ "api-2",
1803
+ ]);
1804
+ expect(response.sections.calls.total).toBe(4);
1805
+ expect(response.sections.calls.unknownTotal).toBe(1);
1806
+ expect(response.sections.calls.nextOffset).toBeNull();
1807
+ expect(response.sections.calls.rows[0]).toEqual({
1808
+ relationshipType: EntityRelationshipType.DependsOn,
1809
+ direction: "out",
1810
+ otherKey: "db-1",
1811
+ otherKnown: true,
1812
+ otherName: "Orders DB",
1813
+ otherType: EntityType.Database,
1814
+ callCount: 10,
1815
+ errorCount: null,
1816
+ avgDurationMs: null,
1817
+ lastSeenAt: IN_RANGE.getTime(),
1818
+ });
1819
+ expect(response.sections.calls.rows[1]).toMatchObject({
1820
+ otherKey: "ghost",
1821
+ otherKnown: false,
1822
+ otherName: null,
1823
+ otherType: null,
1824
+ });
1825
+
1826
+ expect(response.sections.calledBy.rows).toEqual([
1827
+ {
1828
+ relationshipType: EntityRelationshipType.DependsOn,
1829
+ direction: "in",
1830
+ otherKey: "svc-c",
1831
+ otherKnown: true,
1832
+ otherName: "Frontend",
1833
+ otherType: EntityType.Service,
1834
+ callCount: 3,
1835
+ errorCount: 1,
1836
+ avgDurationMs: 12,
1837
+ lastSeenAt: IN_RANGE.getTime(),
1838
+ },
1839
+ ]);
1840
+
1841
+ /*
1842
+ * runsOn: known first, by label ("Host One" < "Pod checkout" in any
1843
+ * collation); an archived end is unknown, and unknown ends tie on
1844
+ * their label and fall back to the key.
1845
+ */
1846
+ expect(otherKeysOf(response.sections.runsOn.rows)).toEqual([
1847
+ "host-1",
1848
+ "pod-1",
1849
+ "archived-1",
1850
+ "gone-pod",
1851
+ ]);
1852
+ expect(response.sections.runsOn.total).toBe(4);
1853
+ expect(response.sections.runsOn.unknownTotal).toBe(2);
1854
+
1855
+ // related: outbound part-of from a service, and inbound runs-on.
1856
+ expect(
1857
+ response.sections.related.rows.map(
1858
+ (row: TopologyConnectionRowJSON): string => {
1859
+ return `${row.direction}:${row.relationshipType}:${row.otherKey}`;
1860
+ },
1861
+ ),
1862
+ ).toEqual(["in:runs-on:pod-1", "out:part-of:unnamed-1"]);
1863
+ expect(response.sections.related.total).toBe(2);
1864
+ expect(response.sections.related.unknownTotal).toBe(0);
1865
+ });
1866
+
1867
+ test("a resource's outbound runs-on is related, not runs-on", async () => {
1868
+ await seedDrawer();
1869
+ const response: TopologyEntityResponseJSON = await entity("pod-1");
1870
+ expect(response.sections.runsOn.total).toBe(0);
1871
+ expect(
1872
+ response.sections.related.rows.map(
1873
+ (row: TopologyConnectionRowJSON): string => {
1874
+ return `${row.direction}:${row.otherKey}`;
1875
+ },
1876
+ ),
1877
+ ).toEqual(["in:svc-a", "out:svc-a"]);
1878
+ });
1879
+
1880
+ test("an archived, deleted, foreign or unknown key is not found and has no sections", async () => {
1881
+ await seedDrawer();
1882
+ await item({
1883
+ key: "svc-deleted",
1884
+ type: EntityType.Service,
1885
+ deleted: true,
1886
+ });
1887
+ await item({
1888
+ key: "svc-foreign",
1889
+ type: EntityType.Service,
1890
+ projectId: OTHER_PROJECT_ID,
1891
+ });
1892
+ for (const key of [
1893
+ "archived-1",
1894
+ "svc-deleted",
1895
+ "svc-foreign",
1896
+ "nope",
1897
+ ]) {
1898
+ const response: TopologyEntityResponseJSON = await entity(key);
1899
+ expect(response.entity).toBeNull();
1900
+ expect(response.sections.calls.total).toBe(0);
1901
+ expect(response.sections.related.rows).toEqual([]);
1902
+ }
1903
+ });
1904
+
1905
+ test("a known type narrows the choice between rows sharing a key", async () => {
1906
+ await items([
1907
+ {
1908
+ key: "shared",
1909
+ type: EntityType.Service,
1910
+ name: "as service",
1911
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
1912
+ },
1913
+ {
1914
+ key: "shared",
1915
+ type: EntityType.Host,
1916
+ name: "as host",
1917
+ createdAt: new Date("2026-09-02T00:00:00.000Z"),
1918
+ },
1919
+ ]);
1920
+ expect((await entity("shared")).entity!.name).toBe("as service");
1921
+ expect((await entity("shared", EntityType.Host)).entity!.name).toBe(
1922
+ "as host",
1923
+ );
1924
+ expect(
1925
+ (await entity("shared", EntityType.KubernetesPod)).entity!.name,
1926
+ ).toBe("as service");
1927
+ });
1928
+
1929
+ test("show more pages one section by offset", async () => {
1930
+ await item({ key: "svc-a", type: EntityType.Service });
1931
+ for (let index: number = 0; index < 7; index++) {
1932
+ await relationship({
1933
+ from: "svc-a",
1934
+ to: `dep-${index}`,
1935
+ type: EntityRelationshipType.DependsOn,
1936
+ callCount: 100 - index,
1937
+ });
1938
+ }
1939
+ const page: (
1940
+ offset: number,
1941
+ ) => Promise<TopologyEntityConnectionsResponseJSON> = async (
1942
+ offset: number,
1943
+ ): Promise<TopologyEntityConnectionsResponseJSON> => {
1944
+ return await TopologyQueries.getEntityConnections({
1945
+ ...scope(),
1946
+ entityKey: "svc-a",
1947
+ entityType: null,
1948
+ section: "calls",
1949
+ offset,
1950
+ limit: 3,
1951
+ });
1952
+ };
1953
+
1954
+ const first: TopologyEntityConnectionsResponseJSON = await page(0);
1955
+ expect(first.section).toBe("calls");
1956
+ expect(otherKeysOf(first.connections.rows)).toEqual([
1957
+ "dep-0",
1958
+ "dep-1",
1959
+ "dep-2",
1960
+ ]);
1961
+ expect(first.connections.total).toBe(7);
1962
+ expect(first.connections.nextOffset).toBe(3);
1963
+
1964
+ const last: TopologyEntityConnectionsResponseJSON = await page(6);
1965
+ expect(otherKeysOf(last.connections.rows)).toEqual(["dep-6"]);
1966
+ expect(last.connections.nextOffset).toBeNull();
1967
+
1968
+ const beyond: TopologyEntityConnectionsResponseJSON = await page(10);
1969
+ expect(beyond.connections.rows).toEqual([]);
1970
+ expect(beyond.connections.total).toBe(7);
1971
+ expect(beyond.connections.nextOffset).toBeNull();
1972
+ });
1973
+
1974
+ test("the drawer's first view stops at the section row budgets", async () => {
1975
+ await item({ key: "svc-a", type: EntityType.Service });
1976
+ for (
1977
+ let index: number = 0;
1978
+ index < TopologyApiLimits.EntityOtherRows + 2;
1979
+ index++
1980
+ ) {
1981
+ await relationship({
1982
+ from: "svc-a",
1983
+ to: `pod-${String(index).padStart(3, "0")}`,
1984
+ type: EntityRelationshipType.RunsOn,
1985
+ });
1986
+ }
1987
+ const response: TopologyEntityResponseJSON = await entity("svc-a");
1988
+ expect(response.sections.runsOn.rows).toHaveLength(
1989
+ TopologyApiLimits.EntityOtherRows,
1990
+ );
1991
+ expect(response.sections.runsOn.total).toBe(
1992
+ TopologyApiLimits.EntityOtherRows + 2,
1993
+ );
1994
+ expect(response.sections.runsOn.nextOffset).toBe(
1995
+ TopologyApiLimits.EntityOtherRows,
1996
+ );
1997
+ });
1998
+
1999
+ test("the scan stops after the limit, outbound first, newest first", async () => {
2000
+ const limit: number = TopologyApiLimits.EntityConnectionScanLimit;
2001
+ TopologyApiLimits.EntityConnectionScanLimit = 3;
2002
+ try {
2003
+ await item({ key: "ns-a", type: EntityType.KubernetesNamespace });
2004
+ await relationship({
2005
+ from: "ns-a",
2006
+ to: "cluster",
2007
+ type: EntityRelationshipType.PartOf,
2008
+ });
2009
+ for (let index: number = 0; index < 5; index++) {
2010
+ await relationship({
2011
+ from: `pod-${index}`,
2012
+ to: "ns-a",
2013
+ type: EntityRelationshipType.PartOf,
2014
+ lastSeenAt: new Date(IN_RANGE.getTime() + index * 1000),
2015
+ });
2016
+ }
2017
+ const response: TopologyEntityResponseJSON = await entity("ns-a");
2018
+ expect(response.isScanLimited).toBe(true);
2019
+ // Outbound (1) + the newest inbound up to limit + 1 relationships read.
2020
+ expect(response.sections.related.total).toBe(4);
2021
+ expect(
2022
+ response.sections.related.rows
2023
+ .map((row: TopologyConnectionRowJSON): string => {
2024
+ return row.otherKey;
2025
+ })
2026
+ .sort(),
2027
+ ).toEqual(["cluster", "pod-2", "pod-3", "pod-4"]);
2028
+ } finally {
2029
+ TopologyApiLimits.EntityConnectionScanLimit = limit;
2030
+ }
2031
+ });
2032
+
2033
+ test("inbound rows that are stale, soft-deleted, undated or another project's reach no section", async () => {
2034
+ await items([
2035
+ { key: "svc-a", type: EntityType.Service, name: "Checkout" },
2036
+ { key: "svc-c", type: EntityType.Service, name: "Frontend" },
2037
+ { key: "svc-stale", type: EntityType.Service },
2038
+ { key: "svc-deleted-edge", type: EntityType.Service },
2039
+ { key: "svc-undated", type: EntityType.Service },
2040
+ {
2041
+ key: "svc-foreign",
2042
+ type: EntityType.Service,
2043
+ projectId: OTHER_PROJECT_ID,
2044
+ },
2045
+ { key: "pod-1", type: EntityType.KubernetesPod },
2046
+ { key: "pod-stale", type: EntityType.KubernetesPod },
2047
+ { key: "pod-deleted-edge", type: EntityType.KubernetesPod },
2048
+ {
2049
+ key: "pod-foreign",
2050
+ type: EntityType.KubernetesPod,
2051
+ projectId: OTHER_PROJECT_ID,
2052
+ },
2053
+ ]);
2054
+ const dependsOn: string = EntityRelationshipType.DependsOn;
2055
+ const runsOn: string = EntityRelationshipType.RunsOn;
2056
+ await relationships([
2057
+ // The two that count: one caller, one resource running the service.
2058
+ { from: "svc-c", to: "svc-a", type: dependsOn, callCount: 3 },
2059
+ { from: "pod-1", to: "svc-a", type: runsOn },
2060
+ // Into svc-a, but not in range, deleted, undated or foreign.
2061
+ {
2062
+ from: "svc-stale",
2063
+ to: "svc-a",
2064
+ type: dependsOn,
2065
+ lastSeenAt: STALE,
2066
+ },
2067
+ {
2068
+ from: "svc-deleted-edge",
2069
+ to: "svc-a",
2070
+ type: dependsOn,
2071
+ deleted: true,
2072
+ },
2073
+ {
2074
+ from: "svc-undated",
2075
+ to: "svc-a",
2076
+ type: dependsOn,
2077
+ lastSeenAt: null,
2078
+ },
2079
+ {
2080
+ from: "svc-foreign",
2081
+ to: "svc-a",
2082
+ type: dependsOn,
2083
+ projectId: OTHER_PROJECT_ID,
2084
+ },
2085
+ { from: "pod-stale", to: "svc-a", type: runsOn, lastSeenAt: STALE },
2086
+ {
2087
+ from: "pod-deleted-edge",
2088
+ to: "svc-a",
2089
+ type: runsOn,
2090
+ deleted: true,
2091
+ },
2092
+ {
2093
+ from: "pod-foreign",
2094
+ to: "svc-a",
2095
+ type: runsOn,
2096
+ projectId: OTHER_PROJECT_ID,
2097
+ },
2098
+ ]);
2099
+
2100
+ const response: TopologyEntityResponseJSON = await entity("svc-a");
2101
+ expect(response.isScanLimited).toBe(false);
2102
+ expect(response.sections.calledBy.total).toBe(1);
2103
+ expect(response.sections.calledBy.unknownTotal).toBe(0);
2104
+ expect(otherKeysOf(response.sections.calledBy.rows)).toEqual(["svc-c"]);
2105
+ expect(response.sections.related.total).toBe(1);
2106
+ expect(response.sections.related.unknownTotal).toBe(0);
2107
+ expect(
2108
+ response.sections.related.rows.map(
2109
+ (row: TopologyConnectionRowJSON): string => {
2110
+ return `${row.direction}:${row.relationshipType}:${row.otherKey}`;
2111
+ },
2112
+ ),
2113
+ ).toEqual([`in:${runsOn}:pod-1`]);
2114
+ expect(response.sections.calls.total).toBe(0);
2115
+ expect(response.sections.runsOn.total).toBe(0);
2116
+
2117
+ // "Show more" reads through the same scan.
2118
+ const page: TopologyEntityConnectionsResponseJSON =
2119
+ await TopologyQueries.getEntityConnections({
2120
+ ...scope(),
2121
+ entityKey: "svc-a",
2122
+ entityType: null,
2123
+ section: "calledBy",
2124
+ offset: 0,
2125
+ limit: 50,
2126
+ });
2127
+ expect(page.connections.total).toBe(1);
2128
+ expect(otherKeysOf(page.connections.rows)).toEqual(["svc-c"]);
2129
+ });
2130
+
2131
+ test("past the scan limit on outbound alone, the newest outbound rows are the ones kept", async () => {
2132
+ const limit: number = TopologyApiLimits.EntityConnectionScanLimit;
2133
+ TopologyApiLimits.EntityConnectionScanLimit = 3;
2134
+ try {
2135
+ await item({ key: "ns-a", type: EntityType.KubernetesNamespace });
2136
+ const outbound: Array<RelationshipFixture> = [];
2137
+ // Inserted oldest-last so insertion order cannot pass for recency.
2138
+ for (let index: number = 5; index >= 0; index--) {
2139
+ outbound.push({
2140
+ from: "ns-a",
2141
+ to: `target-${index}`,
2142
+ type: EntityRelationshipType.PartOf,
2143
+ lastSeenAt: new Date(IN_RANGE.getTime() + index * 60_000),
2144
+ });
2145
+ }
2146
+ await relationships(outbound);
2147
+ // Newer than every outbound row, but outbound is read first.
2148
+ await relationship({
2149
+ from: "pod-newest",
2150
+ to: "ns-a",
2151
+ type: EntityRelationshipType.PartOf,
2152
+ lastSeenAt: new Date(IN_RANGE.getTime() + 3_600_000),
2153
+ });
2154
+
2155
+ const response: TopologyEntityResponseJSON = await entity("ns-a");
2156
+ expect(response.isScanLimited).toBe(true);
2157
+ // limit + 1 relationships read: the four newest outbound.
2158
+ expect(response.sections.related.total).toBe(4);
2159
+ expect(otherKeysOf(response.sections.related.rows).sort()).toEqual([
2160
+ "target-2",
2161
+ "target-3",
2162
+ "target-4",
2163
+ "target-5",
2164
+ ]);
2165
+ } finally {
2166
+ TopologyApiLimits.EntityConnectionScanLimit = limit;
2167
+ }
2168
+ });
2169
+
2170
+ test("a duplicated other-end key is named and typed by its createdAt ASC, _id DESC winner", async () => {
2171
+ const early: Date = new Date("2026-09-01T00:00:00.000Z");
2172
+ const late: Date = new Date("2026-09-02T00:00:00.000Z");
2173
+ await items([
2174
+ { key: "svc-a", type: EntityType.Service, name: "Checkout" },
2175
+ // Earliest live row wins over a later one...
2176
+ {
2177
+ key: "twin",
2178
+ type: EntityType.Database,
2179
+ name: "later row",
2180
+ createdAt: late,
2181
+ },
2182
+ {
2183
+ key: "twin",
2184
+ type: EntityType.Host,
2185
+ name: "earliest row",
2186
+ createdAt: early,
2187
+ },
2188
+ // ...and an even earlier row does not count once archived or deleted.
2189
+ {
2190
+ key: "twin",
2191
+ type: EntityType.KubernetesPod,
2192
+ name: "archived row",
2193
+ createdAt: new Date("2026-08-01T00:00:00.000Z"),
2194
+ archived: true,
2195
+ },
2196
+ {
2197
+ key: "twin",
2198
+ type: EntityType.KubernetesNode,
2199
+ name: "deleted row",
2200
+ createdAt: new Date("2026-08-01T00:00:00.000Z"),
2201
+ deleted: true,
2202
+ },
2203
+ // Same createdAt: the larger _id wins.
2204
+ {
2205
+ key: "tie",
2206
+ type: EntityType.Database,
2207
+ name: "smaller id",
2208
+ createdAt: early,
2209
+ id: "00000000-0000-4000-8000-000000000001",
2210
+ },
2211
+ {
2212
+ key: "tie",
2213
+ type: EntityType.Host,
2214
+ name: "larger id",
2215
+ createdAt: early,
2216
+ id: "00000000-0000-4000-8000-000000000002",
2217
+ },
2218
+ ]);
2219
+ await relationships([
2220
+ {
2221
+ from: "svc-a",
2222
+ to: "twin",
2223
+ type: EntityRelationshipType.DependsOn,
2224
+ callCount: 2,
2225
+ },
2226
+ {
2227
+ from: "svc-a",
2228
+ to: "tie",
2229
+ type: EntityRelationshipType.DependsOn,
2230
+ callCount: 1,
2231
+ },
2232
+ ]);
2233
+
2234
+ const response: TopologyEntityResponseJSON = await entity("svc-a");
2235
+ expect(
2236
+ response.sections.calls.rows.map(
2237
+ (
2238
+ row: TopologyConnectionRowJSON,
2239
+ ): [string, boolean, string | null, string | null] => {
2240
+ return [
2241
+ row.otherKey,
2242
+ row.otherKnown,
2243
+ row.otherName,
2244
+ row.otherType,
2245
+ ];
2246
+ },
2247
+ ),
2248
+ ).toEqual([
2249
+ ["twin", true, "earliest row", EntityType.Host],
2250
+ ["tie", true, "larger id", EntityType.Host],
2251
+ ]);
2252
+ expect(response.sections.calls.total).toBe(2);
2253
+ });
2254
+
2255
+ test("exactly the limit is not scan limited", async () => {
2256
+ const limit: number = TopologyApiLimits.EntityConnectionScanLimit;
2257
+ TopologyApiLimits.EntityConnectionScanLimit = 3;
2258
+ try {
2259
+ await item({ key: "ns-a", type: EntityType.KubernetesNamespace });
2260
+ for (let index: number = 0; index < 3; index++) {
2261
+ await relationship({
2262
+ from: `pod-${index}`,
2263
+ to: "ns-a",
2264
+ type: EntityRelationshipType.PartOf,
2265
+ });
2266
+ }
2267
+ const response: TopologyEntityResponseJSON = await entity("ns-a");
2268
+ expect(response.isScanLimited).toBe(false);
2269
+ expect(response.sections.related.total).toBe(3);
2270
+ } finally {
2271
+ TopologyApiLimits.EntityConnectionScanLimit = limit;
2272
+ }
2273
+ });
2274
+ });
2275
+ /*
2276
+ * The inventory item page's reads: the drawer's sections with no range,
2277
+ * over archived items too, each other end carrying its id.
2278
+ */
2279
+ describe("entity all time", () => {
2280
+ const LONG_AGO: Date = new Date("2025-01-15T00:00:00.000Z");
2281
+ const POD_ID: string = "00000000-0000-4000-8000-0000000000a1";
2282
+ const ARCHIVED_POD_ID: string = "00000000-0000-4000-8000-0000000000a2";
2283
+ const NS_ID: string = "00000000-0000-4000-8000-0000000000a3";
2284
+
2285
+ async function allTime(
2286
+ entityKey: string,
2287
+ entityType: string | null = null,
2288
+ ): Promise<TopologyEntityAllTimeResponseJSON> {
2289
+ return await TopologyQueries.getEntityAllTime({
2290
+ projectId: PROJECT_ID,
2291
+ entityKey,
2292
+ entityType,
2293
+ });
2294
+ }
2295
+
2296
+ test("every stored relationship counts, however old, undated or drawn by hand", async () => {
2297
+ await items([
2298
+ { key: "svc-a", type: EntityType.Service, name: "Checkout" },
2299
+ { key: "svc-c", type: EntityType.Service, name: "Frontend" },
2300
+ { key: "db-1", type: EntityType.Database, name: "Orders DB" },
2301
+ { key: "db-old", type: EntityType.Database, name: "Legacy DB" },
2302
+ { key: "vendor", type: EntityType.RemoteService, name: "Vendor" },
2303
+ { key: "svc-undated", type: EntityType.Service, name: "Undated" },
2304
+ ]);
2305
+ const dependsOn: string = EntityRelationshipType.DependsOn;
2306
+ await relationships([
2307
+ { from: "svc-a", to: "db-1", type: dependsOn, callCount: 10 },
2308
+ { from: "svc-a", to: "db-old", type: dependsOn, lastSeenAt: STALE },
2309
+ // Drawn by hand long ago: never re-bumped, never pruned.
2310
+ {
2311
+ from: "svc-a",
2312
+ to: "vendor",
2313
+ type: dependsOn,
2314
+ source: EntitySource.Manual,
2315
+ lastSeenAt: LONG_AGO,
2316
+ },
2317
+ {
2318
+ from: "svc-undated",
2319
+ to: "svc-a",
2320
+ type: dependsOn,
2321
+ lastSeenAt: null,
2322
+ },
2323
+ { from: "svc-c", to: "svc-a", type: dependsOn, callCount: 3 },
2324
+ // Not stored any more, or not this project's.
2325
+ { from: "svc-a", to: "db-2", type: dependsOn, deleted: true },
2326
+ {
2327
+ from: "svc-a",
2328
+ to: "db-3",
2329
+ type: dependsOn,
2330
+ projectId: OTHER_PROJECT_ID,
2331
+ },
2332
+ ]);
2333
+
2334
+ const response: TopologyEntityAllTimeResponseJSON =
2335
+ await allTime("svc-a");
2336
+ expect(response).not.toHaveProperty("rangeStart");
2337
+ expect(response.isScanLimited).toBe(false);
2338
+ expect(response.sections.calls.total).toBe(3);
2339
+ // callCount DESC NULLS LAST, then by label.
2340
+ expect(otherKeysOf(response.sections.calls.rows)).toEqual([
2341
+ "db-1",
2342
+ "db-old",
2343
+ "vendor",
2344
+ ]);
2345
+ expect(response.sections.calls.rows[2]).toMatchObject({
2346
+ otherKey: "vendor",
2347
+ otherKnown: true,
2348
+ otherName: "Vendor",
2349
+ otherType: EntityType.RemoteService,
2350
+ lastSeenAt: LONG_AGO.getTime(),
2351
+ });
2352
+ expect(response.sections.calledBy.total).toBe(2);
2353
+ expect(otherKeysOf(response.sections.calledBy.rows)).toEqual([
2354
+ "svc-c",
2355
+ "svc-undated",
2356
+ ]);
2357
+ expect(response.sections.calledBy.rows[1]!.lastSeenAt).toBeNull();
2358
+
2359
+ // The drawer, over its range, sees only what reported in it.
2360
+ const drawer: TopologyEntityResponseJSON =
2361
+ await TopologyQueries.getEntity({
2362
+ ...scope(),
2363
+ entityKey: "svc-a",
2364
+ entityType: null,
2365
+ });
2366
+ expect(otherKeysOf(drawer.sections.calls.rows)).toEqual(["db-1"]);
2367
+ expect(otherKeysOf(drawer.sections.calledBy.rows)).toEqual(["svc-c"]);
2368
+ });
2369
+
2370
+ test("an archived item has its connections; archived ends are named and linkable, unknown ends are not", async () => {
2371
+ await items([
2372
+ {
2373
+ key: "ns-a",
2374
+ type: EntityType.KubernetesNamespace,
2375
+ name: "payments",
2376
+ archived: true,
2377
+ id: NS_ID,
2378
+ },
2379
+ {
2380
+ key: "pod-1",
2381
+ type: EntityType.KubernetesPod,
2382
+ name: "api-7d9f",
2383
+ id: POD_ID,
2384
+ },
2385
+ {
2386
+ key: "pod-archived",
2387
+ type: EntityType.KubernetesPod,
2388
+ name: "api-old",
2389
+ archived: true,
2390
+ id: ARCHIVED_POD_ID,
2391
+ },
2392
+ {
2393
+ key: "pod-deleted",
2394
+ type: EntityType.KubernetesPod,
2395
+ name: "api-deleted",
2396
+ deleted: true,
2397
+ },
2398
+ ]);
2399
+ const partOf: string = EntityRelationshipType.PartOf;
2400
+ await relationships([
2401
+ { from: "pod-1", to: "ns-a", type: partOf },
2402
+ { from: "pod-archived", to: "ns-a", type: partOf, lastSeenAt: STALE },
2403
+ { from: "pod-deleted", to: "ns-a", type: partOf },
2404
+ { from: "pod-gone", to: "ns-a", type: partOf },
2405
+ ]);
2406
+
2407
+ const response: TopologyEntityAllTimeResponseJSON =
2408
+ await allTime("ns-a");
2409
+ expect(response.entity).toMatchObject({
2410
+ id: NS_ID,
2411
+ key: "ns-a",
2412
+ name: "payments",
2413
+ });
2414
+ expect(response.sections.related.total).toBe(4);
2415
+ expect(response.sections.related.unknownTotal).toBe(2);
2416
+ // Known first, by label; then the unknown ends by key.
2417
+ expect(
2418
+ response.sections.related.rows.map(
2419
+ (
2420
+ row: TopologyConnectionRowJSON,
2421
+ ): [string, boolean, string | null | undefined, string | null] => {
2422
+ return [row.otherKey, row.otherKnown, row.otherId, row.otherName];
2423
+ },
2424
+ ),
2425
+ ).toEqual([
2426
+ ["pod-1", true, POD_ID, "api-7d9f"],
2427
+ ["pod-archived", true, ARCHIVED_POD_ID, "api-old"],
2428
+ ["pod-deleted", false, null, null],
2429
+ ["pod-gone", false, null, null],
2430
+ ]);
2431
+
2432
+ // The drawer finds no live item with the key, so it lists nothing.
2433
+ const drawer: TopologyEntityResponseJSON =
2434
+ await TopologyQueries.getEntity({
2435
+ ...scope(),
2436
+ entityKey: "ns-a",
2437
+ entityType: null,
2438
+ });
2439
+ expect(drawer.entity).toBeNull();
2440
+ });
2441
+
2442
+ test("a live row wins a key an archived row shares, for the entity and for its other ends", async () => {
2443
+ await items([
2444
+ // Earlier, so it would win if archived rows were not ranked last.
2445
+ {
2446
+ key: "twin",
2447
+ type: EntityType.KubernetesNode,
2448
+ name: "archived twin",
2449
+ archived: true,
2450
+ createdAt: new Date("2026-08-01T00:00:00.000Z"),
2451
+ },
2452
+ {
2453
+ key: "twin",
2454
+ type: EntityType.Host,
2455
+ name: "live twin",
2456
+ createdAt: new Date("2026-09-02T00:00:00.000Z"),
2457
+ id: "00000000-0000-4000-8000-0000000000b1",
2458
+ },
2459
+ { key: "svc-a", type: EntityType.Service, name: "Checkout" },
2460
+ ]);
2461
+ await relationship({
2462
+ from: "svc-a",
2463
+ to: "twin",
2464
+ type: EntityRelationshipType.HostedOn,
2465
+ });
2466
+
2467
+ expect((await allTime("twin")).entity!.name).toBe("live twin");
2468
+ const service: TopologyEntityAllTimeResponseJSON =
2469
+ await allTime("svc-a");
2470
+ expect(service.sections.runsOn.rows).toHaveLength(1);
2471
+ expect(service.sections.runsOn.rows[0]).toMatchObject({
2472
+ otherKey: "twin",
2473
+ otherId: "00000000-0000-4000-8000-0000000000b1",
2474
+ otherName: "live twin",
2475
+ });
2476
+ });
2477
+
2478
+ test("a deleted, foreign or unknown key is not found and has no sections", async () => {
2479
+ await items([
2480
+ { key: "svc-deleted", type: EntityType.Service, deleted: true },
2481
+ {
2482
+ key: "svc-foreign",
2483
+ type: EntityType.Service,
2484
+ projectId: OTHER_PROJECT_ID,
2485
+ },
2486
+ { key: "svc-a", type: EntityType.Service },
2487
+ ]);
2488
+ await relationships([
2489
+ {
2490
+ from: "svc-deleted",
2491
+ to: "svc-a",
2492
+ type: EntityRelationshipType.DependsOn,
2493
+ },
2494
+ {
2495
+ from: "svc-foreign",
2496
+ to: "svc-a",
2497
+ type: EntityRelationshipType.DependsOn,
2498
+ projectId: OTHER_PROJECT_ID,
2499
+ },
2500
+ ]);
2501
+ for (const key of ["svc-deleted", "svc-foreign", "nope"]) {
2502
+ const response: TopologyEntityAllTimeResponseJSON =
2503
+ await allTime(key);
2504
+ expect(response.entity).toBeNull();
2505
+ expect(response.sections.calls.total).toBe(0);
2506
+ expect(response.sections.related.rows).toEqual([]);
2507
+ }
2508
+ });
2509
+
2510
+ test("a hub's section pages to its exact total, every connection exactly once", async () => {
2511
+ const pods: number = TopologyApiLimits.EntityOtherRows * 2 + 7;
2512
+ await item({ key: "ns-hub", type: EntityType.KubernetesNamespace });
2513
+ await bulkItems({
2514
+ type: EntityType.KubernetesPod,
2515
+ prefix: "pod-",
2516
+ count: pods,
2517
+ });
2518
+ const fixtures: Array<RelationshipFixture> = [];
2519
+ for (let index: number = 1; index <= pods; index++) {
2520
+ fixtures.push({
2521
+ from: `pod-${index}`,
2522
+ to: "ns-hub",
2523
+ type: EntityRelationshipType.PartOf,
2524
+ // Most pods are long gone; all time, every edge still counts.
2525
+ lastSeenAt: index % 3 === 0 ? IN_RANGE : STALE,
2526
+ });
2527
+ }
2528
+ await relationships(fixtures);
2529
+
2530
+ const first: TopologyEntityAllTimeResponseJSON =
2531
+ await allTime("ns-hub");
2532
+ expect(first.sections.related.total).toBe(pods);
2533
+ expect(first.sections.related.rows).toHaveLength(
2534
+ TopologyApiLimits.EntityOtherRows,
2535
+ );
2536
+ const seen: Array<string> = otherKeysOf(first.sections.related.rows);
2537
+ let offset: number | null = first.sections.related.nextOffset;
2538
+ let pages: number = 0;
2539
+ while (offset !== null) {
2540
+ const page: TopologyEntityAllTimeConnectionsResponseJSON =
2541
+ await TopologyQueries.getEntityAllTimeConnections({
2542
+ projectId: PROJECT_ID,
2543
+ entityKey: "ns-hub",
2544
+ entityType: null,
2545
+ section: "related",
2546
+ offset,
2547
+ limit: TopologyApiLimits.EntityOtherRows,
2548
+ });
2549
+ expect(page).not.toHaveProperty("rangeStart");
2550
+ expect(page.section).toBe("related");
2551
+ expect(page.connections.total).toBe(pods);
2552
+ seen.push(...otherKeysOf(page.connections.rows));
2553
+ offset = page.connections.nextOffset;
2554
+ pages++;
2555
+ }
2556
+ expect(pages).toBe(2);
2557
+ expect(seen).toHaveLength(pods);
2558
+ expect(new Set<string>(seen).size).toBe(pods);
2559
+
2560
+ // The drawer's range counts only the pods that reported in it.
2561
+ const drawer: TopologyEntityResponseJSON =
2562
+ await TopologyQueries.getEntity({
2563
+ ...scope(),
2564
+ entityKey: "ns-hub",
2565
+ entityType: null,
2566
+ });
2567
+ expect(drawer.sections.related.total).toBe(Math.floor(pods / 3));
2568
+ });
2569
+
2570
+ test("past the scan limit, undated relationships are kept before the oldest dated ones", async () => {
2571
+ const limit: number = TopologyApiLimits.EntityConnectionScanLimit;
2572
+ TopologyApiLimits.EntityConnectionScanLimit = 3;
2573
+ try {
2574
+ await item({ key: "ns-a", type: EntityType.KubernetesNamespace });
2575
+ await relationships([
2576
+ {
2577
+ from: "vendor",
2578
+ to: "ns-a",
2579
+ type: EntityRelationshipType.DependsOn,
2580
+ source: EntitySource.Manual,
2581
+ lastSeenAt: null,
2582
+ },
2583
+ {
2584
+ from: "pod-old",
2585
+ to: "ns-a",
2586
+ type: EntityRelationshipType.PartOf,
2587
+ lastSeenAt: LONG_AGO,
2588
+ },
2589
+ {
2590
+ from: "pod-stale",
2591
+ to: "ns-a",
2592
+ type: EntityRelationshipType.PartOf,
2593
+ lastSeenAt: STALE,
2594
+ },
2595
+ {
2596
+ from: "pod-new",
2597
+ to: "ns-a",
2598
+ type: EntityRelationshipType.PartOf,
2599
+ lastSeenAt: IN_RANGE,
2600
+ },
2601
+ {
2602
+ from: "pod-newest",
2603
+ to: "ns-a",
2604
+ type: EntityRelationshipType.PartOf,
2605
+ lastSeenAt: new Date(IN_RANGE.getTime() + 60_000),
2606
+ },
2607
+ ]);
2608
+ const response: TopologyEntityAllTimeResponseJSON =
2609
+ await allTime("ns-a");
2610
+ expect(response.isScanLimited).toBe(true);
2611
+ // limit + 1 read: the undated one, then the three newest.
2612
+ expect(
2613
+ [
2614
+ ...otherKeysOf(response.sections.calledBy.rows),
2615
+ ...otherKeysOf(response.sections.related.rows),
2616
+ ].sort(),
2617
+ ).toEqual(["pod-new", "pod-newest", "pod-stale", "vendor"]);
2618
+ } finally {
2619
+ TopologyApiLimits.EntityConnectionScanLimit = limit;
2620
+ }
2621
+ });
2622
+ });
2623
+ },
2624
+ );