@oneuptime/common 14.0.3 → 14.0.6

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 (663) hide show
  1. package/Models/DatabaseModels/Alert.ts +55 -0
  2. package/Models/DatabaseModels/DatabaseServer.ts +1650 -0
  3. package/Models/DatabaseModels/DatabaseServerEndpoint.ts +540 -0
  4. package/Models/DatabaseModels/DatabaseServerFeed.ts +625 -0
  5. package/Models/DatabaseModels/DatabaseServerLabelRule.ts +514 -0
  6. package/Models/DatabaseModels/DatabaseServerOwnerRule.ts +596 -0
  7. package/Models/DatabaseModels/DatabaseServerOwnerTeam.ts +491 -0
  8. package/Models/DatabaseModels/DatabaseServerOwnerUser.ts +490 -0
  9. package/Models/DatabaseModels/Incident.ts +55 -0
  10. package/Models/DatabaseModels/Index.ts +16 -0
  11. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +26 -0
  12. package/Models/DatabaseModels/ScheduledMaintenance.ts +55 -0
  13. package/Models/DatabaseModels/TelemetryException.ts +2 -0
  14. package/Models/DatabaseModels/UserProjectSsoConsent.ts +146 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.ts +673 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.ts +51 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  18. package/Server/Middleware/MultipartFormData.ts +1 -1
  19. package/Server/Middleware/TelemetryIngest.ts +15 -8
  20. package/Server/Services/AlertReminderRuleService.ts +100 -24
  21. package/Server/Services/DatabaseServerEndpointService.ts +895 -0
  22. package/Server/Services/DatabaseServerFeedService.ts +128 -0
  23. package/Server/Services/DatabaseServerLabelRuleEngineService.ts +340 -0
  24. package/Server/Services/DatabaseServerLabelRuleService.ts +14 -0
  25. package/Server/Services/DatabaseServerOwnerRuleEngineService.ts +425 -0
  26. package/Server/Services/DatabaseServerOwnerRuleService.ts +14 -0
  27. package/Server/Services/DatabaseServerOwnerTeamService.ts +180 -0
  28. package/Server/Services/DatabaseServerOwnerUserService.ts +173 -0
  29. package/Server/Services/DatabaseServerService.ts +4546 -0
  30. package/Server/Services/DatabaseService.ts +14 -8
  31. package/Server/Services/DockerResourceService.ts +43 -2
  32. package/Server/Services/IncidentReminderRuleService.ts +103 -23
  33. package/Server/Services/Index.ts +16 -0
  34. package/Server/Services/InventoryItemService.ts +113 -5
  35. package/Server/Services/IoTDeviceService.ts +145 -37
  36. package/Server/Services/MetricService.ts +1 -1
  37. package/Server/Services/OpenTelemetryIngestService.ts +19 -0
  38. package/Server/Services/PodmanResourceService.ts +43 -2
  39. package/Server/Services/ScheduledMaintenanceReminderRuleService.ts +107 -23
  40. package/Server/Services/ServiceService.ts +1 -1
  41. package/Server/Services/TelemetryUsageBillingService.ts +29 -2
  42. package/Server/Services/UserProjectSsoConsentService.ts +74 -0
  43. package/Server/Services/UserService.ts +14 -1
  44. package/Server/Types/Database/Permissions/OwnerTableRegistry.ts +18 -0
  45. package/Server/Types/Database/QueryHelper.ts +84 -2
  46. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  47. package/Server/Utils/AI/Toolbox/ResourceTools.ts +81 -2
  48. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  49. package/Server/Utils/Database/AffectedResourceRelations.ts +6 -0
  50. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  51. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +192 -9
  52. package/Server/Utils/Monitor/MonitorAlert.ts +28 -1
  53. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +2264 -755
  54. package/Server/Utils/Monitor/MonitorIncident.ts +28 -1
  55. package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +268 -6
  56. package/Server/Utils/Monitor/MonitorResource.ts +47 -11
  57. package/Server/Utils/Monitor/MonitorResourceContext.ts +1 -0
  58. package/Server/Utils/Monitor/MonitorStepResourceIdentity.ts +239 -21
  59. package/Server/Utils/Monitor/PerSeriesResolutionRootCause.ts +43 -0
  60. package/Server/Utils/Monitor/SeriesResourceLabels.ts +291 -0
  61. package/Server/Utils/Monitor/SeriesResourceLinker.ts +111 -12
  62. package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +25 -0
  63. package/Server/Utils/StartServer.ts +26 -6
  64. package/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.ts +2 -0
  65. package/Server/Utils/Telemetry/DatabaseEndpointDiscovery.ts +688 -0
  66. package/Server/Utils/Telemetry/EntityRegistry.ts +5 -1
  67. package/Server/Utils/Telemetry/LogExceptionExtractor.ts +18 -1
  68. package/Server/Utils/Telemetry/ResourceEntityFilter.ts +175 -12
  69. package/Server/Utils/Telemetry/ResourceFacetResolver.ts +12 -0
  70. package/Server/Utils/Telemetry/ServiceDependencyDiscovery.ts +606 -36
  71. package/Server/Utils/Telemetry/Telemetry.ts +16 -0
  72. package/Server/Utils/Telemetry/TelemetryEntity.ts +22 -22
  73. package/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.ts +16 -10
  74. package/Tests/App/Accounts/SignedInRedirect.test.tsx +241 -0
  75. package/Tests/App/AdminDashboard/AdminHeaderSmallScreens.test.tsx +221 -4
  76. package/Tests/App/Dashboard/AIInsightsResourcePageContext.test.ts +42 -0
  77. package/Tests/App/Dashboard/AffectedResourcesDisplay.test.tsx +100 -0
  78. package/Tests/App/Dashboard/AffectedResourcesPicker.test.tsx +86 -0
  79. package/Tests/App/Dashboard/AffectedResourcesPickerHiddenTypes.test.tsx +2 -0
  80. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +19 -0
  81. package/Tests/App/Dashboard/BreadcrumbCoverage.test.tsx +14 -0
  82. package/Tests/App/Dashboard/CorrelateFilterBuilder.test.tsx +26 -1
  83. package/Tests/App/Dashboard/DashboardAppSsoError.test.tsx +501 -0
  84. package/Tests/App/Dashboard/DashboardHeaderRequests.test.tsx +64 -1
  85. package/Tests/App/Dashboard/DashboardHeaderSmallScreens.test.tsx +178 -18
  86. package/Tests/App/Dashboard/DashboardMasterPageSsoRedirect.test.tsx +444 -0
  87. package/Tests/App/Dashboard/DashboardNavBarForeignHiddenRule.test.tsx +573 -0
  88. package/Tests/App/Dashboard/DatabaseChartAxes.test.tsx +431 -0
  89. package/Tests/App/Dashboard/DatabaseContainerDetailBadge.test.tsx +249 -0
  90. package/Tests/App/Dashboard/DatabaseDashboardE2EFixes.test.ts +681 -0
  91. package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +1801 -0
  92. package/Tests/App/Dashboard/DatabaseFleetSummaryRefresh.test.tsx +280 -0
  93. package/Tests/App/Dashboard/DatabaseFormHelpers.test.tsx +321 -0
  94. package/Tests/App/Dashboard/DatabaseMetricMonitorLink.test.tsx +795 -0
  95. package/Tests/App/Dashboard/DatabaseOverviewHeader.test.tsx +580 -0
  96. package/Tests/App/Dashboard/DatabaseRecommendationsEngineMetrics.test.ts +270 -0
  97. package/Tests/App/Dashboard/DatabaseServerComponents.test.tsx +1486 -0
  98. package/Tests/App/Dashboard/DatabaseServerEngineMetricsFilter.test.ts +223 -0
  99. package/Tests/App/Dashboard/DatabaseServerPresentation.test.ts +435 -0
  100. package/Tests/App/Dashboard/DatabaseServerSummary.test.ts +186 -0
  101. package/Tests/App/Dashboard/DatabaseServerTelemetryQueries.test.ts +1553 -0
  102. package/Tests/App/Dashboard/DatabaseTableTruncation.test.tsx +326 -0
  103. package/Tests/App/Dashboard/DatabaseTelemetryScope.test.ts +512 -0
  104. package/Tests/App/Dashboard/DatabaseTelemetryTabs.test.tsx +774 -0
  105. package/Tests/App/Dashboard/DatabaseViewLayout.test.tsx +233 -0
  106. package/Tests/App/Dashboard/DatabaseWorkloadBadge.test.tsx +972 -0
  107. package/Tests/App/Dashboard/DiscoveryReviewInventoryRefresh.test.tsx +5 -0
  108. package/Tests/App/Dashboard/DiscoveryReviewUnnamedHostHint.test.tsx +1061 -0
  109. package/Tests/App/Dashboard/EventOverlayScope.test.ts +147 -0
  110. package/Tests/App/Dashboard/EventOverlayScopeHook.test.tsx +1 -0
  111. package/Tests/App/Dashboard/LogsResourceFacetChips.test.tsx +1 -0
  112. package/Tests/App/Dashboard/MetricsViewerDrillDown.test.tsx +310 -0
  113. package/Tests/App/Dashboard/MetricsViewerEntityScopedRows.test.tsx +495 -0
  114. package/Tests/App/Dashboard/MonitorCreateFromMonitorBackedDevice.test.tsx +132 -1
  115. package/Tests/App/Dashboard/NavigationSearchKeywords.test.tsx +5 -0
  116. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  117. package/Tests/App/Dashboard/ProductOrientation.test.tsx +4 -3
  118. package/Tests/App/Dashboard/RecommendationsLoadedContext.test.tsx +349 -0
  119. package/Tests/App/Dashboard/ResourceArchiveSettings.test.tsx +62 -0
  120. package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +8 -1
  121. package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +14 -0
  122. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  123. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  124. package/Tests/App/Dashboard/ScheduledMaintenanceOverviewPage.test.tsx +2 -0
  125. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +1085 -0
  126. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  127. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  128. package/Tests/App/Dashboard/TelemetryResourceRetentionSettings.test.tsx +10 -0
  129. package/Tests/App/Dashboard/TracesResourceFacets.test.tsx +2 -1
  130. package/Tests/App/StatusPage/StatusPageHeaderForeignHiddenRule.test.tsx +284 -0
  131. package/Tests/ClassStrings.ts +178 -0
  132. package/Tests/Fixtures/MobilePasskeyUser.json +1 -2
  133. package/Tests/ForeignHiddenRuleGuard.ts +2152 -0
  134. package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +2250 -0
  135. package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +24 -5
  136. package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
  137. package/Tests/RenderedMarkup.ts +518 -0
  138. package/Tests/ResponsiveVisibility.test.ts +490 -4
  139. package/Tests/ResponsiveVisibility.ts +505 -52
  140. package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
  141. package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +541 -0
  142. package/Tests/Server/Infrastructure/Postgres/AddUserProjectSsoConsentMigration.test.ts +234 -0
  143. package/Tests/Server/Services/ContainerSnapshotUpsertPostgres.test.ts +319 -0
  144. package/Tests/Server/Services/CrossProjectRelationWriteGuard.test.ts +13 -0
  145. package/Tests/Server/Services/DatabaseServerEndpointService.test.ts +2033 -0
  146. package/Tests/Server/Services/DatabaseServerFeedAndOwnerServices.test.ts +529 -0
  147. package/Tests/Server/Services/DatabaseServerLifecycle.test.ts +3302 -0
  148. package/Tests/Server/Services/DatabaseServerRegistries.test.ts +603 -0
  149. package/Tests/Server/Services/DatabaseServerRuleEngines.test.ts +754 -0
  150. package/Tests/Server/Services/DatabaseServerService.test.ts +3558 -0
  151. package/Tests/Server/Services/DatabaseServerSqlPostgres.test.ts +2045 -0
  152. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlockedPostgres.test.ts +362 -0
  153. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlockedReply.test.ts +210 -0
  154. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +18 -0
  155. package/Tests/Server/Services/IncidentAlertSloLinkSurvivesEdit.test.ts +2 -0
  156. package/Tests/Server/Services/IncomingCallPolicyEscalationRuleTarget.test.ts +225 -0
  157. package/Tests/Server/Services/InventoryItemDisplayName.test.ts +280 -2
  158. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +8 -2
  159. package/Tests/Server/Services/IoTDeviceLatestMetricsPostgres.test.ts +689 -0
  160. package/Tests/Server/Services/IoTDeviceService.test.ts +379 -0
  161. package/Tests/Server/Services/LatestMetricsValuesShape.test.ts +360 -0
  162. package/Tests/Server/Services/LlmModelPriceService.test.ts +402 -0
  163. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  164. package/Tests/Server/Services/OpenTelemetryResourceRetentionMemo.test.ts +7 -1
  165. package/Tests/Server/Services/OwnerRowUniqueness.test.ts +61 -9
  166. package/Tests/Server/Services/ReminderRuleLookupPostgres.test.ts +2250 -0
  167. package/Tests/Server/Services/ReminderRuleRefreshOpenSubjectsPaging.test.ts +653 -0
  168. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +68 -20
  169. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  170. package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +3 -3
  171. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  172. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  173. package/Tests/Server/Services/TelemetryResourceFacetCounting.test.ts +9 -0
  174. package/Tests/Server/Services/UserProjectSsoConsentPostgres.test.ts +153 -0
  175. package/Tests/Server/Services/UserProjectSsoConsentService.test.ts +192 -0
  176. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +71 -0
  177. package/Tests/Server/Services/UserSignupIdTakeoverPostgres.test.ts +238 -0
  178. package/Tests/Server/Services/WorkspaceNotificationSummaryHelpers.test.ts +592 -0
  179. package/Tests/Server/Types/Billing/PayAsYouGoMeteredAuthorization.test.ts +3 -0
  180. package/Tests/Server/Types/Database/EventOverlayScopePostgres.test.ts +1 -0
  181. package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +11 -0
  182. package/Tests/Server/Types/Database/Permissions/InfrastructureOwnershipScope.test.ts +8 -0
  183. package/Tests/Server/Types/Database/QueryHelperBooleanForCriteriaBackedRule.test.ts +1094 -0
  184. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  185. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  186. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  187. package/Tests/Server/Utils/AI/ResourceTools.test.ts +134 -0
  188. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  189. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.ts +41 -0
  190. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +279 -9
  191. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaDisplayContext.test.ts +933 -0
  192. package/Tests/Server/Utils/Monitor/DatabaseMaintenanceSuppression.test.ts +582 -0
  193. package/Tests/Server/Utils/Monitor/DatabaseServerMonitorLinking.test.ts +753 -0
  194. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  195. package/Tests/Server/Utils/Monitor/MonitorActionTimestamps.test.ts +1 -0
  196. package/Tests/Server/Utils/Monitor/MonitorAlertCriteriaReferences.test.ts +1 -0
  197. package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +119 -0
  198. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +60 -25
  199. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +1815 -0
  200. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorKubernetesAffectedResources.test.ts +1447 -0
  201. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorKubernetesRatioEmail.test.ts +352 -0
  202. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformMetricUnits.test.ts +717 -0
  203. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformRawScan.test.ts +2125 -0
  204. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformSeriesAffectedResources.test.ts +1288 -0
  205. package/Tests/Server/Utils/Monitor/MonitorDependencySuppressionCreatorSkip.test.ts +1 -0
  206. package/Tests/Server/Utils/Monitor/MonitorIncidentCriteriaReferences.test.ts +1 -0
  207. package/Tests/Server/Utils/Monitor/MonitorIncidentMemberRoleMembership.test.ts +1 -0
  208. package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +88 -0
  209. package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +205 -0
  210. package/Tests/Server/Utils/Monitor/MonitorResourceContext.test.ts +62 -0
  211. package/Tests/Server/Utils/Monitor/MonitorResourceDatabaseMaintenance.test.ts +803 -0
  212. package/Tests/Server/Utils/Monitor/MonitorStepResourceIdentity.test.ts +133 -3
  213. package/Tests/Server/Utils/Monitor/MonitorSummaryPersistence.test.ts +1 -0
  214. package/Tests/Server/Utils/Monitor/PerSeriesRecoveryResolution.test.ts +472 -1
  215. package/Tests/Server/Utils/Monitor/PerSeriesSecondEntityAlerting.test.ts +1 -0
  216. package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +236 -2
  217. package/Tests/Server/Utils/SeriesResourceLabels.test.ts +70 -0
  218. package/Tests/Server/Utils/StartServerProtobufContentType.test.ts +54 -0
  219. package/Tests/Server/Utils/Telemetry/DatabaseEndpointDiscovery.test.ts +1895 -0
  220. package/Tests/Server/Utils/Telemetry/EntityRegistry.test.ts +7 -0
  221. package/Tests/Server/Utils/Telemetry/LogExceptionExtractorSeverityGate.test.ts +90 -0
  222. package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +570 -0
  223. package/Tests/Server/Utils/Telemetry/ResourceFacetResolver.test.ts +97 -2
  224. package/Tests/Server/Utils/Telemetry/ServiceDependencyDiscovery.test.ts +1337 -0
  225. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +295 -0
  226. package/Tests/Types/AI/AIResourceContext.test.ts +30 -0
  227. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  228. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  229. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  230. package/Tests/Types/DatabaseServer/DatabaseContainerClassifier.test.ts +3644 -0
  231. package/Tests/Types/DatabaseServer/DatabaseContainerCommand.test.ts +340 -0
  232. package/Tests/Types/DatabaseServer/DatabaseContainerCommandCorpus.ts +826 -0
  233. package/Tests/Types/DatabaseServer/DatabaseContainerCommandPostgres.test.ts +150 -0
  234. package/Tests/Types/DatabaseServer/DatabaseEndpoint.test.ts +2657 -0
  235. package/Tests/Types/DatabaseServer/DatabaseServerDiscoverySource.test.ts +64 -0
  236. package/Tests/Types/DatabaseServer/DatabaseServerEntityKeys.test.ts +952 -0
  237. package/Tests/Types/DatabaseServer/DatabaseServerMetricCatalog.test.ts +1161 -0
  238. package/Tests/Types/DatabaseServer/DatabaseSystem.test.ts +1514 -0
  239. package/Tests/Types/DatabaseServer/DatabaseTelemetryResolver.test.ts +1558 -0
  240. package/Tests/Types/Monitor/CephAlertTemplates.test.ts +255 -6
  241. package/Tests/Types/Monitor/DatabaseAlertTemplates.test.ts +2254 -0
  242. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +292 -0
  243. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +272 -0
  244. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +253 -9
  245. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCoverage.test.ts +13 -5
  246. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationNotificationMode.test.ts +4 -2
  247. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +111 -2
  248. package/Tests/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.test.ts +61 -0
  249. package/Tests/Types/Monitor/SeriesContext/SeriesLabelDisplay.test.ts +114 -0
  250. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +202 -0
  251. package/Tests/Types/NetworkDevice/DiscoveredHostNamingStatus.test.ts +401 -0
  252. package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +4 -1
  253. package/Tests/Types/Telemetry/ResourceFacetCatalog.test.ts +24 -2
  254. package/Tests/UI/Components/Card.test.tsx +93 -2
  255. package/Tests/UI/Components/FeedOptions.test.ts +6 -0
  256. package/Tests/UI/Components/FeedOptionsButton.test.tsx +8 -1
  257. package/Tests/UI/Components/ForeignHiddenRuleResilience.test.tsx +1147 -0
  258. package/Tests/UI/Components/HeaderRightRail.test.tsx +41 -1
  259. package/Tests/UI/Components/LogsEntityNames.test.ts +16 -1
  260. package/Tests/UI/Components/LogsFacetSidebarHiddenFacets.test.tsx +7 -3
  261. package/Tests/UI/Components/ResourceFacetConfigs.test.ts +2 -1
  262. package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
  263. package/Tests/UI/Components/SrOnlyForeignRule.test.tsx +994 -0
  264. package/Tests/UI/Components/StatusPage/ResourceListSelection.test.tsx +34 -1
  265. package/Tests/UI/Components/TelemetryFacetSidebarHiddenFacets.test.tsx +4 -1
  266. package/Tests/UI/ForeignHiddenRuleGuard.test.ts +1951 -0
  267. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  268. package/Tests/UI/Rum/ReplayPlayerChrome.test.tsx +19 -1
  269. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  270. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  271. package/Tests/UI/Rum/ReplayTabSwitcher.test.tsx +19 -1
  272. package/Tests/UI/TailwindPlayCdnForeignHiddenRule.test.ts +1005 -0
  273. package/Tests/UI/ThemeGroupHoverReveal.test.ts +847 -0
  274. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +70 -0
  275. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +21 -0
  276. package/Tests/UI/Utils/ModelAPISsoRedirect.test.ts +127 -0
  277. package/Tests/UI/Utils/Navigation.test.ts +102 -0
  278. package/Tests/UI/Utils/Telemetry/TelemetryEntityNames.test.ts +23 -0
  279. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  280. package/Tests/Utils/LabelRuleImportExport.test.ts +5 -0
  281. package/Tests/Utils/MetricUnitUtil.test.ts +227 -2
  282. package/Tests/Utils/Monitor/MetricValueFormatter.test.ts +288 -2
  283. package/Tests/Utils/Monitor/PlatformResourceIdentityAndUnits.test.ts +1559 -0
  284. package/Tests/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.test.ts +2546 -0
  285. package/Tests/Utils/NetworkDiscovery/DiscoveredHostUtil.test.ts +315 -0
  286. package/Tests/Utils/NetworkDiscovery/DiscoveryReverseDnsChain.test.ts +336 -1
  287. package/Tests/Utils/NetworkDiscovery/NetbiosNameUtil.test.ts +244 -0
  288. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  289. package/Tests/Utils/ValueFormatter.test.ts +50 -0
  290. package/Tests/tsconfig.json +16 -0
  291. package/Types/AI/AIResourceContext.ts +7 -0
  292. package/Types/BaseDatabase/AggregationType.ts +7 -0
  293. package/Types/Dashboard/DashboardTemplates.ts +22 -8
  294. package/Types/DatabaseServer/DatabaseConnectionSpan.ts +77 -0
  295. package/Types/DatabaseServer/DatabaseContainerClassifier.ts +2068 -0
  296. package/Types/DatabaseServer/DatabaseContainerCommand.ts +801 -0
  297. package/Types/DatabaseServer/DatabaseEndpoint.ts +1943 -0
  298. package/Types/DatabaseServer/DatabaseServerDiscoverySource.ts +68 -0
  299. package/Types/DatabaseServer/DatabaseServerMetricCatalog.ts +966 -0
  300. package/Types/DatabaseServer/DatabaseSystem.ts +2124 -0
  301. package/Types/DatabaseServer/DatabaseTelemetryResolver.ts +555 -0
  302. package/Types/Email/EmailTemplateType.ts +1 -0
  303. package/Types/Monitor/CephAlertTemplates.ts +18 -0
  304. package/Types/Monitor/DatabaseAlertTemplates.ts +2020 -0
  305. package/Types/Monitor/KubernetesAlertTemplates.ts +64 -24
  306. package/Types/Monitor/KubernetesMetricCatalog.ts +118 -19
  307. package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +14 -0
  308. package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +93 -10
  309. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +109 -8
  310. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +36 -7
  311. package/Types/Monitor/Recommendation/MonitorRecommendationUtil.ts +43 -2
  312. package/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.ts +41 -6
  313. package/Types/Monitor/SeriesContext/SeriesLabelDisplay.ts +26 -0
  314. package/Types/NetworkDevice/DiscoveredHostNamingStatus.ts +147 -0
  315. package/Types/Permission.ts +267 -0
  316. package/Types/Probe/ProbeApiIngestResponse.ts +8 -0
  317. package/Types/Rules/RuleCriteriaFieldRegistry.ts +10 -0
  318. package/Types/Rules/RuleRun.ts +12 -0
  319. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  320. package/Types/Rum/WebVitals.ts +13 -0
  321. package/Types/Telemetry/EntityType.ts +23 -0
  322. package/Types/Telemetry/ResourceFacetCatalog.ts +14 -0
  323. package/Types/Telemetry/ServiceType.ts +8 -0
  324. package/UI/Components/Banner/Banner.tsx +1 -1
  325. package/UI/Components/Card/Card.tsx +1 -1
  326. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +2 -2
  327. package/UI/Components/Charts/Line/LineChart.tsx +7 -0
  328. package/UI/Components/CommandPalette/CommandPalette.tsx +1 -1
  329. package/UI/Components/Date/StartAndEndDate.tsx +5 -5
  330. package/UI/Components/Diagram/HorizontalStepChain.tsx +1 -1
  331. package/UI/Components/Feed/FeedOptionsButton.tsx +1 -1
  332. package/UI/Components/Forms/BasicForm.tsx +1 -1
  333. package/UI/Components/Header/Header.tsx +1 -1
  334. package/UI/Components/Header/HeaderIconDropdownButton.tsx +1 -1
  335. package/UI/Components/HeaderAlert/HeaderAlert.tsx +1 -1
  336. package/UI/Components/Input/Input.tsx +11 -0
  337. package/UI/Components/LogsViewer/components/FacetValueRow.tsx +1 -1
  338. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +1 -1
  339. package/UI/Components/Modal/ModalFooter.tsx +1 -1
  340. package/UI/Components/ModelDetail/CardModelDetail.tsx +2 -2
  341. package/UI/Components/ModelTable/BaseModelTable.tsx +4 -4
  342. package/UI/Components/Navbar/NavBar.tsx +9 -2
  343. package/UI/Components/Navbar/NavBarMenuModal.tsx +2 -2
  344. package/UI/Components/Page/Page.tsx +1 -1
  345. package/UI/Components/Pagination/Pagination.tsx +3 -3
  346. package/UI/Components/ProgressButtons/ProgressButtonItem.tsx +1 -1
  347. package/UI/Components/SideMenu/SideMenu.tsx +4 -1
  348. package/UI/Components/StatusPage/ResourceGroupNavigator.tsx +4 -2
  349. package/UI/Components/StatusPage/ResourceList.tsx +6 -6
  350. package/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.tsx +1 -1
  351. package/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.tsx +1 -1
  352. package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +1 -1
  353. package/UI/Components/Workflow/ComponentsModal.tsx +2 -2
  354. package/UI/Components/Workflow/StepTraceViewer.tsx +1 -1
  355. package/UI/Styles/Theme.css +8 -1
  356. package/UI/Utils/Navigation.ts +89 -42
  357. package/UI/Utils/Telemetry/TelemetryEntityNames.ts +7 -0
  358. package/UI/Utils/TelemetryService.ts +4 -3
  359. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  360. package/Utils/LabelRuleImportExport.ts +2 -0
  361. package/Utils/MetricUnitUtil.ts +103 -11
  362. package/Utils/Monitor/MetricValueFormatter.ts +66 -13
  363. package/Utils/Monitor/PlatformMetricUnitUtil.ts +215 -0
  364. package/Utils/Monitor/PlatformResourceIdentity.ts +514 -0
  365. package/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.ts +425 -0
  366. package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +32 -0
  367. package/Utils/NetworkDiscovery/NetbiosNameUtil.ts +73 -0
  368. package/Utils/Telemetry/DatabaseServerEntityKeys.ts +275 -0
  369. package/Utils/Telemetry/EntityKey.ts +176 -0
  370. package/Utils/ValueFormatter.ts +16 -0
  371. package/build/dist/Models/DatabaseModels/Alert.js +54 -0
  372. package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
  373. package/build/dist/Models/DatabaseModels/DatabaseServer.js +1683 -0
  374. package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -0
  375. package/build/dist/Models/DatabaseModels/DatabaseServerEndpoint.js +559 -0
  376. package/build/dist/Models/DatabaseModels/DatabaseServerEndpoint.js.map +1 -0
  377. package/build/dist/Models/DatabaseModels/DatabaseServerFeed.js +648 -0
  378. package/build/dist/Models/DatabaseModels/DatabaseServerFeed.js.map +1 -0
  379. package/build/dist/Models/DatabaseModels/DatabaseServerLabelRule.js +522 -0
  380. package/build/dist/Models/DatabaseModels/DatabaseServerLabelRule.js.map +1 -0
  381. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerRule.js +603 -0
  382. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerRule.js.map +1 -0
  383. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerTeam.js +506 -0
  384. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerTeam.js.map +1 -0
  385. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerUser.js +505 -0
  386. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerUser.js.map +1 -0
  387. package/build/dist/Models/DatabaseModels/Incident.js +54 -0
  388. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  389. package/build/dist/Models/DatabaseModels/Index.js +16 -0
  390. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  391. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  392. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +54 -0
  393. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  394. package/build/dist/Models/DatabaseModels/TelemetryException.js +2 -0
  395. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  396. package/build/dist/Models/DatabaseModels/UserProjectSsoConsent.js +162 -0
  397. package/build/dist/Models/DatabaseModels/UserProjectSsoConsent.js.map +1 -0
  398. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.js +240 -0
  399. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.js.map +1 -0
  400. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.js +26 -0
  401. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.js.map +1 -0
  402. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  403. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  404. package/build/dist/Server/Middleware/MultipartFormData.js +1 -1
  405. package/build/dist/Server/Middleware/TelemetryIngest.js +15 -8
  406. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  407. package/build/dist/Server/Services/AlertReminderRuleService.js +72 -23
  408. package/build/dist/Server/Services/AlertReminderRuleService.js.map +1 -1
  409. package/build/dist/Server/Services/DatabaseServerEndpointService.js +694 -0
  410. package/build/dist/Server/Services/DatabaseServerEndpointService.js.map +1 -0
  411. package/build/dist/Server/Services/DatabaseServerFeedService.js +110 -0
  412. package/build/dist/Server/Services/DatabaseServerFeedService.js.map +1 -0
  413. package/build/dist/Server/Services/DatabaseServerLabelRuleEngineService.js +272 -0
  414. package/build/dist/Server/Services/DatabaseServerLabelRuleEngineService.js.map +1 -0
  415. package/build/dist/Server/Services/DatabaseServerLabelRuleService.js +13 -0
  416. package/build/dist/Server/Services/DatabaseServerLabelRuleService.js.map +1 -0
  417. package/build/dist/Server/Services/DatabaseServerOwnerRuleEngineService.js +340 -0
  418. package/build/dist/Server/Services/DatabaseServerOwnerRuleEngineService.js.map +1 -0
  419. package/build/dist/Server/Services/DatabaseServerOwnerRuleService.js +13 -0
  420. package/build/dist/Server/Services/DatabaseServerOwnerRuleService.js.map +1 -0
  421. package/build/dist/Server/Services/DatabaseServerOwnerTeamService.js +160 -0
  422. package/build/dist/Server/Services/DatabaseServerOwnerTeamService.js.map +1 -0
  423. package/build/dist/Server/Services/DatabaseServerOwnerUserService.js +153 -0
  424. package/build/dist/Server/Services/DatabaseServerOwnerUserService.js.map +1 -0
  425. package/build/dist/Server/Services/DatabaseServerService.js +3272 -0
  426. package/build/dist/Server/Services/DatabaseServerService.js.map +1 -0
  427. package/build/dist/Server/Services/DatabaseService.js +8 -4
  428. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  429. package/build/dist/Server/Services/DockerResourceService.js +30 -3
  430. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  431. package/build/dist/Server/Services/IncidentReminderRuleService.js +73 -23
  432. package/build/dist/Server/Services/IncidentReminderRuleService.js.map +1 -1
  433. package/build/dist/Server/Services/Index.js +16 -0
  434. package/build/dist/Server/Services/Index.js.map +1 -1
  435. package/build/dist/Server/Services/InventoryItemService.js +88 -5
  436. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  437. package/build/dist/Server/Services/IoTDeviceService.js +111 -24
  438. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  439. package/build/dist/Server/Services/MetricService.js +1 -1
  440. package/build/dist/Server/Services/OpenTelemetryIngestService.js +24 -9
  441. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  442. package/build/dist/Server/Services/PodmanResourceService.js +30 -3
  443. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  444. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js +75 -23
  445. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js.map +1 -1
  446. package/build/dist/Server/Services/ServiceService.js +1 -1
  447. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  448. package/build/dist/Server/Services/TelemetryUsageBillingService.js +23 -2
  449. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  450. package/build/dist/Server/Services/UserProjectSsoConsentService.js +79 -0
  451. package/build/dist/Server/Services/UserProjectSsoConsentService.js.map +1 -0
  452. package/build/dist/Server/Services/UserService.js +13 -1
  453. package/build/dist/Server/Services/UserService.js.map +1 -1
  454. package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js +18 -0
  455. package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js.map +1 -1
  456. package/build/dist/Server/Types/Database/QueryHelper.js +73 -2
  457. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  458. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  459. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  460. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +58 -1
  461. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  462. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  463. package/build/dist/Server/Utils/Database/AffectedResourceRelations.js +6 -0
  464. package/build/dist/Server/Utils/Database/AffectedResourceRelations.js.map +1 -1
  465. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  466. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  467. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +135 -14
  468. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  469. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -6
  470. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  471. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1489 -524
  472. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  473. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +22 -6
  474. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  475. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +196 -6
  476. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
  477. package/build/dist/Server/Utils/Monitor/MonitorResource.js +36 -9
  478. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  479. package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js +1 -0
  480. package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js.map +1 -1
  481. package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js +175 -17
  482. package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js.map +1 -1
  483. package/build/dist/Server/Utils/Monitor/PerSeriesResolutionRootCause.js +29 -0
  484. package/build/dist/Server/Utils/Monitor/PerSeriesResolutionRootCause.js.map +1 -0
  485. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +211 -0
  486. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  487. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +82 -10
  488. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -1
  489. package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +25 -0
  490. package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
  491. package/build/dist/Server/Utils/StartServer.js +20 -4
  492. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  493. package/build/dist/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.js +2 -0
  494. package/build/dist/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.js.map +1 -1
  495. package/build/dist/Server/Utils/Telemetry/DatabaseEndpointDiscovery.js +442 -0
  496. package/build/dist/Server/Utils/Telemetry/DatabaseEndpointDiscovery.js.map +1 -0
  497. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +5 -1
  498. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  499. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js +15 -1
  500. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js.map +1 -1
  501. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +121 -5
  502. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -1
  503. package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js +12 -0
  504. package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js.map +1 -1
  505. package/build/dist/Server/Utils/Telemetry/ServiceDependencyDiscovery.js +438 -36
  506. package/build/dist/Server/Utils/Telemetry/ServiceDependencyDiscovery.js.map +1 -1
  507. package/build/dist/Server/Utils/Telemetry/Telemetry.js +12 -0
  508. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  509. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +17 -18
  510. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  511. package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js +16 -10
  512. package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js.map +1 -1
  513. package/build/dist/Types/AI/AIResourceContext.js +7 -0
  514. package/build/dist/Types/AI/AIResourceContext.js.map +1 -1
  515. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  516. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  517. package/build/dist/Types/Dashboard/DashboardTemplates.js +22 -8
  518. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  519. package/build/dist/Types/DatabaseServer/DatabaseConnectionSpan.js +71 -0
  520. package/build/dist/Types/DatabaseServer/DatabaseConnectionSpan.js.map +1 -0
  521. package/build/dist/Types/DatabaseServer/DatabaseContainerClassifier.js +1462 -0
  522. package/build/dist/Types/DatabaseServer/DatabaseContainerClassifier.js.map +1 -0
  523. package/build/dist/Types/DatabaseServer/DatabaseContainerCommand.js +691 -0
  524. package/build/dist/Types/DatabaseServer/DatabaseContainerCommand.js.map +1 -0
  525. package/build/dist/Types/DatabaseServer/DatabaseEndpoint.js +1407 -0
  526. package/build/dist/Types/DatabaseServer/DatabaseEndpoint.js.map +1 -0
  527. package/build/dist/Types/DatabaseServer/DatabaseServerDiscoverySource.js +60 -0
  528. package/build/dist/Types/DatabaseServer/DatabaseServerDiscoverySource.js.map +1 -0
  529. package/build/dist/Types/DatabaseServer/DatabaseServerMetricCatalog.js +804 -0
  530. package/build/dist/Types/DatabaseServer/DatabaseServerMetricCatalog.js.map +1 -0
  531. package/build/dist/Types/DatabaseServer/DatabaseSystem.js +1860 -0
  532. package/build/dist/Types/DatabaseServer/DatabaseSystem.js.map +1 -0
  533. package/build/dist/Types/DatabaseServer/DatabaseTelemetryResolver.js +406 -0
  534. package/build/dist/Types/DatabaseServer/DatabaseTelemetryResolver.js.map +1 -0
  535. package/build/dist/Types/Email/EmailTemplateType.js +1 -0
  536. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  537. package/build/dist/Types/Monitor/CephAlertTemplates.js +18 -0
  538. package/build/dist/Types/Monitor/CephAlertTemplates.js.map +1 -1
  539. package/build/dist/Types/Monitor/DatabaseAlertTemplates.js +1518 -0
  540. package/build/dist/Types/Monitor/DatabaseAlertTemplates.js.map +1 -0
  541. package/build/dist/Types/Monitor/KubernetesAlertTemplates.js +57 -24
  542. package/build/dist/Types/Monitor/KubernetesAlertTemplates.js.map +1 -1
  543. package/build/dist/Types/Monitor/KubernetesMetricCatalog.js +104 -18
  544. package/build/dist/Types/Monitor/KubernetesMetricCatalog.js.map +1 -1
  545. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +80 -3
  546. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  547. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +10 -3
  548. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  549. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js +29 -0
  550. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js.map +1 -1
  551. package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js +21 -6
  552. package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js.map +1 -1
  553. package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js +25 -0
  554. package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js.map +1 -1
  555. package/build/dist/Types/NetworkDevice/DiscoveredHostNamingStatus.js +136 -0
  556. package/build/dist/Types/NetworkDevice/DiscoveredHostNamingStatus.js.map +1 -0
  557. package/build/dist/Types/Permission.js +234 -0
  558. package/build/dist/Types/Permission.js.map +1 -1
  559. package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +10 -0
  560. package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js.map +1 -1
  561. package/build/dist/Types/Rules/RuleRun.js +4 -0
  562. package/build/dist/Types/Rules/RuleRun.js.map +1 -1
  563. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  564. package/build/dist/Types/Rum/WebVitals.js +12 -0
  565. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  566. package/build/dist/Types/Telemetry/EntityType.js +23 -0
  567. package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
  568. package/build/dist/Types/Telemetry/ResourceFacetCatalog.js +14 -0
  569. package/build/dist/Types/Telemetry/ResourceFacetCatalog.js.map +1 -1
  570. package/build/dist/Types/Telemetry/ServiceType.js +8 -0
  571. package/build/dist/Types/Telemetry/ServiceType.js.map +1 -1
  572. package/build/dist/UI/Components/Banner/Banner.js +1 -1
  573. package/build/dist/UI/Components/Banner/Banner.js.map +1 -1
  574. package/build/dist/UI/Components/Card/Card.js +1 -1
  575. package/build/dist/UI/Components/Card/Card.js.map +1 -1
  576. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +2 -2
  577. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  578. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  579. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  580. package/build/dist/UI/Components/CommandPalette/CommandPalette.js +1 -1
  581. package/build/dist/UI/Components/CommandPalette/CommandPalette.js.map +1 -1
  582. package/build/dist/UI/Components/Date/StartAndEndDate.js +5 -5
  583. package/build/dist/UI/Components/Date/StartAndEndDate.js.map +1 -1
  584. package/build/dist/UI/Components/Diagram/HorizontalStepChain.js +1 -1
  585. package/build/dist/UI/Components/Diagram/HorizontalStepChain.js.map +1 -1
  586. package/build/dist/UI/Components/Feed/FeedOptionsButton.js +1 -1
  587. package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -1
  588. package/build/dist/UI/Components/Forms/BasicForm.js +1 -1
  589. package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
  590. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +1 -1
  591. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  592. package/build/dist/UI/Components/HeaderAlert/HeaderAlert.js +1 -1
  593. package/build/dist/UI/Components/HeaderAlert/HeaderAlert.js.map +1 -1
  594. package/build/dist/UI/Components/Input/Input.js +1 -1
  595. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  596. package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js +1 -1
  597. package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js.map +1 -1
  598. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +1 -1
  599. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
  600. package/build/dist/UI/Components/Modal/ModalFooter.js +1 -1
  601. package/build/dist/UI/Components/Modal/ModalFooter.js.map +1 -1
  602. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +2 -2
  603. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
  604. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +4 -4
  605. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  606. package/build/dist/UI/Components/Navbar/NavBar.js +9 -2
  607. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  608. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +2 -2
  609. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  610. package/build/dist/UI/Components/Page/Page.js +1 -1
  611. package/build/dist/UI/Components/Page/Page.js.map +1 -1
  612. package/build/dist/UI/Components/Pagination/Pagination.js +3 -3
  613. package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
  614. package/build/dist/UI/Components/ProgressButtons/ProgressButtonItem.js +1 -1
  615. package/build/dist/UI/Components/ProgressButtons/ProgressButtonItem.js.map +1 -1
  616. package/build/dist/UI/Components/SideMenu/SideMenu.js +4 -1
  617. package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
  618. package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js +3 -1
  619. package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js.map +1 -1
  620. package/build/dist/UI/Components/StatusPage/ResourceList.js +6 -6
  621. package/build/dist/UI/Components/StatusPage/ResourceList.js.map +1 -1
  622. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js +1 -1
  623. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js.map +1 -1
  624. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.js +1 -1
  625. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.js.map +1 -1
  626. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +1 -1
  627. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -1
  628. package/build/dist/UI/Components/Workflow/ComponentsModal.js +2 -2
  629. package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
  630. package/build/dist/UI/Components/Workflow/StepTraceViewer.js +1 -1
  631. package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -1
  632. package/build/dist/UI/Utils/Navigation.js +66 -33
  633. package/build/dist/UI/Utils/Navigation.js.map +1 -1
  634. package/build/dist/UI/Utils/Telemetry/TelemetryEntityNames.js +7 -0
  635. package/build/dist/UI/Utils/Telemetry/TelemetryEntityNames.js.map +1 -1
  636. package/build/dist/UI/Utils/TelemetryService.js +3 -2
  637. package/build/dist/UI/Utils/TelemetryService.js.map +1 -1
  638. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  639. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  640. package/build/dist/Utils/LabelRuleImportExport.js +2 -0
  641. package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
  642. package/build/dist/Utils/MetricUnitUtil.js +77 -13
  643. package/build/dist/Utils/MetricUnitUtil.js.map +1 -1
  644. package/build/dist/Utils/Monitor/MetricValueFormatter.js +58 -13
  645. package/build/dist/Utils/Monitor/MetricValueFormatter.js.map +1 -1
  646. package/build/dist/Utils/Monitor/PlatformMetricUnitUtil.js +161 -0
  647. package/build/dist/Utils/Monitor/PlatformMetricUnitUtil.js.map +1 -0
  648. package/build/dist/Utils/Monitor/PlatformResourceIdentity.js +323 -0
  649. package/build/dist/Utils/Monitor/PlatformResourceIdentity.js.map +1 -0
  650. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.js +270 -0
  651. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.js.map +1 -0
  652. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +23 -0
  653. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -1
  654. package/build/dist/Utils/NetworkDiscovery/NetbiosNameUtil.js +61 -0
  655. package/build/dist/Utils/NetworkDiscovery/NetbiosNameUtil.js.map +1 -1
  656. package/build/dist/Utils/Telemetry/DatabaseServerEntityKeys.js +178 -0
  657. package/build/dist/Utils/Telemetry/DatabaseServerEntityKeys.js.map +1 -0
  658. package/build/dist/Utils/Telemetry/EntityKey.js +133 -0
  659. package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
  660. package/build/dist/Utils/ValueFormatter.js +15 -0
  661. package/build/dist/Utils/ValueFormatter.js.map +1 -1
  662. package/package.json +1 -1
  663. package/tsconfig.tests.json +7 -5
@@ -0,0 +1,3558 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
3
+
4
+ /*
5
+ * PasswordHash carries a pre-existing TS5.9 diagnostic that fails any suite
6
+ * whose runtime require graph reaches it, and DatabaseService imports it.
7
+ */
8
+ jest.mock("../../../Server/Utils/PasswordHash", () => {
9
+ return {
10
+ __esModule: true,
11
+ default: {
12
+ hash: jest.fn(),
13
+ verify: jest.fn(),
14
+ generateSalt: jest.fn(),
15
+ needsUpgrade: jest.fn(),
16
+ applyPepper: jest.fn(),
17
+ },
18
+ };
19
+ });
20
+
21
+ import DatabaseServerService, {
22
+ UpsertWorkloadDatabaseData,
23
+ } from "../../../Server/Services/DatabaseServerService";
24
+ import DatabaseServerEndpointService, {
25
+ DatabaseServerEndpointClaimResult,
26
+ } from "../../../Server/Services/DatabaseServerEndpointService";
27
+ import DatabaseServerFeedService from "../../../Server/Services/DatabaseServerFeedService";
28
+ import DatabaseServerLabelRuleEngineService from "../../../Server/Services/DatabaseServerLabelRuleEngineService";
29
+ import DatabaseServerOwnerRuleEngineService from "../../../Server/Services/DatabaseServerOwnerRuleEngineService";
30
+ import DockerHostService from "../../../Server/Services/DockerHostService";
31
+ import KubernetesClusterService from "../../../Server/Services/KubernetesClusterService";
32
+ import PodmanHostService from "../../../Server/Services/PodmanHostService";
33
+ import UserService from "../../../Server/Services/UserService";
34
+ import DatabaseConfig from "../../../Server/DatabaseConfig";
35
+ import GlobalCache from "../../../Server/Infrastructure/GlobalCache";
36
+ import ResourceHeartbeat from "../../../Server/Utils/Telemetry/ResourceHeartbeat";
37
+ import SingleFlight from "../../../Server/Utils/SingleFlight";
38
+ import logger from "../../../Server/Utils/Logger";
39
+ import DatabaseServer from "../../../Models/DatabaseModels/DatabaseServer";
40
+ import DatabaseServerEndpoint from "../../../Models/DatabaseModels/DatabaseServerEndpoint";
41
+ import { DatabaseServerFeedEventType } from "../../../Models/DatabaseModels/DatabaseServerFeed";
42
+ import KubernetesCluster from "../../../Models/DatabaseModels/KubernetesCluster";
43
+ import Label from "../../../Models/DatabaseModels/Label";
44
+ import URL from "../../../Types/API/URL";
45
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
46
+ import DatabaseServerDiscoverySource from "../../../Types/DatabaseServer/DatabaseServerDiscoverySource";
47
+ import {
48
+ DatabaseEndpoint,
49
+ ManualDatabaseEndpoint,
50
+ isKubernetesServiceDnsHost,
51
+ parseManualDatabaseEndpoint,
52
+ } from "../../../Types/DatabaseServer/DatabaseEndpoint";
53
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
54
+ import BadDataException from "../../../Types/Exception/BadDataException";
55
+ import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
56
+ import ObjectID from "../../../Types/ObjectID";
57
+ import Permission, { UserPermission } from "../../../Types/Permission";
58
+ import PositiveNumber from "../../../Types/PositiveNumber";
59
+ import { getJestSpyOn } from "../../Spy";
60
+ import crypto from "crypto";
61
+
62
+ /*
63
+ * DatabaseServerService - the Databases product's root service.
64
+ *
65
+ * Pinned here, everything external mocked at its seam (no Postgres, no Redis):
66
+ *
67
+ * - a MANUAL create through the real create pipeline, column permission
68
+ * check included: engine normalized, endpoint canonicalized, identity
69
+ * computed, discoverySource forced to manual, the primary endpoint
70
+ * claimed, and friendly refusals for every conflict;
71
+ * - findOrCreateByEndpoint: owner first, LOCAL-scope endpoints and
72
+ * allowCreate=false never create, the identifier race and the endpoint
73
+ * race (orphan removed, owner returned, no trace of the orphan);
74
+ * - upsertWorkloadDatabase: lookup by workload, ADOPTION only for exactly
75
+ * one owner that is not another workload's, aliases never taken from
76
+ * another database, member-key merge, never touching the collector
77
+ * status, never throwing;
78
+ * - the two liveness heartbeats (collector vs sighting);
79
+ * - un-archiving only what discovery archived itself;
80
+ * - the stale-collector, auto-archive and budget SQL, each scoping
81
+ * project + deletedAt itself.
82
+ */
83
+
84
+ const PROJECT_ID: ObjectID = new ObjectID(
85
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
86
+ );
87
+ const USER_ID: ObjectID = new ObjectID("dddddddd-dddd-4ddd-8ddd-dddddddddddd");
88
+ const OTHER_DATABASE_ID: ObjectID = new ObjectID(
89
+ "22222222-2222-4222-8222-222222222222",
90
+ );
91
+
92
+ const CLUSTER_ID: ObjectID = new ObjectID(
93
+ "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
94
+ );
95
+
96
+ const ORDERS_ENDPOINT: DatabaseEndpoint = {
97
+ host: "orders-db.example.com",
98
+ port: 5432,
99
+ };
100
+
101
+ const MEMBER_KEY_A: string = "0123456789abcdef";
102
+ const MEMBER_KEY_B: string = "fedcba9876543210";
103
+ const MEMBER_KEY_OLD: string = "00000000000000aa";
104
+
105
+ const service: any = DatabaseServerService;
106
+
107
+ function userPermission(permission: Permission): UserPermission {
108
+ return {
109
+ permission,
110
+ labelIds: [],
111
+ isBlockPermission: false,
112
+ _type: "UserPermission",
113
+ };
114
+ }
115
+
116
+ function memberProps(
117
+ permissions: Array<Permission> = [Permission.ProjectMember],
118
+ ): DatabaseCommonInteractionProps {
119
+ return {
120
+ userId: USER_ID,
121
+ tenantId: PROJECT_ID,
122
+ userGlobalAccessPermission: {
123
+ projectIds: [PROJECT_ID],
124
+ globalPermissions: [Permission.Public, Permission.User],
125
+ _type: "UserGlobalAccessPermission",
126
+ },
127
+ userTenantAccessPermission: {
128
+ [PROJECT_ID.toString()]: {
129
+ projectId: PROJECT_ID,
130
+ permissions: permissions.map(userPermission),
131
+ _type: "UserTenantAccessPermission",
132
+ },
133
+ },
134
+ };
135
+ }
136
+
137
+ function databaseRow(overrides: Partial<DatabaseServer> = {}): DatabaseServer {
138
+ const row: DatabaseServer = new DatabaseServer(ObjectID.generate());
139
+ row.projectId = PROJECT_ID;
140
+ row.name = "PostgreSQL orders-db.example.com:5432";
141
+ row.dbSystem = "postgresql";
142
+ row.databaseIdentifier = "postgresql|orders-db.example.com:5432";
143
+ row.isArchived = false;
144
+ Object.assign(row, overrides);
145
+ return row;
146
+ }
147
+
148
+ /** Let the fire-and-forget rule/feed chains drain before asserting on them. */
149
+ function flushPromises(): Promise<void> {
150
+ return new Promise((resolve: () => void) => {
151
+ setTimeout(resolve, 0);
152
+ });
153
+ }
154
+
155
+ function silenceLogs(): {
156
+ error: jest.SpyInstance;
157
+ warn: jest.SpyInstance;
158
+ info: jest.SpyInstance;
159
+ debug: jest.SpyInstance;
160
+ } {
161
+ return {
162
+ error: jest.spyOn(logger, "error").mockImplementation(() => {
163
+ return undefined as never;
164
+ }),
165
+ warn: jest.spyOn(logger, "warn").mockImplementation(() => {
166
+ return undefined as never;
167
+ }),
168
+ info: jest.spyOn(logger, "info").mockImplementation(() => {
169
+ return undefined as never;
170
+ }),
171
+ debug: jest.spyOn(logger, "debug").mockImplementation(() => {
172
+ return undefined as never;
173
+ }),
174
+ };
175
+ }
176
+
177
+ interface SideEffectSpies {
178
+ labelRules: jest.SpyInstance;
179
+ ownerRules: jest.SpyInstance;
180
+ feed: jest.SpyInstance;
181
+ }
182
+
183
+ function mockSideEffects(): SideEffectSpies {
184
+ getJestSpyOn(service, "getDatabaseServerMarkdownLink").mockResolvedValue(
185
+ "[Database PostgreSQL orders-db.example.com:5432](/db)",
186
+ );
187
+ getJestSpyOn(UserService, "getUserMarkdownString").mockResolvedValue(
188
+ "Jane Doe (jane@example.com)",
189
+ );
190
+
191
+ return {
192
+ labelRules: getJestSpyOn(
193
+ DatabaseServerLabelRuleEngineService,
194
+ "applyRulesToDatabaseServer",
195
+ ).mockResolvedValue(undefined),
196
+ ownerRules: getJestSpyOn(
197
+ DatabaseServerOwnerRuleEngineService,
198
+ "applyRulesToDatabaseServer",
199
+ ).mockResolvedValue(undefined),
200
+ feed: getJestSpyOn(
201
+ DatabaseServerFeedService,
202
+ "createDatabaseServerFeedItem",
203
+ ).mockResolvedValue(undefined),
204
+ };
205
+ }
206
+
207
+ // Raw SQL issued through getRepository().manager.query.
208
+ function mockRawQuery(result: unknown = []): jest.Mock {
209
+ const query: jest.Mock = jest.fn(async () => {
210
+ return result;
211
+ });
212
+ getJestSpyOn(service, "getRepository").mockReturnValue({
213
+ manager: { query },
214
+ } as never);
215
+ return query;
216
+ }
217
+
218
+ function withEnv(name: string, value: string | undefined): () => void {
219
+ const previous: string | undefined = process.env[name];
220
+ if (value === undefined) {
221
+ delete process.env[name];
222
+ } else {
223
+ process.env[name] = value;
224
+ }
225
+ return () => {
226
+ if (previous === undefined) {
227
+ delete process.env[name];
228
+ } else {
229
+ process.env[name] = previous;
230
+ }
231
+ };
232
+ }
233
+
234
+ afterEach(() => {
235
+ jest.restoreAllMocks();
236
+ });
237
+
238
+ /*
239
+ * ---------------------------------------------------------------------------
240
+ * A person adding a database - the real create pipeline
241
+ * ---------------------------------------------------------------------------
242
+ */
243
+ describe("DatabaseServerService - manual create (real create pipeline)", () => {
244
+ let save: jest.Mock;
245
+ let findOwner: jest.SpyInstance;
246
+ let findSameIdentity: jest.SpyInstance;
247
+ let claim: jest.SpyInstance;
248
+ let deleteBy: jest.SpyInstance;
249
+ let sideEffects: SideEffectSpies;
250
+
251
+ function manualRequest(overrides: Partial<DatabaseServer> = {}): {
252
+ data: DatabaseServer;
253
+ props: DatabaseCommonInteractionProps;
254
+ } {
255
+ const data: DatabaseServer = new DatabaseServer();
256
+ data.dbSystem = "postgres";
257
+ data.serverAddress = "Orders-DB.Example.com";
258
+ Object.assign(data, overrides);
259
+ return { data: data, props: memberProps() };
260
+ }
261
+
262
+ beforeEach(() => {
263
+ silenceLogs();
264
+ save = jest.fn(async (entity: any) => {
265
+ entity._id = ObjectID.generate().toString();
266
+ return entity;
267
+ });
268
+ getJestSpyOn(service, "getRepository").mockReturnValue({ save } as never);
269
+ getJestSpyOn(service, "countBy").mockResolvedValue(
270
+ new PositiveNumber(0) as never,
271
+ );
272
+ getJestSpyOn(service, "onTriggerWorkflow").mockResolvedValue(
273
+ undefined as never,
274
+ );
275
+ getJestSpyOn(service, "onTriggerRealtime").mockResolvedValue(
276
+ undefined as never,
277
+ );
278
+ findOwner = getJestSpyOn(
279
+ DatabaseServerEndpointService,
280
+ "findOwnerByEndpoint",
281
+ ).mockResolvedValue(null);
282
+ findSameIdentity = getJestSpyOn(service, "findOneBy").mockResolvedValue(
283
+ null,
284
+ );
285
+ claim = getJestSpyOn(
286
+ DatabaseServerEndpointService,
287
+ "claimEndpoint",
288
+ ).mockResolvedValue("claimed");
289
+ deleteBy = getJestSpyOn(service, "deleteBy").mockResolvedValue(1);
290
+ sideEffects = mockSideEffects();
291
+ });
292
+
293
+ test("computes identity from the engine and canonical endpoint, and stamps 'manual'", async () => {
294
+ const created: DatabaseServer = await DatabaseServerService.create(
295
+ manualRequest({
296
+ // A caller cannot pretend a collector found it.
297
+ discoverySource: DatabaseServerDiscoverySource.Collector,
298
+ }),
299
+ );
300
+
301
+ expect(save).toHaveBeenCalledTimes(1);
302
+ expect(created.dbSystem).toBe("postgresql");
303
+ expect(created.serverAddress).toBe("orders-db.example.com");
304
+ expect(created.serverPort).toBe(5432);
305
+ expect(created.databaseIdentifier).toBe(
306
+ "postgresql|orders-db.example.com:5432",
307
+ );
308
+ expect(created.discoverySource).toBe("manual");
309
+ expect(created.name).toBe("PostgreSQL orders-db.example.com:5432");
310
+ expect(created.projectId!.toString()).toBe(PROJECT_ID.toString());
311
+ expect(created.createdByUserId!.toString()).toBe(USER_ID.toString());
312
+ expect(created.slug).toBeTruthy();
313
+ });
314
+
315
+ test("claims the canonical endpoint as the row's primary, added by a person", async () => {
316
+ const created: DatabaseServer =
317
+ await DatabaseServerService.create(manualRequest());
318
+
319
+ expect(claim).toHaveBeenCalledTimes(1);
320
+ const call: any = claim.mock.calls[0]![0];
321
+ expect(call.projectId.toString()).toBe(PROJECT_ID.toString());
322
+ expect(call.databaseServerId.toString()).toBe(created.id!.toString());
323
+ expect(call.endpoint).toBe("orders-db.example.com:5432");
324
+ expect(call.isPrimary).toBe(true);
325
+ expect(call.source).toBe("user");
326
+ });
327
+
328
+ test("runs the label then owner rules and records who created it", async () => {
329
+ const created: DatabaseServer =
330
+ await DatabaseServerService.create(manualRequest());
331
+ await flushPromises();
332
+
333
+ expect(sideEffects.labelRules).toHaveBeenCalledWith(created);
334
+ expect(sideEffects.ownerRules).toHaveBeenCalledWith(created);
335
+ expect(sideEffects.labelRules.mock.invocationCallOrder[0]!).toBeLessThan(
336
+ sideEffects.ownerRules.mock.invocationCallOrder[0]!,
337
+ );
338
+
339
+ const feed: any = sideEffects.feed.mock.calls[0]![0];
340
+ expect(feed.databaseServerFeedEventType).toBe(
341
+ DatabaseServerFeedEventType.DatabaseServerCreated,
342
+ );
343
+ expect(feed.userId.toString()).toBe(USER_ID.toString());
344
+ expect(feed.feedInfoInMarkdown).toContain("was created by");
345
+ expect(feed.moreInformationInMarkdown).toContain(
346
+ "**Discovered from**: Added manually",
347
+ );
348
+ expect(feed.moreInformationInMarkdown).toContain(
349
+ "postgresql|orders-db.example.com:5432",
350
+ );
351
+ });
352
+
353
+ test("keeps a name the person chose", async () => {
354
+ const created: DatabaseServer = await DatabaseServerService.create(
355
+ manualRequest({ name: " Orders primary " }),
356
+ );
357
+
358
+ expect(created.name).toBe("Orders primary");
359
+ });
360
+
361
+ test("the port field wins over a port typed into the address", async () => {
362
+ const created: DatabaseServer = await DatabaseServerService.create(
363
+ manualRequest({
364
+ serverAddress: "orders-db.example.com:6000",
365
+ serverPort: 6432,
366
+ }),
367
+ );
368
+
369
+ expect(created.serverPort).toBe(6432);
370
+ expect(created.databaseIdentifier).toBe(
371
+ "postgresql|orders-db.example.com:6432",
372
+ );
373
+ });
374
+
375
+ test("a valid port field overrides a bad port typed into the address", async () => {
376
+ const created: DatabaseServer = await DatabaseServerService.create(
377
+ manualRequest({
378
+ serverAddress: "orders-db.example.com:99999",
379
+ serverPort: 5433,
380
+ }),
381
+ );
382
+
383
+ expect(created.databaseIdentifier).toBe(
384
+ "postgresql|orders-db.example.com:5433",
385
+ );
386
+ });
387
+
388
+ test("an IPv6 address takes the port field without being mangled", async () => {
389
+ const created: DatabaseServer = await DatabaseServerService.create(
390
+ manualRequest({ serverAddress: "2001:db8::10", serverPort: 5433 }),
391
+ );
392
+
393
+ expect(created.serverAddress).toBe("2001:db8::10");
394
+ expect(created.databaseIdentifier).toBe("postgresql|[2001:db8::10]:5433");
395
+ });
396
+
397
+ test("a private IP is a perfectly good manual endpoint", async () => {
398
+ const created: DatabaseServer = await DatabaseServerService.create(
399
+ manualRequest({ serverAddress: "10.0.1.5" }),
400
+ );
401
+
402
+ expect(created.databaseIdentifier).toBe("postgresql|10.0.1.5:5432");
403
+ });
404
+
405
+ test("a value the form cannot use is refused before anything is looked up", async () => {
406
+ await expect(
407
+ DatabaseServerService.create(
408
+ manualRequest({ serverAddress: "admin@10.0.0.5:5432" }),
409
+ ),
410
+ ).rejects.toThrow(BadDataException);
411
+
412
+ expect(findOwner).not.toHaveBeenCalled();
413
+ expect(findSameIdentity).not.toHaveBeenCalled();
414
+ });
415
+
416
+ test("an address naming its cluster keeps the qualifier in its identity and endpoint", async () => {
417
+ const clusters: jest.SpyInstance = getJestSpyOn(
418
+ service,
419
+ "hasKubernetesClusters",
420
+ ).mockResolvedValue(true);
421
+
422
+ const created: DatabaseServer = await DatabaseServerService.create(
423
+ manualRequest({ serverAddress: "pg.shop.svc.cluster.local:5432@Prod" }),
424
+ );
425
+
426
+ expect(created.serverAddress).toBe("pg.shop.svc.cluster.local");
427
+ expect(created.databaseIdentifier).toBe(
428
+ "postgresql|pg.shop.svc.cluster.local:5432@prod",
429
+ );
430
+ expect(claim.mock.calls[0]![0].endpoint).toBe(
431
+ "pg.shop.svc.cluster.local:5432@prod",
432
+ );
433
+ // Already qualified: nothing to advise, no cluster lookup.
434
+ expect(clusters).not.toHaveBeenCalled();
435
+ });
436
+
437
+ test("the port field replaces a SQL Server named instance - the port already names it", async () => {
438
+ const created: DatabaseServer = await DatabaseServerService.create(
439
+ manualRequest({
440
+ dbSystem: "mssql",
441
+ serverAddress: "sql1.corp.example.com\\INST01",
442
+ serverPort: 14330,
443
+ }),
444
+ );
445
+
446
+ expect(created.serverAddress).toBe("sql1.corp.example.com");
447
+ expect(created.serverPort).toBe(14330);
448
+ expect(claim.mock.calls[0]![0].endpoint).toBe(
449
+ "sql1.corp.example.com:14330",
450
+ );
451
+ });
452
+
453
+ test("without a port, a SQL Server named instance is part of the host and gets no default port", async () => {
454
+ const created: DatabaseServer = await DatabaseServerService.create(
455
+ manualRequest({
456
+ dbSystem: "mssql",
457
+ serverAddress: "sql1.corp.example.com\\INST01",
458
+ }),
459
+ );
460
+
461
+ expect(created.serverAddress).toBe("sql1.corp.example.com\\inst01");
462
+ expect(created.serverPort).toBeUndefined();
463
+ expect(claim.mock.calls[0]![0].endpoint).toBe(
464
+ "sql1.corp.example.com\\inst01",
465
+ );
466
+ });
467
+
468
+ /*
469
+ * A Kubernetes Service name only resolves inside one cluster, and pods
470
+ * whose telemetry goes through the Kubernetes agent report that cluster:
471
+ * their calls are keyed `<endpoint>@<cluster>`. A manual row holding only
472
+ * the unqualified name would stay empty while discovery creates a second
473
+ * row for the qualified one - so in a project with clusters the person is
474
+ * asked to name the cluster.
475
+ */
476
+ describe("a Kubernetes Service name typed without its cluster", () => {
477
+ let clusters: jest.SpyInstance;
478
+ let clusterReads: jest.SpyInstance;
479
+
480
+ function clusterRows(names: Array<string>): Array<KubernetesCluster> {
481
+ return names.map((name: string): KubernetesCluster => {
482
+ const cluster: KubernetesCluster = new KubernetesCluster();
483
+ cluster.clusterIdentifier = name;
484
+ return cluster;
485
+ });
486
+ }
487
+
488
+ beforeEach(() => {
489
+ clusters = getJestSpyOn(
490
+ service,
491
+ "hasKubernetesClusters",
492
+ ).mockResolvedValue(true);
493
+ clusterReads = getJestSpyOn(
494
+ KubernetesClusterService,
495
+ "findBy",
496
+ ).mockResolvedValue(clusterRows(["prod-eu", "staging"]));
497
+ });
498
+
499
+ test("is refused in a project with clusters, naming the qualified form and the project's clusters", async () => {
500
+ const error: Error = (await DatabaseServerService.create(
501
+ manualRequest({ serverAddress: "pg.shop.svc.cluster.local" }),
502
+ ).catch((e: unknown) => {
503
+ return e;
504
+ })) as Error;
505
+
506
+ expect(error).toBeInstanceOf(BadDataException);
507
+ expect(error.message).toBe(
508
+ "pg.shop.svc.cluster.local:5432 only resolves inside one Kubernetes cluster or private network. Applications that report their cluster (k8s.cluster.name) are matched to pg.shop.svc.cluster.local:5432@<cluster name> instead, so name the cluster the way they report it, for example pg.shop.svc.cluster.local:5432@prod-eu (this project's clusters: prod-eu, staging). To also match a Database Agent or applications that do not report their cluster, add pg.shop.svc.cluster.local:5432 as an endpoint on the database's Endpoints tab once it is created.",
509
+ );
510
+ expect(clusters).toHaveBeenCalledWith(PROJECT_ID);
511
+ expect(save).not.toHaveBeenCalled();
512
+ expect(claim).not.toHaveBeenCalled();
513
+ });
514
+
515
+ /*
516
+ * Cluster names are the Kubernetes clusters' own resource, with their own
517
+ * read permission and label scope: the refusal names only the clusters
518
+ * the caller may read, read through their own permissions.
519
+ */
520
+ test("the clusters named are read with the caller's own permissions", async () => {
521
+ const request: {
522
+ data: DatabaseServer;
523
+ props: DatabaseCommonInteractionProps;
524
+ } = manualRequest({ serverAddress: "pg.shop.svc.cluster.local" });
525
+
526
+ await DatabaseServerService.create(request).catch(() => {
527
+ return null;
528
+ });
529
+
530
+ expect(clusterReads).toHaveBeenCalledTimes(1);
531
+ const read: any = clusterReads.mock.calls[0]![0];
532
+ expect(read.query).toEqual({ projectId: PROJECT_ID });
533
+ expect(read.select).toEqual({ clusterIdentifier: true });
534
+ expect(read.sort).toEqual({ clusterIdentifier: SortOrder.Ascending });
535
+ expect(read.limit).toBe(10);
536
+ expect(read.props).toBe(request.props);
537
+ expect(read.props.isRoot).toBeFalsy();
538
+ });
539
+
540
+ test("a caller who may read only some clusters sees only those", async () => {
541
+ clusterReads.mockResolvedValue(clusterRows(["prod-eu"]));
542
+
543
+ const error: Error = (await DatabaseServerService.create(
544
+ manualRequest({ serverAddress: "pg.shop.svc.cluster.local" }),
545
+ ).catch((e: unknown) => {
546
+ return e;
547
+ })) as Error;
548
+
549
+ expect(error.message).toContain("(this project's clusters: prod-eu)");
550
+ expect(error.message).not.toContain("staging");
551
+ });
552
+
553
+ test("a caller who may not read clusters is still refused, with the generic `@<cluster>` advice and no cluster named", async () => {
554
+ clusterReads.mockRejectedValue(
555
+ new NotAuthorizedException(
556
+ "You do not have permission to read Kubernetes clusters.",
557
+ ),
558
+ );
559
+
560
+ const error: Error = (await DatabaseServerService.create(
561
+ manualRequest({ serverAddress: "pg.shop.svc.cluster.local" }),
562
+ ).catch((e: unknown) => {
563
+ return e;
564
+ })) as Error;
565
+
566
+ expect(error).toBeInstanceOf(BadDataException);
567
+ expect(error.message).toBe(
568
+ "pg.shop.svc.cluster.local:5432 only resolves inside one Kubernetes cluster or private network. Applications that report their cluster (k8s.cluster.name) are matched to pg.shop.svc.cluster.local:5432@<cluster name> instead, so name the cluster the way they report it. To also match a Database Agent or applications that do not report their cluster, add pg.shop.svc.cluster.local:5432 as an endpoint on the database's Endpoints tab once it is created.",
569
+ );
570
+ expect(save).not.toHaveBeenCalled();
571
+ });
572
+
573
+ test("no readable cluster at all gives the generic advice too", async () => {
574
+ clusterReads.mockResolvedValue([]);
575
+
576
+ await expect(
577
+ DatabaseServerService.create(
578
+ manualRequest({ serverAddress: "pg.shop.svc.cluster.local" }),
579
+ ),
580
+ ).rejects.toThrow(
581
+ "so name the cluster the way they report it. To also match",
582
+ );
583
+ });
584
+
585
+ test("the short `<service>.<namespace>.svc` form is the same name", async () => {
586
+ await expect(
587
+ DatabaseServerService.create(
588
+ manualRequest({ serverAddress: "pg.shop.svc:6432" }),
589
+ ),
590
+ ).rejects.toThrow("pg.shop.svc.cluster.local:6432@prod-eu");
591
+ expect(save).not.toHaveBeenCalled();
592
+ });
593
+
594
+ test("a StatefulSet member's name is refused the same way", async () => {
595
+ await expect(
596
+ DatabaseServerService.create(
597
+ manualRequest({
598
+ serverAddress: "pg-0.pg-headless.shop.svc.cluster.local",
599
+ }),
600
+ ),
601
+ ).rejects.toThrow("pg-0.pg-headless.shop.svc.cluster.local:5432@prod-eu");
602
+ });
603
+
604
+ test("is accepted as typed in a project without clusters - nothing could report the qualified form", async () => {
605
+ clusters.mockResolvedValue(false);
606
+
607
+ const created: DatabaseServer = await DatabaseServerService.create(
608
+ manualRequest({ serverAddress: "pg.shop.svc.cluster.local" }),
609
+ );
610
+
611
+ expect(created.databaseIdentifier).toBe(
612
+ "postgresql|pg.shop.svc.cluster.local:5432",
613
+ );
614
+ expect(claim.mock.calls[0]![0].endpoint).toBe(
615
+ "pg.shop.svc.cluster.local:5432",
616
+ );
617
+ });
618
+
619
+ test("an address another database already holds is answered with that first", async () => {
620
+ findOwner.mockResolvedValue({
621
+ databaseServerId: OTHER_DATABASE_ID,
622
+ isPrimary: false,
623
+ });
624
+ getJestSpyOn(
625
+ service,
626
+ "getDatabaseServerNameIfReadable",
627
+ ).mockResolvedValue("Redis shop/pg");
628
+
629
+ await expect(
630
+ DatabaseServerService.create(
631
+ manualRequest({ serverAddress: "pg.shop.svc.cluster.local" }),
632
+ ),
633
+ ).rejects.toThrow(
634
+ 'pg.shop.svc.cluster.local:5432 already belongs to the database "Redis shop/pg".',
635
+ );
636
+ expect(clusters).not.toHaveBeenCalled();
637
+ });
638
+
639
+ test.each([
640
+ ["a private IP", "10.0.1.5", "postgresql|10.0.1.5:5432"],
641
+ [
642
+ "a private-zone name",
643
+ "orders-db.corp.internal",
644
+ "postgresql|orders-db.corp.internal:5432",
645
+ ],
646
+ ["a .local name", "orders-db.local", "postgresql|orders-db.local:5432"],
647
+ ])(
648
+ "%s stays a valid unqualified endpoint - virtual machines and the Database Agent report it that way",
649
+ async (_label: string, address: string, identifier: string) => {
650
+ const created: DatabaseServer = await DatabaseServerService.create(
651
+ manualRequest({ serverAddress: address }),
652
+ );
653
+
654
+ expect(created.databaseIdentifier).toBe(identifier);
655
+ expect(clusters).not.toHaveBeenCalled();
656
+ },
657
+ );
658
+
659
+ /*
660
+ * The create form runs parseManualDatabaseEndpoint in the browser and
661
+ * shows its clusterQualifierHint only as advice; only the server knows
662
+ * whether the project has clusters. Its refusal is that same hint, with
663
+ * the project's cluster names filled in.
664
+ */
665
+ test("the refusal is the form's own advice, with the project's clusters named", async () => {
666
+ const typed: { serverAddress: string; serverPort: number } = {
667
+ serverAddress: "pg.shop.svc",
668
+ serverPort: 6432,
669
+ };
670
+ const inTheBrowser: ManualDatabaseEndpoint = parseManualDatabaseEndpoint(
671
+ typed.serverAddress,
672
+ { system: "postgresql", port: typed.serverPort },
673
+ );
674
+ expect(inTheBrowser.error).toBeNull();
675
+ expect(inTheBrowser.clusterQualifierHint).not.toBeNull();
676
+
677
+ const error: unknown = await DatabaseServerService.create(
678
+ manualRequest(typed),
679
+ ).catch((e: unknown) => {
680
+ return e;
681
+ });
682
+
683
+ expect(error).toBeInstanceOf(BadDataException);
684
+ expect(
685
+ (error as Error).message.startsWith(
686
+ parseManualDatabaseEndpoint(typed.serverAddress, {
687
+ system: "postgresql",
688
+ port: typed.serverPort,
689
+ knownClusterNames: ["prod-eu", "staging"],
690
+ }).clusterQualifierHint!,
691
+ ),
692
+ ).toBe(true);
693
+ });
694
+
695
+ test.each([
696
+ ["a Service name", "pg.shop.svc.cluster.local", true],
697
+ ["the short Service form", "pg.shop.svc", true],
698
+ ["a StatefulSet member", "pg-0.pg-hl.shop.svc.cluster.local", true],
699
+ ["a private IP", "10.0.1.5", false],
700
+ ["a private-zone name", "orders-db.corp.internal", false],
701
+ ])(
702
+ "%s: refused exactly when it is a Kubernetes Service name the form would advise on",
703
+ async (_label: string, address: string, refused: boolean) => {
704
+ expect(
705
+ isKubernetesServiceDnsHost(
706
+ parseManualDatabaseEndpoint(address, { system: "postgresql" })
707
+ .endpoint!.host,
708
+ ),
709
+ ).toBe(refused);
710
+
711
+ const outcome: unknown = await DatabaseServerService.create(
712
+ manualRequest({ serverAddress: address }),
713
+ ).catch((e: unknown) => {
714
+ return e;
715
+ });
716
+
717
+ expect(outcome instanceof BadDataException).toBe(refused);
718
+ },
719
+ );
720
+
721
+ test("clusters that cannot be read never fail the create", async () => {
722
+ clusters.mockRejectedValue(new Error("connection terminated"));
723
+
724
+ const created: DatabaseServer = await DatabaseServerService.create(
725
+ manualRequest({ serverAddress: "pg.shop.svc.cluster.local" }),
726
+ );
727
+
728
+ expect(created.databaseIdentifier).toBe(
729
+ "postgresql|pg.shop.svc.cluster.local:5432",
730
+ );
731
+ });
732
+ });
733
+
734
+ test.each([
735
+ [
736
+ "no engine",
737
+ { dbSystem: " " },
738
+ "Database engine is required. Choose the engine this database runs, for example PostgreSQL or MySQL.",
739
+ ],
740
+ [
741
+ "no address",
742
+ { serverAddress: "" },
743
+ "Server address is required. Enter the host name or IP address applications use to reach this database, for example orders-db.example.com:5432.",
744
+ ],
745
+ [
746
+ "localhost",
747
+ { serverAddress: "localhost" },
748
+ '"localhost" is a loopback or host-local address, such as localhost or host.docker.internal. Every application reaches its own, so it cannot identify one database.',
749
+ ],
750
+ [
751
+ "a host-relative Docker name",
752
+ { serverAddress: "host.docker.internal:5432" },
753
+ '"host.docker.internal:5432" is a loopback or host-local address',
754
+ ],
755
+ [
756
+ "an out-of-range port typed into the address",
757
+ { serverAddress: "orders-db.example.com:99999" },
758
+ '"orders-db.example.com:99999" has a port outside 1-65535. Enter a port between 1 and 65535.',
759
+ ],
760
+ [
761
+ "an out-of-range port inside a connection URL",
762
+ { serverAddress: "postgresql://app@orders-db.example.com:70000/orders" },
763
+ "has a port outside 1-65535.",
764
+ ],
765
+ [
766
+ "user@host - the user name read as the host before",
767
+ { serverAddress: "admin@10.0.0.5:5432" },
768
+ '"admin@10.0.0.5:5432" looks like user@host. Remove "admin@": the database user does not belong in an endpoint.',
769
+ ],
770
+ [
771
+ "a dotted user name before the host",
772
+ { serverAddress: "john.doe@orders-db.example.com" },
773
+ 'Remove "john.doe@"',
774
+ ],
775
+ [
776
+ "a cluster qualifier on a public host - dropped silently before",
777
+ { serverAddress: "orders-db.example.com@prod" },
778
+ '"orders-db.example.com" resolves the same way everywhere, so it cannot take a Kubernetes cluster qualifier. Remove "@prod".',
779
+ ],
780
+ [
781
+ "a cluster qualifier on a bare service name",
782
+ { serverAddress: "postgres@prod" },
783
+ '"postgres" is a single-label name, which only means one Service once its namespace is known. Enter it with the namespace, for example postgres.<namespace>@prod.',
784
+ ],
785
+ [
786
+ "something that is not an address",
787
+ { serverAddress: "orders db" },
788
+ '"orders db" is not a valid host[:port] endpoint. Enter a host name or IP address with an optional port, for example orders-db.example.com:5432.',
789
+ ],
790
+ [
791
+ "a port of 0",
792
+ { serverPort: 0 },
793
+ "Server port must be a whole number between 1 and 65535.",
794
+ ],
795
+ [
796
+ "a port above 65535",
797
+ { serverPort: 70000 },
798
+ "Server port must be a whole number between 1 and 65535.",
799
+ ],
800
+ [
801
+ "a fractional port",
802
+ { serverPort: 54.5 },
803
+ "Server port must be a whole number between 1 and 65535.",
804
+ ],
805
+ ])(
806
+ "refuses %s with a message the form can show",
807
+ async (_label: string, overrides: any, message: string) => {
808
+ const error: unknown = await DatabaseServerService.create(
809
+ manualRequest(overrides),
810
+ ).catch((e: unknown) => {
811
+ return e;
812
+ });
813
+
814
+ expect(error).toBeInstanceOf(BadDataException);
815
+ expect((error as Error).message).toContain(message);
816
+ expect(save).not.toHaveBeenCalled();
817
+ expect(claim).not.toHaveBeenCalled();
818
+ },
819
+ );
820
+
821
+ test.each([
822
+ { serverAddress: "localhost" },
823
+ { serverAddress: "admin@10.0.0.5:5432" },
824
+ { serverAddress: "orders-db.example.com@prod" },
825
+ { serverAddress: "postgres@prod" },
826
+ { serverAddress: "orders db" },
827
+ { serverAddress: "orders-db.example.com:99999" },
828
+ { serverPort: 70000 },
829
+ ])(
830
+ "the create form's check and the server say the same thing: %j",
831
+ async (overrides: any) => {
832
+ const request: { data: DatabaseServer } = manualRequest(overrides);
833
+ const inTheBrowser: ManualDatabaseEndpoint = parseManualDatabaseEndpoint(
834
+ request.data.serverAddress,
835
+ { system: "postgresql", port: request.data.serverPort },
836
+ );
837
+ expect(inTheBrowser.error).toBeTruthy();
838
+
839
+ await expect(
840
+ DatabaseServerService.create(manualRequest(overrides)),
841
+ ).rejects.toThrow(new BadDataException(inTheBrowser.error!));
842
+ },
843
+ );
844
+
845
+ test("an endpoint another database owns is refused, naming that database", async () => {
846
+ findOwner.mockResolvedValue({
847
+ databaseServerId: OTHER_DATABASE_ID,
848
+ isPrimary: true,
849
+ });
850
+ const nameIfReadable: jest.SpyInstance = getJestSpyOn(
851
+ service,
852
+ "getDatabaseServerNameIfReadable",
853
+ ).mockResolvedValue("CockroachDB orders-db.example.com:5432");
854
+
855
+ await expect(DatabaseServerService.create(manualRequest())).rejects.toThrow(
856
+ 'orders-db.example.com:5432 already belongs to the database "CockroachDB orders-db.example.com:5432".',
857
+ );
858
+ expect(findOwner).toHaveBeenCalledWith(
859
+ PROJECT_ID,
860
+ "orders-db.example.com:5432",
861
+ );
862
+ // The owner is named only through the caller's own read permission.
863
+ const nameCall: any = nameIfReadable.mock.calls[0]![0];
864
+ expect(nameCall.databaseServerId).toBe(OTHER_DATABASE_ID);
865
+ expect(nameCall.props.userId).toBe(USER_ID);
866
+ expect(nameCall.props.isRoot).toBeFalsy();
867
+ expect(save).not.toHaveBeenCalled();
868
+ });
869
+
870
+ test("an owner the caller cannot read is not named", async () => {
871
+ findOwner.mockResolvedValue({
872
+ databaseServerId: OTHER_DATABASE_ID,
873
+ isPrimary: true,
874
+ });
875
+ getJestSpyOn(service, "getDatabaseServerNameIfReadable").mockResolvedValue(
876
+ "",
877
+ );
878
+ const rootName: jest.SpyInstance = getJestSpyOn(
879
+ service,
880
+ "getDatabaseServerName",
881
+ ).mockResolvedValue("Payments (team B only)");
882
+
883
+ const error: Error = (await DatabaseServerService.create(
884
+ manualRequest(),
885
+ ).catch((e: unknown) => {
886
+ return e;
887
+ })) as Error;
888
+
889
+ expect(error.message).toContain(
890
+ "orders-db.example.com:5432 already belongs to another database.",
891
+ );
892
+ expect(error.message).not.toContain("Payments");
893
+ expect(rootName).not.toHaveBeenCalled();
894
+ });
895
+
896
+ test("the same identity held by a row the caller cannot read is not named", async () => {
897
+ findSameIdentity.mockImplementation(async (findOneBy: any) => {
898
+ // The identity lookup runs as root; the name lookup as the caller.
899
+ return findOneBy.props.isRoot
900
+ ? databaseRow({ name: "Payments (team B only)" })
901
+ : null;
902
+ });
903
+
904
+ const error: Error = (await DatabaseServerService.create(
905
+ manualRequest(),
906
+ ).catch((e: unknown) => {
907
+ return e;
908
+ })) as Error;
909
+
910
+ expect(error.message).toBe(
911
+ "A PostgreSQL database at orders-db.example.com:5432 already exists.",
912
+ );
913
+ });
914
+
915
+ test("a caller who may not add databases learns nothing: refused before any lookup", async () => {
916
+ findOwner.mockResolvedValue({
917
+ databaseServerId: OTHER_DATABASE_ID,
918
+ isPrimary: true,
919
+ });
920
+ const request: {
921
+ data: DatabaseServer;
922
+ props: DatabaseCommonInteractionProps;
923
+ } = manualRequest();
924
+ request.props = memberProps([Permission.ReadDatabaseServer]);
925
+
926
+ const error: Error = (await DatabaseServerService.create(request).catch(
927
+ (e: unknown) => {
928
+ return e;
929
+ },
930
+ )) as Error;
931
+
932
+ expect(error.message).not.toContain("already belongs");
933
+ expect(findOwner).not.toHaveBeenCalled();
934
+ expect(findSameIdentity).not.toHaveBeenCalled();
935
+ expect(save).not.toHaveBeenCalled();
936
+ });
937
+
938
+ test("the same engine + endpoint twice is refused before the unique index can", async () => {
939
+ findSameIdentity.mockResolvedValue(databaseRow({ name: "Orders primary" }));
940
+
941
+ await expect(DatabaseServerService.create(manualRequest())).rejects.toThrow(
942
+ 'A PostgreSQL database at orders-db.example.com:5432 already exists: "Orders primary".',
943
+ );
944
+ const call: any = findSameIdentity.mock.calls[0]![0];
945
+ expect(call.query.projectId).toBe(PROJECT_ID);
946
+ expect(call.query.databaseIdentifier).toBe(
947
+ "postgresql|orders-db.example.com:5432",
948
+ );
949
+ expect(save).not.toHaveBeenCalled();
950
+ });
951
+
952
+ test("losing the endpoint to a concurrent writer removes the new row and names the winner", async () => {
953
+ claim.mockResolvedValue("owned-by-other");
954
+ findOwner.mockResolvedValueOnce(null).mockResolvedValueOnce({
955
+ databaseServerId: OTHER_DATABASE_ID,
956
+ isPrimary: true,
957
+ });
958
+ const nameIfReadable: jest.SpyInstance = getJestSpyOn(
959
+ service,
960
+ "getDatabaseServerNameIfReadable",
961
+ ).mockResolvedValue("PostgreSQL orders-db.example.com:5432");
962
+
963
+ await expect(DatabaseServerService.create(manualRequest())).rejects.toThrow(
964
+ 'orders-db.example.com:5432 already belongs to the database "PostgreSQL orders-db.example.com:5432".',
965
+ );
966
+ // Named through the creating caller's own read permission.
967
+ expect(nameIfReadable.mock.calls[0]![0].props.userId).toBe(USER_ID);
968
+
969
+ expect(save).toHaveBeenCalledTimes(1);
970
+ const savedId: string = (save.mock.calls[0]![0] as any)._id;
971
+ expect(deleteBy).toHaveBeenCalledTimes(1);
972
+ const deleteCall: any = deleteBy.mock.calls[0]![0];
973
+ expect(deleteCall.query._id).toBe(savedId);
974
+ expect(deleteCall.query.projectId.toString()).toBe(PROJECT_ID.toString());
975
+ expect(deleteCall.props).toEqual({ isRoot: true, ignoreHooks: true });
976
+
977
+ // The row never existed as far as anyone watching is concerned.
978
+ await flushPromises();
979
+ expect(sideEffects.feed).not.toHaveBeenCalled();
980
+ expect(sideEffects.labelRules).not.toHaveBeenCalled();
981
+ });
982
+
983
+ test("a failing claim removes the new row and surfaces the error", async () => {
984
+ claim.mockRejectedValue(new Error("connection terminated"));
985
+
986
+ await expect(DatabaseServerService.create(manualRequest())).rejects.toThrow(
987
+ "connection terminated",
988
+ );
989
+ expect(deleteBy).toHaveBeenCalledTimes(1);
990
+ });
991
+
992
+ /*
993
+ * The whole point of the column ACL note on the model: the hook sets only
994
+ * user-creatable columns, and everything else a caller sends still meets
995
+ * the real column permission check.
996
+ */
997
+ test.each([
998
+ ["workloadIdentifier", "postgresql|kubernetes:prod/data/statefulset/x"],
999
+ ["otelCollectorStatus", "connected"],
1000
+ ["lastSeenAt", new Date()],
1001
+ ["autoArchivedAt", new Date()],
1002
+ ["memberEntityKeys", { [MEMBER_KEY_A]: new Date().toISOString() }],
1003
+ ])(
1004
+ "a caller-supplied root-only %s is refused by the column permission check",
1005
+ async (column: string, value: unknown) => {
1006
+ await expect(
1007
+ DatabaseServerService.create(
1008
+ manualRequest({ [column]: value } as Partial<DatabaseServer>),
1009
+ ),
1010
+ ).rejects.toThrow(
1011
+ `User is not allowed to create on ${column} column of Database`,
1012
+ );
1013
+ expect(save).not.toHaveBeenCalled();
1014
+ },
1015
+ );
1016
+
1017
+ test("the user-creatable columns - labels and description included - pass the check", async () => {
1018
+ const label: Label = new Label(ObjectID.generate());
1019
+
1020
+ const created: DatabaseServer = await DatabaseServerService.create(
1021
+ manualRequest({
1022
+ description: "Orders primary",
1023
+ labels: [label],
1024
+ }),
1025
+ );
1026
+
1027
+ expect(save).toHaveBeenCalledTimes(1);
1028
+ expect(created.description).toBe("Orders primary");
1029
+ });
1030
+
1031
+ test("a read-only member cannot add a database at all", async () => {
1032
+ await expect(
1033
+ DatabaseServerService.create({
1034
+ data: manualRequest().data,
1035
+ props: memberProps([Permission.ReadDatabaseServer]),
1036
+ }),
1037
+ ).rejects.toThrow();
1038
+ expect(save).not.toHaveBeenCalled();
1039
+ });
1040
+
1041
+ test("a root create passes through untouched - discovery computes its own identity", async () => {
1042
+ const data: DatabaseServer = databaseRow({
1043
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
1044
+ databaseIdentifier: "postgresql|kubernetes:prod/data/statefulset/orders",
1045
+ });
1046
+ delete data._id;
1047
+
1048
+ const created: DatabaseServer = await DatabaseServerService.create({
1049
+ data: data,
1050
+ props: { isRoot: true },
1051
+ });
1052
+
1053
+ expect(findOwner).not.toHaveBeenCalled();
1054
+ expect(claim).not.toHaveBeenCalled();
1055
+ expect(created.discoverySource).toBe("kubernetes");
1056
+ expect(created.databaseIdentifier).toBe(
1057
+ "postgresql|kubernetes:prod/data/statefulset/orders",
1058
+ );
1059
+ });
1060
+ });
1061
+
1062
+ describe("DatabaseServerService.hasKubernetesClusters", () => {
1063
+ test("asks whether the project has one live, named cluster - reading no name", async () => {
1064
+ const query: jest.Mock = mockRawQuery([{ found: 1 }]);
1065
+
1066
+ await expect(service.hasKubernetesClusters(PROJECT_ID)).resolves.toBe(true);
1067
+
1068
+ const [sql, params] = query.mock.calls[0] as [string, Array<unknown>];
1069
+ expect(sql).toContain(`FROM "KubernetesCluster" kc`);
1070
+ expect(sql).toContain(`kc."projectId" = $1`);
1071
+ expect(sql).toContain(`kc."deletedAt" IS NULL`);
1072
+ expect(sql).toContain(`kc."clusterIdentifier" <> ''`);
1073
+ expect(sql).toContain("LIMIT 1");
1074
+ expect(sql).not.toContain(`AS "clusterIdentifier"`);
1075
+ expect(params).toEqual([PROJECT_ID.toString()]);
1076
+ });
1077
+
1078
+ test("none, or an unexpected driver answer, is no clusters", async () => {
1079
+ mockRawQuery([]);
1080
+ await expect(service.hasKubernetesClusters(PROJECT_ID)).resolves.toBe(
1081
+ false,
1082
+ );
1083
+
1084
+ mockRawQuery(null);
1085
+ await expect(service.hasKubernetesClusters(PROJECT_ID)).resolves.toBe(
1086
+ false,
1087
+ );
1088
+ });
1089
+ });
1090
+
1091
+ describe("DatabaseServerService.findReadableKubernetesClusterNames", () => {
1092
+ function clusterRow(name: string | undefined): KubernetesCluster {
1093
+ const cluster: KubernetesCluster = new KubernetesCluster();
1094
+ if (name !== undefined) {
1095
+ cluster.clusterIdentifier = name;
1096
+ }
1097
+ return cluster;
1098
+ }
1099
+
1100
+ test("the names the caller may read, trimmed and deduped, empty ones dropped", async () => {
1101
+ getJestSpyOn(KubernetesClusterService, "findBy").mockResolvedValue([
1102
+ clusterRow(" prod-eu "),
1103
+ clusterRow("prod-eu"),
1104
+ clusterRow("staging"),
1105
+ clusterRow(""),
1106
+ clusterRow(undefined),
1107
+ ]);
1108
+
1109
+ await expect(
1110
+ service.findReadableKubernetesClusterNames(PROJECT_ID, memberProps()),
1111
+ ).resolves.toEqual(["prod-eu", "staging"]);
1112
+ });
1113
+
1114
+ test("a caller refused the read gets no names, never an error", async () => {
1115
+ getJestSpyOn(KubernetesClusterService, "findBy").mockRejectedValue(
1116
+ new NotAuthorizedException("no"),
1117
+ );
1118
+
1119
+ await expect(
1120
+ service.findReadableKubernetesClusterNames(PROJECT_ID, memberProps()),
1121
+ ).resolves.toEqual([]);
1122
+ });
1123
+ });
1124
+
1125
+ /*
1126
+ * ---------------------------------------------------------------------------
1127
+ * findOrCreateByEndpoint
1128
+ * ---------------------------------------------------------------------------
1129
+ */
1130
+ describe("DatabaseServerService.findOrCreateByEndpoint", () => {
1131
+ let findOwner: jest.SpyInstance;
1132
+ let findOneBy: jest.SpyInstance;
1133
+ let create: jest.SpyInstance;
1134
+ let claim: jest.SpyInstance;
1135
+ let deleteBy: jest.SpyInstance;
1136
+ let rawQuery: jest.Mock;
1137
+ let sideEffects: SideEffectSpies;
1138
+
1139
+ beforeEach(() => {
1140
+ silenceLogs();
1141
+ findOwner = getJestSpyOn(
1142
+ DatabaseServerEndpointService,
1143
+ "findOwnerByEndpoint",
1144
+ ).mockResolvedValue(null);
1145
+ findOneBy = getJestSpyOn(service, "findOneBy").mockResolvedValue(null);
1146
+ create = getJestSpyOn(service, "create").mockImplementation(
1147
+ async (input: any) => {
1148
+ input.data._id = ObjectID.generate().toString();
1149
+ return input.data;
1150
+ },
1151
+ );
1152
+ claim = getJestSpyOn(
1153
+ DatabaseServerEndpointService,
1154
+ "claimEndpoint",
1155
+ ).mockResolvedValue("claimed");
1156
+ deleteBy = getJestSpyOn(service, "deleteBy").mockResolvedValue(1);
1157
+ rawQuery = mockRawQuery([]);
1158
+ sideEffects = mockSideEffects();
1159
+ });
1160
+
1161
+ test("returns the row that owns the endpoint, without creating anything", async () => {
1162
+ const existing: DatabaseServer = databaseRow({
1163
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1164
+ });
1165
+ findOwner.mockResolvedValue({
1166
+ databaseServerId: existing.id,
1167
+ isPrimary: true,
1168
+ });
1169
+ findOneBy.mockResolvedValue(existing);
1170
+
1171
+ const result: DatabaseServer | null =
1172
+ await DatabaseServerService.findOrCreateByEndpoint({
1173
+ projectId: PROJECT_ID,
1174
+ dbSystem: "postgresql",
1175
+ endpoint: ORDERS_ENDPOINT,
1176
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1177
+ allowCreate: true,
1178
+ });
1179
+
1180
+ expect(result).toBe(existing);
1181
+ expect(findOwner).toHaveBeenCalledWith(
1182
+ PROJECT_ID,
1183
+ "orders-db.example.com:5432",
1184
+ );
1185
+ const lookup: any = findOneBy.mock.calls[0]![0];
1186
+ expect(lookup.query._id).toBe(existing.id!.toString());
1187
+ expect(lookup.query.projectId).toBe(PROJECT_ID);
1188
+ expect(create).not.toHaveBeenCalled();
1189
+ expect(claim).not.toHaveBeenCalled();
1190
+ expect(rawQuery).not.toHaveBeenCalled();
1191
+ });
1192
+
1193
+ /*
1194
+ * The endpoint key is engine-agnostic by design: CockroachDB speaks the
1195
+ * postgresql wire protocol, so a span that says "postgresql" still lands on
1196
+ * the row a collector created as "cockroachdb".
1197
+ */
1198
+ test("the owner is found by endpoint whatever engine the caller reports", async () => {
1199
+ const cockroach: DatabaseServer = databaseRow({ dbSystem: "cockroachdb" });
1200
+ findOwner.mockResolvedValue({
1201
+ databaseServerId: cockroach.id,
1202
+ isPrimary: true,
1203
+ });
1204
+ findOneBy.mockResolvedValue(cockroach);
1205
+
1206
+ await expect(
1207
+ DatabaseServerService.findOrCreateByEndpoint({
1208
+ projectId: PROJECT_ID,
1209
+ dbSystem: "postgresql",
1210
+ endpoint: ORDERS_ENDPOINT,
1211
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1212
+ allowCreate: true,
1213
+ }),
1214
+ ).resolves.toBe(cockroach);
1215
+ });
1216
+
1217
+ test("an owner that discovery auto-archived is restored when seen again", async () => {
1218
+ const archived: DatabaseServer = databaseRow({
1219
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1220
+ isArchived: true,
1221
+ autoArchivedAt: new Date("2026-09-01T00:00:00.000Z"),
1222
+ });
1223
+ findOwner.mockResolvedValue({ databaseServerId: archived.id });
1224
+ findOneBy.mockResolvedValue(archived);
1225
+ rawQuery.mockResolvedValue([{ _id: archived.id!.toString() }]);
1226
+
1227
+ const result: DatabaseServer | null =
1228
+ await DatabaseServerService.findOrCreateByEndpoint({
1229
+ projectId: PROJECT_ID,
1230
+ dbSystem: "postgresql",
1231
+ endpoint: ORDERS_ENDPOINT,
1232
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1233
+ allowCreate: true,
1234
+ });
1235
+
1236
+ expect(result!.isArchived).toBe(false);
1237
+ expect(result!.autoArchivedAt).toBeUndefined();
1238
+
1239
+ expect(rawQuery).toHaveBeenCalledTimes(1);
1240
+ const [sql, params] = rawQuery.mock.calls[0] as [string, Array<unknown>];
1241
+ // The conditions live in the UPDATE, so a racing person always wins.
1242
+ expect(sql).toContain('"isArchived" = true');
1243
+ expect(sql).toContain('"autoArchivedAt" IS NOT NULL');
1244
+ expect(sql).toContain('"deletedAt" IS NULL');
1245
+ expect(sql).toContain('"autoArchivedAt" = NULL');
1246
+ expect(sql).toContain('"archivedByUserId" = NULL');
1247
+ expect(params).toEqual([archived.id!.toString(), PROJECT_ID.toString()]);
1248
+
1249
+ expect(sideEffects.feed).toHaveBeenCalledTimes(1);
1250
+ const feed: any = sideEffects.feed.mock.calls[0]![0];
1251
+ expect(feed.databaseServerFeedEventType).toBe(
1252
+ DatabaseServerFeedEventType.DatabaseServerRestored,
1253
+ );
1254
+ expect(feed.feedInfoInMarkdown).toContain("restored from the archive");
1255
+ });
1256
+
1257
+ test("a restore that lost to someone else writes no second feed item", async () => {
1258
+ const archived: DatabaseServer = databaseRow({
1259
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1260
+ isArchived: true,
1261
+ autoArchivedAt: new Date(),
1262
+ });
1263
+ findOwner.mockResolvedValue({ databaseServerId: archived.id });
1264
+ findOneBy.mockResolvedValue(archived);
1265
+ rawQuery.mockResolvedValue([]);
1266
+
1267
+ await DatabaseServerService.findOrCreateByEndpoint({
1268
+ projectId: PROJECT_ID,
1269
+ dbSystem: "postgresql",
1270
+ endpoint: ORDERS_ENDPOINT,
1271
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1272
+ allowCreate: true,
1273
+ });
1274
+
1275
+ expect(rawQuery).toHaveBeenCalledTimes(1);
1276
+ expect(sideEffects.feed).not.toHaveBeenCalled();
1277
+ });
1278
+
1279
+ test("a row a PERSON archived is never un-archived by discovery", async () => {
1280
+ const archivedByPerson: DatabaseServer = databaseRow({
1281
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1282
+ isArchived: true,
1283
+ archivedByUserId: USER_ID,
1284
+ });
1285
+ findOwner.mockResolvedValue({ databaseServerId: archivedByPerson.id });
1286
+ findOneBy.mockResolvedValue(archivedByPerson);
1287
+
1288
+ const result: DatabaseServer | null =
1289
+ await DatabaseServerService.findOrCreateByEndpoint({
1290
+ projectId: PROJECT_ID,
1291
+ dbSystem: "postgresql",
1292
+ endpoint: ORDERS_ENDPOINT,
1293
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1294
+ allowCreate: true,
1295
+ });
1296
+
1297
+ expect(result!.isArchived).toBe(true);
1298
+ expect(rawQuery).not.toHaveBeenCalled();
1299
+ });
1300
+
1301
+ test("a restore failure never fails the lookup", async () => {
1302
+ const archived: DatabaseServer = databaseRow({
1303
+ isArchived: true,
1304
+ autoArchivedAt: new Date(),
1305
+ });
1306
+ findOwner.mockResolvedValue({ databaseServerId: archived.id });
1307
+ findOneBy.mockResolvedValue(archived);
1308
+ rawQuery.mockRejectedValue(new Error("deadlock detected"));
1309
+
1310
+ await expect(
1311
+ DatabaseServerService.findOrCreateByEndpoint({
1312
+ projectId: PROJECT_ID,
1313
+ dbSystem: "postgresql",
1314
+ endpoint: ORDERS_ENDPOINT,
1315
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1316
+ allowCreate: true,
1317
+ }),
1318
+ ).resolves.toBe(archived);
1319
+ });
1320
+
1321
+ test("nothing is created when creation is not allowed", async () => {
1322
+ await expect(
1323
+ DatabaseServerService.findOrCreateByEndpoint({
1324
+ projectId: PROJECT_ID,
1325
+ dbSystem: "postgresql",
1326
+ endpoint: ORDERS_ENDPOINT,
1327
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1328
+ allowCreate: false,
1329
+ }),
1330
+ ).resolves.toBeNull();
1331
+ expect(create).not.toHaveBeenCalled();
1332
+ });
1333
+
1334
+ test.each([
1335
+ ["a single-label name", { host: "postgres", port: 5432 }],
1336
+ [
1337
+ "unqualified cluster-local DNS",
1338
+ { host: "orders-db.data.svc.cluster.local", port: 5432 },
1339
+ ],
1340
+ ["an unqualified private IP", { host: "10.0.0.5", port: 5432 }],
1341
+ ])(
1342
+ "%s (LOCAL scope) never creates a row",
1343
+ async (_label: string, endpoint: DatabaseEndpoint) => {
1344
+ await expect(
1345
+ DatabaseServerService.findOrCreateByEndpoint({
1346
+ projectId: PROJECT_ID,
1347
+ dbSystem: "postgresql",
1348
+ endpoint: endpoint,
1349
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1350
+ allowCreate: true,
1351
+ }),
1352
+ ).resolves.toBeNull();
1353
+ expect(create).not.toHaveBeenCalled();
1354
+ },
1355
+ );
1356
+
1357
+ test("a LOCAL-scope endpoint still joins a row that already lists it", async () => {
1358
+ const existing: DatabaseServer = databaseRow();
1359
+ findOwner.mockResolvedValue({ databaseServerId: existing.id });
1360
+ findOneBy.mockResolvedValue(existing);
1361
+
1362
+ await expect(
1363
+ DatabaseServerService.findOrCreateByEndpoint({
1364
+ projectId: PROJECT_ID,
1365
+ dbSystem: "postgresql",
1366
+ endpoint: { host: "postgres", port: 5432 },
1367
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1368
+ allowCreate: true,
1369
+ }),
1370
+ ).resolves.toBe(existing);
1371
+ expect(findOwner).toHaveBeenCalledWith(PROJECT_ID, "postgres:5432");
1372
+ });
1373
+
1374
+ test("a cluster-qualified private IP is GLOBAL and may create", async () => {
1375
+ const result: DatabaseServer | null =
1376
+ await DatabaseServerService.findOrCreateByEndpoint({
1377
+ projectId: PROJECT_ID,
1378
+ dbSystem: "redis",
1379
+ endpoint: {
1380
+ host: "10.0.0.5",
1381
+ port: 6379,
1382
+ kubernetesClusterName: "prod",
1383
+ },
1384
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1385
+ allowCreate: true,
1386
+ });
1387
+
1388
+ expect(result).not.toBeNull();
1389
+ expect(result!.databaseIdentifier).toBe("redis|10.0.0.5:6379@prod");
1390
+ // The display name never carries the cluster qualifier.
1391
+ expect(result!.name).toBe("Redis 10.0.0.5:6379");
1392
+ expect(claim.mock.calls[0]![0].endpoint).toBe("10.0.0.5:6379@prod");
1393
+ });
1394
+
1395
+ test("creates a client-spans row as root, without hooks, and claims its primary endpoint", async () => {
1396
+ const before: number = Date.now();
1397
+ const result: DatabaseServer | null =
1398
+ await DatabaseServerService.findOrCreateByEndpoint({
1399
+ projectId: PROJECT_ID,
1400
+ dbSystem: "Postgres",
1401
+ endpoint: ORDERS_ENDPOINT,
1402
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1403
+ allowCreate: true,
1404
+ });
1405
+
1406
+ expect(create).toHaveBeenCalledTimes(1);
1407
+ const call: any = create.mock.calls[0]![0];
1408
+ expect(call.props).toEqual({ isRoot: true, ignoreHooks: true });
1409
+
1410
+ const row: DatabaseServer = call.data;
1411
+ expect(row.projectId).toBe(PROJECT_ID);
1412
+ expect(row.dbSystem).toBe("postgresql");
1413
+ expect(row.name).toBe("PostgreSQL orders-db.example.com:5432");
1414
+ expect(row.databaseIdentifier).toBe(
1415
+ "postgresql|orders-db.example.com:5432",
1416
+ );
1417
+ expect(row.serverAddress).toBe("orders-db.example.com");
1418
+ expect(row.serverPort).toBe(5432);
1419
+ expect(row.discoverySource).toBe("client-spans");
1420
+ expect(row.lastSeenAt!.getTime()).toBeGreaterThanOrEqual(before);
1421
+ // Being queried says nothing about engine metrics.
1422
+ expect(row.otelCollectorStatus).toBeUndefined();
1423
+ expect(row.collectorLastSeenAt).toBeUndefined();
1424
+
1425
+ expect(claim).toHaveBeenCalledWith({
1426
+ projectId: PROJECT_ID,
1427
+ databaseServerId: row.id,
1428
+ endpoint: "orders-db.example.com:5432",
1429
+ isPrimary: true,
1430
+ source: "auto",
1431
+ });
1432
+ expect(result).toBe(row);
1433
+
1434
+ // The side effects run only once the row has won its endpoint.
1435
+ await flushPromises();
1436
+ expect(sideEffects.labelRules).toHaveBeenCalledWith(row);
1437
+ expect(sideEffects.ownerRules).toHaveBeenCalledWith(row);
1438
+ const feed: any = sideEffects.feed.mock.calls[0]![0];
1439
+ expect(feed.databaseServerFeedEventType).toBe(
1440
+ DatabaseServerFeedEventType.DatabaseServerCreated,
1441
+ );
1442
+ expect(feed.userId).toBeUndefined();
1443
+ expect(feed.feedInfoInMarkdown).toContain("created automatically");
1444
+ expect(feed.moreInformationInMarkdown).toContain(
1445
+ "**Discovered from**: Application traces",
1446
+ );
1447
+ });
1448
+
1449
+ test("a collector-created row is connected from its first moment", async () => {
1450
+ await DatabaseServerService.findOrCreateByEndpoint({
1451
+ projectId: PROJECT_ID,
1452
+ dbSystem: "postgresql",
1453
+ endpoint: ORDERS_ENDPOINT,
1454
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1455
+ displayName: " Orders (collector) ",
1456
+ allowCreate: true,
1457
+ });
1458
+
1459
+ const row: DatabaseServer = create.mock.calls[0]![0].data;
1460
+ expect(row.otelCollectorStatus).toBe("connected");
1461
+ expect(row.collectorLastSeenAt).toBeInstanceOf(Date);
1462
+ expect(row.name).toBe("Orders (collector)");
1463
+ });
1464
+
1465
+ /*
1466
+ * e2e: the Database Agent (0.161.0) against MariaDB 11.4.13 created
1467
+ * "MySQL mariamysql.rcv-e2e.example.net:3306" at 22:45:47.695, and the
1468
+ * heartbeat right behind the create lost the row lock to the create's own
1469
+ * Created feed item - so agentVersion and dbVersion read "-" until the
1470
+ * ingest fence let the next heartbeat through at 22:51:47 (a Memcached
1471
+ * agent that ran for 2 minutes never got one at all).
1472
+ */
1473
+ test("a collector-created row starts with the versions its first batch reported, not waiting for a heartbeat", async () => {
1474
+ await DatabaseServerService.findOrCreateByEndpoint({
1475
+ projectId: PROJECT_ID,
1476
+ dbSystem: "mysql",
1477
+ endpoint: { host: "mariamysql.rcv-e2e.example.net", port: 3306 },
1478
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1479
+ allowCreate: true,
1480
+ collector: {
1481
+ agentVersion: " 0.161.0 ",
1482
+ dbVersion: "11.4.13-MariaDB-ubu2404",
1483
+ reportedByDatabaseAgent: true,
1484
+ },
1485
+ });
1486
+
1487
+ const row: DatabaseServer = create.mock.calls[0]![0].data;
1488
+ expect(row.otelCollectorStatus).toBe("connected");
1489
+ expect(row.agentVersion).toBe("0.161.0");
1490
+ expect(row.dbVersion).toBe("11.4.13-MariaDB-ubu2404");
1491
+ // Still one insert, as root and without hooks.
1492
+ expect(create.mock.calls[0]![0].props).toEqual({
1493
+ isRoot: true,
1494
+ ignoreHooks: true,
1495
+ });
1496
+ });
1497
+
1498
+ test("a collector batch without versions leaves them unset, never blank", async () => {
1499
+ await DatabaseServerService.findOrCreateByEndpoint({
1500
+ projectId: PROJECT_ID,
1501
+ dbSystem: "postgresql",
1502
+ endpoint: ORDERS_ENDPOINT,
1503
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1504
+ allowCreate: true,
1505
+ collector: { agentVersion: " ", dbVersion: undefined },
1506
+ });
1507
+
1508
+ const row: DatabaseServer = create.mock.calls[0]![0].data;
1509
+ expect(row.agentVersion).toBeUndefined();
1510
+ expect(row.dbVersion).toBeUndefined();
1511
+ });
1512
+
1513
+ test("versions reported to another discovery path are not a collector's: never written", async () => {
1514
+ await DatabaseServerService.findOrCreateByEndpoint({
1515
+ projectId: PROJECT_ID,
1516
+ dbSystem: "postgresql",
1517
+ endpoint: ORDERS_ENDPOINT,
1518
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1519
+ allowCreate: true,
1520
+ collector: { agentVersion: "0.161.0", dbVersion: "16.4" },
1521
+ });
1522
+
1523
+ const row: DatabaseServer = create.mock.calls[0]![0].data;
1524
+ expect(row.agentVersion).toBeUndefined();
1525
+ expect(row.dbVersion).toBeUndefined();
1526
+ });
1527
+
1528
+ describe("the Created feed item says what found the database, and where", () => {
1529
+ async function createdFeed(): Promise<{
1530
+ feedInfoInMarkdown: string;
1531
+ moreInformationInMarkdown: string;
1532
+ userId?: ObjectID;
1533
+ }> {
1534
+ await flushPromises();
1535
+ await flushPromises();
1536
+ const calls: Array<Array<any>> = sideEffects.feed.mock.calls;
1537
+ expect(calls).toHaveLength(1);
1538
+ return calls[0]![0];
1539
+ }
1540
+
1541
+ test("the Database Agent: named with its version and the endpoint it reported", async () => {
1542
+ await DatabaseServerService.findOrCreateByEndpoint({
1543
+ projectId: PROJECT_ID,
1544
+ dbSystem: "mysql",
1545
+ endpoint: { host: "mariamysql.rcv-e2e.example.net", port: 3306 },
1546
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1547
+ allowCreate: true,
1548
+ collector: { agentVersion: "0.161.0", reportedByDatabaseAgent: true },
1549
+ });
1550
+
1551
+ const feed: any = await createdFeed();
1552
+ expect(feed.feedInfoInMarkdown).toBe(
1553
+ "🤖 [Database PostgreSQL orders-db.example.com:5432](/db) was created automatically: reported by the Database Agent 0.161.0, which sent engine metrics for mariamysql.rcv-e2e.example.net:3306.",
1554
+ );
1555
+ expect(feed.feedInfoInMarkdown).not.toContain("the first time telemetry");
1556
+ expect(feed.moreInformationInMarkdown).toContain(
1557
+ "**Discovered from**: OpenTelemetry Collector",
1558
+ );
1559
+ expect(feed.moreInformationInMarkdown).toContain(
1560
+ "**Database identifier**: `mysql|mariamysql.rcv-e2e.example.net:3306`",
1561
+ );
1562
+ expect(feed.userId).toBeUndefined();
1563
+ });
1564
+
1565
+ test("any other collector: its database receiver", async () => {
1566
+ await DatabaseServerService.findOrCreateByEndpoint({
1567
+ projectId: PROJECT_ID,
1568
+ dbSystem: "postgresql",
1569
+ endpoint: ORDERS_ENDPOINT,
1570
+ discoverySource: DatabaseServerDiscoverySource.Collector,
1571
+ allowCreate: true,
1572
+ collector: { reportedByDatabaseAgent: false },
1573
+ });
1574
+
1575
+ const feed: any = await createdFeed();
1576
+ expect(feed.feedInfoInMarkdown).toContain(
1577
+ "was created automatically: reported by an OpenTelemetry Collector database receiver, which sent engine metrics for orders-db.example.com:5432.",
1578
+ );
1579
+ });
1580
+
1581
+ test("application traces: the endpoint they called", async () => {
1582
+ await DatabaseServerService.findOrCreateByEndpoint({
1583
+ projectId: PROJECT_ID,
1584
+ dbSystem: "postgresql",
1585
+ endpoint: { host: "rare-db.example.com", port: 5432 },
1586
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1587
+ allowCreate: true,
1588
+ });
1589
+
1590
+ const feed: any = await createdFeed();
1591
+ expect(feed.feedInfoInMarkdown).toContain(
1592
+ "was created automatically: detected from application traces calling rare-db.example.com:5432.",
1593
+ );
1594
+ expect(feed.moreInformationInMarkdown).toContain(
1595
+ "**Discovered from**: Application traces",
1596
+ );
1597
+ });
1598
+ });
1599
+
1600
+ test("an engine that cannot be named creates nothing", async () => {
1601
+ await expect(
1602
+ DatabaseServerService.findOrCreateByEndpoint({
1603
+ projectId: PROJECT_ID,
1604
+ dbSystem: " ",
1605
+ endpoint: ORDERS_ENDPOINT,
1606
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1607
+ allowCreate: true,
1608
+ }),
1609
+ ).resolves.toBeNull();
1610
+ expect(create).not.toHaveBeenCalled();
1611
+ });
1612
+
1613
+ test("an unknown engine is kept, clamped to its column", async () => {
1614
+ await DatabaseServerService.findOrCreateByEndpoint({
1615
+ projectId: PROJECT_ID,
1616
+ dbSystem: ` ${"Q".repeat(300)} `,
1617
+ endpoint: ORDERS_ENDPOINT,
1618
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1619
+ allowCreate: true,
1620
+ });
1621
+
1622
+ const row: DatabaseServer = create.mock.calls[0]![0].data;
1623
+ expect(row.dbSystem).toBe("q".repeat(100));
1624
+ expect(row.databaseIdentifier).toBe(
1625
+ `${"q".repeat(100)}|orders-db.example.com:5432`,
1626
+ );
1627
+ });
1628
+
1629
+ test("an endpoint without a host is ignored before any read", async () => {
1630
+ await expect(
1631
+ DatabaseServerService.findOrCreateByEndpoint({
1632
+ projectId: PROJECT_ID,
1633
+ dbSystem: "postgresql",
1634
+ endpoint: { host: "", port: 5432 },
1635
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1636
+ allowCreate: true,
1637
+ }),
1638
+ ).resolves.toBeNull();
1639
+ expect(findOwner).not.toHaveBeenCalled();
1640
+ });
1641
+
1642
+ test("the identifier race: a racing writer's row is returned and gets the endpoint", async () => {
1643
+ const winner: DatabaseServer = databaseRow();
1644
+ create.mockRejectedValue(
1645
+ new BadDataException("duplicate key value violates unique constraint"),
1646
+ );
1647
+ findOneBy.mockResolvedValue(winner);
1648
+ claim.mockResolvedValue("already-owned-by-this");
1649
+
1650
+ const result: DatabaseServer | null =
1651
+ await DatabaseServerService.findOrCreateByEndpoint({
1652
+ projectId: PROJECT_ID,
1653
+ dbSystem: "postgresql",
1654
+ endpoint: ORDERS_ENDPOINT,
1655
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1656
+ allowCreate: true,
1657
+ });
1658
+
1659
+ expect(result).toBe(winner);
1660
+ const refetch: any = findOneBy.mock.calls[0]![0];
1661
+ expect(refetch.query.databaseIdentifier).toBe(
1662
+ "postgresql|orders-db.example.com:5432",
1663
+ );
1664
+ expect(refetch.query.projectId).toBe(PROJECT_ID);
1665
+ expect(deleteBy).not.toHaveBeenCalled();
1666
+ // The winner already recorded its own creation.
1667
+ await flushPromises();
1668
+ expect(sideEffects.feed).not.toHaveBeenCalled();
1669
+ });
1670
+
1671
+ test("the endpoint race: our orphan is removed and the owner's row returned", async () => {
1672
+ const owner: DatabaseServer = databaseRow({ dbSystem: "cockroachdb" });
1673
+ claim.mockResolvedValue("owned-by-other");
1674
+ findOwner
1675
+ .mockResolvedValueOnce(null)
1676
+ .mockResolvedValueOnce({ databaseServerId: owner.id, isPrimary: true });
1677
+ findOneBy.mockResolvedValue(owner);
1678
+
1679
+ const result: DatabaseServer | null =
1680
+ await DatabaseServerService.findOrCreateByEndpoint({
1681
+ projectId: PROJECT_ID,
1682
+ dbSystem: "postgresql",
1683
+ endpoint: ORDERS_ENDPOINT,
1684
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1685
+ allowCreate: true,
1686
+ });
1687
+
1688
+ expect(result).toBe(owner);
1689
+
1690
+ const orphan: DatabaseServer = create.mock.calls[0]![0].data;
1691
+ expect(deleteBy).toHaveBeenCalledTimes(1);
1692
+ const deleteCall: any = deleteBy.mock.calls[0]![0];
1693
+ expect(deleteCall.query._id).toBe(orphan.id!.toString());
1694
+ expect(deleteCall.query.projectId).toBe(PROJECT_ID);
1695
+ expect(deleteCall.props).toEqual({ isRoot: true, ignoreHooks: true });
1696
+
1697
+ // No feed item, rule run or notification ever referred to the orphan.
1698
+ await flushPromises();
1699
+ expect(sideEffects.feed).not.toHaveBeenCalled();
1700
+ expect(sideEffects.labelRules).not.toHaveBeenCalled();
1701
+ });
1702
+
1703
+ test("the endpoint race against a row that has since vanished answers null", async () => {
1704
+ claim.mockResolvedValue("owned-by-other");
1705
+ findOwner.mockResolvedValue(null);
1706
+
1707
+ await expect(
1708
+ DatabaseServerService.findOrCreateByEndpoint({
1709
+ projectId: PROJECT_ID,
1710
+ dbSystem: "postgresql",
1711
+ endpoint: ORDERS_ENDPOINT,
1712
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1713
+ allowCreate: true,
1714
+ }),
1715
+ ).resolves.toBeNull();
1716
+ expect(deleteBy).toHaveBeenCalledTimes(1);
1717
+ });
1718
+
1719
+ test("a create failure that is not a race is surfaced", async () => {
1720
+ create.mockRejectedValue(new Error("connection terminated"));
1721
+ findOneBy.mockResolvedValue(null);
1722
+
1723
+ await expect(
1724
+ DatabaseServerService.findOrCreateByEndpoint({
1725
+ projectId: PROJECT_ID,
1726
+ dbSystem: "postgresql",
1727
+ endpoint: ORDERS_ENDPOINT,
1728
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1729
+ allowCreate: true,
1730
+ }),
1731
+ ).rejects.toThrow("connection terminated");
1732
+ });
1733
+
1734
+ test("a failing claim removes our new row and surfaces the error", async () => {
1735
+ claim.mockRejectedValue(new Error("connection terminated"));
1736
+
1737
+ await expect(
1738
+ DatabaseServerService.findOrCreateByEndpoint({
1739
+ projectId: PROJECT_ID,
1740
+ dbSystem: "postgresql",
1741
+ endpoint: ORDERS_ENDPOINT,
1742
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1743
+ allowCreate: true,
1744
+ }),
1745
+ ).rejects.toThrow("connection terminated");
1746
+ expect(deleteBy).toHaveBeenCalledTimes(1);
1747
+ });
1748
+ });
1749
+
1750
+ /*
1751
+ * e2e: the Created feed item of c961ace7, a database Kubernetes discovery
1752
+ * found as StatefulSet data/postgres on cluster e2e-kind, read "was created
1753
+ * automatically by OneUptime the first time telemetry for it arrived" - one
1754
+ * sentence for five discovery paths, wrong for every workload one.
1755
+ */
1756
+ describe("DatabaseServerService - the Created feed item of a discovered workload", () => {
1757
+ let sideEffects: SideEffectSpies;
1758
+ let findCluster: jest.SpyInstance;
1759
+ let findDockerHost: jest.SpyInstance;
1760
+ let findPodmanHost: jest.SpyInstance;
1761
+
1762
+ beforeEach(() => {
1763
+ silenceLogs();
1764
+ sideEffects = mockSideEffects();
1765
+ findCluster = getJestSpyOn(
1766
+ KubernetesClusterService,
1767
+ "findOneById",
1768
+ ).mockResolvedValue({ name: "e2e-kind" } as never);
1769
+ findDockerHost = getJestSpyOn(
1770
+ DockerHostService,
1771
+ "findOneById",
1772
+ ).mockResolvedValue({ name: "e2e-docker-host" } as never);
1773
+ findPodmanHost = getJestSpyOn(
1774
+ PodmanHostService,
1775
+ "findOneById",
1776
+ ).mockResolvedValue({ name: "podman-1" } as never);
1777
+ });
1778
+
1779
+ async function writeCreatedFeed(row: DatabaseServer): Promise<any> {
1780
+ await service.writeDatabaseServerCreatedFeed(row, undefined);
1781
+ expect(sideEffects.feed).toHaveBeenCalledTimes(1);
1782
+ return sideEffects.feed.mock.calls[0]![0];
1783
+ }
1784
+
1785
+ test("Kubernetes: the pods of the workload, and the cluster they run on", async () => {
1786
+ const row: DatabaseServer = databaseRow({
1787
+ name: "PostgreSQL data/postgres",
1788
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
1789
+ databaseIdentifier: "postgresql|k8s|e2e-kind|data|StatefulSet|postgres",
1790
+ kubernetesClusterId: CLUSTER_ID,
1791
+ kubernetesNamespace: "data",
1792
+ workloadKind: "StatefulSet",
1793
+ workloadName: "postgres",
1794
+ });
1795
+
1796
+ const feed: any = await writeCreatedFeed(row);
1797
+
1798
+ expect(feed.feedInfoInMarkdown).toBe(
1799
+ "🤖 [Database PostgreSQL orders-db.example.com:5432](/db) was created automatically: detected from the pods of StatefulSet data/postgres on Kubernetes cluster e2e-kind.",
1800
+ );
1801
+ expect(feed.moreInformationInMarkdown).not.toContain("telemetry for it");
1802
+ expect(feed.moreInformationInMarkdown).toContain(
1803
+ "**Discovered from**: Kubernetes",
1804
+ );
1805
+ expect(feed.databaseServerFeedEventType).toBe(
1806
+ DatabaseServerFeedEventType.DatabaseServerCreated,
1807
+ );
1808
+ expect(findCluster).toHaveBeenCalledWith({
1809
+ id: CLUSTER_ID,
1810
+ select: { name: true },
1811
+ props: { isRoot: true },
1812
+ });
1813
+ });
1814
+
1815
+ test("Kubernetes: a bare pod is named as one", async () => {
1816
+ const feed: any = await writeCreatedFeed(
1817
+ databaseRow({
1818
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
1819
+ kubernetesClusterId: CLUSTER_ID,
1820
+ kubernetesNamespace: "data",
1821
+ workloadKind: "Pod",
1822
+ workloadName: "pg-debug",
1823
+ }),
1824
+ );
1825
+
1826
+ expect(feed.feedInfoInMarkdown).toContain(
1827
+ "detected from pod data/pg-debug on Kubernetes cluster e2e-kind.",
1828
+ );
1829
+ });
1830
+
1831
+ test("a cluster that cannot be read is left out, never guessed", async () => {
1832
+ findCluster.mockRejectedValue(new Error("connection terminated"));
1833
+
1834
+ const feed: any = await writeCreatedFeed(
1835
+ databaseRow({
1836
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
1837
+ kubernetesClusterId: CLUSTER_ID,
1838
+ kubernetesNamespace: "cache",
1839
+ workloadKind: "Deployment",
1840
+ workloadName: "sessions",
1841
+ }),
1842
+ );
1843
+
1844
+ expect(feed.feedInfoInMarkdown).toContain(
1845
+ "detected from the pods of Deployment cache/sessions.",
1846
+ );
1847
+ });
1848
+
1849
+ test("Docker: the container and its host", async () => {
1850
+ const dockerHostId: ObjectID = ObjectID.generate();
1851
+
1852
+ const feed: any = await writeCreatedFeed(
1853
+ databaseRow({
1854
+ name: "MariaDB e2e-receivers-mariadb",
1855
+ discoverySource: DatabaseServerDiscoverySource.Docker,
1856
+ workloadKind: "Container",
1857
+ workloadName: "e2e-receivers-mariadb",
1858
+ dockerHostId: dockerHostId,
1859
+ }),
1860
+ );
1861
+
1862
+ expect(feed.feedInfoInMarkdown).toContain(
1863
+ "was created automatically: detected from container e2e-receivers-mariadb on Docker host e2e-docker-host.",
1864
+ );
1865
+ expect(feed.moreInformationInMarkdown).toContain(
1866
+ "**Discovered from**: Docker",
1867
+ );
1868
+ expect(findDockerHost.mock.calls[0]![0].id).toBe(dockerHostId);
1869
+ expect(findPodmanHost).not.toHaveBeenCalled();
1870
+ });
1871
+
1872
+ test("Podman: the container and its host", async () => {
1873
+ const feed: any = await writeCreatedFeed(
1874
+ databaseRow({
1875
+ discoverySource: DatabaseServerDiscoverySource.Podman,
1876
+ workloadKind: "Container",
1877
+ workloadName: "orders-pg",
1878
+ podmanHostId: ObjectID.generate(),
1879
+ }),
1880
+ );
1881
+
1882
+ expect(feed.feedInfoInMarkdown).toContain(
1883
+ "detected from container orders-pg on Podman host podman-1.",
1884
+ );
1885
+ expect(findDockerHost).not.toHaveBeenCalled();
1886
+ });
1887
+
1888
+ /*
1889
+ * Regression (live re-verification): a two-replica Compose service was
1890
+ * stored as a "Container", and its feed said "detected from container
1891
+ * e2e-docker-spans-compose-postgres" — a name no container has.
1892
+ */
1893
+ test("Docker: a Compose service is named as one, not as a container", async () => {
1894
+ const feed: any = await writeCreatedFeed(
1895
+ databaseRow({
1896
+ discoverySource: DatabaseServerDiscoverySource.Docker,
1897
+ workloadKind: "Compose service",
1898
+ workloadName: "e2e-docker-spans-compose-postgres",
1899
+ dockerHostId: ObjectID.generate(),
1900
+ }),
1901
+ );
1902
+
1903
+ expect(feed.feedInfoInMarkdown).toContain(
1904
+ "was created automatically: detected from Compose service e2e-docker-spans-compose-postgres on Docker host e2e-docker-host.",
1905
+ );
1906
+ expect(feed.feedInfoInMarkdown).not.toContain("container");
1907
+ expect(feed.moreInformationInMarkdown).toContain(
1908
+ "running in the containers of this Compose service and registered the service as one database",
1909
+ );
1910
+ });
1911
+
1912
+ test("a Swarm service, and a service whose name is unknown, are named as services", async () => {
1913
+ const swarm: any = await writeCreatedFeed(
1914
+ databaseRow({
1915
+ discoverySource: DatabaseServerDiscoverySource.Docker,
1916
+ workloadKind: "Swarm service",
1917
+ workloadName: "mystack_db",
1918
+ dockerHostId: ObjectID.generate(),
1919
+ }),
1920
+ );
1921
+ expect(swarm.feedInfoInMarkdown).toContain(
1922
+ "detected from Swarm service mystack_db on Docker host e2e-docker-host.",
1923
+ );
1924
+
1925
+ sideEffects.feed.mockClear();
1926
+ const unnamed: any = await writeCreatedFeed(
1927
+ databaseRow({
1928
+ discoverySource: DatabaseServerDiscoverySource.Podman,
1929
+ workloadKind: "Compose service",
1930
+ podmanHostId: ObjectID.generate(),
1931
+ }),
1932
+ );
1933
+ expect(unnamed.feedInfoInMarkdown).toContain(
1934
+ "detected from a Compose service on Podman host podman-1.",
1935
+ );
1936
+ });
1937
+
1938
+ test("a person's create still names the person", async () => {
1939
+ await service.writeDatabaseServerCreatedFeed(
1940
+ databaseRow({ discoverySource: DatabaseServerDiscoverySource.Manual }),
1941
+ USER_ID,
1942
+ );
1943
+
1944
+ const feed: any = sideEffects.feed.mock.calls[0]![0];
1945
+ expect(feed.feedInfoInMarkdown).toBe(
1946
+ "🚀 [Database PostgreSQL orders-db.example.com:5432](/db) was created by **Jane Doe (jane@example.com)**.",
1947
+ );
1948
+ expect(feed.moreInformationInMarkdown).toContain(
1949
+ "**Discovered from**: Added manually",
1950
+ );
1951
+ expect(feed.userId).toBe(USER_ID);
1952
+ });
1953
+
1954
+ test("a manual create with no person (an API key) is not called automatic", async () => {
1955
+ const feed: any = await writeCreatedFeed(
1956
+ databaseRow({ discoverySource: DatabaseServerDiscoverySource.Manual }),
1957
+ );
1958
+
1959
+ expect(feed.feedInfoInMarkdown).toBe(
1960
+ "🚀 [Database PostgreSQL orders-db.example.com:5432](/db) was added manually.",
1961
+ );
1962
+ expect(feed.feedInfoInMarkdown).not.toContain("automatically");
1963
+ });
1964
+ });
1965
+
1966
+ describe("DatabaseServerService.findByIdInProject", () => {
1967
+ test("reads one row, scoped to the project, with the discovery columns", async () => {
1968
+ const row: DatabaseServer = databaseRow();
1969
+ const findOneBy: jest.SpyInstance = getJestSpyOn(
1970
+ service,
1971
+ "findOneBy",
1972
+ ).mockResolvedValue(row);
1973
+
1974
+ await expect(
1975
+ DatabaseServerService.findByIdInProject(PROJECT_ID, row.id!),
1976
+ ).resolves.toBe(row);
1977
+
1978
+ const call: any = findOneBy.mock.calls[0]![0];
1979
+ expect(call.query).toEqual({
1980
+ _id: row.id!.toString(),
1981
+ projectId: PROJECT_ID,
1982
+ });
1983
+ expect(call.props.isRoot).toBe(true);
1984
+ expect(call.select).toMatchObject({
1985
+ _id: true,
1986
+ projectId: true,
1987
+ name: true,
1988
+ dbSystem: true,
1989
+ isArchived: true,
1990
+ autoArchivedAt: true,
1991
+ });
1992
+ // The member-key map is the workload path's business only.
1993
+ expect(call.select.memberEntityKeys).toBeUndefined();
1994
+ });
1995
+ });
1996
+
1997
+ /*
1998
+ * ---------------------------------------------------------------------------
1999
+ * upsertWorkloadDatabase - an in-memory world behind the seams
2000
+ * ---------------------------------------------------------------------------
2001
+ */
2002
+ describe("DatabaseServerService.upsertWorkloadDatabase", () => {
2003
+ interface World {
2004
+ rows: Map<string, DatabaseServer>;
2005
+ // endpoint -> owning databaseServerId
2006
+ owners: Map<string, string>;
2007
+ writes: Array<{ id: string; data: Record<string, unknown> }>;
2008
+ claims: Array<any>;
2009
+ }
2010
+
2011
+ let world: World;
2012
+ let create: jest.SpyInstance;
2013
+ let rawQuery: jest.Mock;
2014
+ let logs: ReturnType<typeof silenceLogs>;
2015
+
2016
+ const WORKLOAD: string =
2017
+ "postgresql|kubernetes:prod/data/statefulset/orders-db";
2018
+ const SERVICE_ALIAS: string = "orders-db.data.svc.cluster.local:5432@prod";
2019
+ const POD_ALIAS: string =
2020
+ "orders-db-0.orders-db-hl.data.svc.cluster.local:5432@prod";
2021
+ const UNQUALIFIED_ALIAS: string = "orders-db.data.svc.cluster.local:5432";
2022
+
2023
+ function input(
2024
+ overrides: Partial<UpsertWorkloadDatabaseData> = {},
2025
+ ): UpsertWorkloadDatabaseData {
2026
+ return {
2027
+ projectId: PROJECT_ID,
2028
+ workloadIdentifier: WORKLOAD,
2029
+ dbSystem: "postgresql",
2030
+ displayName: "PostgreSQL data/orders-db",
2031
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
2032
+ aliases: [SERVICE_ALIAS, POD_ALIAS],
2033
+ memberKeysSeenNow: [MEMBER_KEY_A],
2034
+ instanceCount: 3,
2035
+ dbVersion: "16.4",
2036
+ kubernetesClusterId: CLUSTER_ID,
2037
+ kubernetesNamespace: "data",
2038
+ workloadKind: "StatefulSet",
2039
+ workloadName: "orders-db",
2040
+ allowCreate: true,
2041
+ ...overrides,
2042
+ };
2043
+ }
2044
+
2045
+ function addRow(row: DatabaseServer): DatabaseServer {
2046
+ world.rows.set(row.id!.toString(), row);
2047
+ return row;
2048
+ }
2049
+
2050
+ function matches(row: DatabaseServer, query: any): boolean {
2051
+ if (query._id && row.id!.toString() !== query._id.toString()) {
2052
+ return false;
2053
+ }
2054
+ if (
2055
+ query.projectId &&
2056
+ row.projectId!.toString() !== query.projectId.toString()
2057
+ ) {
2058
+ return false;
2059
+ }
2060
+ if (
2061
+ query.workloadIdentifier !== undefined &&
2062
+ row.workloadIdentifier !== query.workloadIdentifier
2063
+ ) {
2064
+ return false;
2065
+ }
2066
+ if (
2067
+ query.databaseIdentifier !== undefined &&
2068
+ row.databaseIdentifier !== query.databaseIdentifier
2069
+ ) {
2070
+ return false;
2071
+ }
2072
+ return true;
2073
+ }
2074
+
2075
+ function lastWriteFor(row: DatabaseServer): Record<string, unknown> {
2076
+ const writes: Array<{ id: string; data: Record<string, unknown> }> =
2077
+ world.writes.filter((w: { id: string }) => {
2078
+ return w.id === row.id!.toString();
2079
+ });
2080
+ return writes[writes.length - 1]!.data;
2081
+ }
2082
+
2083
+ beforeEach(() => {
2084
+ logs = silenceLogs();
2085
+ world = { rows: new Map(), owners: new Map(), writes: [], claims: [] };
2086
+
2087
+ getJestSpyOn(service, "findOneBy").mockImplementation(
2088
+ async (findOneBy: any) => {
2089
+ for (const row of world.rows.values()) {
2090
+ if (matches(row, findOneBy.query)) {
2091
+ return row;
2092
+ }
2093
+ }
2094
+ return null;
2095
+ },
2096
+ );
2097
+
2098
+ // Rows by a list of workload identifiers (the engine-family lookup).
2099
+ getJestSpyOn(service, "findBy").mockImplementation(async (findBy: any) => {
2100
+ const wanted: Array<string> = [];
2101
+ for (const values of Object.values(
2102
+ findBy.query.workloadIdentifier?.objectLiteralParameters || {},
2103
+ )) {
2104
+ wanted.push(...(values as Array<string>));
2105
+ }
2106
+ return Array.from(world.rows.values()).filter((row: DatabaseServer) => {
2107
+ return Boolean(
2108
+ row.workloadIdentifier && wanted.includes(row.workloadIdentifier),
2109
+ );
2110
+ });
2111
+ });
2112
+
2113
+ getJestSpyOn(DatabaseServerEndpointService, "findBy").mockImplementation(
2114
+ async (findBy: any) => {
2115
+ expect(findBy.query.projectId).toBe(PROJECT_ID);
2116
+ /*
2117
+ * QueryHelper.any is a Raw operator: the endpoints ride in its bound
2118
+ * parameters, not in its value.
2119
+ */
2120
+ const wanted: Array<string> = [];
2121
+ for (const values of Object.values(
2122
+ findBy.query.endpoint.objectLiteralParameters || {},
2123
+ )) {
2124
+ wanted.push(...(values as Array<string>));
2125
+ }
2126
+ const rows: Array<DatabaseServerEndpoint> = [];
2127
+ for (const endpoint of wanted) {
2128
+ const ownerId: string | undefined = world.owners.get(endpoint);
2129
+ if (ownerId) {
2130
+ const row: DatabaseServerEndpoint = new DatabaseServerEndpoint();
2131
+ row.endpoint = endpoint;
2132
+ row.databaseServerId = new ObjectID(ownerId);
2133
+ rows.push(row);
2134
+ }
2135
+ }
2136
+ return rows;
2137
+ },
2138
+ );
2139
+
2140
+ getJestSpyOn(
2141
+ DatabaseServerEndpointService,
2142
+ "claimEndpoint",
2143
+ ).mockImplementation(
2144
+ async (claim: any): Promise<DatabaseServerEndpointClaimResult> => {
2145
+ world.claims.push(claim);
2146
+ const ownerId: string | undefined = world.owners.get(claim.endpoint);
2147
+ if (ownerId) {
2148
+ return ownerId === claim.databaseServerId.toString()
2149
+ ? "already-owned-by-this"
2150
+ : "owned-by-other";
2151
+ }
2152
+ world.owners.set(claim.endpoint, claim.databaseServerId.toString());
2153
+ return "claimed";
2154
+ },
2155
+ );
2156
+
2157
+ getJestSpyOn(service, "updateColumnsByIdWithoutHooks").mockImplementation(
2158
+ async (update: any) => {
2159
+ const row: DatabaseServer | undefined = world.rows.get(
2160
+ update.id.toString(),
2161
+ );
2162
+ if (!row) {
2163
+ return;
2164
+ }
2165
+ for (const [column, expected] of Object.entries(
2166
+ update.expectedData || {},
2167
+ )) {
2168
+ if (((row as any)[column] ?? null) !== expected) {
2169
+ // Compare-and-set lost: nothing written.
2170
+ return;
2171
+ }
2172
+ }
2173
+ world.writes.push({ id: update.id.toString(), data: update.data });
2174
+ Object.assign(row, update.data);
2175
+ },
2176
+ );
2177
+
2178
+ create = getJestSpyOn(service, "create").mockImplementation(
2179
+ async (createBy: any) => {
2180
+ const row: DatabaseServer = createBy.data;
2181
+ row._id = ObjectID.generate().toString();
2182
+ addRow(row);
2183
+ return row;
2184
+ },
2185
+ );
2186
+
2187
+ rawQuery = mockRawQuery([]);
2188
+ mockSideEffects();
2189
+ });
2190
+
2191
+ test("finds the workload's own row and refreshes it - without touching the collector status", async () => {
2192
+ const existing: DatabaseServer = addRow(
2193
+ databaseRow({
2194
+ workloadIdentifier: WORKLOAD,
2195
+ databaseIdentifier: WORKLOAD,
2196
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
2197
+ otelCollectorStatus: "connected",
2198
+ }),
2199
+ );
2200
+ world.owners.set(SERVICE_ALIAS, existing.id!.toString());
2201
+
2202
+ const before: number = Date.now();
2203
+ const result: DatabaseServer | null =
2204
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2205
+
2206
+ expect(result).toBe(existing);
2207
+ expect(create).not.toHaveBeenCalled();
2208
+
2209
+ const write: Record<string, unknown> = lastWriteFor(existing);
2210
+ expect(write["instanceCount"]).toBe(3);
2211
+ expect((write["lastSeenAt"] as Date).getTime()).toBeGreaterThanOrEqual(
2212
+ before,
2213
+ );
2214
+ expect(write["workloadKind"]).toBe("StatefulSet");
2215
+ expect(write["workloadName"]).toBe("orders-db");
2216
+ expect(write["kubernetesNamespace"]).toBe("data");
2217
+ expect((write["kubernetesClusterId"] as ObjectID).toString()).toBe(
2218
+ CLUSTER_ID.toString(),
2219
+ );
2220
+ expect(write["dbVersion"]).toBe("16.4");
2221
+ // Only this path stamps when the workload was last seen.
2222
+ expect((write["workloadLastSeenAt"] as Date).getTime()).toBe(
2223
+ (write["lastSeenAt"] as Date).getTime(),
2224
+ );
2225
+ expect("otelCollectorStatus" in write).toBe(false);
2226
+ expect("collectorLastSeenAt" in write).toBe(false);
2227
+ // Neither discovery source nor the name a person may have chosen.
2228
+ expect("discoverySource" in write).toBe(false);
2229
+ expect("name" in write).toBe(false);
2230
+ // Same engine, same evidence: nothing to record about the engine.
2231
+ expect("dbSystem" in write).toBe(false);
2232
+ expect("dbSystemSource" in write).toBe(false);
2233
+ expect(existing.otelCollectorStatus).toBe("connected");
2234
+
2235
+ // Already ours: not re-claimed. New: claimed as a workload alias.
2236
+ expect(
2237
+ world.claims.map((claim: any) => {
2238
+ return claim.endpoint;
2239
+ }),
2240
+ ).toEqual([POD_ALIAS]);
2241
+ expect(world.claims[0].isPrimary).toBe(false);
2242
+ expect(world.claims[0].source).toBe("workload");
2243
+ });
2244
+
2245
+ test("merges member keys - a pod restarted last week keeps its key, a stale one ages out", async () => {
2246
+ const now: number = Date.now();
2247
+ const existing: DatabaseServer = addRow(
2248
+ databaseRow({
2249
+ workloadIdentifier: WORKLOAD,
2250
+ memberEntityKeys: {
2251
+ [MEMBER_KEY_B]: new Date(now - 7 * 24 * 3600 * 1000).toISOString(),
2252
+ [MEMBER_KEY_OLD]: new Date(now - 45 * 24 * 3600 * 1000).toISOString(),
2253
+ },
2254
+ }),
2255
+ );
2256
+
2257
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2258
+
2259
+ const keys: Record<string, string> = lastWriteFor(existing)[
2260
+ "memberEntityKeys"
2261
+ ] as Record<string, string>;
2262
+ expect(Object.keys(keys).sort()).toEqual(
2263
+ [MEMBER_KEY_A, MEMBER_KEY_B].sort(),
2264
+ );
2265
+ expect(Date.parse(keys[MEMBER_KEY_A]!)).toBeGreaterThanOrEqual(now - 1000);
2266
+ expect(existing.memberEntityKeys).toBe(keys);
2267
+ });
2268
+
2269
+ test("ADOPTS the one endpoint row application traces found - no duplicate database", async () => {
2270
+ const tracesRow: DatabaseServer = addRow(
2271
+ databaseRow({
2272
+ name: "PostgreSQL orders-db.data.svc.cluster.local:5432",
2273
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2274
+ databaseIdentifier: `postgresql|${SERVICE_ALIAS}`,
2275
+ }),
2276
+ );
2277
+ world.owners.set(SERVICE_ALIAS, tracesRow.id!.toString());
2278
+
2279
+ const result: DatabaseServer | null =
2280
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2281
+
2282
+ expect(result).toBe(tracesRow);
2283
+ expect(create).not.toHaveBeenCalled();
2284
+ expect(tracesRow.workloadIdentifier).toBe(WORKLOAD);
2285
+ // First creator wins: adoption never rewrites where the row came from.
2286
+ expect(tracesRow.discoverySource).toBe("client-spans");
2287
+ expect(tracesRow.databaseIdentifier).toBe(`postgresql|${SERVICE_ALIAS}`);
2288
+ expect(tracesRow.name).toBe(
2289
+ "PostgreSQL orders-db.data.svc.cluster.local:5432",
2290
+ );
2291
+
2292
+ // The adoption itself was a compare-and-set on an empty workloadIdentifier.
2293
+ const adoption: any = (
2294
+ service.updateColumnsByIdWithoutHooks as jest.SpyInstance
2295
+ ).mock.calls[0]![0];
2296
+ expect(adoption.data).toEqual({ workloadIdentifier: WORKLOAD });
2297
+ expect(adoption.expectedData).toEqual({ workloadIdentifier: null });
2298
+ });
2299
+
2300
+ test("never adopts when the endpoints point at two databases - no tie-break", async () => {
2301
+ const a: DatabaseServer = addRow(databaseRow());
2302
+ const b: DatabaseServer = addRow(databaseRow());
2303
+ world.owners.set(SERVICE_ALIAS, a.id!.toString());
2304
+ world.owners.set(POD_ALIAS, b.id!.toString());
2305
+
2306
+ const result: DatabaseServer | null =
2307
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2308
+
2309
+ expect(create).toHaveBeenCalledTimes(1);
2310
+ expect(result).not.toBe(a);
2311
+ expect(result).not.toBe(b);
2312
+ expect(a.workloadIdentifier).toBeUndefined();
2313
+ expect(b.workloadIdentifier).toBeUndefined();
2314
+ // Neither endpoint is taken away from its owner.
2315
+ expect(world.owners.get(SERVICE_ALIAS)).toBe(a.id!.toString());
2316
+ expect(world.owners.get(POD_ALIAS)).toBe(b.id!.toString());
2317
+ expect(logs.info).toHaveBeenCalledWith(
2318
+ expect.stringContaining("belong to 2 different databases"),
2319
+ expect.anything(),
2320
+ );
2321
+ });
2322
+
2323
+ test("never adopts a row that already belongs to another workload", async () => {
2324
+ const otherWorkload: DatabaseServer = addRow(
2325
+ databaseRow({
2326
+ workloadIdentifier:
2327
+ "postgresql|kubernetes:prod/data/statefulset/orders-db-old",
2328
+ }),
2329
+ );
2330
+ world.owners.set(SERVICE_ALIAS, otherWorkload.id!.toString());
2331
+
2332
+ const result: DatabaseServer | null =
2333
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2334
+
2335
+ expect(create).toHaveBeenCalledTimes(1);
2336
+ expect(result).not.toBe(otherWorkload);
2337
+ expect(otherWorkload.workloadIdentifier).toBe(
2338
+ "postgresql|kubernetes:prod/data/statefulset/orders-db-old",
2339
+ );
2340
+ });
2341
+
2342
+ test("an UNQUALIFIED (local-scope) alias is never evidence for adoption", async () => {
2343
+ const localOwner: DatabaseServer = addRow(databaseRow());
2344
+ world.owners.set(UNQUALIFIED_ALIAS, localOwner.id!.toString());
2345
+
2346
+ await DatabaseServerService.upsertWorkloadDatabase(
2347
+ input({ aliases: [SERVICE_ALIAS, UNQUALIFIED_ALIAS] }),
2348
+ );
2349
+
2350
+ expect(localOwner.workloadIdentifier).toBeUndefined();
2351
+ expect(create).toHaveBeenCalledTimes(1);
2352
+ });
2353
+
2354
+ test("a lost adoption race falls back to the workload index, never a second adoption", async () => {
2355
+ const tracesRow: DatabaseServer = addRow(databaseRow());
2356
+ world.owners.set(SERVICE_ALIAS, tracesRow.id!.toString());
2357
+ // Another workload adopted it between our read and our write.
2358
+ getJestSpyOn(service, "findByIdInProject").mockImplementation(async () => {
2359
+ const snapshot: DatabaseServer = databaseRow({});
2360
+ snapshot._id = tracesRow._id!;
2361
+ tracesRow.workloadIdentifier = "postgresql|kubernetes:prod/x/y/z";
2362
+ return snapshot;
2363
+ });
2364
+
2365
+ const result: DatabaseServer | null =
2366
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2367
+
2368
+ expect(tracesRow.workloadIdentifier).toBe(
2369
+ "postgresql|kubernetes:prod/x/y/z",
2370
+ );
2371
+ expect(create).toHaveBeenCalledTimes(1);
2372
+ expect(result).not.toBe(tracesRow);
2373
+ });
2374
+
2375
+ test("creates the workload's row: identity is the workload, first alias is primary", async () => {
2376
+ const result: DatabaseServer | null =
2377
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2378
+
2379
+ expect(create).toHaveBeenCalledTimes(1);
2380
+ const call: any = create.mock.calls[0]![0];
2381
+ expect(call.props).toEqual({ isRoot: true });
2382
+ const row: DatabaseServer = call.data;
2383
+ expect(result).toBe(row);
2384
+ expect(row.databaseIdentifier).toBe(WORKLOAD);
2385
+ expect(row.workloadIdentifier).toBe(WORKLOAD);
2386
+ expect(row.discoverySource).toBe("kubernetes");
2387
+ expect(row.name).toBe("PostgreSQL data/orders-db");
2388
+ expect(row.dbSystem).toBe("postgresql");
2389
+ expect(row.serverAddress).toBe("orders-db.data.svc.cluster.local");
2390
+ expect(row.serverPort).toBe(5432);
2391
+ expect(row.kubernetesClusterId).toBe(CLUSTER_ID);
2392
+ expect(row.otelCollectorStatus).toBeUndefined();
2393
+
2394
+ expect(world.claims).toHaveLength(2);
2395
+ expect(world.claims[0]).toMatchObject({
2396
+ endpoint: SERVICE_ALIAS,
2397
+ isPrimary: true,
2398
+ source: "workload",
2399
+ });
2400
+ expect(world.claims[1]).toMatchObject({
2401
+ endpoint: POD_ALIAS,
2402
+ isPrimary: false,
2403
+ });
2404
+ });
2405
+
2406
+ test("a container workload has no aliases and no endpoint to show", async () => {
2407
+ const dockerHostId: ObjectID = ObjectID.generate();
2408
+
2409
+ await DatabaseServerService.upsertWorkloadDatabase(
2410
+ input({
2411
+ workloadIdentifier: "redis|docker:web-01/cache",
2412
+ dbSystem: "redis",
2413
+ displayName: "",
2414
+ discoverySource: DatabaseServerDiscoverySource.Docker,
2415
+ aliases: [],
2416
+ kubernetesClusterId: undefined,
2417
+ kubernetesNamespace: undefined,
2418
+ workloadKind: "Container",
2419
+ workloadName: "cache",
2420
+ dockerHostId: dockerHostId,
2421
+ }),
2422
+ );
2423
+
2424
+ const row: DatabaseServer = create.mock.calls[0]![0].data;
2425
+ expect(row.name).toBe("Redis cache");
2426
+ expect(row.dockerHostId).toBe(dockerHostId);
2427
+ expect(row.serverAddress).toBeUndefined();
2428
+ expect(world.claims).toHaveLength(0);
2429
+ });
2430
+
2431
+ test("aliases owned by another database are skipped, never taken", async () => {
2432
+ const other: DatabaseServer = addRow(
2433
+ databaseRow({ workloadIdentifier: "postgresql|kubernetes:prod/a/b/c" }),
2434
+ );
2435
+ const existing: DatabaseServer = addRow(
2436
+ databaseRow({ workloadIdentifier: WORKLOAD }),
2437
+ );
2438
+ world.owners.set(POD_ALIAS, other.id!.toString());
2439
+
2440
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2441
+
2442
+ expect(world.owners.get(POD_ALIAS)).toBe(other.id!.toString());
2443
+ expect(world.owners.get(SERVICE_ALIAS)).toBe(existing.id!.toString());
2444
+ expect(
2445
+ world.claims.map((claim: any) => {
2446
+ return claim.endpoint;
2447
+ }),
2448
+ ).toEqual([SERVICE_ALIAS]);
2449
+ expect(logs.debug).toHaveBeenCalledWith(
2450
+ expect.stringContaining(`belong to other databases: ${POD_ALIAS}`),
2451
+ expect.anything(),
2452
+ );
2453
+ });
2454
+
2455
+ test("aliases are re-canonicalized and deduped before anything is claimed", async () => {
2456
+ addRow(databaseRow({ workloadIdentifier: WORKLOAD }));
2457
+
2458
+ await DatabaseServerService.upsertWorkloadDatabase(
2459
+ input({
2460
+ aliases: [
2461
+ "Orders-DB.data.svc.cluster.local:5432@PROD",
2462
+ SERVICE_ALIAS,
2463
+ "localhost:5432",
2464
+ "",
2465
+ ],
2466
+ }),
2467
+ );
2468
+
2469
+ expect(
2470
+ world.claims.map((claim: any) => {
2471
+ return claim.endpoint;
2472
+ }),
2473
+ ).toEqual([SERVICE_ALIAS]);
2474
+ });
2475
+
2476
+ test("one failing alias claim does not stop the rest", async () => {
2477
+ const existing: DatabaseServer = addRow(
2478
+ databaseRow({ workloadIdentifier: WORKLOAD }),
2479
+ );
2480
+ (
2481
+ DatabaseServerEndpointService.claimEndpoint as unknown as jest.SpyInstance
2482
+ ).mockRejectedValueOnce(new Error("deadlock detected"));
2483
+
2484
+ const result: DatabaseServer | null =
2485
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2486
+
2487
+ expect(result).toBe(existing);
2488
+ expect(world.owners.get(POD_ALIAS)).toBe(existing.id!.toString());
2489
+ expect(logs.warn).toHaveBeenCalled();
2490
+ });
2491
+
2492
+ test("nothing is created or written when creation is not allowed", async () => {
2493
+ await expect(
2494
+ DatabaseServerService.upsertWorkloadDatabase(
2495
+ input({ allowCreate: false }),
2496
+ ),
2497
+ ).resolves.toBeNull();
2498
+ expect(create).not.toHaveBeenCalled();
2499
+ expect(world.writes).toHaveLength(0);
2500
+ expect(world.claims).toHaveLength(0);
2501
+ });
2502
+
2503
+ test("adoption still happens when creation is not allowed", async () => {
2504
+ const tracesRow: DatabaseServer = addRow(databaseRow());
2505
+ world.owners.set(SERVICE_ALIAS, tracesRow.id!.toString());
2506
+
2507
+ await expect(
2508
+ DatabaseServerService.upsertWorkloadDatabase(
2509
+ input({ allowCreate: false }),
2510
+ ),
2511
+ ).resolves.toBe(tracesRow);
2512
+ });
2513
+
2514
+ test("a racing run that created the workload first is picked up, not duplicated", async () => {
2515
+ const racer: DatabaseServer = databaseRow({
2516
+ workloadIdentifier: WORKLOAD,
2517
+ });
2518
+ create.mockImplementation(async () => {
2519
+ addRow(racer);
2520
+ throw new BadDataException("duplicate key value");
2521
+ });
2522
+
2523
+ await expect(
2524
+ DatabaseServerService.upsertWorkloadDatabase(input()),
2525
+ ).resolves.toBe(racer);
2526
+ });
2527
+
2528
+ test("an auto-archived workload row is restored when seen again", async () => {
2529
+ const archived: DatabaseServer = addRow(
2530
+ databaseRow({
2531
+ workloadIdentifier: WORKLOAD,
2532
+ isArchived: true,
2533
+ autoArchivedAt: new Date(),
2534
+ }),
2535
+ );
2536
+ rawQuery.mockResolvedValue([{ _id: archived.id!.toString() }]);
2537
+
2538
+ const result: DatabaseServer | null =
2539
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2540
+
2541
+ expect(result!.isArchived).toBe(false);
2542
+ expect(rawQuery).toHaveBeenCalledTimes(1);
2543
+ });
2544
+
2545
+ test("oversized collector-free strings are clamped before the hook-free write", async () => {
2546
+ const existing: DatabaseServer = addRow(
2547
+ databaseRow({ workloadIdentifier: WORKLOAD }),
2548
+ );
2549
+
2550
+ await DatabaseServerService.upsertWorkloadDatabase(
2551
+ input({
2552
+ workloadName: "w".repeat(900),
2553
+ workloadKind: "k".repeat(300),
2554
+ kubernetesNamespace: "n".repeat(300),
2555
+ dbVersion: "v".repeat(300),
2556
+ }),
2557
+ );
2558
+
2559
+ const write: Record<string, unknown> = lastWriteFor(existing);
2560
+ expect((write["workloadName"] as string).length).toBe(500);
2561
+ expect((write["workloadKind"] as string).length).toBe(100);
2562
+ expect((write["kubernetesNamespace"] as string).length).toBe(100);
2563
+ expect((write["dbVersion"] as string).length).toBe(100);
2564
+ });
2565
+
2566
+ test("a nonsense instance count is written as 0", async () => {
2567
+ const existing: DatabaseServer = addRow(
2568
+ databaseRow({ workloadIdentifier: WORKLOAD }),
2569
+ );
2570
+
2571
+ await DatabaseServerService.upsertWorkloadDatabase(
2572
+ input({ instanceCount: Number.NaN }),
2573
+ );
2574
+
2575
+ expect(lastWriteFor(existing)["instanceCount"]).toBe(0);
2576
+ });
2577
+
2578
+ test("never throws - a failing read logs and answers null", async () => {
2579
+ getJestSpyOn(service, "findOneBy").mockRejectedValue(
2580
+ new Error("connection terminated"),
2581
+ );
2582
+
2583
+ await expect(
2584
+ DatabaseServerService.upsertWorkloadDatabase(input()),
2585
+ ).resolves.toBeNull();
2586
+ expect(logs.error).toHaveBeenCalledWith(
2587
+ expect.stringContaining("connection terminated"),
2588
+ expect.anything(),
2589
+ );
2590
+ });
2591
+
2592
+ test.each([
2593
+ ["an empty workload identifier", { workloadIdentifier: " " }],
2594
+ ["an empty engine", { dbSystem: "" }],
2595
+ ])(
2596
+ "%s answers null without a read",
2597
+ async (_label: string, overrides: any) => {
2598
+ await expect(
2599
+ DatabaseServerService.upsertWorkloadDatabase(input(overrides)),
2600
+ ).resolves.toBeNull();
2601
+ expect(service.findOneBy).not.toHaveBeenCalled();
2602
+ },
2603
+ );
2604
+ });
2605
+
2606
+ /*
2607
+ * ---------------------------------------------------------------------------
2608
+ * Liveness: collector heartbeat vs sighting
2609
+ * ---------------------------------------------------------------------------
2610
+ */
2611
+ describe("DatabaseServerService liveness", () => {
2612
+ type WriteCall = { id: ObjectID; data: Record<string, unknown> };
2613
+
2614
+ const DATABASE_ID: ObjectID = ObjectID.generate();
2615
+ let writes: Array<WriteCall>;
2616
+ let cache: Map<string, string>;
2617
+ let findOneById: jest.SpyInstance;
2618
+ let rawQuery: jest.Mock;
2619
+
2620
+ /** A faithful in-memory Redis for the atomic gate primitives. */
2621
+ function mockCache(): void {
2622
+ getJestSpyOn(GlobalCache, "setStringIfNotExists").mockImplementation(
2623
+ async (ns: string, key: string, value: string) => {
2624
+ const full: string = `${ns}:${key}`;
2625
+ if (cache.has(full)) {
2626
+ return false;
2627
+ }
2628
+ cache.set(full, value);
2629
+ return true;
2630
+ },
2631
+ );
2632
+ getJestSpyOn(GlobalCache, "setStringIfChanged").mockImplementation(
2633
+ async (ns: string, key: string, value: string) => {
2634
+ const full: string = `${ns}:${key}`;
2635
+ if (cache.get(full) === value) {
2636
+ return false;
2637
+ }
2638
+ cache.set(full, value);
2639
+ return true;
2640
+ },
2641
+ );
2642
+ getJestSpyOn(GlobalCache, "deleteKey").mockImplementation(
2643
+ async (ns: string, key: string) => {
2644
+ cache.delete(`${ns}:${key}`);
2645
+ },
2646
+ );
2647
+ }
2648
+
2649
+ function resetWindows(): void {
2650
+ SingleFlight.clear();
2651
+ ResourceHeartbeat.clearRecentHeartbeatMemo();
2652
+ DatabaseServerService.clearAutoRestoreCheckMemo();
2653
+ }
2654
+
2655
+ function lastWrite(): Record<string, unknown> {
2656
+ return writes[writes.length - 1]!.data;
2657
+ }
2658
+
2659
+ beforeEach(() => {
2660
+ silenceLogs();
2661
+ writes = [];
2662
+ cache = new Map<string, string>();
2663
+ resetWindows();
2664
+ mockCache();
2665
+ getJestSpyOn(
2666
+ service,
2667
+ "updateColumnsByIdIfUnlockedWithoutHooks",
2668
+ ).mockImplementation(async (input: any) => {
2669
+ writes.push({ id: input.id, data: { ...input.data } });
2670
+ return true;
2671
+ });
2672
+ findOneById = getJestSpyOn(service, "findOneById").mockResolvedValue(null);
2673
+ rawQuery = mockRawQuery([]);
2674
+ mockSideEffects();
2675
+ });
2676
+
2677
+ afterEach(() => {
2678
+ resetWindows();
2679
+ });
2680
+
2681
+ describe("recordCollectorHeartbeat", () => {
2682
+ test("writes all three collector liveness columns to the row it was given", async () => {
2683
+ const before: number = Date.now();
2684
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID);
2685
+
2686
+ expect(writes).toHaveLength(1);
2687
+ expect(writes[0]!.id.toString()).toBe(DATABASE_ID.toString());
2688
+ expect(Object.keys(lastWrite()).sort()).toEqual([
2689
+ "collectorLastSeenAt",
2690
+ "lastSeenAt",
2691
+ "otelCollectorStatus",
2692
+ ]);
2693
+ expect(lastWrite()["otelCollectorStatus"]).toBe("connected");
2694
+ expect(
2695
+ (lastWrite()["lastSeenAt"] as Date).getTime(),
2696
+ ).toBeGreaterThanOrEqual(before);
2697
+ expect(lastWrite()["collectorLastSeenAt"]).toEqual(
2698
+ lastWrite()["lastSeenAt"],
2699
+ );
2700
+ });
2701
+
2702
+ test("carries the agent and engine versions", async () => {
2703
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID, {
2704
+ agentVersion: " 0.118.0 ",
2705
+ dbVersion: "16.4",
2706
+ });
2707
+
2708
+ expect(lastWrite()).toMatchObject({
2709
+ agentVersion: "0.118.0",
2710
+ dbVersion: "16.4",
2711
+ });
2712
+ });
2713
+
2714
+ test("an empty version is not written", async () => {
2715
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID, {
2716
+ agentVersion: "",
2717
+ dbVersion: " ",
2718
+ });
2719
+
2720
+ expect("agentVersion" in lastWrite()).toBe(false);
2721
+ expect("dbVersion" in lastWrite()).toBe(false);
2722
+ });
2723
+
2724
+ test("an oversized version is clamped to the column width", async () => {
2725
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID, {
2726
+ dbVersion: "x".repeat(5000),
2727
+ });
2728
+
2729
+ expect((lastWrite()["dbVersion"] as string).length).toBe(100);
2730
+ });
2731
+
2732
+ test("one heartbeat per window, however many batches arrive", async () => {
2733
+ for (let batch: number = 0; batch < 10; batch++) {
2734
+ SingleFlight.clear();
2735
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID, {
2736
+ dbVersion: "16.4",
2737
+ });
2738
+ }
2739
+
2740
+ expect(writes).toHaveLength(1);
2741
+ expect(
2742
+ cache.has(`database-server-last-seen:${DATABASE_ID.toString()}`),
2743
+ ).toBe(true);
2744
+ });
2745
+
2746
+ test("liveness survives a failing enriched write", async () => {
2747
+ getJestSpyOn(
2748
+ service,
2749
+ "updateColumnsByIdIfUnlockedWithoutHooks",
2750
+ ).mockImplementation(async (input: any) => {
2751
+ const data: Record<string, unknown> = { ...input.data };
2752
+ writes.push({ id: input.id, data: data });
2753
+ if ("dbVersion" in data) {
2754
+ throw new Error("value too long");
2755
+ }
2756
+ return true;
2757
+ });
2758
+
2759
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID, {
2760
+ dbVersion: "16.4",
2761
+ });
2762
+
2763
+ expect(writes).toHaveLength(2);
2764
+ expect(Object.keys(lastWrite()).sort()).toEqual([
2765
+ "collectorLastSeenAt",
2766
+ "lastSeenAt",
2767
+ "otelCollectorStatus",
2768
+ ]);
2769
+ });
2770
+
2771
+ test("restores a row discovery auto-archived - once per window, fleet-wide", async () => {
2772
+ const archived: DatabaseServer = databaseRow({
2773
+ isArchived: true,
2774
+ autoArchivedAt: new Date(),
2775
+ });
2776
+ archived._id = DATABASE_ID.toString();
2777
+ findOneById.mockResolvedValue(archived);
2778
+ rawQuery.mockResolvedValue([{ _id: DATABASE_ID.toString() }]);
2779
+
2780
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID);
2781
+
2782
+ expect(findOneById).toHaveBeenCalledTimes(1);
2783
+ // The archive state, plus what weighing engine evidence needs.
2784
+ expect(findOneById.mock.calls[0]![0].select).toMatchObject({
2785
+ _id: true,
2786
+ projectId: true,
2787
+ isArchived: true,
2788
+ autoArchivedAt: true,
2789
+ dbSystem: true,
2790
+ dbSystemSource: true,
2791
+ discoverySource: true,
2792
+ name: true,
2793
+ });
2794
+ expect(findOneById.mock.calls[0]![0].props).toEqual({ isRoot: true });
2795
+ expect(rawQuery).toHaveBeenCalledTimes(1);
2796
+ expect(archived.isArchived).toBe(false);
2797
+
2798
+ // The next batch in this process: the in-process memo answers.
2799
+ SingleFlight.clear();
2800
+ ResourceHeartbeat.clearRecentHeartbeatMemo();
2801
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID);
2802
+ expect(findOneById).toHaveBeenCalledTimes(1);
2803
+
2804
+ // Another pod (fresh memo): the shared gate answers.
2805
+ DatabaseServerService.clearAutoRestoreCheckMemo();
2806
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID);
2807
+ expect(findOneById).toHaveBeenCalledTimes(1);
2808
+
2809
+ // The window has passed: look again.
2810
+ DatabaseServerService.clearAutoRestoreCheckMemo();
2811
+ cache.delete(
2812
+ `database-server-auto-restore-check:${DATABASE_ID.toString()}`,
2813
+ );
2814
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID);
2815
+ expect(findOneById).toHaveBeenCalledTimes(2);
2816
+ });
2817
+
2818
+ test("a live row costs no restore statement", async () => {
2819
+ const live: DatabaseServer = databaseRow();
2820
+ live._id = DATABASE_ID.toString();
2821
+ findOneById.mockResolvedValue(live);
2822
+
2823
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID);
2824
+
2825
+ expect(rawQuery).not.toHaveBeenCalled();
2826
+ });
2827
+
2828
+ test("a Redis outage skips the restore check rather than reading per batch", async () => {
2829
+ getJestSpyOn(GlobalCache, "setStringIfNotExists").mockRejectedValue(
2830
+ new Error("redis down"),
2831
+ );
2832
+
2833
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID);
2834
+
2835
+ // Liveness still fails OPEN...
2836
+ expect(writes).toHaveLength(1);
2837
+ // ...but the restore check fails CLOSED.
2838
+ expect(findOneById).not.toHaveBeenCalled();
2839
+ });
2840
+
2841
+ test("a failing restore check never fails the heartbeat", async () => {
2842
+ findOneById.mockRejectedValue(new Error("connection terminated"));
2843
+
2844
+ await expect(
2845
+ DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID),
2846
+ ).resolves.toBeUndefined();
2847
+ });
2848
+ });
2849
+
2850
+ describe("recordSighting", () => {
2851
+ test("moves lastSeenAt only - being queried is not a connected collector", async () => {
2852
+ await DatabaseServerService.recordSighting(DATABASE_ID);
2853
+
2854
+ expect(writes).toHaveLength(1);
2855
+ expect(Object.keys(lastWrite())).toEqual(["lastSeenAt"]);
2856
+ expect(
2857
+ cache.has(`database-server-sighting:${DATABASE_ID.toString()}`),
2858
+ ).toBe(true);
2859
+ });
2860
+
2861
+ test("one sighting per window", async () => {
2862
+ for (let batch: number = 0; batch < 5; batch++) {
2863
+ SingleFlight.clear();
2864
+ await DatabaseServerService.recordSighting(DATABASE_ID);
2865
+ }
2866
+
2867
+ expect(writes).toHaveLength(1);
2868
+ });
2869
+
2870
+ /*
2871
+ * Separate namespaces: a database queried every second must not be able
2872
+ * to suppress the heartbeat that says its collector is alive.
2873
+ */
2874
+ test("a sighting never suppresses the collector heartbeat", async () => {
2875
+ await DatabaseServerService.recordSighting(DATABASE_ID);
2876
+ SingleFlight.clear();
2877
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID);
2878
+
2879
+ expect(writes).toHaveLength(2);
2880
+ expect(lastWrite()["otelCollectorStatus"]).toBe("connected");
2881
+ });
2882
+
2883
+ test("never runs the auto-archive check", async () => {
2884
+ await DatabaseServerService.recordSighting(DATABASE_ID);
2885
+
2886
+ expect(findOneById).not.toHaveBeenCalled();
2887
+ });
2888
+ });
2889
+ });
2890
+
2891
+ /*
2892
+ * ---------------------------------------------------------------------------
2893
+ * Sweeps: stale collectors, auto-archive, budget
2894
+ * ---------------------------------------------------------------------------
2895
+ */
2896
+ describe("DatabaseServerService.markDisconnectedDatabaseServers", () => {
2897
+ test("flips connected rows whose COLLECTOR went quiet, in one conditional statement", async () => {
2898
+ const query: jest.Mock = mockRawQuery([{ count: 3 }]);
2899
+ const restore: () => void = withEnv(
2900
+ "DATABASE_SERVER_COLLECTOR_STALE_MINUTES",
2901
+ undefined,
2902
+ );
2903
+
2904
+ try {
2905
+ const before: number = Date.now();
2906
+ const flipped: number =
2907
+ await DatabaseServerService.markDisconnectedDatabaseServers();
2908
+ const after: number = Date.now();
2909
+
2910
+ expect(flipped).toBe(3);
2911
+ const [sql, params] = query.mock.calls[0] as [string, Array<unknown>];
2912
+ expect(sql).toContain(`SET "otelCollectorStatus" = 'disconnected'`);
2913
+ expect(sql).toContain(`WHERE "otelCollectorStatus" = 'connected'`);
2914
+ expect(sql).toContain(`"collectorLastSeenAt" < $1`);
2915
+ expect(sql).toContain(`"deletedAt" IS NULL`);
2916
+ // An application still querying the database must not hide a dead collector.
2917
+ expect(sql).not.toContain(`"lastSeenAt"`);
2918
+
2919
+ /*
2920
+ * 15 minutes of silence, measured from the last data: the heartbeat
2921
+ * behind the ingest fence can trail it by up to 7 minutes.
2922
+ */
2923
+ const threshold: Date = params[0] as Date;
2924
+ const cutoffAge: number = (15 + 7) * 60 * 1000;
2925
+ expect(before - threshold.getTime()).toBeGreaterThanOrEqual(
2926
+ cutoffAge - 1000,
2927
+ );
2928
+ expect(after - threshold.getTime()).toBeLessThanOrEqual(cutoffAge + 1000);
2929
+ } finally {
2930
+ restore();
2931
+ }
2932
+ });
2933
+
2934
+ /*
2935
+ * e2e, the MariaDB agent: its last data reached ClickHouse at 22:57:08,
2936
+ * the heartbeat behind the ingest fence had last written
2937
+ * collectorLastSeenAt at 22:51:47, and the 23:10:00 sweep flipped it -
2938
+ * after 12m52s of silence, not the documented 15 minutes.
2939
+ */
2940
+ test("the e2e MariaDB agent is not Disconnected after 12m52s of silence, and is once 15 minutes have passed", () => {
2941
+ const lastData: number = Date.parse("2026-09-24T22:57:08.000Z");
2942
+ const collectorLastSeenAt: number = Date.parse("2026-09-24T22:51:47.000Z");
2943
+ const sweep: number = Date.parse("2026-09-24T23:10:00.190Z");
2944
+
2945
+ // The old rule: 15 minutes after the heartbeat - flipped at 23:10.
2946
+ expect(collectorLastSeenAt).toBeLessThan(sweep - 15 * 60 * 1000);
2947
+
2948
+ expect(
2949
+ collectorLastSeenAt <
2950
+ DatabaseServerService.getCollectorSilenceCutoff(
2951
+ new Date(sweep),
2952
+ ).getTime(),
2953
+ ).toBe(false);
2954
+
2955
+ // The sweep runs every five minutes; the first that flips it.
2956
+ let firstFlip: number = sweep;
2957
+ while (
2958
+ collectorLastSeenAt >=
2959
+ DatabaseServerService.getCollectorSilenceCutoff(
2960
+ new Date(firstFlip),
2961
+ ).getTime()
2962
+ ) {
2963
+ firstFlip += 5 * 60 * 1000;
2964
+ }
2965
+ expect(firstFlip - lastData).toBeGreaterThanOrEqual(15 * 60 * 1000);
2966
+ });
2967
+
2968
+ test("however far the fenced heartbeat trails the data, no database flips before 15 minutes of silence", () => {
2969
+ const lastData: number = Date.parse("2026-09-24T22:05:04.000Z");
2970
+ // The fence: 5 minutes, +25% jitter, +30 s refusal memo = up to 6.75 minutes.
2971
+ for (let lagSeconds: number = 0; lagSeconds <= 405; lagSeconds += 15) {
2972
+ const collectorLastSeenAt: number = lastData - lagSeconds * 1000;
2973
+ const silentFor: number = 14 * 60 * 1000 + 59 * 1000;
2974
+ const cutoff: Date = DatabaseServerService.getCollectorSilenceCutoff(
2975
+ new Date(lastData + silentFor),
2976
+ );
2977
+ expect(collectorLastSeenAt < cutoff.getTime()).toBe(false);
2978
+ }
2979
+ });
2980
+
2981
+ test("a string count from the driver is still a number", async () => {
2982
+ mockRawQuery([{ count: "7" }]);
2983
+
2984
+ await expect(
2985
+ DatabaseServerService.markDisconnectedDatabaseServers(),
2986
+ ).resolves.toBe(7);
2987
+ });
2988
+
2989
+ test("an empty result is 0", async () => {
2990
+ mockRawQuery([]);
2991
+
2992
+ await expect(
2993
+ DatabaseServerService.markDisconnectedDatabaseServers(),
2994
+ ).resolves.toBe(0);
2995
+ });
2996
+ });
2997
+
2998
+ describe("DatabaseServerService.getCollectorStaleThresholdMinutes", () => {
2999
+ test.each([
3000
+ [undefined, 15],
3001
+ ["", 15],
3002
+ ["30", 30],
3003
+ ["10", 10],
3004
+ // Below the floor flaps healthy databases against the ingest fence.
3005
+ ["5", 10],
3006
+ ["0", 10],
3007
+ ["-3", 10],
3008
+ ["abc", 15],
3009
+ ["12.5", 15],
3010
+ ])("%s -> %s minutes", (value: string | undefined, expected: number) => {
3011
+ const restore: () => void = withEnv(
3012
+ "DATABASE_SERVER_COLLECTOR_STALE_MINUTES",
3013
+ value,
3014
+ );
3015
+ try {
3016
+ expect(DatabaseServerService.getCollectorStaleThresholdMinutes()).toBe(
3017
+ expected,
3018
+ );
3019
+ } finally {
3020
+ restore();
3021
+ }
3022
+ });
3023
+ });
3024
+
3025
+ describe("DatabaseServerService.autoArchiveStaleDatabaseServers", () => {
3026
+ let feed: jest.SpyInstance;
3027
+
3028
+ beforeEach(() => {
3029
+ silenceLogs();
3030
+ feed = mockSideEffects().feed;
3031
+ });
3032
+
3033
+ test("archives in ONE statement that checks every 'untouched' condition itself", async () => {
3034
+ const query: jest.Mock = mockRawQuery([]);
3035
+ const restore: () => void = withEnv(
3036
+ "DATABASE_SERVER_AUTO_ARCHIVE_DAYS",
3037
+ undefined,
3038
+ );
3039
+
3040
+ try {
3041
+ const before: number = Date.now();
3042
+ await DatabaseServerService.autoArchiveStaleDatabaseServers();
3043
+
3044
+ expect(query).toHaveBeenCalledTimes(1);
3045
+ const [sql, params] = query.mock.calls[0] as [string, Array<unknown>];
3046
+
3047
+ // Discovered, live, not yet archived, unseen since the cutoff.
3048
+ expect(sql).toContain(`ds."deletedAt" IS NULL`);
3049
+ expect(sql).toContain(`ds."isArchived" = false`);
3050
+ expect(sql).toContain(`ds."discoverySource" IS NOT NULL`);
3051
+ expect(sql).toContain(`ds."discoverySource" <> $3`);
3052
+ expect(params[2]).toBe("manual");
3053
+ expect(sql).toContain(`COALESCE(ds."lastSeenAt", ds."createdAt") < $1`);
3054
+ const cutoff: Date = params[0] as Date;
3055
+ const sevenDays: number = 7 * 24 * 3600 * 1000;
3056
+ expect(before - cutoff.getTime()).toBeGreaterThanOrEqual(
3057
+ sevenDays - 1000,
3058
+ );
3059
+ expect(before - cutoff.getTime()).toBeLessThanOrEqual(sevenDays + 5000);
3060
+
3061
+ // No retention override.
3062
+ expect(sql).toContain(`ds."retainTelemetryDataForDays" IS NULL`);
3063
+ expect(sql).toContain(`ds."telemetryRetentionConfig" IS NULL`);
3064
+
3065
+ // Nobody invested in it: every join is scoped to the row's project and live rows.
3066
+ for (const table of [
3067
+ "DatabaseServerLabel",
3068
+ "DatabaseServerOwnerUser",
3069
+ "DatabaseServerOwnerTeam",
3070
+ "IncidentDatabaseServer",
3071
+ "AlertDatabaseServer",
3072
+ "ScheduledMaintenanceDatabaseServer",
3073
+ "DatabaseServerEndpoint",
3074
+ ]) {
3075
+ expect(sql).toMatch(
3076
+ new RegExp(`NOT EXISTS \\(\\s*SELECT 1 FROM "${table}"`),
3077
+ );
3078
+ }
3079
+ expect((sql.match(/"projectId" = ds\."projectId"/g) || []).length).toBe(
3080
+ 7,
3081
+ );
3082
+ // The row, its seven investment joins and its three possible parents.
3083
+ expect((sql.match(/"deletedAt" IS NULL/g) || []).length).toBe(11);
3084
+ expect(sql).toContain(`e."source" = 'user'`);
3085
+
3086
+ /*
3087
+ * Only a PERSON's labels and owners count: the ones rules and ingest
3088
+ * attached (automaticAssignments) are excluded inside each join.
3089
+ */
3090
+ expect(sql).toContain(
3091
+ `NOT (COALESCE(ds."automaticAssignments" -> 'labelIds', '[]'::jsonb) @> jsonb_build_array(l."labelId"::text))`,
3092
+ );
3093
+ expect(sql).toContain(
3094
+ `NOT (COALESCE(ds."automaticAssignments" -> 'ownerUserIds', '[]'::jsonb) @> jsonb_build_array(ou."userId"::text))`,
3095
+ );
3096
+ expect(sql).toContain(
3097
+ `NOT (COALESCE(ds."automaticAssignments" -> 'ownerTeamIds', '[]'::jsonb) @> jsonb_build_array(ot."teamId"::text))`,
3098
+ );
3099
+
3100
+ /*
3101
+ * Staleness is anchored to the cluster / host the row was found on:
3102
+ * while the parent is dark, its databases are not "unseen".
3103
+ */
3104
+ expect(sql).toMatch(
3105
+ /LEFT JOIN "KubernetesCluster" kc\s+ON kc\."_id" = ds\."kubernetesClusterId" AND kc\."deletedAt" IS NULL/,
3106
+ );
3107
+ expect(sql).toMatch(
3108
+ /LEFT JOIN "DockerHost" dh\s+ON dh\."_id" = ds\."dockerHostId" AND dh\."deletedAt" IS NULL/,
3109
+ );
3110
+ expect(sql).toMatch(
3111
+ /LEFT JOIN "PodmanHost" ph\s+ON ph\."_id" = ds\."podmanHostId" AND ph\."deletedAt" IS NULL/,
3112
+ );
3113
+ expect(sql).toContain(
3114
+ `COALESCE(ds."lastSeenAt", ds."createdAt") < COALESCE(kc."lastSeenAt", dh."lastSeenAt", ph."lastSeenAt", $2) - INTERVAL '1 hour'`,
3115
+ );
3116
+
3117
+ /*
3118
+ * A person's Restore holds until the row is seen again or the grace
3119
+ * period (30 days, or the archive window if longer) passes.
3120
+ */
3121
+ expect(sql).toContain(`ds."manuallyRestoredAt" IS NULL`);
3122
+ expect(sql).toContain(`ds."manuallyRestoredAt" < $5`);
3123
+ expect(sql).toContain(
3124
+ `COALESCE(ds."lastSeenAt", ds."createdAt") > ds."manuallyRestoredAt"`,
3125
+ );
3126
+ const graceCutoff: Date = params[4] as Date;
3127
+ const thirtyDays: number = 30 * 24 * 3600 * 1000;
3128
+ expect(before - graceCutoff.getTime()).toBeGreaterThanOrEqual(
3129
+ thirtyDays - 1000,
3130
+ );
3131
+ expect(before - graceCutoff.getTime()).toBeLessThanOrEqual(
3132
+ thirtyDays + 5000,
3133
+ );
3134
+
3135
+ // Marked as discovery's own archive, bounded, and never re-archiving.
3136
+ expect(sql).toContain(`"autoArchivedAt" = $2`);
3137
+ expect(sql).toContain(`"isArchived" = true`);
3138
+ expect(sql).toContain(`stale."isArchived" = false`);
3139
+ expect(sql).toContain("LIMIT $4");
3140
+ expect(params[3]).toBe(500);
3141
+ expect(params).toHaveLength(5);
3142
+ } finally {
3143
+ restore();
3144
+ }
3145
+ });
3146
+
3147
+ test("records why on each archived row's feed, and returns the count", async () => {
3148
+ const a: ObjectID = ObjectID.generate();
3149
+ const b: ObjectID = ObjectID.generate();
3150
+ mockRawQuery([
3151
+ { _id: a.toString(), projectId: PROJECT_ID.toString() },
3152
+ { _id: b.toString(), projectId: PROJECT_ID.toString() },
3153
+ ]);
3154
+
3155
+ const archived: number =
3156
+ await DatabaseServerService.autoArchiveStaleDatabaseServers();
3157
+
3158
+ expect(archived).toBe(2);
3159
+ expect(feed).toHaveBeenCalledTimes(2);
3160
+ const item: any = feed.mock.calls[0]![0];
3161
+ expect(item.databaseServerId.toString()).toBe(a.toString());
3162
+ expect(item.projectId.toString()).toBe(PROJECT_ID.toString());
3163
+ expect(item.databaseServerFeedEventType).toBe(
3164
+ DatabaseServerFeedEventType.DatabaseServerArchived,
3165
+ );
3166
+ expect(item.feedInfoInMarkdown).toContain(
3167
+ "was automatically archived - not seen for 7 days.",
3168
+ );
3169
+ expect(item.moreInformationInMarkdown).toContain(
3170
+ "restored automatically as soon as it is seen again",
3171
+ );
3172
+ expect(item.userId).toBeUndefined();
3173
+ });
3174
+
3175
+ /*
3176
+ * Application traces never bring back an archived workload row (its
3177
+ * workload is gone by then), so its feed item does not promise they will.
3178
+ */
3179
+ test("a workload's archive feed item says only its workload or a collector brings it back", async () => {
3180
+ const workload: ObjectID = ObjectID.generate();
3181
+ const traces: ObjectID = ObjectID.generate();
3182
+ const query: jest.Mock = mockRawQuery([
3183
+ {
3184
+ _id: workload.toString(),
3185
+ projectId: PROJECT_ID.toString(),
3186
+ workloadIdentifier: "postgresql|kubernetes:prod/data/statefulset/pg",
3187
+ },
3188
+ {
3189
+ _id: traces.toString(),
3190
+ projectId: PROJECT_ID.toString(),
3191
+ workloadIdentifier: null,
3192
+ },
3193
+ ]);
3194
+
3195
+ await DatabaseServerService.autoArchiveStaleDatabaseServers();
3196
+
3197
+ const [sql] = query.mock.calls[0] as [string, Array<unknown>];
3198
+ expect(sql).toContain(
3199
+ `RETURNING stale."_id" AS "_id", stale."projectId" AS "projectId", stale."workloadIdentifier" AS "workloadIdentifier"`,
3200
+ );
3201
+ const workloadItem: any = feed.mock.calls[0]![0];
3202
+ expect(workloadItem.databaseServerId.toString()).toBe(workload.toString());
3203
+ expect(workloadItem.moreInformationInMarkdown).toContain(
3204
+ "It is restored automatically when its Kubernetes, Docker or Podman workload, or a collector, reports it again - application traces alone do not restore it.",
3205
+ );
3206
+ expect(workloadItem.moreInformationInMarkdown).not.toContain(
3207
+ "as soon as it is seen again",
3208
+ );
3209
+ const tracesItem: any = feed.mock.calls[1]![0];
3210
+ expect(tracesItem.moreInformationInMarkdown).toContain(
3211
+ "restored automatically as soon as it is seen again",
3212
+ );
3213
+ });
3214
+
3215
+ test("the day count follows DATABASE_SERVER_AUTO_ARCHIVE_DAYS", async () => {
3216
+ const query: jest.Mock = mockRawQuery([
3217
+ { _id: ObjectID.generate().toString(), projectId: PROJECT_ID.toString() },
3218
+ ]);
3219
+ const restore: () => void = withEnv(
3220
+ "DATABASE_SERVER_AUTO_ARCHIVE_DAYS",
3221
+ "1",
3222
+ );
3223
+
3224
+ try {
3225
+ const before: number = Date.now();
3226
+ await DatabaseServerService.autoArchiveStaleDatabaseServers();
3227
+
3228
+ const cutoff: Date = (
3229
+ query.mock.calls[0]![1] as Array<unknown>
3230
+ )[0] as Date;
3231
+ expect(before - cutoff.getTime()).toBeLessThanOrEqual(
3232
+ 24 * 3600 * 1000 + 5000,
3233
+ );
3234
+ expect(feed.mock.calls[0]![0].feedInfoInMarkdown).toContain(
3235
+ "not seen for 1 day.",
3236
+ );
3237
+ } finally {
3238
+ restore();
3239
+ }
3240
+ });
3241
+
3242
+ test("nothing stale means no feed writes", async () => {
3243
+ mockRawQuery([]);
3244
+
3245
+ await expect(
3246
+ DatabaseServerService.autoArchiveStaleDatabaseServers(),
3247
+ ).resolves.toBe(0);
3248
+ expect(feed).not.toHaveBeenCalled();
3249
+ });
3250
+
3251
+ test("a failing feed write never stops the rest of the batch", async () => {
3252
+ mockRawQuery([
3253
+ { _id: ObjectID.generate().toString(), projectId: PROJECT_ID.toString() },
3254
+ { _id: ObjectID.generate().toString(), projectId: PROJECT_ID.toString() },
3255
+ ]);
3256
+ getJestSpyOn(service, "getDatabaseServerMarkdownLink")
3257
+ .mockRejectedValueOnce(new Error("row gone"))
3258
+ .mockResolvedValue("[Database x](/x)");
3259
+
3260
+ await expect(
3261
+ DatabaseServerService.autoArchiveStaleDatabaseServers(),
3262
+ ).resolves.toBe(2);
3263
+ expect(feed).toHaveBeenCalledTimes(1);
3264
+ });
3265
+ });
3266
+
3267
+ describe("DatabaseServerService.getAutoArchiveDays", () => {
3268
+ test.each([
3269
+ [undefined, 7],
3270
+ ["14", 14],
3271
+ ["1", 1],
3272
+ ["0", 1],
3273
+ ["nope", 7],
3274
+ ])("%s -> %s days", (value: string | undefined, expected: number) => {
3275
+ const restore: () => void = withEnv(
3276
+ "DATABASE_SERVER_AUTO_ARCHIVE_DAYS",
3277
+ value,
3278
+ );
3279
+ try {
3280
+ expect(DatabaseServerService.getAutoArchiveDays()).toBe(expected);
3281
+ } finally {
3282
+ restore();
3283
+ }
3284
+ });
3285
+ });
3286
+
3287
+ describe("DatabaseServerService auto-create budget", () => {
3288
+ test.each([
3289
+ [undefined, 500],
3290
+ ["50", 50],
3291
+ ["0", 0],
3292
+ ["-1", 0],
3293
+ ["x", 500],
3294
+ ])("budget %s -> %s", (value: string | undefined, expected: number) => {
3295
+ const restore: () => void = withEnv(
3296
+ "DATABASE_SERVER_AUTO_CREATE_BUDGET",
3297
+ value,
3298
+ );
3299
+ try {
3300
+ expect(DatabaseServerService.getAutoCreateBudget()).toBe(expected);
3301
+ } finally {
3302
+ restore();
3303
+ }
3304
+ });
3305
+
3306
+ test("counts live, non-archived DISCOVERED rows of this project only - collector rows included", async () => {
3307
+ const query: jest.Mock = mockRawQuery([{ count: 499 }]);
3308
+ const restore: () => void = withEnv(
3309
+ "DATABASE_SERVER_AUTO_CREATE_BUDGET",
3310
+ undefined,
3311
+ );
3312
+
3313
+ try {
3314
+ await expect(
3315
+ DatabaseServerService.isUnderAutoCreateBudget(PROJECT_ID),
3316
+ ).resolves.toBe(true);
3317
+
3318
+ const [sql, params] = query.mock.calls[0] as [string, Array<unknown>];
3319
+ expect(sql).toContain(`"projectId" = $1`);
3320
+ expect(sql).toContain(`"deletedAt" IS NULL`);
3321
+ expect(sql).toContain(`"isArchived" = false`);
3322
+ /*
3323
+ * Only a person's rows are exempt. A collector keyed on pod IPs mints
3324
+ * rows too, so its rows count against the budget.
3325
+ */
3326
+ expect(sql).toContain(`COALESCE("discoverySource", '') <> $2`);
3327
+ expect(params).toEqual([PROJECT_ID.toString(), "manual"]);
3328
+ } finally {
3329
+ restore();
3330
+ }
3331
+ });
3332
+
3333
+ test("at the budget, discovery stops creating", async () => {
3334
+ mockRawQuery([{ count: "500" }]);
3335
+
3336
+ await expect(
3337
+ DatabaseServerService.isUnderAutoCreateBudget(PROJECT_ID),
3338
+ ).resolves.toBe(false);
3339
+ });
3340
+
3341
+ test("a zero budget turns auto-creation off without a query", async () => {
3342
+ const query: jest.Mock = mockRawQuery([{ count: 0 }]);
3343
+ const restore: () => void = withEnv(
3344
+ "DATABASE_SERVER_AUTO_CREATE_BUDGET",
3345
+ "0",
3346
+ );
3347
+
3348
+ try {
3349
+ await expect(
3350
+ DatabaseServerService.isUnderAutoCreateBudget(PROJECT_ID),
3351
+ ).resolves.toBe(false);
3352
+ expect(query).not.toHaveBeenCalled();
3353
+ } finally {
3354
+ restore();
3355
+ }
3356
+ });
3357
+ });
3358
+
3359
+ /*
3360
+ * ---------------------------------------------------------------------------
3361
+ * Updates, labels, links
3362
+ * ---------------------------------------------------------------------------
3363
+ */
3364
+ describe("DatabaseServerService.onUpdateSuccess", () => {
3365
+ let clearWrites: jest.SpyInstance;
3366
+ let feed: jest.SpyInstance;
3367
+
3368
+ function onUpdate(data: Record<string, unknown>): any {
3369
+ return {
3370
+ updateBy: {
3371
+ query: {},
3372
+ data: data,
3373
+ props: { userId: USER_ID },
3374
+ },
3375
+ carryForward: null,
3376
+ };
3377
+ }
3378
+
3379
+ beforeEach(() => {
3380
+ silenceLogs();
3381
+ feed = mockSideEffects().feed;
3382
+ clearWrites = getJestSpyOn(
3383
+ service,
3384
+ "updateColumnsByIdWithoutHooks",
3385
+ ).mockResolvedValue(undefined);
3386
+ getJestSpyOn(service, "findOneById").mockResolvedValue(databaseRow());
3387
+ });
3388
+
3389
+ /*
3390
+ * A person archiving (or restoring) a row takes it out of discovery's
3391
+ * hands: from then on only a person restores it.
3392
+ */
3393
+ test.each([[true], [false]])(
3394
+ "a person setting isArchived=%s clears autoArchivedAt",
3395
+ async (isArchived: boolean) => {
3396
+ const id: ObjectID = ObjectID.generate();
3397
+ const before: number = Date.now();
3398
+
3399
+ await service.onUpdateSuccess(onUpdate({ isArchived }), [id]);
3400
+
3401
+ expect(clearWrites).toHaveBeenCalledTimes(1);
3402
+ const write: any = clearWrites.mock.calls[0]![0];
3403
+ expect(write.id).toBe(id);
3404
+ expect(write.skipUpdateDateColumn).toBe(true);
3405
+ expect(write.data.autoArchivedAt).toBeNull();
3406
+ /*
3407
+ * A Restore is stamped so the sweep leaves the row alone; an archive
3408
+ * clears the stamp.
3409
+ */
3410
+ if (isArchived) {
3411
+ expect(write.data.manuallyRestoredAt).toBeNull();
3412
+ } else {
3413
+ expect(
3414
+ (write.data.manuallyRestoredAt as Date).getTime(),
3415
+ ).toBeGreaterThanOrEqual(before);
3416
+ }
3417
+ await flushPromises();
3418
+ expect(feed.mock.calls[0]![0].databaseServerFeedEventType).toBe(
3419
+ isArchived
3420
+ ? DatabaseServerFeedEventType.DatabaseServerArchived
3421
+ : DatabaseServerFeedEventType.DatabaseServerRestored,
3422
+ );
3423
+ expect(feed.mock.calls[0]![0].userId).toBe(USER_ID);
3424
+ },
3425
+ );
3426
+
3427
+ test("an edit that is not an archive change leaves autoArchivedAt alone", async () => {
3428
+ await service.onUpdateSuccess(onUpdate({ name: "Orders" }), [
3429
+ ObjectID.generate(),
3430
+ ]);
3431
+
3432
+ expect(clearWrites).not.toHaveBeenCalled();
3433
+ await flushPromises();
3434
+ expect(feed.mock.calls[0]![0].databaseServerFeedEventType).toBe(
3435
+ DatabaseServerFeedEventType.DatabaseServerUpdated,
3436
+ );
3437
+ expect(feed.mock.calls[0]![0].moreInformationInMarkdown).toContain(
3438
+ "`name`",
3439
+ );
3440
+ });
3441
+
3442
+ test("bookkeeping columns never earn a feed item", async () => {
3443
+ await service.onUpdateSuccess(
3444
+ onUpdate({ lastSeenAt: new Date(), otelCollectorStatus: "connected" }),
3445
+ [ObjectID.generate()],
3446
+ );
3447
+ await flushPromises();
3448
+
3449
+ expect(feed).not.toHaveBeenCalled();
3450
+ });
3451
+
3452
+ test("a failing clear is logged, not thrown", async () => {
3453
+ clearWrites.mockRejectedValue(new Error("connection terminated"));
3454
+
3455
+ await expect(
3456
+ service.onUpdateSuccess(onUpdate({ isArchived: true }), [
3457
+ ObjectID.generate(),
3458
+ ]),
3459
+ ).resolves.toBeDefined();
3460
+ });
3461
+ });
3462
+
3463
+ describe("DatabaseServerService.attachLabels", () => {
3464
+ test("adds only the labels the database is missing, and caches the set", async () => {
3465
+ const existing: Label = new Label(ObjectID.generate());
3466
+ const added: ObjectID = ObjectID.generate();
3467
+ const add: jest.Mock = jest.fn(async () => {
3468
+ return undefined;
3469
+ });
3470
+ const builder: any = {
3471
+ createQueryBuilder: () => {
3472
+ return builder;
3473
+ },
3474
+ relation: () => {
3475
+ return builder;
3476
+ },
3477
+ of: () => {
3478
+ return builder;
3479
+ },
3480
+ loadMany: async () => {
3481
+ return [existing];
3482
+ },
3483
+ add: add,
3484
+ };
3485
+ getJestSpyOn(service, "getRepository").mockReturnValue(builder);
3486
+ getJestSpyOn(GlobalCache, "getString").mockResolvedValue(null);
3487
+ const setString: jest.SpyInstance = getJestSpyOn(
3488
+ GlobalCache,
3489
+ "setString",
3490
+ ).mockResolvedValue(undefined);
3491
+
3492
+ await DatabaseServerService.attachLabels({
3493
+ databaseServerId: ObjectID.generate(),
3494
+ labelIds: [existing.id!, added, added],
3495
+ });
3496
+
3497
+ expect(add).toHaveBeenCalledWith([added.toString()]);
3498
+ expect(setString.mock.calls[0]![0]).toBe("database-server-labels-applied");
3499
+ });
3500
+
3501
+ test("a label set applied within the minute costs no join-table read", async () => {
3502
+ const labelId: ObjectID = ObjectID.generate();
3503
+ getJestSpyOn(GlobalCache, "getString").mockResolvedValue(
3504
+ crypto.createHash("sha256").update(labelId.toString()).digest("hex"),
3505
+ );
3506
+ const getRepository: jest.SpyInstance = getJestSpyOn(
3507
+ service,
3508
+ "getRepository",
3509
+ );
3510
+
3511
+ await DatabaseServerService.attachLabels({
3512
+ databaseServerId: ObjectID.generate(),
3513
+ labelIds: [labelId],
3514
+ });
3515
+
3516
+ expect(getRepository).not.toHaveBeenCalled();
3517
+ });
3518
+
3519
+ test("no labels is a no-op", async () => {
3520
+ const getString: jest.SpyInstance = getJestSpyOn(GlobalCache, "getString");
3521
+
3522
+ await DatabaseServerService.attachLabels({
3523
+ databaseServerId: ObjectID.generate(),
3524
+ labelIds: [],
3525
+ });
3526
+
3527
+ expect(getString).not.toHaveBeenCalled();
3528
+ });
3529
+ });
3530
+
3531
+ describe("DatabaseServerService names and links", () => {
3532
+ test("links to /databases/<id> and names the database in feed markdown", async () => {
3533
+ const id: ObjectID = ObjectID.generate();
3534
+ getJestSpyOn(service, "findOneById").mockResolvedValue(
3535
+ databaseRow({ name: "PostgreSQL orders-db.example.com:5432" }),
3536
+ );
3537
+ getJestSpyOn(DatabaseConfig, "getDashboardUrl").mockResolvedValue(
3538
+ URL.fromString("https://oneuptime.example.com/dashboard"),
3539
+ );
3540
+
3541
+ const markdown: string =
3542
+ await DatabaseServerService.getDatabaseServerMarkdownLink(PROJECT_ID, id);
3543
+
3544
+ expect(markdown).toBe(
3545
+ `[Database PostgreSQL orders-db.example.com:5432](https://oneuptime.example.com/dashboard/${PROJECT_ID.toString()}/databases/${id.toString()})`,
3546
+ );
3547
+ });
3548
+
3549
+ test("a missing row names as an empty string instead of throwing", async () => {
3550
+ getJestSpyOn(service, "findOneById").mockResolvedValue(null);
3551
+
3552
+ await expect(
3553
+ DatabaseServerService.getDatabaseServerName({
3554
+ databaseServerId: ObjectID.generate(),
3555
+ }),
3556
+ ).resolves.toBe("");
3557
+ });
3558
+ });