@oneuptime/common 14.0.2 → 14.0.5

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 (765) hide show
  1. package/Models/DatabaseModels/Alert.ts +55 -0
  2. package/Models/DatabaseModels/AlertFeed.ts +2 -0
  3. package/Models/DatabaseModels/DatabaseServer.ts +1650 -0
  4. package/Models/DatabaseModels/DatabaseServerEndpoint.ts +540 -0
  5. package/Models/DatabaseModels/DatabaseServerFeed.ts +625 -0
  6. package/Models/DatabaseModels/DatabaseServerLabelRule.ts +514 -0
  7. package/Models/DatabaseModels/DatabaseServerOwnerRule.ts +596 -0
  8. package/Models/DatabaseModels/DatabaseServerOwnerTeam.ts +491 -0
  9. package/Models/DatabaseModels/DatabaseServerOwnerUser.ts +490 -0
  10. package/Models/DatabaseModels/Incident.ts +55 -0
  11. package/Models/DatabaseModels/IncidentAlert.ts +540 -0
  12. package/Models/DatabaseModels/IncidentFeed.ts +2 -0
  13. package/Models/DatabaseModels/Index.ts +18 -0
  14. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +26 -0
  15. package/Models/DatabaseModels/Project.ts +60 -0
  16. package/Models/DatabaseModels/RumApplication.ts +52 -1
  17. package/Models/DatabaseModels/ScheduledMaintenance.ts +55 -0
  18. package/Models/DatabaseModels/TelemetryException.ts +2 -0
  19. package/Models/DatabaseModels/UserProjectSsoConsent.ts +146 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.ts +673 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.ts +51 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  25. package/Server/Middleware/MultipartFormData.ts +1 -1
  26. package/Server/Middleware/TelemetryIngest.ts +15 -8
  27. package/Server/Services/AlertReminderRuleService.ts +100 -24
  28. package/Server/Services/DatabaseServerEndpointService.ts +895 -0
  29. package/Server/Services/DatabaseServerFeedService.ts +128 -0
  30. package/Server/Services/DatabaseServerLabelRuleEngineService.ts +340 -0
  31. package/Server/Services/DatabaseServerLabelRuleService.ts +14 -0
  32. package/Server/Services/DatabaseServerOwnerRuleEngineService.ts +425 -0
  33. package/Server/Services/DatabaseServerOwnerRuleService.ts +14 -0
  34. package/Server/Services/DatabaseServerOwnerTeamService.ts +180 -0
  35. package/Server/Services/DatabaseServerOwnerUserService.ts +173 -0
  36. package/Server/Services/DatabaseServerService.ts +4546 -0
  37. package/Server/Services/DatabaseService.ts +14 -8
  38. package/Server/Services/DockerResourceService.ts +43 -2
  39. package/Server/Services/IncidentAlertService.ts +1811 -0
  40. package/Server/Services/IncidentReminderRuleService.ts +103 -23
  41. package/Server/Services/IncidentService.ts +210 -10
  42. package/Server/Services/IncidentStateTimelineService.ts +24 -0
  43. package/Server/Services/Index.ts +18 -0
  44. package/Server/Services/InventoryItemService.ts +113 -5
  45. package/Server/Services/IoTDeviceService.ts +145 -37
  46. package/Server/Services/OpenTelemetryIngestService.ts +19 -0
  47. package/Server/Services/PodmanResourceService.ts +43 -2
  48. package/Server/Services/ScheduledMaintenanceReminderRuleService.ts +107 -23
  49. package/Server/Services/ServiceService.ts +1 -1
  50. package/Server/Services/TelemetryUsageBillingService.ts +29 -2
  51. package/Server/Services/UserProjectSsoConsentService.ts +74 -0
  52. package/Server/Services/UserService.ts +14 -1
  53. package/Server/Types/Database/Permissions/OwnerTableRegistry.ts +18 -0
  54. package/Server/Types/Database/QueryHelper.ts +30 -1
  55. package/Server/Utils/AI/Toolbox/ResourceTools.ts +69 -1
  56. package/Server/Utils/Database/AffectedResourceRelations.ts +6 -0
  57. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +188 -6
  58. package/Server/Utils/Monitor/MonitorAlert.ts +28 -1
  59. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +2262 -755
  60. package/Server/Utils/Monitor/MonitorIncident.ts +28 -1
  61. package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +268 -6
  62. package/Server/Utils/Monitor/MonitorResource.ts +47 -11
  63. package/Server/Utils/Monitor/MonitorResourceContext.ts +1 -0
  64. package/Server/Utils/Monitor/MonitorStepResourceIdentity.ts +239 -21
  65. package/Server/Utils/Monitor/PerSeriesResolutionRootCause.ts +43 -0
  66. package/Server/Utils/Monitor/SeriesResourceLabels.ts +291 -0
  67. package/Server/Utils/Monitor/SeriesResourceLinker.ts +111 -12
  68. package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +25 -0
  69. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
  70. package/Server/Utils/StartServer.ts +26 -6
  71. package/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.ts +2 -0
  72. package/Server/Utils/Telemetry/DatabaseEndpointDiscovery.ts +688 -0
  73. package/Server/Utils/Telemetry/EntityRegistry.ts +5 -1
  74. package/Server/Utils/Telemetry/LogExceptionExtractor.ts +18 -1
  75. package/Server/Utils/Telemetry/ResourceEntityFilter.ts +175 -12
  76. package/Server/Utils/Telemetry/ResourceFacetResolver.ts +12 -0
  77. package/Server/Utils/Telemetry/ServiceDependencyDiscovery.ts +606 -36
  78. package/Server/Utils/Telemetry/Telemetry.ts +16 -0
  79. package/Server/Utils/Telemetry/TelemetryEntity.ts +22 -22
  80. package/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.ts +16 -10
  81. package/Tests/App/Accounts/SignedInRedirect.test.tsx +241 -0
  82. package/Tests/App/AdminDashboard/AdminHeaderSmallScreens.test.tsx +221 -4
  83. package/Tests/App/Dashboard/AIInsightsResourcePageContext.test.ts +42 -0
  84. package/Tests/App/Dashboard/AffectedResourcesDisplay.test.tsx +100 -0
  85. package/Tests/App/Dashboard/AffectedResourcesPicker.test.tsx +86 -0
  86. package/Tests/App/Dashboard/AffectedResourcesPickerHiddenTypes.test.tsx +2 -0
  87. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +19 -0
  88. package/Tests/App/Dashboard/BreadcrumbCoverage.test.tsx +14 -0
  89. package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
  90. package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
  91. package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
  92. package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
  93. package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
  94. package/Tests/App/Dashboard/CorrelateFilterBuilder.test.tsx +26 -1
  95. package/Tests/App/Dashboard/DashboardAppSsoError.test.tsx +501 -0
  96. package/Tests/App/Dashboard/DashboardHeaderRequests.test.tsx +64 -1
  97. package/Tests/App/Dashboard/DashboardHeaderSmallScreens.test.tsx +178 -18
  98. package/Tests/App/Dashboard/DashboardMasterPageSsoRedirect.test.tsx +444 -0
  99. package/Tests/App/Dashboard/DashboardNavBarForeignHiddenRule.test.tsx +573 -0
  100. package/Tests/App/Dashboard/DatabaseChartAxes.test.tsx +431 -0
  101. package/Tests/App/Dashboard/DatabaseContainerDetailBadge.test.tsx +249 -0
  102. package/Tests/App/Dashboard/DatabaseDashboardE2EFixes.test.ts +681 -0
  103. package/Tests/App/Dashboard/DatabaseDocumentationMarkdown.test.ts +1801 -0
  104. package/Tests/App/Dashboard/DatabaseFleetSummaryRefresh.test.tsx +280 -0
  105. package/Tests/App/Dashboard/DatabaseFormHelpers.test.tsx +321 -0
  106. package/Tests/App/Dashboard/DatabaseMetricMonitorLink.test.tsx +795 -0
  107. package/Tests/App/Dashboard/DatabaseOverviewHeader.test.tsx +580 -0
  108. package/Tests/App/Dashboard/DatabaseRecommendationsEngineMetrics.test.ts +270 -0
  109. package/Tests/App/Dashboard/DatabaseServerComponents.test.tsx +1486 -0
  110. package/Tests/App/Dashboard/DatabaseServerEngineMetricsFilter.test.ts +223 -0
  111. package/Tests/App/Dashboard/DatabaseServerPresentation.test.ts +435 -0
  112. package/Tests/App/Dashboard/DatabaseServerSummary.test.ts +186 -0
  113. package/Tests/App/Dashboard/DatabaseServerTelemetryQueries.test.ts +1553 -0
  114. package/Tests/App/Dashboard/DatabaseTableTruncation.test.tsx +326 -0
  115. package/Tests/App/Dashboard/DatabaseTelemetryScope.test.ts +512 -0
  116. package/Tests/App/Dashboard/DatabaseTelemetryTabs.test.tsx +774 -0
  117. package/Tests/App/Dashboard/DatabaseViewLayout.test.tsx +233 -0
  118. package/Tests/App/Dashboard/DatabaseWorkloadBadge.test.tsx +972 -0
  119. package/Tests/App/Dashboard/DiscoveryReviewInventoryRefresh.test.tsx +5 -0
  120. package/Tests/App/Dashboard/DiscoveryReviewUnnamedHostHint.test.tsx +1061 -0
  121. package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
  122. package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
  123. package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
  124. package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
  125. package/Tests/App/Dashboard/EventOverlayScope.test.ts +147 -0
  126. package/Tests/App/Dashboard/EventOverlayScopeHook.test.tsx +1 -0
  127. package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
  128. package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
  129. package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
  130. package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
  131. package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
  132. package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
  133. package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
  134. package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
  135. package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
  136. package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
  137. package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
  138. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
  139. package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
  140. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
  141. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
  142. package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
  143. package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
  144. package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
  145. package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
  146. package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
  147. package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
  148. package/Tests/App/Dashboard/LogsResourceFacetChips.test.tsx +1 -0
  149. package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
  150. package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
  151. package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
  152. package/Tests/App/Dashboard/MetricsViewerDrillDown.test.tsx +310 -0
  153. package/Tests/App/Dashboard/MetricsViewerEntityScopedRows.test.tsx +495 -0
  154. package/Tests/App/Dashboard/MonitorCreateFromMonitorBackedDevice.test.tsx +132 -1
  155. package/Tests/App/Dashboard/NavigationSearchKeywords.test.tsx +5 -0
  156. package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
  157. package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
  158. package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
  159. package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
  160. package/Tests/App/Dashboard/ProductOrientation.test.tsx +4 -3
  161. package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
  162. package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
  163. package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
  164. package/Tests/App/Dashboard/RecommendationsLoadedContext.test.tsx +349 -0
  165. package/Tests/App/Dashboard/ResourceArchiveSettings.test.tsx +62 -0
  166. package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +8 -1
  167. package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +14 -0
  168. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
  169. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
  170. package/Tests/App/Dashboard/ScheduledMaintenanceOverviewPage.test.tsx +2 -0
  171. package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
  172. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +1085 -0
  173. package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
  174. package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
  175. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
  176. package/Tests/App/Dashboard/TelemetryResourceRetentionSettings.test.tsx +10 -0
  177. package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
  178. package/Tests/App/Dashboard/TracesResourceFacets.test.tsx +2 -1
  179. package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
  180. package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
  181. package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
  182. package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
  183. package/Tests/App/StatusPage/StatusPageHeaderForeignHiddenRule.test.tsx +284 -0
  184. package/Tests/ClassStrings.ts +178 -0
  185. package/Tests/Fixtures/MobilePasskeyUser.json +1 -2
  186. package/Tests/ForeignHiddenRuleGuard.ts +2152 -0
  187. package/Tests/Models/DatabaseModels/DatabaseServerModels.test.ts +2250 -0
  188. package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
  189. package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +24 -5
  190. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
  191. package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
  192. package/Tests/RenderedMarkup.ts +518 -0
  193. package/Tests/ResponsiveVisibility.test.ts +490 -4
  194. package/Tests/ResponsiveVisibility.ts +505 -52
  195. package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
  196. package/Tests/Server/Infrastructure/Postgres/AddDatabaseServerTablesMigration.test.ts +541 -0
  197. package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
  198. package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
  199. package/Tests/Server/Infrastructure/Postgres/AddUserProjectSsoConsentMigration.test.ts +234 -0
  200. package/Tests/Server/Services/ContainerSnapshotUpsertPostgres.test.ts +319 -0
  201. package/Tests/Server/Services/CrossProjectRelationWriteGuard.test.ts +13 -0
  202. package/Tests/Server/Services/DatabaseServerEndpointService.test.ts +2033 -0
  203. package/Tests/Server/Services/DatabaseServerFeedAndOwnerServices.test.ts +529 -0
  204. package/Tests/Server/Services/DatabaseServerLifecycle.test.ts +3302 -0
  205. package/Tests/Server/Services/DatabaseServerRegistries.test.ts +603 -0
  206. package/Tests/Server/Services/DatabaseServerRuleEngines.test.ts +754 -0
  207. package/Tests/Server/Services/DatabaseServerService.test.ts +3558 -0
  208. package/Tests/Server/Services/DatabaseServerSqlPostgres.test.ts +2045 -0
  209. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlockedPostgres.test.ts +362 -0
  210. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlockedReply.test.ts +210 -0
  211. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +18 -0
  212. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
  213. package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
  214. package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
  215. package/Tests/Server/Services/IncidentAlertSloLinkSurvivesEdit.test.ts +2 -0
  216. package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
  217. package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
  218. package/Tests/Server/Services/IncomingCallPolicyEscalationRuleTarget.test.ts +225 -0
  219. package/Tests/Server/Services/InventoryItemDisplayName.test.ts +280 -2
  220. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +8 -2
  221. package/Tests/Server/Services/IoTDeviceLatestMetricsPostgres.test.ts +689 -0
  222. package/Tests/Server/Services/IoTDeviceService.test.ts +379 -0
  223. package/Tests/Server/Services/LatestMetricsValuesShape.test.ts +360 -0
  224. package/Tests/Server/Services/LlmModelPriceService.test.ts +402 -0
  225. package/Tests/Server/Services/OpenTelemetryResourceRetentionMemo.test.ts +7 -1
  226. package/Tests/Server/Services/OwnerRowUniqueness.test.ts +61 -9
  227. package/Tests/Server/Services/ReminderRuleLookupPostgres.test.ts +2250 -0
  228. package/Tests/Server/Services/ReminderRuleRefreshOpenSubjectsPaging.test.ts +653 -0
  229. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +68 -20
  230. package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +3 -3
  231. package/Tests/Server/Services/TelemetryResourceFacetCounting.test.ts +9 -0
  232. package/Tests/Server/Services/UserProjectSsoConsentPostgres.test.ts +153 -0
  233. package/Tests/Server/Services/UserProjectSsoConsentService.test.ts +192 -0
  234. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +71 -0
  235. package/Tests/Server/Services/UserSignupIdTakeoverPostgres.test.ts +238 -0
  236. package/Tests/Server/Services/WorkspaceNotificationSummaryHelpers.test.ts +592 -0
  237. package/Tests/Server/Types/Billing/PayAsYouGoMeteredAuthorization.test.ts +3 -0
  238. package/Tests/Server/Types/Database/EventOverlayScopePostgres.test.ts +1 -0
  239. package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +11 -0
  240. package/Tests/Server/Types/Database/Permissions/InfrastructureOwnershipScope.test.ts +8 -0
  241. package/Tests/Server/Types/Database/QueryHelperBooleanForCriteriaBackedRule.test.ts +1094 -0
  242. package/Tests/Server/Utils/AI/ResourceTools.test.ts +134 -0
  243. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.ts +41 -0
  244. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +279 -9
  245. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaDisplayContext.test.ts +933 -0
  246. package/Tests/Server/Utils/Monitor/DatabaseMaintenanceSuppression.test.ts +582 -0
  247. package/Tests/Server/Utils/Monitor/DatabaseServerMonitorLinking.test.ts +753 -0
  248. package/Tests/Server/Utils/Monitor/MonitorActionTimestamps.test.ts +1 -0
  249. package/Tests/Server/Utils/Monitor/MonitorAlertCriteriaReferences.test.ts +1 -0
  250. package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +119 -0
  251. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +60 -25
  252. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +1719 -0
  253. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorKubernetesAffectedResources.test.ts +1447 -0
  254. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorKubernetesRatioEmail.test.ts +352 -0
  255. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformMetricUnits.test.ts +717 -0
  256. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformRawScan.test.ts +2125 -0
  257. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorPlatformSeriesAffectedResources.test.ts +1288 -0
  258. package/Tests/Server/Utils/Monitor/MonitorDependencySuppressionCreatorSkip.test.ts +1 -0
  259. package/Tests/Server/Utils/Monitor/MonitorIncidentCriteriaReferences.test.ts +1 -0
  260. package/Tests/Server/Utils/Monitor/MonitorIncidentMemberRoleMembership.test.ts +1 -0
  261. package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +88 -0
  262. package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +205 -0
  263. package/Tests/Server/Utils/Monitor/MonitorResourceContext.test.ts +62 -0
  264. package/Tests/Server/Utils/Monitor/MonitorResourceDatabaseMaintenance.test.ts +803 -0
  265. package/Tests/Server/Utils/Monitor/MonitorStepResourceIdentity.test.ts +133 -3
  266. package/Tests/Server/Utils/Monitor/MonitorSummaryPersistence.test.ts +1 -0
  267. package/Tests/Server/Utils/Monitor/PerSeriesRecoveryResolution.test.ts +472 -1
  268. package/Tests/Server/Utils/Monitor/PerSeriesSecondEntityAlerting.test.ts +1 -0
  269. package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +236 -2
  270. package/Tests/Server/Utils/SeriesResourceLabels.test.ts +70 -0
  271. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
  272. package/Tests/Server/Utils/StartServerProtobufContentType.test.ts +54 -0
  273. package/Tests/Server/Utils/Telemetry/DatabaseEndpointDiscovery.test.ts +1895 -0
  274. package/Tests/Server/Utils/Telemetry/EntityRegistry.test.ts +7 -0
  275. package/Tests/Server/Utils/Telemetry/LogExceptionExtractorSeverityGate.test.ts +90 -0
  276. package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +570 -0
  277. package/Tests/Server/Utils/Telemetry/ResourceFacetResolver.test.ts +97 -2
  278. package/Tests/Server/Utils/Telemetry/ServiceDependencyDiscovery.test.ts +1337 -0
  279. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +295 -0
  280. package/Tests/Types/AI/AIResourceContext.test.ts +30 -0
  281. package/Tests/Types/DatabaseServer/DatabaseContainerClassifier.test.ts +3644 -0
  282. package/Tests/Types/DatabaseServer/DatabaseContainerCommand.test.ts +340 -0
  283. package/Tests/Types/DatabaseServer/DatabaseContainerCommandCorpus.ts +826 -0
  284. package/Tests/Types/DatabaseServer/DatabaseContainerCommandPostgres.test.ts +150 -0
  285. package/Tests/Types/DatabaseServer/DatabaseEndpoint.test.ts +2657 -0
  286. package/Tests/Types/DatabaseServer/DatabaseServerDiscoverySource.test.ts +64 -0
  287. package/Tests/Types/DatabaseServer/DatabaseServerEntityKeys.test.ts +952 -0
  288. package/Tests/Types/DatabaseServer/DatabaseServerMetricCatalog.test.ts +1161 -0
  289. package/Tests/Types/DatabaseServer/DatabaseSystem.test.ts +1514 -0
  290. package/Tests/Types/DatabaseServer/DatabaseTelemetryResolver.test.ts +1558 -0
  291. package/Tests/Types/Monitor/CephAlertTemplates.test.ts +255 -6
  292. package/Tests/Types/Monitor/DatabaseAlertTemplates.test.ts +2254 -0
  293. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +298 -0
  294. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +271 -0
  295. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +253 -9
  296. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCoverage.test.ts +13 -5
  297. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationNotificationMode.test.ts +4 -2
  298. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationUtil.test.ts +111 -2
  299. package/Tests/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.test.ts +61 -0
  300. package/Tests/Types/Monitor/SeriesContext/SeriesLabelDisplay.test.ts +114 -0
  301. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +181 -0
  302. package/Tests/Types/NetworkDevice/DiscoveredHostNamingStatus.test.ts +401 -0
  303. package/Tests/Types/Rum/WebVitals.test.ts +173 -0
  304. package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +4 -1
  305. package/Tests/Types/Telemetry/ResourceFacetCatalog.test.ts +24 -2
  306. package/Tests/UI/Components/Card.test.tsx +93 -2
  307. package/Tests/UI/Components/FeedOptions.test.ts +6 -0
  308. package/Tests/UI/Components/FeedOptionsButton.test.tsx +8 -1
  309. package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
  310. package/Tests/UI/Components/ForeignHiddenRuleResilience.test.tsx +1147 -0
  311. package/Tests/UI/Components/HeaderRightRail.test.tsx +41 -1
  312. package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
  313. package/Tests/UI/Components/LogsEntityNames.test.ts +16 -1
  314. package/Tests/UI/Components/LogsFacetSidebarHiddenFacets.test.tsx +7 -3
  315. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
  316. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
  317. package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
  318. package/Tests/UI/Components/ResourceFacetConfigs.test.ts +2 -1
  319. package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
  320. package/Tests/UI/Components/SrOnlyForeignRule.test.tsx +994 -0
  321. package/Tests/UI/Components/StatusPage/ResourceListSelection.test.tsx +34 -1
  322. package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
  323. package/Tests/UI/Components/TelemetryFacetSidebarHiddenFacets.test.tsx +4 -1
  324. package/Tests/UI/ForeignHiddenRuleGuard.test.ts +1951 -0
  325. package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
  326. package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
  327. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
  328. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
  329. package/Tests/UI/Rum/ReplayPlayerChrome.test.tsx +19 -1
  330. package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
  331. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
  332. package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
  333. package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
  334. package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
  335. package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
  336. package/Tests/UI/Rum/ReplayTabSwitcher.test.tsx +19 -1
  337. package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
  338. package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
  339. package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
  340. package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
  341. package/Tests/UI/TailwindPlayCdnForeignHiddenRule.test.ts +1005 -0
  342. package/Tests/UI/ThemeGroupHoverReveal.test.ts +847 -0
  343. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +80 -0
  344. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +23 -0
  345. package/Tests/UI/Utils/ModelAPISsoRedirect.test.ts +127 -0
  346. package/Tests/UI/Utils/Navigation.test.ts +102 -0
  347. package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
  348. package/Tests/UI/Utils/Telemetry/TelemetryEntityNames.test.ts +23 -0
  349. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
  350. package/Tests/Utils/LabelRuleImportExport.test.ts +5 -0
  351. package/Tests/Utils/MetricUnitUtil.test.ts +227 -2
  352. package/Tests/Utils/Monitor/MetricValueFormatter.test.ts +288 -2
  353. package/Tests/Utils/Monitor/PlatformResourceIdentityAndUnits.test.ts +1559 -0
  354. package/Tests/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.test.ts +2546 -0
  355. package/Tests/Utils/NetworkDiscovery/DiscoveredHostUtil.test.ts +315 -0
  356. package/Tests/Utils/NetworkDiscovery/DiscoveryReverseDnsChain.test.ts +336 -1
  357. package/Tests/Utils/NetworkDiscovery/NetbiosNameUtil.test.ts +244 -0
  358. package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
  359. package/Tests/Utils/ValueFormatter.test.ts +50 -0
  360. package/Tests/tsconfig.json +16 -0
  361. package/Types/AI/AIResourceContext.ts +7 -0
  362. package/Types/Dashboard/DashboardTemplates.ts +2 -1
  363. package/Types/DatabaseServer/DatabaseConnectionSpan.ts +77 -0
  364. package/Types/DatabaseServer/DatabaseContainerClassifier.ts +2068 -0
  365. package/Types/DatabaseServer/DatabaseContainerCommand.ts +801 -0
  366. package/Types/DatabaseServer/DatabaseEndpoint.ts +1943 -0
  367. package/Types/DatabaseServer/DatabaseServerDiscoverySource.ts +68 -0
  368. package/Types/DatabaseServer/DatabaseServerMetricCatalog.ts +966 -0
  369. package/Types/DatabaseServer/DatabaseSystem.ts +2124 -0
  370. package/Types/DatabaseServer/DatabaseTelemetryResolver.ts +555 -0
  371. package/Types/Email/EmailTemplateType.ts +1 -0
  372. package/Types/Incident/IncidentAlertLink.ts +38 -0
  373. package/Types/Monitor/CephAlertTemplates.ts +18 -0
  374. package/Types/Monitor/DatabaseAlertTemplates.ts +2020 -0
  375. package/Types/Monitor/KubernetesAlertTemplates.ts +64 -24
  376. package/Types/Monitor/KubernetesMetricCatalog.ts +118 -19
  377. package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +14 -0
  378. package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +93 -10
  379. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +109 -8
  380. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +36 -7
  381. package/Types/Monitor/Recommendation/MonitorRecommendationUtil.ts +43 -2
  382. package/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.ts +41 -6
  383. package/Types/Monitor/SeriesContext/SeriesLabelDisplay.ts +26 -0
  384. package/Types/NetworkDevice/DiscoveredHostNamingStatus.ts +147 -0
  385. package/Types/Permission.ts +315 -0
  386. package/Types/Probe/ProbeApiIngestResponse.ts +8 -0
  387. package/Types/Rules/RuleCriteriaFieldRegistry.ts +10 -0
  388. package/Types/Rules/RuleRun.ts +12 -0
  389. package/Types/Rum/SessionReplay.ts +25 -6
  390. package/Types/Rum/WebVitals.ts +52 -0
  391. package/Types/Telemetry/EntityType.ts +23 -0
  392. package/Types/Telemetry/ResourceFacetCatalog.ts +14 -0
  393. package/Types/Telemetry/ServiceType.ts +8 -0
  394. package/UI/Components/Banner/Banner.tsx +1 -1
  395. package/UI/Components/Card/Card.tsx +1 -1
  396. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +2 -2
  397. package/UI/Components/Charts/Line/LineChart.tsx +7 -0
  398. package/UI/Components/CommandPalette/CommandPalette.tsx +1 -1
  399. package/UI/Components/Date/StartAndEndDate.tsx +5 -5
  400. package/UI/Components/Detail/FieldLabel.tsx +8 -0
  401. package/UI/Components/Diagram/HorizontalStepChain.tsx +1 -1
  402. package/UI/Components/Feed/FeedOptionsButton.tsx +1 -1
  403. package/UI/Components/Forms/BasicForm.tsx +1 -1
  404. package/UI/Components/Header/Header.tsx +1 -1
  405. package/UI/Components/Header/HeaderIconDropdownButton.tsx +1 -1
  406. package/UI/Components/HeaderAlert/HeaderAlert.tsx +1 -1
  407. package/UI/Components/InfoCard/InfoCard.tsx +47 -1
  408. package/UI/Components/LogsViewer/components/FacetValueRow.tsx +1 -1
  409. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +1 -1
  410. package/UI/Components/Modal/ModalFooter.tsx +1 -1
  411. package/UI/Components/ModelDetail/CardModelDetail.tsx +2 -2
  412. package/UI/Components/ModelTable/BaseModelTable.tsx +99 -21
  413. package/UI/Components/ModelTable/Column.ts +5 -0
  414. package/UI/Components/Navbar/NavBar.tsx +9 -2
  415. package/UI/Components/Navbar/NavBarMenuModal.tsx +2 -2
  416. package/UI/Components/Page/Page.tsx +1 -1
  417. package/UI/Components/Pagination/Pagination.tsx +3 -3
  418. package/UI/Components/ProgressButtons/ProgressButtonItem.tsx +1 -1
  419. package/UI/Components/SideMenu/SideMenu.tsx +4 -1
  420. package/UI/Components/StatusPage/ResourceGroupNavigator.tsx +4 -2
  421. package/UI/Components/StatusPage/ResourceList.tsx +6 -6
  422. package/UI/Components/Table/TableHeader.tsx +47 -2
  423. package/UI/Components/Table/Types/Column.ts +7 -0
  424. package/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.tsx +1 -1
  425. package/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.tsx +1 -1
  426. package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
  427. package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +1 -1
  428. package/UI/Components/Workflow/ComponentsModal.tsx +2 -2
  429. package/UI/Components/Workflow/StepTraceViewer.tsx +1 -1
  430. package/UI/Styles/Theme.css +8 -1
  431. package/UI/Utils/Navigation.ts +89 -42
  432. package/UI/Utils/PermissionGate.ts +11 -2
  433. package/UI/Utils/Telemetry/TelemetryEntityNames.ts +7 -0
  434. package/UI/Utils/TelemetryService.ts +4 -3
  435. package/Utils/Incident/IncidentFromAlerts.ts +516 -0
  436. package/Utils/LabelRuleImportExport.ts +2 -0
  437. package/Utils/MetricUnitUtil.ts +103 -11
  438. package/Utils/Monitor/MetricValueFormatter.ts +66 -13
  439. package/Utils/Monitor/PlatformMetricUnitUtil.ts +215 -0
  440. package/Utils/Monitor/PlatformResourceIdentity.ts +514 -0
  441. package/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.ts +425 -0
  442. package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +32 -0
  443. package/Utils/NetworkDiscovery/NetbiosNameUtil.ts +73 -0
  444. package/Utils/Rum/SessionTraceState.ts +212 -0
  445. package/Utils/Telemetry/DatabaseServerEntityKeys.ts +275 -0
  446. package/Utils/Telemetry/EntityKey.ts +176 -0
  447. package/Utils/ValueFormatter.ts +16 -0
  448. package/build/dist/Models/DatabaseModels/Alert.js +54 -0
  449. package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
  450. package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
  451. package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
  452. package/build/dist/Models/DatabaseModels/DatabaseServer.js +1683 -0
  453. package/build/dist/Models/DatabaseModels/DatabaseServer.js.map +1 -0
  454. package/build/dist/Models/DatabaseModels/DatabaseServerEndpoint.js +559 -0
  455. package/build/dist/Models/DatabaseModels/DatabaseServerEndpoint.js.map +1 -0
  456. package/build/dist/Models/DatabaseModels/DatabaseServerFeed.js +648 -0
  457. package/build/dist/Models/DatabaseModels/DatabaseServerFeed.js.map +1 -0
  458. package/build/dist/Models/DatabaseModels/DatabaseServerLabelRule.js +522 -0
  459. package/build/dist/Models/DatabaseModels/DatabaseServerLabelRule.js.map +1 -0
  460. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerRule.js +603 -0
  461. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerRule.js.map +1 -0
  462. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerTeam.js +506 -0
  463. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerTeam.js.map +1 -0
  464. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerUser.js +505 -0
  465. package/build/dist/Models/DatabaseModels/DatabaseServerOwnerUser.js.map +1 -0
  466. package/build/dist/Models/DatabaseModels/Incident.js +54 -0
  467. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  468. package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
  469. package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
  470. package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
  471. package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
  472. package/build/dist/Models/DatabaseModels/Index.js +18 -0
  473. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  474. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  475. package/build/dist/Models/DatabaseModels/Project.js +62 -0
  476. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  477. package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
  478. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  479. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +54 -0
  480. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  481. package/build/dist/Models/DatabaseModels/TelemetryException.js +2 -0
  482. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  483. package/build/dist/Models/DatabaseModels/UserProjectSsoConsent.js +162 -0
  484. package/build/dist/Models/DatabaseModels/UserProjectSsoConsent.js.map +1 -0
  485. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
  486. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
  487. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
  488. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
  489. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.js +240 -0
  490. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795000000000-AddDatabaseServerTables.js.map +1 -0
  491. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.js +26 -0
  492. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795100000000-AddUserProjectSsoConsent.js.map +1 -0
  493. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  494. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  495. package/build/dist/Server/Middleware/MultipartFormData.js +1 -1
  496. package/build/dist/Server/Middleware/TelemetryIngest.js +15 -8
  497. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  498. package/build/dist/Server/Services/AlertReminderRuleService.js +72 -23
  499. package/build/dist/Server/Services/AlertReminderRuleService.js.map +1 -1
  500. package/build/dist/Server/Services/DatabaseServerEndpointService.js +694 -0
  501. package/build/dist/Server/Services/DatabaseServerEndpointService.js.map +1 -0
  502. package/build/dist/Server/Services/DatabaseServerFeedService.js +110 -0
  503. package/build/dist/Server/Services/DatabaseServerFeedService.js.map +1 -0
  504. package/build/dist/Server/Services/DatabaseServerLabelRuleEngineService.js +272 -0
  505. package/build/dist/Server/Services/DatabaseServerLabelRuleEngineService.js.map +1 -0
  506. package/build/dist/Server/Services/DatabaseServerLabelRuleService.js +13 -0
  507. package/build/dist/Server/Services/DatabaseServerLabelRuleService.js.map +1 -0
  508. package/build/dist/Server/Services/DatabaseServerOwnerRuleEngineService.js +340 -0
  509. package/build/dist/Server/Services/DatabaseServerOwnerRuleEngineService.js.map +1 -0
  510. package/build/dist/Server/Services/DatabaseServerOwnerRuleService.js +13 -0
  511. package/build/dist/Server/Services/DatabaseServerOwnerRuleService.js.map +1 -0
  512. package/build/dist/Server/Services/DatabaseServerOwnerTeamService.js +160 -0
  513. package/build/dist/Server/Services/DatabaseServerOwnerTeamService.js.map +1 -0
  514. package/build/dist/Server/Services/DatabaseServerOwnerUserService.js +153 -0
  515. package/build/dist/Server/Services/DatabaseServerOwnerUserService.js.map +1 -0
  516. package/build/dist/Server/Services/DatabaseServerService.js +3272 -0
  517. package/build/dist/Server/Services/DatabaseServerService.js.map +1 -0
  518. package/build/dist/Server/Services/DatabaseService.js +8 -4
  519. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  520. package/build/dist/Server/Services/DockerResourceService.js +30 -3
  521. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  522. package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
  523. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
  524. package/build/dist/Server/Services/IncidentReminderRuleService.js +73 -23
  525. package/build/dist/Server/Services/IncidentReminderRuleService.js.map +1 -1
  526. package/build/dist/Server/Services/IncidentService.js +164 -12
  527. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  528. package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
  529. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  530. package/build/dist/Server/Services/Index.js +18 -0
  531. package/build/dist/Server/Services/Index.js.map +1 -1
  532. package/build/dist/Server/Services/InventoryItemService.js +88 -5
  533. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  534. package/build/dist/Server/Services/IoTDeviceService.js +111 -24
  535. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  536. package/build/dist/Server/Services/OpenTelemetryIngestService.js +24 -9
  537. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  538. package/build/dist/Server/Services/PodmanResourceService.js +30 -3
  539. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  540. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js +75 -23
  541. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js.map +1 -1
  542. package/build/dist/Server/Services/ServiceService.js +1 -1
  543. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  544. package/build/dist/Server/Services/TelemetryUsageBillingService.js +23 -2
  545. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  546. package/build/dist/Server/Services/UserProjectSsoConsentService.js +79 -0
  547. package/build/dist/Server/Services/UserProjectSsoConsentService.js.map +1 -0
  548. package/build/dist/Server/Services/UserService.js +13 -1
  549. package/build/dist/Server/Services/UserService.js.map +1 -1
  550. package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js +18 -0
  551. package/build/dist/Server/Types/Database/Permissions/OwnerTableRegistry.js.map +1 -1
  552. package/build/dist/Server/Types/Database/QueryHelper.js +26 -1
  553. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  554. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +46 -0
  555. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  556. package/build/dist/Server/Utils/Database/AffectedResourceRelations.js +6 -0
  557. package/build/dist/Server/Utils/Database/AffectedResourceRelations.js.map +1 -1
  558. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +134 -13
  559. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  560. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -6
  561. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  562. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1489 -524
  563. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  564. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +22 -6
  565. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  566. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +196 -6
  567. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
  568. package/build/dist/Server/Utils/Monitor/MonitorResource.js +36 -9
  569. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  570. package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js +1 -0
  571. package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js.map +1 -1
  572. package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js +175 -17
  573. package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js.map +1 -1
  574. package/build/dist/Server/Utils/Monitor/PerSeriesResolutionRootCause.js +29 -0
  575. package/build/dist/Server/Utils/Monitor/PerSeriesResolutionRootCause.js.map +1 -0
  576. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +211 -0
  577. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  578. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +82 -10
  579. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -1
  580. package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +25 -0
  581. package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
  582. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
  583. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  584. package/build/dist/Server/Utils/StartServer.js +20 -4
  585. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  586. package/build/dist/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.js +2 -0
  587. package/build/dist/Server/Utils/TeamMember/ProjectLeaveResourceCleanup.js.map +1 -1
  588. package/build/dist/Server/Utils/Telemetry/DatabaseEndpointDiscovery.js +442 -0
  589. package/build/dist/Server/Utils/Telemetry/DatabaseEndpointDiscovery.js.map +1 -0
  590. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +5 -1
  591. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  592. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js +15 -1
  593. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js.map +1 -1
  594. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +121 -5
  595. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -1
  596. package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js +12 -0
  597. package/build/dist/Server/Utils/Telemetry/ResourceFacetResolver.js.map +1 -1
  598. package/build/dist/Server/Utils/Telemetry/ServiceDependencyDiscovery.js +438 -36
  599. package/build/dist/Server/Utils/Telemetry/ServiceDependencyDiscovery.js.map +1 -1
  600. package/build/dist/Server/Utils/Telemetry/Telemetry.js +12 -0
  601. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  602. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +17 -18
  603. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  604. package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js +16 -10
  605. package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js.map +1 -1
  606. package/build/dist/Types/AI/AIResourceContext.js +7 -0
  607. package/build/dist/Types/AI/AIResourceContext.js.map +1 -1
  608. package/build/dist/Types/Dashboard/DashboardTemplates.js +2 -1
  609. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  610. package/build/dist/Types/DatabaseServer/DatabaseConnectionSpan.js +71 -0
  611. package/build/dist/Types/DatabaseServer/DatabaseConnectionSpan.js.map +1 -0
  612. package/build/dist/Types/DatabaseServer/DatabaseContainerClassifier.js +1462 -0
  613. package/build/dist/Types/DatabaseServer/DatabaseContainerClassifier.js.map +1 -0
  614. package/build/dist/Types/DatabaseServer/DatabaseContainerCommand.js +691 -0
  615. package/build/dist/Types/DatabaseServer/DatabaseContainerCommand.js.map +1 -0
  616. package/build/dist/Types/DatabaseServer/DatabaseEndpoint.js +1407 -0
  617. package/build/dist/Types/DatabaseServer/DatabaseEndpoint.js.map +1 -0
  618. package/build/dist/Types/DatabaseServer/DatabaseServerDiscoverySource.js +60 -0
  619. package/build/dist/Types/DatabaseServer/DatabaseServerDiscoverySource.js.map +1 -0
  620. package/build/dist/Types/DatabaseServer/DatabaseServerMetricCatalog.js +804 -0
  621. package/build/dist/Types/DatabaseServer/DatabaseServerMetricCatalog.js.map +1 -0
  622. package/build/dist/Types/DatabaseServer/DatabaseSystem.js +1860 -0
  623. package/build/dist/Types/DatabaseServer/DatabaseSystem.js.map +1 -0
  624. package/build/dist/Types/DatabaseServer/DatabaseTelemetryResolver.js +406 -0
  625. package/build/dist/Types/DatabaseServer/DatabaseTelemetryResolver.js.map +1 -0
  626. package/build/dist/Types/Email/EmailTemplateType.js +1 -0
  627. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  628. package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
  629. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
  630. package/build/dist/Types/Monitor/CephAlertTemplates.js +18 -0
  631. package/build/dist/Types/Monitor/CephAlertTemplates.js.map +1 -1
  632. package/build/dist/Types/Monitor/DatabaseAlertTemplates.js +1518 -0
  633. package/build/dist/Types/Monitor/DatabaseAlertTemplates.js.map +1 -0
  634. package/build/dist/Types/Monitor/KubernetesAlertTemplates.js +57 -24
  635. package/build/dist/Types/Monitor/KubernetesAlertTemplates.js.map +1 -1
  636. package/build/dist/Types/Monitor/KubernetesMetricCatalog.js +104 -18
  637. package/build/dist/Types/Monitor/KubernetesMetricCatalog.js.map +1 -1
  638. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +80 -3
  639. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  640. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +10 -3
  641. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  642. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js +29 -0
  643. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js.map +1 -1
  644. package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js +21 -6
  645. package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js.map +1 -1
  646. package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js +25 -0
  647. package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js.map +1 -1
  648. package/build/dist/Types/NetworkDevice/DiscoveredHostNamingStatus.js +136 -0
  649. package/build/dist/Types/NetworkDevice/DiscoveredHostNamingStatus.js.map +1 -0
  650. package/build/dist/Types/Permission.js +276 -0
  651. package/build/dist/Types/Permission.js.map +1 -1
  652. package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +10 -0
  653. package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js.map +1 -1
  654. package/build/dist/Types/Rules/RuleRun.js +4 -0
  655. package/build/dist/Types/Rules/RuleRun.js.map +1 -1
  656. package/build/dist/Types/Rum/WebVitals.js +25 -0
  657. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  658. package/build/dist/Types/Telemetry/EntityType.js +23 -0
  659. package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
  660. package/build/dist/Types/Telemetry/ResourceFacetCatalog.js +14 -0
  661. package/build/dist/Types/Telemetry/ResourceFacetCatalog.js.map +1 -1
  662. package/build/dist/Types/Telemetry/ServiceType.js +8 -0
  663. package/build/dist/Types/Telemetry/ServiceType.js.map +1 -1
  664. package/build/dist/UI/Components/Banner/Banner.js +1 -1
  665. package/build/dist/UI/Components/Banner/Banner.js.map +1 -1
  666. package/build/dist/UI/Components/Card/Card.js +1 -1
  667. package/build/dist/UI/Components/Card/Card.js.map +1 -1
  668. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +2 -2
  669. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  670. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  671. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  672. package/build/dist/UI/Components/CommandPalette/CommandPalette.js +1 -1
  673. package/build/dist/UI/Components/CommandPalette/CommandPalette.js.map +1 -1
  674. package/build/dist/UI/Components/Date/StartAndEndDate.js +5 -5
  675. package/build/dist/UI/Components/Date/StartAndEndDate.js.map +1 -1
  676. package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
  677. package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
  678. package/build/dist/UI/Components/Diagram/HorizontalStepChain.js +1 -1
  679. package/build/dist/UI/Components/Diagram/HorizontalStepChain.js.map +1 -1
  680. package/build/dist/UI/Components/Feed/FeedOptionsButton.js +1 -1
  681. package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -1
  682. package/build/dist/UI/Components/Forms/BasicForm.js +1 -1
  683. package/build/dist/UI/Components/Forms/BasicForm.js.map +1 -1
  684. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +1 -1
  685. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  686. package/build/dist/UI/Components/HeaderAlert/HeaderAlert.js +1 -1
  687. package/build/dist/UI/Components/HeaderAlert/HeaderAlert.js.map +1 -1
  688. package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
  689. package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
  690. package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js +1 -1
  691. package/build/dist/UI/Components/LogsViewer/components/FacetValueRow.js.map +1 -1
  692. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +1 -1
  693. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
  694. package/build/dist/UI/Components/Modal/ModalFooter.js +1 -1
  695. package/build/dist/UI/Components/Modal/ModalFooter.js.map +1 -1
  696. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +2 -2
  697. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
  698. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +70 -18
  699. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  700. package/build/dist/UI/Components/Navbar/NavBar.js +9 -2
  701. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  702. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +2 -2
  703. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  704. package/build/dist/UI/Components/Page/Page.js +1 -1
  705. package/build/dist/UI/Components/Page/Page.js.map +1 -1
  706. package/build/dist/UI/Components/Pagination/Pagination.js +3 -3
  707. package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
  708. package/build/dist/UI/Components/ProgressButtons/ProgressButtonItem.js +1 -1
  709. package/build/dist/UI/Components/ProgressButtons/ProgressButtonItem.js.map +1 -1
  710. package/build/dist/UI/Components/SideMenu/SideMenu.js +4 -1
  711. package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
  712. package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js +3 -1
  713. package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js.map +1 -1
  714. package/build/dist/UI/Components/StatusPage/ResourceList.js +6 -6
  715. package/build/dist/UI/Components/StatusPage/ResourceList.js.map +1 -1
  716. package/build/dist/UI/Components/Table/TableHeader.js +18 -2
  717. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  718. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js +1 -1
  719. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetSidebar.js.map +1 -1
  720. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.js +1 -1
  721. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryFacetValueRow.js.map +1 -1
  722. package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
  723. package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
  724. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +1 -1
  725. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -1
  726. package/build/dist/UI/Components/Workflow/ComponentsModal.js +2 -2
  727. package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
  728. package/build/dist/UI/Components/Workflow/StepTraceViewer.js +1 -1
  729. package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -1
  730. package/build/dist/UI/Utils/Navigation.js +66 -33
  731. package/build/dist/UI/Utils/Navigation.js.map +1 -1
  732. package/build/dist/UI/Utils/PermissionGate.js +4 -2
  733. package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
  734. package/build/dist/UI/Utils/Telemetry/TelemetryEntityNames.js +7 -0
  735. package/build/dist/UI/Utils/Telemetry/TelemetryEntityNames.js.map +1 -1
  736. package/build/dist/UI/Utils/TelemetryService.js +3 -2
  737. package/build/dist/UI/Utils/TelemetryService.js.map +1 -1
  738. package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
  739. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
  740. package/build/dist/Utils/LabelRuleImportExport.js +2 -0
  741. package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
  742. package/build/dist/Utils/MetricUnitUtil.js +77 -13
  743. package/build/dist/Utils/MetricUnitUtil.js.map +1 -1
  744. package/build/dist/Utils/Monitor/MetricValueFormatter.js +58 -13
  745. package/build/dist/Utils/Monitor/MetricValueFormatter.js.map +1 -1
  746. package/build/dist/Utils/Monitor/PlatformMetricUnitUtil.js +161 -0
  747. package/build/dist/Utils/Monitor/PlatformMetricUnitUtil.js.map +1 -0
  748. package/build/dist/Utils/Monitor/PlatformResourceIdentity.js +323 -0
  749. package/build/dist/Utils/Monitor/PlatformResourceIdentity.js.map +1 -0
  750. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.js +270 -0
  751. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostNamingDiagnosis.js.map +1 -0
  752. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +23 -0
  753. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -1
  754. package/build/dist/Utils/NetworkDiscovery/NetbiosNameUtil.js +61 -0
  755. package/build/dist/Utils/NetworkDiscovery/NetbiosNameUtil.js.map +1 -1
  756. package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
  757. package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
  758. package/build/dist/Utils/Telemetry/DatabaseServerEntityKeys.js +178 -0
  759. package/build/dist/Utils/Telemetry/DatabaseServerEntityKeys.js.map +1 -0
  760. package/build/dist/Utils/Telemetry/EntityKey.js +133 -0
  761. package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
  762. package/build/dist/Utils/ValueFormatter.js +15 -0
  763. package/build/dist/Utils/ValueFormatter.js.map +1 -1
  764. package/package.json +1 -1
  765. package/tsconfig.tests.json +7 -5
@@ -0,0 +1,3302 @@
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
+ DatabaseSystemDetermination,
23
+ DatabaseSystemEvidence,
24
+ UpsertWorkloadDatabaseData,
25
+ decideDatabaseSystem,
26
+ getDatabaseSystemEvidenceForSource,
27
+ getDatabaseSystemsOfFamily,
28
+ getStoredDatabaseSystemEvidence,
29
+ hasDiscoveryGeneratedName,
30
+ renameForDatabaseSystem,
31
+ } from "../../../Server/Services/DatabaseServerService";
32
+ import DatabaseServerEndpointService, {
33
+ DatabaseServerEndpointClaimResult,
34
+ DatabaseServerEndpointOwner,
35
+ } from "../../../Server/Services/DatabaseServerEndpointService";
36
+ import DatabaseServerFeedService from "../../../Server/Services/DatabaseServerFeedService";
37
+ import DatabaseServerLabelRuleEngineService from "../../../Server/Services/DatabaseServerLabelRuleEngineService";
38
+ import DatabaseServerOwnerRuleEngineService from "../../../Server/Services/DatabaseServerOwnerRuleEngineService";
39
+ import GlobalCache from "../../../Server/Infrastructure/GlobalCache";
40
+ import ResourceHeartbeat from "../../../Server/Utils/Telemetry/ResourceHeartbeat";
41
+ import SingleFlight from "../../../Server/Utils/SingleFlight";
42
+ import logger from "../../../Server/Utils/Logger";
43
+ import DatabaseServer from "../../../Models/DatabaseModels/DatabaseServer";
44
+ import DatabaseServerEndpoint from "../../../Models/DatabaseModels/DatabaseServerEndpoint";
45
+ import DatabaseServerOwnerUser from "../../../Models/DatabaseModels/DatabaseServerOwnerUser";
46
+ import { DatabaseServerFeedEventType } from "../../../Models/DatabaseModels/DatabaseServerFeed";
47
+ import Label from "../../../Models/DatabaseModels/Label";
48
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
49
+ import DatabaseServerDiscoverySource from "../../../Types/DatabaseServer/DatabaseServerDiscoverySource";
50
+ import { DatabaseEndpoint } from "../../../Types/DatabaseServer/DatabaseEndpoint";
51
+ import {
52
+ DATABASE_SYSTEMS,
53
+ DatabaseSystemDescriptor,
54
+ getMoreSpecificDatabaseSystem,
55
+ } from "../../../Types/DatabaseServer/DatabaseSystem";
56
+ import ObjectID from "../../../Types/ObjectID";
57
+ import Permission, { UserPermission } from "../../../Types/Permission";
58
+ import { getJestSpyOn } from "../../Spy";
59
+
60
+ /*
61
+ * The lifecycle of a DatabaseServer row once it exists: which evidence may
62
+ * change its engine and version, how discovered endpoints are kept honest,
63
+ * handed over and released, which labels and owners count as a person
64
+ * caring about it, and the collector path's auto-create budget.
65
+ *
66
+ * Everything external is mocked at its seam (no Postgres, no Redis); the SQL
67
+ * itself is executed by DatabaseServerSqlPostgres.test.ts.
68
+ */
69
+
70
+ const PROJECT_ID: ObjectID = new ObjectID(
71
+ "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
72
+ );
73
+ const USER_ID: ObjectID = new ObjectID("dddddddd-dddd-4ddd-8ddd-dddddddddddd");
74
+ const CLUSTER_ID: ObjectID = new ObjectID(
75
+ "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
76
+ );
77
+ const MINUTE_MS: number = 60 * 1000;
78
+ const HOUR_MS: number = 60 * MINUTE_MS;
79
+
80
+ const service: any = DatabaseServerService;
81
+
82
+ function databaseRow(overrides: Partial<DatabaseServer> = {}): DatabaseServer {
83
+ const row: DatabaseServer = new DatabaseServer(ObjectID.generate());
84
+ row.projectId = PROJECT_ID;
85
+ row.name = "PostgreSQL orders-db.example.com:5432";
86
+ row.dbSystem = "postgresql";
87
+ row.databaseIdentifier = "postgresql|orders-db.example.com:5432";
88
+ row.serverAddress = "orders-db.example.com";
89
+ row.serverPort = 5432;
90
+ row.isArchived = false;
91
+ Object.assign(row, overrides);
92
+ return row;
93
+ }
94
+
95
+ function flushPromises(): Promise<void> {
96
+ return new Promise((resolve: () => void) => {
97
+ setTimeout(resolve, 0);
98
+ });
99
+ }
100
+
101
+ function silenceLogs(): {
102
+ error: jest.SpyInstance;
103
+ warn: jest.SpyInstance;
104
+ info: jest.SpyInstance;
105
+ debug: jest.SpyInstance;
106
+ } {
107
+ return {
108
+ error: jest.spyOn(logger, "error").mockImplementation(() => {
109
+ return undefined as never;
110
+ }),
111
+ warn: jest.spyOn(logger, "warn").mockImplementation(() => {
112
+ return undefined as never;
113
+ }),
114
+ info: jest.spyOn(logger, "info").mockImplementation(() => {
115
+ return undefined as never;
116
+ }),
117
+ debug: jest.spyOn(logger, "debug").mockImplementation(() => {
118
+ return undefined as never;
119
+ }),
120
+ };
121
+ }
122
+
123
+ function mockFeed(): jest.SpyInstance {
124
+ getJestSpyOn(service, "getDatabaseServerMarkdownLink").mockResolvedValue(
125
+ "[Database x](/x)",
126
+ );
127
+ getJestSpyOn(
128
+ DatabaseServerLabelRuleEngineService,
129
+ "applyRulesToDatabaseServer",
130
+ ).mockResolvedValue(undefined);
131
+ getJestSpyOn(
132
+ DatabaseServerOwnerRuleEngineService,
133
+ "applyRulesToDatabaseServer",
134
+ ).mockResolvedValue(undefined);
135
+ return getJestSpyOn(
136
+ DatabaseServerFeedService,
137
+ "createDatabaseServerFeedItem",
138
+ ).mockResolvedValue(undefined);
139
+ }
140
+
141
+ function mockRawQuery(result: unknown = []): jest.Mock {
142
+ const query: jest.Mock = jest.fn(async () => {
143
+ return result;
144
+ });
145
+ getJestSpyOn(service, "getRepository").mockReturnValue({
146
+ manager: { query },
147
+ } as never);
148
+ return query;
149
+ }
150
+
151
+ /*
152
+ * The engine compare-and-sets applyDatabaseSystemEvidence ran, among every
153
+ * raw statement of a test: their SQL and parameters.
154
+ */
155
+ interface RawStatement {
156
+ sql: string;
157
+ params: Array<unknown>;
158
+ }
159
+
160
+ function isEngineWrite(sql: unknown): boolean {
161
+ return typeof sql === "string" && sql.includes('SET "dbSystemSource" =');
162
+ }
163
+
164
+ function engineWrites(query: jest.Mock): Array<RawStatement> {
165
+ return query.mock.calls
166
+ .filter((call: Array<unknown>) => {
167
+ return isEngineWrite(call[0]);
168
+ })
169
+ .map((call: Array<unknown>): RawStatement => {
170
+ return { sql: call[0] as string, params: call[1] as Array<unknown> };
171
+ });
172
+ }
173
+
174
+ /*
175
+ * Postgres answering the engine compare-and-set: `matched` rows back (the
176
+ * row as written, name included) or none when another writer got there
177
+ * first. Every other statement answers `others`.
178
+ */
179
+ function answerEngineWrites(
180
+ query: jest.Mock,
181
+ matched: Array<{ _id: string; name: string }>,
182
+ others: unknown = [],
183
+ ): void {
184
+ query.mockImplementation(async (sql: unknown) => {
185
+ return isEngineWrite(sql) ? matched : others;
186
+ });
187
+ }
188
+
189
+ function withEnv(name: string, value: string | undefined): () => void {
190
+ const previous: string | undefined = process.env[name];
191
+ if (value === undefined) {
192
+ delete process.env[name];
193
+ } else {
194
+ process.env[name] = value;
195
+ }
196
+ return () => {
197
+ if (previous === undefined) {
198
+ delete process.env[name];
199
+ } else {
200
+ process.env[name] = previous;
201
+ }
202
+ };
203
+ }
204
+
205
+ function memberProps(): DatabaseCommonInteractionProps {
206
+ const permission: UserPermission = {
207
+ permission: Permission.ProjectMember,
208
+ labelIds: [],
209
+ isBlockPermission: false,
210
+ _type: "UserPermission",
211
+ };
212
+ return {
213
+ userId: USER_ID,
214
+ tenantId: PROJECT_ID,
215
+ userGlobalAccessPermission: {
216
+ projectIds: [PROJECT_ID],
217
+ globalPermissions: [Permission.Public, Permission.User],
218
+ _type: "UserGlobalAccessPermission",
219
+ },
220
+ userTenantAccessPermission: {
221
+ [PROJECT_ID.toString()]: {
222
+ projectId: PROJECT_ID,
223
+ permissions: [permission],
224
+ _type: "UserTenantAccessPermission",
225
+ },
226
+ },
227
+ };
228
+ }
229
+
230
+ afterEach(() => {
231
+ jest.restoreAllMocks();
232
+ DatabaseServerService.clearAutoCreateBudgetMemo();
233
+ });
234
+
235
+ /*
236
+ * ---------------------------------------------------------------------------
237
+ * Engine evidence - pure
238
+ * ---------------------------------------------------------------------------
239
+ */
240
+ describe("decideDatabaseSystem", () => {
241
+ function decide(
242
+ current: [string | null, DatabaseSystemEvidence | null],
243
+ incoming: [string | null, DatabaseSystemEvidence],
244
+ ): DatabaseSystemDetermination | null {
245
+ return decideDatabaseSystem({
246
+ current: { system: current[0], evidence: current[1] },
247
+ incoming: { system: incoming[0], evidence: incoming[1] },
248
+ });
249
+ }
250
+
251
+ const {
252
+ Manual,
253
+ Collector,
254
+ Container,
255
+ ClientSpans,
256
+ }: typeof DatabaseSystemEvidence = DatabaseSystemEvidence;
257
+
258
+ test("an engine a person chose is never changed - whatever the evidence", () => {
259
+ for (const evidence of [Collector, Container, ClientSpans]) {
260
+ expect(decide(["postgresql", Manual], ["mysql", evidence])).toBeNull();
261
+ expect(decide(["redis", Manual], ["valkey", evidence])).toBeNull();
262
+ expect(
263
+ decide(["postgresql", Manual], ["postgresql", evidence]),
264
+ ).toBeNull();
265
+ }
266
+ });
267
+
268
+ test("a container image corrects the engine client spans guessed (pgx said PostgreSQL, the image says MySQL)", () => {
269
+ expect(decide(["postgresql", ClientSpans], ["mysql", Container])).toEqual({
270
+ system: "mysql",
271
+ evidence: Container,
272
+ });
273
+ });
274
+
275
+ test("a collector corrects what a container image said", () => {
276
+ expect(decide(["mysql", Container], ["postgresql", Collector])).toEqual({
277
+ system: "postgresql",
278
+ evidence: Collector,
279
+ });
280
+ });
281
+
282
+ test.each([
283
+ [
284
+ "client spans cannot override an image",
285
+ "mysql",
286
+ Container,
287
+ "postgresql",
288
+ ClientSpans,
289
+ ],
290
+ [
291
+ "client spans cannot override a collector",
292
+ "mysql",
293
+ Collector,
294
+ "postgresql",
295
+ ClientSpans,
296
+ ],
297
+ [
298
+ "an image cannot override a collector",
299
+ "mysql",
300
+ Collector,
301
+ "mongodb",
302
+ Container,
303
+ ],
304
+ [
305
+ "equal evidence never flips a different engine back and forth",
306
+ "postgresql",
307
+ ClientSpans,
308
+ "mysql",
309
+ ClientSpans,
310
+ ],
311
+ [
312
+ "two images disagreeing do not flip either",
313
+ "mysql",
314
+ Container,
315
+ "mongodb",
316
+ Container,
317
+ ],
318
+ ])(
319
+ "%s",
320
+ (
321
+ _label: string,
322
+ currentSystem: string,
323
+ currentEvidence: DatabaseSystemEvidence,
324
+ incomingSystem: string,
325
+ incomingEvidence: DatabaseSystemEvidence,
326
+ ) => {
327
+ expect(
328
+ decide(
329
+ [currentSystem, currentEvidence],
330
+ [incomingSystem, incomingEvidence],
331
+ ),
332
+ ).toBeNull();
333
+ },
334
+ );
335
+
336
+ test.each([
337
+ [
338
+ "an image refines a collector's Redis to Valkey (the redis receiver cannot tell them apart; the image names the engine)",
339
+ "redis",
340
+ Collector,
341
+ "valkey",
342
+ Container,
343
+ ],
344
+ [
345
+ "a collector names the fork an image's family engine hid",
346
+ "mysql",
347
+ Container,
348
+ "mariadb",
349
+ Collector,
350
+ ],
351
+ [
352
+ "an image refines trace-found PostgreSQL to CockroachDB",
353
+ "postgresql",
354
+ ClientSpans,
355
+ "cockroachdb",
356
+ Container,
357
+ ],
358
+ ["equal evidence may refine too", "redis", Container, "keydb", Container],
359
+ [
360
+ "client spans may refine what only client spans named",
361
+ "mysql",
362
+ ClientSpans,
363
+ "mariadb",
364
+ ClientSpans,
365
+ ],
366
+ ])(
367
+ "within one family a fork refines the family engine on evidence at least as strong, or from an image: %s",
368
+ (
369
+ _label: string,
370
+ currentSystem: string,
371
+ currentEvidence: DatabaseSystemEvidence,
372
+ incomingSystem: string,
373
+ incomingEvidence: DatabaseSystemEvidence,
374
+ ) => {
375
+ expect(
376
+ decide(
377
+ [currentSystem, currentEvidence],
378
+ [incomingSystem, incomingEvidence],
379
+ ),
380
+ ).toEqual({ system: incomingSystem, evidence: incomingEvidence });
381
+ },
382
+ );
383
+
384
+ /*
385
+ * A client names the protocol it speaks, not always the server it
386
+ * reached: MariaDB Connector/J (jdbc:mariadb:) reports "mariadb" against a
387
+ * MySQL server. So a fork named only by client spans never overrides what
388
+ * an image or a collector determined - and never lowers that evidence.
389
+ */
390
+ test.each([
391
+ [
392
+ "client spans cannot turn an image's MySQL into MariaDB",
393
+ "mysql",
394
+ Container,
395
+ "mariadb",
396
+ ],
397
+ [
398
+ "client spans cannot turn a collector's MySQL into MariaDB",
399
+ "mysql",
400
+ Collector,
401
+ "mariadb",
402
+ ],
403
+ [
404
+ "client spans cannot turn a collector's Redis into Valkey",
405
+ "redis",
406
+ Collector,
407
+ "valkey",
408
+ ],
409
+ [
410
+ "client spans cannot turn an image's PostgreSQL into CockroachDB",
411
+ "postgresql",
412
+ Container,
413
+ "cockroachdb",
414
+ ],
415
+ ])(
416
+ "a fork named only by weaker evidence refines nothing: %s",
417
+ (
418
+ _label: string,
419
+ currentSystem: string,
420
+ currentEvidence: DatabaseSystemEvidence,
421
+ incomingSystem: string,
422
+ ) => {
423
+ expect(
424
+ decide([currentSystem, currentEvidence], [incomingSystem, ClientSpans]),
425
+ ).toBeNull();
426
+ },
427
+ );
428
+
429
+ test("a fork is never downgraded back to its family engine by a collector or client spans (the mysql receiver cannot tell MariaDB apart)", () => {
430
+ expect(decide(["mariadb", Container], ["mysql", Collector])).toBeNull();
431
+ expect(decide(["valkey", ClientSpans], ["redis", Collector])).toBeNull();
432
+ expect(decide(["mariadb", ClientSpans], ["mysql", ClientSpans])).toBeNull();
433
+ expect(decide(["mariadb", Collector], ["mysql", Collector])).toBeNull();
434
+ });
435
+
436
+ test("an image naming the family engine undoes a fork that only client spans named", () => {
437
+ expect(decide(["mariadb", ClientSpans], ["mysql", Container])).toEqual({
438
+ system: "mysql",
439
+ evidence: Container,
440
+ });
441
+ expect(decide(["valkey", null], ["redis", Container])).toEqual({
442
+ system: "redis",
443
+ evidence: Container,
444
+ });
445
+ // An image or a collector that named the fork is not overruled by an image.
446
+ expect(decide(["mariadb", Container], ["mysql", Container])).toBeNull();
447
+ expect(decide(["mariadb", Collector], ["mysql", Container])).toBeNull();
448
+ });
449
+
450
+ /*
451
+ * The review's sequence: a MySQL StatefulSet whose applications use
452
+ * MariaDB Connector/J. The spans never move the engine, so the image and
453
+ * the collector keep being heard.
454
+ */
455
+ test("a MySQL server reached through a MariaDB client stays MySQL, whatever order the evidence arrives in", () => {
456
+ type Stored = [string, DatabaseSystemEvidence | null];
457
+
458
+ function apply(
459
+ stored: Stored,
460
+ incoming: [string, DatabaseSystemEvidence],
461
+ ): Stored {
462
+ const decision: DatabaseSystemDetermination | null = decide(
463
+ stored,
464
+ incoming,
465
+ );
466
+ return decision ? [decision.system, decision.evidence] : stored;
467
+ }
468
+
469
+ let fromImage: Stored = ["mysql", Container];
470
+ fromImage = apply(fromImage, ["mariadb", ClientSpans]);
471
+ expect(fromImage).toEqual(["mysql", Container]);
472
+ fromImage = apply(fromImage, ["mysql", Collector]);
473
+ expect(fromImage).toEqual(["mysql", Collector]);
474
+
475
+ let fromTraces: Stored = ["mariadb", ClientSpans];
476
+ fromTraces = apply(fromTraces, ["mysql", Container]);
477
+ expect(fromTraces).toEqual(["mysql", Container]);
478
+ fromTraces = apply(fromTraces, ["mariadb", ClientSpans]);
479
+ expect(fromTraces).toEqual(["mysql", Container]);
480
+ });
481
+
482
+ test("client spans never move a collector's Redis, so only an image naming the engine refines it later", () => {
483
+ expect(decide(["redis", Collector], ["valkey", ClientSpans])).toBeNull();
484
+ expect(decide(["redis", Collector], ["keydb", Container])).toEqual({
485
+ system: "keydb",
486
+ evidence: Container,
487
+ });
488
+ });
489
+
490
+ test("one fork replaces another only on stronger evidence", () => {
491
+ expect(decide(["valkey", ClientSpans], ["keydb", Container])).toEqual({
492
+ system: "keydb",
493
+ evidence: Container,
494
+ });
495
+ expect(decide(["valkey", Container], ["keydb", Container])).toBeNull();
496
+ expect(decide(["valkey", Collector], ["keydb", Container])).toBeNull();
497
+ });
498
+
499
+ test("the same engine on stronger evidence records that evidence, so weaker evidence cannot move it later", () => {
500
+ expect(
501
+ decide(["postgresql", ClientSpans], ["postgres", Container]),
502
+ ).toEqual({
503
+ system: "postgresql",
504
+ evidence: Container,
505
+ });
506
+ expect(
507
+ decide(["postgresql", Container], ["postgresql", Container]),
508
+ ).toBeNull();
509
+ expect(
510
+ decide(["postgresql", Collector], ["postgresql", Container]),
511
+ ).toBeNull();
512
+ });
513
+
514
+ test("aliases are compared normalized", () => {
515
+ expect(decide(["postgresql", ClientSpans], ["PG", ClientSpans])).toBeNull();
516
+ });
517
+
518
+ test("a row with no engine takes the incoming one; empty evidence changes nothing", () => {
519
+ expect(decide([null, null], ["Redis", ClientSpans])).toEqual({
520
+ system: "redis",
521
+ evidence: ClientSpans,
522
+ });
523
+ expect(decide(["redis", Collector], [" ", Collector])).toBeNull();
524
+ expect(decide(["redis", Collector], [null, Collector])).toBeNull();
525
+ });
526
+
527
+ test("a row whose evidence is unknown (no discovery source) yields to any source", () => {
528
+ expect(decide(["postgresql", null], ["mysql", ClientSpans])).toEqual({
529
+ system: "mysql",
530
+ evidence: ClientSpans,
531
+ });
532
+ });
533
+
534
+ test("a sibling fork is a different engine, not a refinement: only stronger evidence moves it", () => {
535
+ expect(decide(["tidb", Container], ["mariadb", ClientSpans])).toBeNull();
536
+ expect(decide(["tidb", ClientSpans], ["mariadb", Container])).toEqual({
537
+ system: "mariadb",
538
+ evidence: Container,
539
+ });
540
+ });
541
+
542
+ test("an engine outside the catalog is never refined by a fork - only replaced on stronger evidence", () => {
543
+ expect(decide(["acmedb", Container], ["mariadb", Container])).toBeNull();
544
+ expect(decide(["acmedb", ClientSpans], ["mariadb", Container])).toEqual({
545
+ system: "mariadb",
546
+ evidence: Container,
547
+ });
548
+ });
549
+
550
+ /*
551
+ * ONE rule for "more specific": within a family, what decideDatabaseSystem
552
+ * does is exactly what getMoreSpecificDatabaseSystem (the rule the docs
553
+ * state) says - for every pair of catalogued engines, whatever evidence
554
+ * either side carries short of a person's choice.
555
+ */
556
+ describe("agrees with getMoreSpecificDatabaseSystem across the catalog", () => {
557
+ const systems: Array<string> = DATABASE_SYSTEMS.map(
558
+ (descriptor: DatabaseSystemDescriptor): string => {
559
+ return descriptor.system;
560
+ },
561
+ );
562
+ const evidences: Array<DatabaseSystemEvidence> = [
563
+ Collector,
564
+ Container,
565
+ ClientSpans,
566
+ ];
567
+
568
+ test("the catalog has forks to check", () => {
569
+ expect(
570
+ DATABASE_SYSTEMS.filter((descriptor: DatabaseSystemDescriptor) => {
571
+ return Boolean(descriptor.family);
572
+ }).length,
573
+ ).toBeGreaterThan(5);
574
+ });
575
+
576
+ /*
577
+ * A refinement is taken on evidence at least as strong as what named the
578
+ * current engine, or from an image (it names the exact engine); a fork
579
+ * is undone only by an image, and only when no more than client spans
580
+ * named it.
581
+ */
582
+ test("a refinement is taken on evidence at least as strong or from an image, and a fork is undone only by an image over client spans", () => {
583
+ const rank: Record<DatabaseSystemEvidence, number> = {
584
+ [Manual]: 4,
585
+ [Collector]: 3,
586
+ [Container]: 2,
587
+ [ClientSpans]: 1,
588
+ };
589
+ const disagreements: Array<string> = [];
590
+ let refinements: number = 0;
591
+ let undoings: number = 0;
592
+
593
+ for (const current of systems) {
594
+ for (const observed of systems) {
595
+ if (current === observed) {
596
+ continue;
597
+ }
598
+
599
+ const refines: boolean =
600
+ getMoreSpecificDatabaseSystem(current, observed) === observed;
601
+ const undoes: boolean =
602
+ getMoreSpecificDatabaseSystem(observed, current) === current;
603
+
604
+ if (!refines && !undoes) {
605
+ continue;
606
+ }
607
+
608
+ if (refines) {
609
+ refinements++;
610
+ } else {
611
+ undoings++;
612
+ }
613
+
614
+ for (const currentEvidence of evidences) {
615
+ for (const incomingEvidence of evidences) {
616
+ const decision: DatabaseSystemDetermination | null = decide(
617
+ [current, currentEvidence],
618
+ [observed, incomingEvidence],
619
+ );
620
+ let expected: DatabaseSystemDetermination | null = null;
621
+
622
+ if (
623
+ refines &&
624
+ (rank[incomingEvidence] >= rank[currentEvidence] ||
625
+ incomingEvidence === Container)
626
+ ) {
627
+ expected = { system: observed, evidence: incomingEvidence };
628
+ }
629
+
630
+ if (
631
+ undoes &&
632
+ incomingEvidence === Container &&
633
+ currentEvidence === ClientSpans
634
+ ) {
635
+ expected = { system: observed, evidence: Container };
636
+ }
637
+
638
+ if (JSON.stringify(decision) !== JSON.stringify(expected)) {
639
+ disagreements.push(
640
+ `${current} (${currentEvidence}) seen as ${observed} (${incomingEvidence}): ${JSON.stringify(decision)}`,
641
+ );
642
+ }
643
+ }
644
+ }
645
+ }
646
+ }
647
+
648
+ expect(disagreements).toEqual([]);
649
+ expect(refinements).toBeGreaterThan(5);
650
+ expect(undoings).toBe(refinements);
651
+ });
652
+ });
653
+ });
654
+
655
+ describe("getDatabaseSystemsOfFamily", () => {
656
+ test("the family engine first, then its forks", () => {
657
+ const redis: Array<string> = getDatabaseSystemsOfFamily("valkey");
658
+ expect(redis[0]).toBe("redis");
659
+ expect(redis).toEqual(expect.arrayContaining(["valkey", "keydb"]));
660
+ expect(getDatabaseSystemsOfFamily("mariadb")).toEqual(
661
+ expect.arrayContaining(["mysql", "mariadb"]),
662
+ );
663
+ });
664
+
665
+ test("families never mix, and an unknown engine is a family of one", () => {
666
+ expect(getDatabaseSystemsOfFamily("redis")).not.toContain("mysql");
667
+ expect(getDatabaseSystemsOfFamily("acme-db")).toEqual(["acme-db"]);
668
+ expect(getDatabaseSystemsOfFamily("")).toEqual([]);
669
+ });
670
+ });
671
+
672
+ describe("engine evidence of a row", () => {
673
+ test.each([
674
+ [DatabaseServerDiscoverySource.Manual, DatabaseSystemEvidence.Manual],
675
+ [DatabaseServerDiscoverySource.Collector, DatabaseSystemEvidence.Collector],
676
+ [
677
+ DatabaseServerDiscoverySource.Kubernetes,
678
+ DatabaseSystemEvidence.Container,
679
+ ],
680
+ [DatabaseServerDiscoverySource.Docker, DatabaseSystemEvidence.Container],
681
+ [DatabaseServerDiscoverySource.Podman, DatabaseSystemEvidence.Container],
682
+ [
683
+ DatabaseServerDiscoverySource.ClientSpans,
684
+ DatabaseSystemEvidence.ClientSpans,
685
+ ],
686
+ ])("the %s source is %s evidence", (source: string, evidence: string) => {
687
+ expect(getDatabaseSystemEvidenceForSource(source)).toBe(evidence);
688
+ });
689
+
690
+ test("an unknown source is no evidence", () => {
691
+ expect(getDatabaseSystemEvidenceForSource("carrier-pigeon")).toBeNull();
692
+ expect(getDatabaseSystemEvidenceForSource(undefined)).toBeNull();
693
+ });
694
+
695
+ test("a refinement's recorded evidence wins over the creating source; nonsense falls back to it", () => {
696
+ expect(
697
+ getStoredDatabaseSystemEvidence({
698
+ dbSystemSource: "collector",
699
+ discoverySource: "client-spans",
700
+ }),
701
+ ).toBe(DatabaseSystemEvidence.Collector);
702
+ expect(
703
+ getStoredDatabaseSystemEvidence({
704
+ dbSystemSource: "guesswork",
705
+ discoverySource: "kubernetes",
706
+ }),
707
+ ).toBe(DatabaseSystemEvidence.Container);
708
+ expect(getStoredDatabaseSystemEvidence({ discoverySource: "manual" })).toBe(
709
+ DatabaseSystemEvidence.Manual,
710
+ );
711
+ });
712
+ });
713
+
714
+ describe("hasDiscoveryGeneratedName", () => {
715
+ test("true while the row carries the endpoint or workload name discovery generated for its engine", () => {
716
+ expect(
717
+ hasDiscoveryGeneratedName({
718
+ name: "Redis redis.cache.svc.cluster.local:6379",
719
+ dbSystem: "redis",
720
+ serverAddress: "redis.cache.svc.cluster.local",
721
+ serverPort: 6379,
722
+ }),
723
+ ).toBe(true);
724
+ expect(
725
+ hasDiscoveryGeneratedName({
726
+ name: "PostgreSQL data/orders",
727
+ dbSystem: "postgresql",
728
+ kubernetesNamespace: "data",
729
+ workloadName: "orders",
730
+ }),
731
+ ).toBe(true);
732
+ expect(
733
+ hasDiscoveryGeneratedName({
734
+ name: "PostgreSQL db.example.com",
735
+ dbSystem: "postgres",
736
+ serverAddress: "db.example.com",
737
+ }),
738
+ ).toBe(true);
739
+ });
740
+
741
+ test("false for a name a person chose, a name of another engine, or nothing to compare with", () => {
742
+ const endpoint: {
743
+ dbSystem: string;
744
+ serverAddress: string;
745
+ serverPort: number;
746
+ } = {
747
+ dbSystem: "redis",
748
+ serverAddress: "redis.cache.svc.cluster.local",
749
+ serverPort: 6379,
750
+ };
751
+ expect(
752
+ hasDiscoveryGeneratedName({ ...endpoint, name: "Orders cache (prod)" }),
753
+ ).toBe(false);
754
+ expect(
755
+ hasDiscoveryGeneratedName({
756
+ ...endpoint,
757
+ name: "Valkey redis.cache.svc.cluster.local:6379",
758
+ }),
759
+ ).toBe(false);
760
+ expect(hasDiscoveryGeneratedName({ ...endpoint, name: " " })).toBe(false);
761
+ expect(
762
+ hasDiscoveryGeneratedName({ name: "Redis", dbSystem: "redis" }),
763
+ ).toBe(false);
764
+ });
765
+ });
766
+
767
+ describe("renameForDatabaseSystem", () => {
768
+ test("an endpoint row still carrying its generated name is renamed for the new engine", () => {
769
+ expect(
770
+ renameForDatabaseSystem(
771
+ {
772
+ name: "PostgreSQL cockroachdb-public.crdb.svc.cluster.local:26257",
773
+ dbSystem: "postgresql",
774
+ serverAddress: "cockroachdb-public.crdb.svc.cluster.local",
775
+ serverPort: 26257,
776
+ },
777
+ "cockroachdb",
778
+ ),
779
+ ).toBe("CockroachDB cockroachdb-public.crdb.svc.cluster.local:26257");
780
+ });
781
+
782
+ test("a workload row still carrying its generated name is renamed for the new engine", () => {
783
+ expect(
784
+ renameForDatabaseSystem(
785
+ {
786
+ name: "Redis cache/redis",
787
+ dbSystem: "redis",
788
+ serverAddress: "redis.cache.svc.cluster.local",
789
+ serverPort: 6379,
790
+ kubernetesNamespace: "cache",
791
+ workloadName: "redis",
792
+ },
793
+ "valkey",
794
+ ),
795
+ ).toBe("Valkey cache/redis");
796
+ });
797
+
798
+ test("a name a person chose is kept", () => {
799
+ expect(
800
+ renameForDatabaseSystem(
801
+ {
802
+ name: "Orders primary",
803
+ dbSystem: "postgresql",
804
+ serverAddress: "orders-db.example.com",
805
+ serverPort: 5432,
806
+ },
807
+ "cockroachdb",
808
+ ),
809
+ ).toBeNull();
810
+ });
811
+
812
+ test("nothing to rename without a name or an engine", () => {
813
+ expect(
814
+ renameForDatabaseSystem(
815
+ { dbSystem: "postgresql", serverAddress: "x.example.com" },
816
+ "mysql",
817
+ ),
818
+ ).toBeNull();
819
+ expect(
820
+ renameForDatabaseSystem(
821
+ {
822
+ name: "PostgreSQL x.example.com:5432",
823
+ serverAddress: "x.example.com",
824
+ },
825
+ "mysql",
826
+ ),
827
+ ).toBeNull();
828
+ });
829
+ });
830
+
831
+ /*
832
+ * ---------------------------------------------------------------------------
833
+ * The workload path: engine, version and the alias lifecycle
834
+ * ---------------------------------------------------------------------------
835
+ */
836
+ describe("DatabaseServerService.upsertWorkloadDatabase - lifecycle", () => {
837
+ interface StoredEndpointFixture {
838
+ id: ObjectID;
839
+ databaseServerId: string;
840
+ source: string;
841
+ isPrimary: boolean;
842
+ lastMatchedAt: Date | null;
843
+ }
844
+
845
+ interface World {
846
+ rows: Map<string, DatabaseServer>;
847
+ endpoints: Map<string, StoredEndpointFixture>;
848
+ writes: Array<{ id: string; data: Record<string, unknown>; expected: any }>;
849
+ claims: Array<any>;
850
+ transfers: Array<any>;
851
+ untouchedTraceRows: Set<string>;
852
+ }
853
+
854
+ let world: World;
855
+ let feed: jest.SpyInstance;
856
+ let logs: ReturnType<typeof silenceLogs>;
857
+ let refresh: jest.SpyInstance;
858
+ let release: jest.SpyInstance;
859
+ let hasPrimary: jest.SpyInstance;
860
+ let rawQuery: jest.Mock;
861
+
862
+ const WORKLOAD: string = "redis|kubernetes:prod/cache/statefulset/redis";
863
+ const OLD_WORKLOAD: string = "redis|kubernetes:prod/cache/deployment/redis";
864
+ const SERVICE_ALIAS: string = "redis.cache.svc.cluster.local:6379@prod";
865
+ const POD_ALIAS: string =
866
+ "redis-0.redis-hl.cache.svc.cluster.local:6379@prod";
867
+
868
+ function input(
869
+ overrides: Partial<UpsertWorkloadDatabaseData> = {},
870
+ ): UpsertWorkloadDatabaseData {
871
+ return {
872
+ projectId: PROJECT_ID,
873
+ workloadIdentifier: WORKLOAD,
874
+ dbSystem: "redis",
875
+ displayName: "Redis cache/redis",
876
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
877
+ aliases: [SERVICE_ALIAS, POD_ALIAS],
878
+ memberKeysSeenNow: [],
879
+ instanceCount: 1,
880
+ dbVersion: "7.2",
881
+ kubernetesClusterId: CLUSTER_ID,
882
+ kubernetesNamespace: "cache",
883
+ workloadKind: "StatefulSet",
884
+ workloadName: "redis",
885
+ allowCreate: true,
886
+ ...overrides,
887
+ };
888
+ }
889
+
890
+ function addRow(row: DatabaseServer): DatabaseServer {
891
+ world.rows.set(row.id!.toString(), row);
892
+ return row;
893
+ }
894
+
895
+ function workloadRow(
896
+ overrides: Partial<DatabaseServer> = {},
897
+ ): DatabaseServer {
898
+ return addRow(
899
+ databaseRow({
900
+ name: "Redis cache/redis",
901
+ dbSystem: "redis",
902
+ databaseIdentifier: WORKLOAD,
903
+ workloadIdentifier: WORKLOAD,
904
+ workloadName: "redis",
905
+ kubernetesNamespace: "cache",
906
+ serverAddress: "redis.cache.svc.cluster.local",
907
+ serverPort: 6379,
908
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
909
+ workloadLastSeenAt: new Date(),
910
+ ...overrides,
911
+ }),
912
+ );
913
+ }
914
+
915
+ // A row application traces created for the Service name, as discovery named it.
916
+ function traceDuplicate(
917
+ overrides: Partial<DatabaseServer> = {},
918
+ ): DatabaseServer {
919
+ return databaseRow({
920
+ name: "Redis redis.cache.svc.cluster.local:6379",
921
+ dbSystem: "redis",
922
+ databaseIdentifier: "redis|redis.cache.svc.cluster.local:6379@prod",
923
+ serverAddress: "redis.cache.svc.cluster.local",
924
+ serverPort: 6379,
925
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
926
+ ...overrides,
927
+ });
928
+ }
929
+
930
+ function own(
931
+ endpoint: string,
932
+ row: DatabaseServer,
933
+ overrides: Partial<StoredEndpointFixture> = {},
934
+ ): StoredEndpointFixture {
935
+ const stored: StoredEndpointFixture = {
936
+ id: ObjectID.generate(),
937
+ databaseServerId: row.id!.toString(),
938
+ source: "workload",
939
+ isPrimary: false,
940
+ lastMatchedAt: new Date(),
941
+ ...overrides,
942
+ };
943
+ world.endpoints.set(endpoint, stored);
944
+ return stored;
945
+ }
946
+
947
+ function lastWriteFor(row: DatabaseServer): Record<string, unknown> {
948
+ const writes: Array<{ id: string; data: Record<string, unknown> }> =
949
+ world.writes.filter((w: { id: string }) => {
950
+ return w.id === row.id!.toString();
951
+ });
952
+ return writes[writes.length - 1]!.data;
953
+ }
954
+
955
+ beforeEach(() => {
956
+ logs = silenceLogs();
957
+ world = {
958
+ rows: new Map(),
959
+ endpoints: new Map(),
960
+ writes: [],
961
+ claims: [],
962
+ transfers: [],
963
+ untouchedTraceRows: new Set(),
964
+ };
965
+ feed = mockFeed();
966
+
967
+ getJestSpyOn(service, "findOneBy").mockImplementation(
968
+ async (findOneBy: any) => {
969
+ for (const row of world.rows.values()) {
970
+ if (
971
+ findOneBy.query.workloadIdentifier !== undefined &&
972
+ row.workloadIdentifier !== findOneBy.query.workloadIdentifier
973
+ ) {
974
+ continue;
975
+ }
976
+ if (
977
+ findOneBy.query._id &&
978
+ row.id!.toString() !== findOneBy.query._id.toString()
979
+ ) {
980
+ continue;
981
+ }
982
+ return row;
983
+ }
984
+ return null;
985
+ },
986
+ );
987
+
988
+ getJestSpyOn(service, "findBy").mockImplementation(async (findBy: any) => {
989
+ const wanted: Array<string> = [];
990
+ const operator: any = findBy.query._id || findBy.query.workloadIdentifier;
991
+ for (const values of Object.values(
992
+ operator.objectLiteralParameters || {},
993
+ )) {
994
+ wanted.push(...(values as Array<string>));
995
+ }
996
+ if (findBy.query.workloadIdentifier) {
997
+ // The engine-family lookup: rows by workload identifier.
998
+ return Array.from(world.rows.values()).filter((row: DatabaseServer) => {
999
+ return Boolean(
1000
+ row.workloadIdentifier && wanted.includes(row.workloadIdentifier),
1001
+ );
1002
+ });
1003
+ }
1004
+ return wanted
1005
+ .map((id: string) => {
1006
+ return world.rows.get(id);
1007
+ })
1008
+ .filter(Boolean);
1009
+ });
1010
+
1011
+ getJestSpyOn(DatabaseServerEndpointService, "findBy").mockImplementation(
1012
+ async (findBy: any) => {
1013
+ const wanted: Array<string> = [];
1014
+ for (const values of Object.values(
1015
+ findBy.query.endpoint.objectLiteralParameters || {},
1016
+ )) {
1017
+ wanted.push(...(values as Array<string>));
1018
+ }
1019
+ const rows: Array<DatabaseServerEndpoint> = [];
1020
+ for (const endpoint of wanted) {
1021
+ const stored: StoredEndpointFixture | undefined =
1022
+ world.endpoints.get(endpoint);
1023
+ if (stored) {
1024
+ const row: DatabaseServerEndpoint = new DatabaseServerEndpoint(
1025
+ stored.id,
1026
+ );
1027
+ row.endpoint = endpoint;
1028
+ row.databaseServerId = new ObjectID(stored.databaseServerId);
1029
+ row.source = stored.source;
1030
+ row.isPrimary = stored.isPrimary;
1031
+ if (stored.lastMatchedAt) {
1032
+ row.lastMatchedAt = stored.lastMatchedAt;
1033
+ }
1034
+ rows.push(row);
1035
+ }
1036
+ }
1037
+ return rows;
1038
+ },
1039
+ );
1040
+
1041
+ getJestSpyOn(
1042
+ DatabaseServerEndpointService,
1043
+ "claimEndpoint",
1044
+ ).mockImplementation(
1045
+ async (claim: any): Promise<DatabaseServerEndpointClaimResult> => {
1046
+ world.claims.push(claim);
1047
+ if (world.endpoints.has(claim.endpoint)) {
1048
+ return "owned-by-other";
1049
+ }
1050
+ world.endpoints.set(claim.endpoint, {
1051
+ id: ObjectID.generate(),
1052
+ databaseServerId: claim.databaseServerId.toString(),
1053
+ source: claim.source,
1054
+ isPrimary: claim.isPrimary,
1055
+ lastMatchedAt: new Date(),
1056
+ });
1057
+ return "claimed";
1058
+ },
1059
+ );
1060
+
1061
+ getJestSpyOn(
1062
+ DatabaseServerEndpointService,
1063
+ "transferEndpoint",
1064
+ ).mockImplementation(async (transfer: any) => {
1065
+ world.transfers.push(transfer);
1066
+ for (const stored of world.endpoints.values()) {
1067
+ if (stored.id.toString() === transfer.endpointId.toString()) {
1068
+ if (
1069
+ stored.databaseServerId !== transfer.fromDatabaseServerId.toString()
1070
+ ) {
1071
+ return false;
1072
+ }
1073
+ stored.databaseServerId = transfer.toDatabaseServerId.toString();
1074
+ stored.source = "workload";
1075
+ stored.isPrimary = transfer.isPrimary;
1076
+ return true;
1077
+ }
1078
+ }
1079
+ return false;
1080
+ });
1081
+
1082
+ refresh = getJestSpyOn(
1083
+ DatabaseServerEndpointService,
1084
+ "refreshMatchedEndpoints",
1085
+ ).mockResolvedValue(undefined);
1086
+ release = getJestSpyOn(
1087
+ DatabaseServerEndpointService,
1088
+ "releaseUnproducedWorkloadEndpoints",
1089
+ ).mockResolvedValue([]);
1090
+ hasPrimary = getJestSpyOn(
1091
+ DatabaseServerEndpointService,
1092
+ "hasPrimaryEndpoint",
1093
+ ).mockResolvedValue(true);
1094
+
1095
+ getJestSpyOn(service, "updateColumnsByIdWithoutHooks").mockImplementation(
1096
+ async (update: any) => {
1097
+ const row: DatabaseServer | undefined = world.rows.get(
1098
+ update.id.toString(),
1099
+ );
1100
+ if (!row) {
1101
+ return;
1102
+ }
1103
+ world.writes.push({
1104
+ id: update.id.toString(),
1105
+ data: update.data,
1106
+ expected: update.expectedData,
1107
+ });
1108
+ Object.assign(row, update.data);
1109
+ },
1110
+ );
1111
+
1112
+ getJestSpyOn(service, "create").mockImplementation(
1113
+ async (createBy: any) => {
1114
+ const row: DatabaseServer = createBy.data;
1115
+ row._id = ObjectID.generate().toString();
1116
+ addRow(row);
1117
+ return row;
1118
+ },
1119
+ );
1120
+
1121
+ /*
1122
+ * Raw SQL: the untouched-duplicate lookup, and restores. The lookup's
1123
+ * SQL-side conditions are world.untouchedTraceRows plus a person's
1124
+ * restore within the grace window ($4); it answers the columns the
1125
+ * service reads back.
1126
+ */
1127
+ rawQuery = jest.fn(async (sql: string, params: Array<unknown>) => {
1128
+ if (sql.includes(`ds."workloadIdentifier" IS NULL`)) {
1129
+ return (params[1] as Array<string>)
1130
+ .filter((id: string) => {
1131
+ const row: DatabaseServer | undefined = world.rows.get(id);
1132
+ return (
1133
+ world.untouchedTraceRows.has(id) &&
1134
+ (!row?.manuallyRestoredAt ||
1135
+ row.manuallyRestoredAt < (params[3] as Date)) &&
1136
+ !(row?.description || "").trim()
1137
+ );
1138
+ })
1139
+ .map((id: string) => {
1140
+ const row: DatabaseServer | undefined = world.rows.get(id);
1141
+ return {
1142
+ _id: id,
1143
+ name: row?.name ?? null,
1144
+ dbSystem: row?.dbSystem ?? null,
1145
+ serverAddress: row?.serverAddress ?? null,
1146
+ serverPort: row?.serverPort ?? null,
1147
+ };
1148
+ });
1149
+ }
1150
+ return [];
1151
+ });
1152
+ getJestSpyOn(service, "getRepository").mockReturnValue({
1153
+ manager: { query: rawQuery },
1154
+ } as never);
1155
+ });
1156
+
1157
+ describe("engine and version", () => {
1158
+ test("an adopted trace row named after the wrong engine is corrected by the image, and renamed", async () => {
1159
+ const tracesRow: DatabaseServer = addRow(
1160
+ databaseRow({
1161
+ name: "PostgreSQL cockroachdb-public.crdb.svc.cluster.local:26257",
1162
+ dbSystem: "postgresql",
1163
+ serverAddress: "cockroachdb-public.crdb.svc.cluster.local",
1164
+ serverPort: 26257,
1165
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1166
+ }),
1167
+ );
1168
+ const alias: string =
1169
+ "cockroachdb-public.crdb.svc.cluster.local:26257@prod";
1170
+ own(alias, tracesRow, { source: "auto", isPrimary: true });
1171
+
1172
+ const result: DatabaseServer | null =
1173
+ await DatabaseServerService.upsertWorkloadDatabase(
1174
+ input({
1175
+ workloadIdentifier:
1176
+ "cockroachdb|kubernetes:prod/crdb/statefulset/cockroachdb",
1177
+ dbSystem: "cockroachdb",
1178
+ aliases: [alias],
1179
+ kubernetesNamespace: "crdb",
1180
+ workloadName: "cockroachdb",
1181
+ }),
1182
+ );
1183
+
1184
+ expect(result).toBe(tracesRow);
1185
+ const write: Record<string, unknown> = lastWriteFor(tracesRow);
1186
+ expect(write["dbSystem"]).toBe("cockroachdb");
1187
+ expect(write["dbSystemSource"]).toBe("container");
1188
+ expect(write["name"]).toBe(
1189
+ "CockroachDB cockroachdb-public.crdb.svc.cluster.local:26257",
1190
+ );
1191
+ expect(tracesRow.dbSystem).toBe("cockroachdb");
1192
+
1193
+ await flushPromises();
1194
+ const item: any = feed.mock.calls.find((call: any) => {
1195
+ return (
1196
+ call[0].databaseServerFeedEventType ===
1197
+ DatabaseServerFeedEventType.DatabaseServerUpdated
1198
+ );
1199
+ })![0];
1200
+ expect(item.feedInfoInMarkdown).toContain(
1201
+ "is now identified as **CockroachDB** (it was shown as PostgreSQL), from its container image.",
1202
+ );
1203
+ });
1204
+
1205
+ test("a name a person gave the adopted row survives the engine correction", async () => {
1206
+ const tracesRow: DatabaseServer = addRow(
1207
+ databaseRow({
1208
+ name: "Orders (prod)",
1209
+ dbSystem: "postgresql",
1210
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1211
+ }),
1212
+ );
1213
+ own("orders-db.example.com:5432", tracesRow, { source: "auto" });
1214
+
1215
+ await DatabaseServerService.upsertWorkloadDatabase(
1216
+ input({
1217
+ workloadIdentifier: "mysql|kubernetes:prod/data/statefulset/orders",
1218
+ dbSystem: "mysql",
1219
+ aliases: ["orders-db.example.com:5432"],
1220
+ }),
1221
+ );
1222
+
1223
+ const write: Record<string, unknown> = lastWriteFor(tracesRow);
1224
+ expect(write["dbSystem"]).toBe("mysql");
1225
+ expect("name" in write).toBe(false);
1226
+ expect(tracesRow.name).toBe("Orders (prod)");
1227
+ });
1228
+
1229
+ test("a manually added database adopted by a workload keeps the engine the person chose", async () => {
1230
+ const manual: DatabaseServer = addRow(
1231
+ databaseRow({
1232
+ name: "Orders",
1233
+ dbSystem: "postgresql",
1234
+ discoverySource: DatabaseServerDiscoverySource.Manual,
1235
+ }),
1236
+ );
1237
+ own("orders-db.example.com:5432", manual, {
1238
+ source: "user",
1239
+ isPrimary: true,
1240
+ });
1241
+
1242
+ await DatabaseServerService.upsertWorkloadDatabase(
1243
+ input({
1244
+ workloadIdentifier: "mysql|kubernetes:prod/data/statefulset/orders",
1245
+ dbSystem: "mysql",
1246
+ aliases: ["orders-db.example.com:5432"],
1247
+ }),
1248
+ );
1249
+
1250
+ const write: Record<string, unknown> = lastWriteFor(manual);
1251
+ expect("dbSystem" in write).toBe(false);
1252
+ expect("dbSystemSource" in write).toBe(false);
1253
+ expect(manual.dbSystem).toBe("postgresql");
1254
+ });
1255
+
1256
+ test("an image refines the workload's Redis to Valkey", async () => {
1257
+ const row: DatabaseServer = workloadRow();
1258
+
1259
+ await DatabaseServerService.upsertWorkloadDatabase(
1260
+ input({ dbSystem: "valkey" }),
1261
+ );
1262
+
1263
+ const write: Record<string, unknown> = lastWriteFor(row);
1264
+ expect(write["dbSystem"]).toBe("valkey");
1265
+ expect(write["name"]).toBe("Valkey cache/redis");
1266
+ });
1267
+
1268
+ test("an image moving from Redis to Valkey keeps the SAME database: re-keyed, refined and renamed", async () => {
1269
+ const row: DatabaseServer = workloadRow();
1270
+ own(SERVICE_ALIAS, row, { isPrimary: true });
1271
+ const valkeyWorkload: string =
1272
+ "valkey|kubernetes:prod/cache/statefulset/redis";
1273
+
1274
+ const result: DatabaseServer | null =
1275
+ await DatabaseServerService.upsertWorkloadDatabase(
1276
+ input({ workloadIdentifier: valkeyWorkload, dbSystem: "valkey" }),
1277
+ );
1278
+
1279
+ expect(result).toBe(row);
1280
+ expect(Array.from(world.rows.values())).toHaveLength(1);
1281
+ // Re-keyed with a compare-and-set on the old identifier.
1282
+ const rekey: any = world.writes.find((write: any) => {
1283
+ return write.data.workloadIdentifier === valkeyWorkload;
1284
+ });
1285
+ expect(rekey.expected).toEqual({ workloadIdentifier: WORKLOAD });
1286
+ const write: Record<string, unknown> = lastWriteFor(row);
1287
+ expect(write["dbSystem"]).toBe("valkey");
1288
+ expect(write["name"]).toBe("Valkey cache/redis");
1289
+ // Its endpoints stayed where they were.
1290
+ expect(world.endpoints.get(SERVICE_ALIAS)!.databaseServerId).toBe(
1291
+ row.id!.toString(),
1292
+ );
1293
+ expect(logs.info).toHaveBeenCalledWith(
1294
+ expect.stringContaining(
1295
+ `workload ${WORKLOAD} is now ${valkeyWorkload}`,
1296
+ ),
1297
+ expect.anything(),
1298
+ );
1299
+ });
1300
+
1301
+ test("a row stored under the old fork-keyed identifier is re-keyed to the family key while the fork is still reported - not duplicated", async () => {
1302
+ const legacyWorkload: string =
1303
+ "valkey|kubernetes:prod/cache/statefulset/redis";
1304
+ const row: DatabaseServer = workloadRow({
1305
+ name: "Valkey cache/redis",
1306
+ dbSystem: "valkey",
1307
+ databaseIdentifier: legacyWorkload,
1308
+ workloadIdentifier: legacyWorkload,
1309
+ });
1310
+ own(SERVICE_ALIAS, row, { isPrimary: true });
1311
+
1312
+ // What buildWorkloadDatabaseServerIdentifier produces for Valkey now.
1313
+ const result: DatabaseServer | null =
1314
+ await DatabaseServerService.upsertWorkloadDatabase(
1315
+ input({
1316
+ workloadIdentifier: WORKLOAD,
1317
+ dbSystem: "valkey",
1318
+ displayName: "Valkey cache/redis",
1319
+ }),
1320
+ );
1321
+
1322
+ expect(result).toBe(row);
1323
+ expect(Array.from(world.rows.values())).toHaveLength(1);
1324
+ expect(row.workloadIdentifier).toBe(WORKLOAD);
1325
+ // A compare-and-set on the legacy identifier.
1326
+ const rekey: any = world.writes.find((write: any) => {
1327
+ return write.data.workloadIdentifier === WORKLOAD;
1328
+ });
1329
+ expect(rekey.expected).toEqual({ workloadIdentifier: legacyWorkload });
1330
+ expect(row.dbSystem).toBe("valkey");
1331
+ expect(world.endpoints.get(SERVICE_ALIAS)!.databaseServerId).toBe(
1332
+ row.id!.toString(),
1333
+ );
1334
+
1335
+ // The next run finds it by the family key: nothing re-keyed again.
1336
+ const writesBefore: number = world.writes.length;
1337
+ const again: DatabaseServer | null =
1338
+ await DatabaseServerService.upsertWorkloadDatabase(
1339
+ input({
1340
+ workloadIdentifier: WORKLOAD,
1341
+ dbSystem: "valkey",
1342
+ displayName: "Valkey cache/redis",
1343
+ }),
1344
+ );
1345
+ expect(again).toBe(row);
1346
+ expect(Array.from(world.rows.values())).toHaveLength(1);
1347
+ expect(
1348
+ world.writes.slice(writesBefore).some((write: any) => {
1349
+ return "workloadIdentifier" in write.data;
1350
+ }),
1351
+ ).toBe(false);
1352
+ });
1353
+
1354
+ test("the engine-family lookup asks for every legacy key of the family - the reported fork's too - but never the family key itself", async () => {
1355
+ const findBy: jest.SpyInstance = service.findBy as jest.SpyInstance;
1356
+
1357
+ await DatabaseServerService.upsertWorkloadDatabase(
1358
+ input({ workloadIdentifier: WORKLOAD, dbSystem: "valkey" }),
1359
+ );
1360
+
1361
+ const lookup: any = findBy.mock.calls.find((call: any) => {
1362
+ return Boolean(call[0].query.workloadIdentifier);
1363
+ });
1364
+ const candidates: Array<string> = [];
1365
+ for (const values of Object.values(
1366
+ lookup[0].query.workloadIdentifier.objectLiteralParameters || {},
1367
+ )) {
1368
+ candidates.push(...(values as Array<string>));
1369
+ }
1370
+ expect(candidates).toContain(
1371
+ "valkey|kubernetes:prod/cache/statefulset/redis",
1372
+ );
1373
+ expect(candidates).toContain(
1374
+ "keydb|kubernetes:prod/cache/statefulset/redis",
1375
+ );
1376
+ expect(candidates).not.toContain(WORKLOAD);
1377
+ // Nothing to adopt: one new row, under the family key.
1378
+ const rows: Array<DatabaseServer> = Array.from(world.rows.values());
1379
+ expect(rows).toHaveLength(1);
1380
+ expect(rows[0]!.workloadIdentifier).toBe(WORKLOAD);
1381
+ });
1382
+
1383
+ test("a workload of another engine family is a different database - never re-keyed", async () => {
1384
+ workloadRow({
1385
+ workloadIdentifier:
1386
+ "postgresql|kubernetes:prod/cache/statefulset/redis",
1387
+ dbSystem: "postgresql",
1388
+ });
1389
+
1390
+ await DatabaseServerService.upsertWorkloadDatabase(
1391
+ input({
1392
+ workloadIdentifier: "mysql|kubernetes:prod/cache/statefulset/redis",
1393
+ dbSystem: "mysql",
1394
+ aliases: [],
1395
+ }),
1396
+ );
1397
+
1398
+ expect(Array.from(world.rows.values())).toHaveLength(2);
1399
+ });
1400
+
1401
+ test("two same-family candidates are ambiguous: neither is re-keyed", async () => {
1402
+ const redis: DatabaseServer = workloadRow();
1403
+ const keydb: DatabaseServer = workloadRow({
1404
+ workloadIdentifier: "keydb|kubernetes:prod/cache/statefulset/redis",
1405
+ dbSystem: "keydb",
1406
+ });
1407
+
1408
+ await DatabaseServerService.upsertWorkloadDatabase(
1409
+ input({
1410
+ workloadIdentifier: "valkey|kubernetes:prod/cache/statefulset/redis",
1411
+ dbSystem: "valkey",
1412
+ aliases: [],
1413
+ }),
1414
+ );
1415
+
1416
+ expect(Array.from(world.rows.values())).toHaveLength(3);
1417
+ expect(redis.workloadIdentifier).toBe(WORKLOAD);
1418
+ expect(keydb.workloadIdentifier).toBe(
1419
+ "keydb|kubernetes:prod/cache/statefulset/redis",
1420
+ );
1421
+ });
1422
+
1423
+ test("a row created in this run is neither re-weighed nor written a second time", async () => {
1424
+ await DatabaseServerService.upsertWorkloadDatabase(input());
1425
+
1426
+ const created: DatabaseServer = Array.from(world.rows.values())[0]!;
1427
+ // The create itself wrote every workload column.
1428
+ expect(created.dbSystem).toBe("redis");
1429
+ expect(created.dbSystemSource).toBeUndefined();
1430
+ expect(
1431
+ world.writes.filter((write: { id: string }) => {
1432
+ return write.id === created.id!.toString();
1433
+ }),
1434
+ ).toEqual([]);
1435
+ });
1436
+
1437
+ test("while a collector reports the engine's own version, the image tag does not overwrite it", async () => {
1438
+ const row: DatabaseServer = workloadRow({
1439
+ dbVersion: "7.2.4",
1440
+ collectorLastSeenAt: new Date(Date.now() - 2 * MINUTE_MS),
1441
+ });
1442
+
1443
+ await DatabaseServerService.upsertWorkloadDatabase(
1444
+ input({ dbVersion: "7.2" }),
1445
+ );
1446
+
1447
+ expect("dbVersion" in lastWriteFor(row)).toBe(false);
1448
+ expect(row.dbVersion).toBe("7.2.4");
1449
+ });
1450
+
1451
+ test("once the collector is quiet, the image tag is the version again", async () => {
1452
+ const row: DatabaseServer = workloadRow({
1453
+ dbVersion: "7.2.4",
1454
+ collectorLastSeenAt: new Date(Date.now() - 2 * HOUR_MS),
1455
+ });
1456
+
1457
+ await DatabaseServerService.upsertWorkloadDatabase(
1458
+ input({ dbVersion: "7.4" }),
1459
+ );
1460
+
1461
+ expect(lastWriteFor(row)["dbVersion"]).toBe("7.4");
1462
+ });
1463
+
1464
+ test("a live collector that reported no version leaves the image tag to fill it", async () => {
1465
+ const row: DatabaseServer = workloadRow({
1466
+ collectorLastSeenAt: new Date(),
1467
+ });
1468
+
1469
+ await DatabaseServerService.upsertWorkloadDatabase(input());
1470
+
1471
+ expect(lastWriteFor(row)["dbVersion"]).toBe("7.2");
1472
+ });
1473
+
1474
+ test("the workload's last sighting is stamped once it was last written long ago", async () => {
1475
+ const row: DatabaseServer = workloadRow({
1476
+ workloadLastSeenAt: new Date(Date.now() - 3 * HOUR_MS),
1477
+ });
1478
+ const before: number = Date.now();
1479
+
1480
+ await DatabaseServerService.upsertWorkloadDatabase(input());
1481
+
1482
+ expect(
1483
+ (lastWriteFor(row)["workloadLastSeenAt"] as Date).getTime(),
1484
+ ).toBeGreaterThanOrEqual(before);
1485
+ });
1486
+ });
1487
+
1488
+ /*
1489
+ * Discovery sees every workload every five minutes. Rewriting a row whose
1490
+ * columns would not change (the member-key JSON included) costs a write
1491
+ * per database per run for nothing, so it is skipped while the row was
1492
+ * written within the last quarter hour - which keeps its lastSeenAt inside
1493
+ * the dashboard's 30-minute "seen recently" window, and far inside the
1494
+ * hour after which a workload counts as gone.
1495
+ */
1496
+ describe("a workload seen again with nothing new", () => {
1497
+ const MEMBER_KEY_A: string = "0123456789abcdef";
1498
+ const MEMBER_KEY_B: string = "fedcba9876543210";
1499
+
1500
+ // The row exactly as input() would write it, last written `ageMs` ago.
1501
+ function unchangedRow(
1502
+ ageMs: number,
1503
+ overrides: Partial<DatabaseServer> = {},
1504
+ ): DatabaseServer {
1505
+ const writtenAt: Date = new Date(Date.now() - ageMs);
1506
+ return workloadRow({
1507
+ instanceCount: 1,
1508
+ dbVersion: "7.2",
1509
+ workloadKind: "StatefulSet",
1510
+ workloadName: "redis",
1511
+ kubernetesNamespace: "cache",
1512
+ kubernetesClusterId: new ObjectID(CLUSTER_ID.toString()),
1513
+ dbSystemSource: DatabaseSystemEvidence.Container,
1514
+ memberEntityKeys: { [MEMBER_KEY_A]: writtenAt.toISOString() },
1515
+ lastSeenAt: writtenAt,
1516
+ workloadLastSeenAt: writtenAt,
1517
+ ...overrides,
1518
+ });
1519
+ }
1520
+
1521
+ function writesFor(row: DatabaseServer): Array<Record<string, unknown>> {
1522
+ return world.writes
1523
+ .filter((write: { id: string }) => {
1524
+ return write.id === row.id!.toString();
1525
+ })
1526
+ .map((write: { data: Record<string, unknown> }) => {
1527
+ return write.data;
1528
+ });
1529
+ }
1530
+
1531
+ function seenAgain(
1532
+ overrides: Partial<UpsertWorkloadDatabaseData> = {},
1533
+ ): UpsertWorkloadDatabaseData {
1534
+ return input({ memberKeysSeenNow: [MEMBER_KEY_A], ...overrides });
1535
+ }
1536
+
1537
+ test("a row written five minutes ago is not rewritten - and is returned as it was read", async () => {
1538
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS);
1539
+ const writtenAt: Date = row.lastSeenAt!;
1540
+
1541
+ const result: DatabaseServer | null =
1542
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain());
1543
+
1544
+ expect(result).toBe(row);
1545
+ expect(writesFor(row)).toEqual([]);
1546
+ expect(row.lastSeenAt).toBe(writtenAt);
1547
+ expect(row.memberEntityKeys).toEqual({
1548
+ [MEMBER_KEY_A]: writtenAt.toISOString(),
1549
+ });
1550
+ });
1551
+
1552
+ test("its endpoints are still claimed and refreshed", async () => {
1553
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS);
1554
+ own(SERVICE_ALIAS, row, {
1555
+ lastMatchedAt: new Date(Date.now() - 2 * HOUR_MS),
1556
+ });
1557
+
1558
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain());
1559
+
1560
+ expect(writesFor(row)).toEqual([]);
1561
+ // The Service alias is re-stamped, the pod alias claimed.
1562
+ expect(refresh.mock.calls[0]![0].endpoints).toEqual([SERVICE_ALIAS]);
1563
+ expect(
1564
+ world.claims.map((claim: any) => {
1565
+ return claim.endpoint;
1566
+ }),
1567
+ ).toEqual([POD_ALIAS]);
1568
+ expect(release).toHaveBeenCalled();
1569
+ });
1570
+
1571
+ test("an auto-archived row is still restored", async () => {
1572
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS, {
1573
+ isArchived: true,
1574
+ autoArchivedAt: new Date(Date.now() - HOUR_MS),
1575
+ });
1576
+
1577
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain());
1578
+
1579
+ expect(writesFor(row)).toEqual([]);
1580
+ expect(
1581
+ rawQuery.mock.calls.some((call: Array<unknown>) => {
1582
+ return String(call[0]).includes(`WITH "restored" AS`);
1583
+ }),
1584
+ ).toBe(true);
1585
+ });
1586
+
1587
+ test("14 minutes after the last write it is still skipped, 16 minutes after it is written - so lastSeenAt never ages past the 30-minute seen-recently window", async () => {
1588
+ const recent: DatabaseServer = unchangedRow(14 * MINUTE_MS);
1589
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain());
1590
+ expect(writesFor(recent)).toEqual([]);
1591
+
1592
+ world.rows.clear();
1593
+ const stale: DatabaseServer = unchangedRow(16 * MINUTE_MS);
1594
+ const before: number = Date.now();
1595
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain());
1596
+
1597
+ const writes: Array<Record<string, unknown>> = writesFor(stale);
1598
+ expect(writes).toHaveLength(1);
1599
+ expect((writes[0]!["lastSeenAt"] as Date).getTime()).toBeGreaterThan(
1600
+ before - 1,
1601
+ );
1602
+ expect(
1603
+ (writes[0]!["workloadLastSeenAt"] as Date).getTime(),
1604
+ ).toBeGreaterThan(before - 1);
1605
+ // The member keys' own timestamps move with it.
1606
+ expect(
1607
+ Date.parse(
1608
+ (writes[0]!["memberEntityKeys"] as Record<string, string>)[
1609
+ MEMBER_KEY_A
1610
+ ]!,
1611
+ ),
1612
+ ).toBeGreaterThan(before - 1);
1613
+ });
1614
+
1615
+ test("an application querying the row keeps lastSeenAt fresh - the workload's own stamp still decides", async () => {
1616
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS, {
1617
+ workloadLastSeenAt: new Date(Date.now() - 20 * MINUTE_MS),
1618
+ });
1619
+
1620
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain());
1621
+
1622
+ expect(writesFor(row)).toHaveLength(1);
1623
+ });
1624
+
1625
+ test("a row with no liveness stamp is written", async () => {
1626
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS);
1627
+ delete row.lastSeenAt;
1628
+
1629
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain());
1630
+
1631
+ expect(writesFor(row)).toHaveLength(1);
1632
+ });
1633
+
1634
+ test("a new member (a pod restarted under a new name) is written at once", async () => {
1635
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS);
1636
+
1637
+ await DatabaseServerService.upsertWorkloadDatabase(
1638
+ seenAgain({ memberKeysSeenNow: [MEMBER_KEY_A, MEMBER_KEY_B] }),
1639
+ );
1640
+
1641
+ const write: Record<string, unknown> = lastWriteFor(row);
1642
+ expect(
1643
+ Object.keys(
1644
+ write["memberEntityKeys"] as Record<string, unknown>,
1645
+ ).sort(),
1646
+ ).toEqual([MEMBER_KEY_A, MEMBER_KEY_B].sort());
1647
+ expect(row.memberEntityKeys).toBe(write["memberEntityKeys"]);
1648
+ });
1649
+
1650
+ test("a member that aged out is dropped at once", async () => {
1651
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS, {
1652
+ memberEntityKeys: {
1653
+ [MEMBER_KEY_A]: new Date(Date.now() - 5 * MINUTE_MS).toISOString(),
1654
+ [MEMBER_KEY_B]: new Date(
1655
+ Date.now() - 31 * 24 * HOUR_MS,
1656
+ ).toISOString(),
1657
+ },
1658
+ });
1659
+
1660
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain());
1661
+
1662
+ expect(
1663
+ Object.keys(
1664
+ lastWriteFor(row)["memberEntityKeys"] as Record<string, unknown>,
1665
+ ),
1666
+ ).toEqual([MEMBER_KEY_A]);
1667
+ });
1668
+
1669
+ test.each([
1670
+ ["the instance count", { instanceCount: 3 }, "instanceCount", 3],
1671
+ ["the image version", { dbVersion: "7.4" }, "dbVersion", "7.4"],
1672
+ [
1673
+ "the workload kind",
1674
+ { workloadKind: "Deployment" },
1675
+ "workloadKind",
1676
+ "Deployment",
1677
+ ],
1678
+ [
1679
+ "the workload name",
1680
+ { workloadName: "redis-main" },
1681
+ "workloadName",
1682
+ "redis-main",
1683
+ ],
1684
+ [
1685
+ "the namespace",
1686
+ { kubernetesNamespace: "cache-v2" },
1687
+ "kubernetesNamespace",
1688
+ "cache-v2",
1689
+ ],
1690
+ ])(
1691
+ "a change of %s is written at once",
1692
+ async (
1693
+ _label: string,
1694
+ change: Partial<UpsertWorkloadDatabaseData>,
1695
+ column: string,
1696
+ value: unknown,
1697
+ ) => {
1698
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS);
1699
+
1700
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain(change));
1701
+
1702
+ expect(lastWriteFor(row)[column]).toBe(value);
1703
+ },
1704
+ );
1705
+
1706
+ test("a scaled-to-zero row whose pods came back is written at once", async () => {
1707
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS, {
1708
+ instanceCount: 0,
1709
+ });
1710
+
1711
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain());
1712
+
1713
+ expect(lastWriteFor(row)["instanceCount"]).toBe(1);
1714
+ expect(row.instanceCount).toBe(1);
1715
+ });
1716
+
1717
+ test("another parent is written at once, compared by id", async () => {
1718
+ const otherCluster: ObjectID = new ObjectID(
1719
+ "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
1720
+ );
1721
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS);
1722
+
1723
+ await DatabaseServerService.upsertWorkloadDatabase(
1724
+ seenAgain({ kubernetesClusterId: otherCluster }),
1725
+ );
1726
+
1727
+ expect(String(lastWriteFor(row)["kubernetesClusterId"])).toBe(
1728
+ otherCluster.toString(),
1729
+ );
1730
+ });
1731
+
1732
+ test("the same parent as another ObjectID instance, in another case, is no change", async () => {
1733
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS, {
1734
+ kubernetesClusterId: new ObjectID(CLUSTER_ID.toString().toUpperCase()),
1735
+ });
1736
+
1737
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain());
1738
+
1739
+ expect(writesFor(row)).toEqual([]);
1740
+ });
1741
+
1742
+ test("an image that refines the engine is written at once, with its feed item", async () => {
1743
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS);
1744
+
1745
+ await DatabaseServerService.upsertWorkloadDatabase(
1746
+ seenAgain({ dbSystem: "valkey" }),
1747
+ );
1748
+
1749
+ expect(lastWriteFor(row)["dbSystem"]).toBe("valkey");
1750
+ expect(row.dbSystem).toBe("valkey");
1751
+ await flushPromises();
1752
+ expect(feed).toHaveBeenCalledWith(
1753
+ expect.objectContaining({
1754
+ databaseServerFeedEventType:
1755
+ DatabaseServerFeedEventType.DatabaseServerUpdated,
1756
+ }),
1757
+ );
1758
+ });
1759
+
1760
+ test("stronger evidence for the same engine is recorded at once", async () => {
1761
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS, {
1762
+ dbSystemSource: DatabaseSystemEvidence.ClientSpans,
1763
+ });
1764
+
1765
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain());
1766
+
1767
+ expect(lastWriteFor(row)["dbSystemSource"]).toBe(
1768
+ DatabaseSystemEvidence.Container,
1769
+ );
1770
+ });
1771
+
1772
+ test("a version the live collector reported is no change, however the image is tagged", async () => {
1773
+ const row: DatabaseServer = unchangedRow(5 * MINUTE_MS, {
1774
+ dbVersion: "7.2.4",
1775
+ collectorLastSeenAt: new Date(Date.now() - 2 * MINUTE_MS),
1776
+ });
1777
+
1778
+ await DatabaseServerService.upsertWorkloadDatabase(seenAgain());
1779
+
1780
+ expect(writesFor(row)).toEqual([]);
1781
+ expect(row.dbVersion).toBe("7.2.4");
1782
+ });
1783
+ });
1784
+
1785
+ describe("endpoints held by another row", () => {
1786
+ test("a Deployment moved to a StatefulSet: the gone workload's Service alias is handed to the new one", async () => {
1787
+ const oldRow: DatabaseServer = workloadRow({
1788
+ workloadIdentifier: OLD_WORKLOAD,
1789
+ databaseIdentifier: OLD_WORKLOAD,
1790
+ workloadLastSeenAt: new Date(Date.now() - 2 * HOUR_MS),
1791
+ });
1792
+ const moved: StoredEndpointFixture = own(SERVICE_ALIAS, oldRow, {
1793
+ isPrimary: true,
1794
+ });
1795
+
1796
+ const result: DatabaseServer | null =
1797
+ await DatabaseServerService.upsertWorkloadDatabase(input());
1798
+
1799
+ expect(result).not.toBe(oldRow);
1800
+ expect(world.transfers).toHaveLength(1);
1801
+ const transfer: any = world.transfers[0];
1802
+ expect(transfer.endpointId.toString()).toBe(moved.id.toString());
1803
+ expect(transfer.fromDatabaseServerId.toString()).toBe(
1804
+ oldRow.id!.toString(),
1805
+ );
1806
+ expect(transfer.toDatabaseServerId.toString()).toBe(
1807
+ result!.id!.toString(),
1808
+ );
1809
+ // The new row's first alias was taken, so the moved one is its primary.
1810
+ expect(transfer.isPrimary).toBe(true);
1811
+ expect(world.endpoints.get(SERVICE_ALIAS)!.databaseServerId).toBe(
1812
+ result!.id!.toString(),
1813
+ );
1814
+ expect(logs.info).toHaveBeenCalledWith(
1815
+ expect.stringContaining("its workload is gone"),
1816
+ expect.anything(),
1817
+ );
1818
+ });
1819
+
1820
+ test("a workload that is still running keeps its aliases - no tie-break between two live workloads", async () => {
1821
+ const liveRow: DatabaseServer = workloadRow({
1822
+ workloadIdentifier: OLD_WORKLOAD,
1823
+ databaseIdentifier: OLD_WORKLOAD,
1824
+ workloadLastSeenAt: new Date(Date.now() - 10 * MINUTE_MS),
1825
+ });
1826
+ own(SERVICE_ALIAS, liveRow);
1827
+
1828
+ await DatabaseServerService.upsertWorkloadDatabase(input());
1829
+
1830
+ expect(world.transfers).toHaveLength(0);
1831
+ expect(world.endpoints.get(SERVICE_ALIAS)!.databaseServerId).toBe(
1832
+ liveRow.id!.toString(),
1833
+ );
1834
+ });
1835
+
1836
+ test("a person's endpoint is never taken, whoever holds it", async () => {
1837
+ const oldRow: DatabaseServer = workloadRow({
1838
+ workloadIdentifier: OLD_WORKLOAD,
1839
+ databaseIdentifier: OLD_WORKLOAD,
1840
+ workloadLastSeenAt: new Date(Date.now() - 10 * HOUR_MS),
1841
+ });
1842
+ own(SERVICE_ALIAS, oldRow, { source: "user" });
1843
+
1844
+ await DatabaseServerService.upsertWorkloadDatabase(input());
1845
+
1846
+ expect(world.transfers).toHaveLength(0);
1847
+ });
1848
+
1849
+ test("the create race: an untouched duplicate that application traces created hands its endpoint over", async () => {
1850
+ const row: DatabaseServer = workloadRow();
1851
+ const duplicate: DatabaseServer = addRow(traceDuplicate());
1852
+ own(SERVICE_ALIAS, duplicate, { source: "auto", isPrimary: true });
1853
+ world.untouchedTraceRows.add(duplicate.id!.toString());
1854
+ hasPrimary.mockResolvedValue(false);
1855
+
1856
+ await DatabaseServerService.upsertWorkloadDatabase(input());
1857
+
1858
+ expect(world.transfers).toHaveLength(1);
1859
+ expect(world.transfers[0].toDatabaseServerId.toString()).toBe(
1860
+ row.id!.toString(),
1861
+ );
1862
+ // An existing row with no primary is asked, then given one.
1863
+ expect(hasPrimary).toHaveBeenCalledTimes(1);
1864
+ expect(world.transfers[0].isPrimary).toBe(true);
1865
+ expect(logs.info).toHaveBeenCalledWith(
1866
+ expect.stringContaining("an untouched duplicate"),
1867
+ expect.anything(),
1868
+ );
1869
+
1870
+ // The duplicate lookup is one project-scoped statement for the candidates.
1871
+ const [sql, params] = rawQuery.mock.calls.find((call: any) => {
1872
+ return String(call[0]).includes(`ds."workloadIdentifier" IS NULL`);
1873
+ }) as [string, Array<unknown>];
1874
+ expect(sql).toContain(`ds."projectId" = $1`);
1875
+ expect(sql).toContain(`ds."_id" = ANY($2::uuid[])`);
1876
+ expect(sql).toContain(`ds."discoverySource" = $3`);
1877
+ expect(sql).toContain(`NOT EXISTS`);
1878
+ // A person's recent Restore and a person's description are investment.
1879
+ expect(sql).toContain(
1880
+ `(ds."manuallyRestoredAt" IS NULL OR ds."manuallyRestoredAt" < $4)`,
1881
+ );
1882
+ expect(sql).toContain(`COALESCE(BTRIM(ds."description"), '') = ''`);
1883
+ expect(params[0]).toBe(PROJECT_ID.toString());
1884
+ expect(params[1]).toEqual([duplicate.id!.toString()]);
1885
+ expect(params[2]).toBe("client-spans");
1886
+ // The same grace a person's Restore gets from the archive sweep.
1887
+ const thirtyDays: number = 30 * 24 * HOUR_MS;
1888
+ const graceCutoff: number = (params[3] as Date).getTime();
1889
+ expect(Date.now() - graceCutoff).toBeGreaterThanOrEqual(
1890
+ thirtyDays - 5000,
1891
+ );
1892
+ expect(Date.now() - graceCutoff).toBeLessThanOrEqual(thirtyDays + 5000);
1893
+ });
1894
+
1895
+ /*
1896
+ * Moving a row's endpoints empties it: the sweep then archives it. So a
1897
+ * row a person renamed, described or restored from the archive is theirs,
1898
+ * whatever the untouched predicate says - its endpoints stay, and the
1899
+ * workload takes only what nobody invested in.
1900
+ */
1901
+ test.each([
1902
+ ["a person renamed it", { name: "Orders cache (prod)" }],
1903
+ ["a person described it", { description: "Checkout sessions" }],
1904
+ [
1905
+ "a person restored it from the archive last week",
1906
+ { manuallyRestoredAt: new Date(Date.now() - 7 * 24 * HOUR_MS) },
1907
+ ],
1908
+ ])(
1909
+ "a trace-discovered duplicate keeps its endpoints when %s",
1910
+ async (_label: string, overrides: Partial<DatabaseServer>) => {
1911
+ workloadRow();
1912
+ const kept: DatabaseServer = addRow(traceDuplicate(overrides));
1913
+ own(SERVICE_ALIAS, kept, { source: "auto", isPrimary: true });
1914
+ world.untouchedTraceRows.add(kept.id!.toString());
1915
+ const other: DatabaseServer = addRow(
1916
+ traceDuplicate({
1917
+ serverAddress: "redis-0.redis-hl.cache.svc.cluster.local",
1918
+ name: "Redis redis-0.redis-hl.cache.svc.cluster.local:6379",
1919
+ }),
1920
+ );
1921
+ own(POD_ALIAS, other, { source: "auto", isPrimary: true });
1922
+ world.untouchedTraceRows.add(other.id!.toString());
1923
+
1924
+ await DatabaseServerService.upsertWorkloadDatabase(input());
1925
+
1926
+ expect(world.endpoints.get(SERVICE_ALIAS)!.databaseServerId).toBe(
1927
+ kept.id!.toString(),
1928
+ );
1929
+ // ...while the untouched one hands its endpoint over.
1930
+ expect(world.transfers).toHaveLength(1);
1931
+ expect(world.transfers[0].fromDatabaseServerId.toString()).toBe(
1932
+ other.id!.toString(),
1933
+ );
1934
+ },
1935
+ );
1936
+
1937
+ test("a Restore older than the grace window no longer holds the endpoints", async () => {
1938
+ workloadRow();
1939
+ const duplicate: DatabaseServer = addRow(
1940
+ traceDuplicate({
1941
+ manuallyRestoredAt: new Date(Date.now() - 45 * 24 * HOUR_MS),
1942
+ }),
1943
+ );
1944
+ own(SERVICE_ALIAS, duplicate, { source: "auto", isPrimary: true });
1945
+ world.untouchedTraceRows.add(duplicate.id!.toString());
1946
+
1947
+ await DatabaseServerService.upsertWorkloadDatabase(input());
1948
+
1949
+ expect(world.transfers).toHaveLength(1);
1950
+ });
1951
+
1952
+ test("a duplicate whose engine a fork refined still carries its generated name, and hands over", async () => {
1953
+ workloadRow({ dbSystem: "valkey", name: "Valkey cache/redis" });
1954
+ const duplicate: DatabaseServer = addRow(
1955
+ traceDuplicate({
1956
+ dbSystem: "valkey",
1957
+ name: "Valkey redis.cache.svc.cluster.local:6379",
1958
+ }),
1959
+ );
1960
+ own(SERVICE_ALIAS, duplicate, { source: "auto", isPrimary: true });
1961
+ world.untouchedTraceRows.add(duplicate.id!.toString());
1962
+
1963
+ await DatabaseServerService.upsertWorkloadDatabase(
1964
+ input({ dbSystem: "valkey" }),
1965
+ );
1966
+
1967
+ expect(world.transfers).toHaveLength(1);
1968
+ });
1969
+
1970
+ test("a trace-discovered row somebody invested in keeps its endpoint", async () => {
1971
+ workloadRow();
1972
+ const invested: DatabaseServer = addRow(
1973
+ databaseRow({
1974
+ dbSystem: "redis",
1975
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
1976
+ }),
1977
+ );
1978
+ own(SERVICE_ALIAS, invested, { source: "auto" });
1979
+ // Not in world.untouchedTraceRows: labelled, owned, linked ...
1980
+
1981
+ await DatabaseServerService.upsertWorkloadDatabase(input());
1982
+
1983
+ expect(world.transfers).toHaveLength(0);
1984
+ });
1985
+
1986
+ test.each([
1987
+ [DatabaseServerDiscoverySource.Collector],
1988
+ [DatabaseServerDiscoverySource.Manual],
1989
+ ])(
1990
+ "a %s row never hands over its endpoints to a workload",
1991
+ async (source: DatabaseServerDiscoverySource) => {
1992
+ workloadRow();
1993
+ const configured: DatabaseServer = addRow(
1994
+ databaseRow({ dbSystem: "redis", discoverySource: source }),
1995
+ );
1996
+ own(SERVICE_ALIAS, configured, { source: "auto" });
1997
+ world.untouchedTraceRows.add(configured.id!.toString());
1998
+
1999
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2000
+
2001
+ expect(world.transfers).toHaveLength(0);
2002
+ },
2003
+ );
2004
+
2005
+ test("an existing row that already has a primary gets a moved alias as a plain alias", async () => {
2006
+ workloadRow();
2007
+ const oldRow: DatabaseServer = workloadRow({
2008
+ workloadIdentifier: OLD_WORKLOAD,
2009
+ databaseIdentifier: OLD_WORKLOAD,
2010
+ workloadLastSeenAt: new Date(Date.now() - 2 * HOUR_MS),
2011
+ });
2012
+ own(POD_ALIAS, oldRow);
2013
+
2014
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2015
+
2016
+ expect(world.transfers).toHaveLength(1);
2017
+ expect(world.transfers[0].isPrimary).toBe(false);
2018
+ });
2019
+
2020
+ test("a failing hand-over never fails the run", async () => {
2021
+ const row: DatabaseServer = workloadRow();
2022
+ const oldRow: DatabaseServer = workloadRow({
2023
+ workloadIdentifier: OLD_WORKLOAD,
2024
+ databaseIdentifier: OLD_WORKLOAD,
2025
+ workloadLastSeenAt: new Date(Date.now() - 2 * HOUR_MS),
2026
+ });
2027
+ own(POD_ALIAS, oldRow);
2028
+ (
2029
+ DatabaseServerEndpointService.transferEndpoint as unknown as jest.SpyInstance
2030
+ ).mockRejectedValue(new Error("deadlock detected"));
2031
+
2032
+ await expect(
2033
+ DatabaseServerService.upsertWorkloadDatabase(input()),
2034
+ ).resolves.toBe(row);
2035
+ expect(logs.warn).toHaveBeenCalledWith(
2036
+ expect.stringContaining("taking over endpoints"),
2037
+ expect.anything(),
2038
+ );
2039
+ });
2040
+ });
2041
+
2042
+ describe("endpoints this row holds", () => {
2043
+ test("aliases produced again are re-stamped when their last match is over an hour old", async () => {
2044
+ const row: DatabaseServer = workloadRow();
2045
+ own(SERVICE_ALIAS, row, {
2046
+ lastMatchedAt: new Date(Date.now() - 2 * HOUR_MS),
2047
+ });
2048
+ own(POD_ALIAS, row, { lastMatchedAt: new Date() });
2049
+
2050
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2051
+
2052
+ expect(refresh).toHaveBeenCalledTimes(1);
2053
+ const call: any = refresh.mock.calls[0]![0];
2054
+ expect(call.endpoints).toEqual([SERVICE_ALIAS]);
2055
+ expect(call.databaseServerId.toString()).toBe(row.id!.toString());
2056
+ expect(call.now.getTime() - call.staleBefore.getTime()).toBe(HOUR_MS);
2057
+ });
2058
+
2059
+ test("nothing stale, nothing re-stamped", async () => {
2060
+ const row: DatabaseServer = workloadRow();
2061
+ own(SERVICE_ALIAS, row);
2062
+ own(POD_ALIAS, row);
2063
+
2064
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2065
+
2066
+ expect(refresh).not.toHaveBeenCalled();
2067
+ });
2068
+
2069
+ test("aliases the workload no longer produces are released after two hours - an unqualified alias once the project has two clusters", async () => {
2070
+ const row: DatabaseServer = workloadRow();
2071
+
2072
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2073
+
2074
+ expect(release).toHaveBeenCalledTimes(1);
2075
+ const call: any = release.mock.calls[0]![0];
2076
+ expect(call.projectId).toBe(PROJECT_ID);
2077
+ expect(call.databaseServerId.toString()).toBe(row.id!.toString());
2078
+ expect(call.keepEndpoints).toEqual([SERVICE_ALIAS, POD_ALIAS]);
2079
+ expect(Date.now() - call.staleBefore.getTime()).toBeGreaterThanOrEqual(
2080
+ 2 * HOUR_MS - 1000,
2081
+ );
2082
+ expect(Date.now() - call.staleBefore.getTime()).toBeLessThanOrEqual(
2083
+ 2 * HOUR_MS + 5000,
2084
+ );
2085
+ });
2086
+
2087
+ test("a released alias is logged", async () => {
2088
+ workloadRow();
2089
+ release.mockResolvedValue(["redis.cache.svc.cluster.local:6379"]);
2090
+
2091
+ await DatabaseServerService.upsertWorkloadDatabase(input());
2092
+
2093
+ expect(logs.info).toHaveBeenCalledWith(
2094
+ expect.stringContaining(
2095
+ "released 1 endpoint(s) workload redis|kubernetes:prod/cache/statefulset/redis no longer serves: redis.cache.svc.cluster.local:6379",
2096
+ ),
2097
+ expect.anything(),
2098
+ );
2099
+ });
2100
+
2101
+ test("a container workload has no aliases to release", async () => {
2102
+ await DatabaseServerService.upsertWorkloadDatabase(
2103
+ input({
2104
+ workloadIdentifier: "redis|docker:web-01/cache",
2105
+ discoverySource: DatabaseServerDiscoverySource.Docker,
2106
+ aliases: [],
2107
+ kubernetesClusterId: undefined,
2108
+ kubernetesNamespace: undefined,
2109
+ workloadKind: "Container",
2110
+ workloadName: "cache",
2111
+ }),
2112
+ );
2113
+
2114
+ expect(release).not.toHaveBeenCalled();
2115
+ });
2116
+
2117
+ test("a failing release never fails the run", async () => {
2118
+ const row: DatabaseServer = workloadRow();
2119
+ release.mockRejectedValue(new Error("connection terminated"));
2120
+
2121
+ await expect(
2122
+ DatabaseServerService.upsertWorkloadDatabase(input()),
2123
+ ).resolves.toBe(row);
2124
+ });
2125
+ });
2126
+ });
2127
+
2128
+ /*
2129
+ * ---------------------------------------------------------------------------
2130
+ * The endpoint path: sightings and the collector's auto-create budget
2131
+ * ---------------------------------------------------------------------------
2132
+ */
2133
+ describe("DatabaseServerService.findOrCreateByEndpoint - lifecycle", () => {
2134
+ const ENDPOINT: DatabaseEndpoint = {
2135
+ host: "orders-db.example.com",
2136
+ port: 5432,
2137
+ };
2138
+
2139
+ let findOwner: jest.SpyInstance;
2140
+ let findOneBy: jest.SpyInstance;
2141
+ let create: jest.SpyInstance;
2142
+ let mark: jest.SpyInstance;
2143
+ let writes: jest.SpyInstance;
2144
+ let rawQuery: jest.Mock;
2145
+ let logs: ReturnType<typeof silenceLogs>;
2146
+
2147
+ function ownedBy(row: DatabaseServer): DatabaseServerEndpointOwner {
2148
+ return {
2149
+ databaseServerId: row.id!,
2150
+ isPrimary: true,
2151
+ endpointId: ObjectID.generate(),
2152
+ source: "auto",
2153
+ lastMatchedAt: new Date(Date.now() - 2 * HOUR_MS),
2154
+ };
2155
+ }
2156
+
2157
+ beforeEach(() => {
2158
+ logs = silenceLogs();
2159
+ mockFeed();
2160
+ findOwner = getJestSpyOn(
2161
+ DatabaseServerEndpointService,
2162
+ "findOwnerByEndpoint",
2163
+ ).mockResolvedValue(null);
2164
+ findOneBy = getJestSpyOn(service, "findOneBy").mockResolvedValue(null);
2165
+ create = getJestSpyOn(service, "create").mockImplementation(
2166
+ async (input: any) => {
2167
+ input.data._id = ObjectID.generate().toString();
2168
+ return input.data;
2169
+ },
2170
+ );
2171
+ getJestSpyOn(
2172
+ DatabaseServerEndpointService,
2173
+ "claimEndpoint",
2174
+ ).mockResolvedValue("claimed");
2175
+ mark = getJestSpyOn(
2176
+ DatabaseServerEndpointService,
2177
+ "markEndpointMatched",
2178
+ ).mockResolvedValue(undefined);
2179
+ writes = getJestSpyOn(
2180
+ service,
2181
+ "updateColumnsByIdWithoutHooks",
2182
+ ).mockResolvedValue(undefined);
2183
+ rawQuery = mockRawQuery([]);
2184
+ });
2185
+
2186
+ test("a sighting of an owned endpoint records the match on the endpoint", async () => {
2187
+ const row: DatabaseServer = databaseRow({
2188
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2189
+ });
2190
+ const owner: DatabaseServerEndpointOwner = ownedBy(row);
2191
+ findOwner.mockResolvedValue(owner);
2192
+ findOneBy.mockResolvedValue(row);
2193
+
2194
+ await DatabaseServerService.findOrCreateByEndpoint({
2195
+ projectId: PROJECT_ID,
2196
+ dbSystem: "postgresql",
2197
+ endpoint: ENDPOINT,
2198
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2199
+ allowCreate: false,
2200
+ });
2201
+
2202
+ expect(mark).toHaveBeenCalledWith(owner);
2203
+ });
2204
+
2205
+ test("a collector reporting a different engine corrects a trace-found row - one compare-and-set write, renamed", async () => {
2206
+ const row: DatabaseServer = databaseRow({
2207
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2208
+ });
2209
+ findOwner.mockResolvedValue(ownedBy(row));
2210
+ findOneBy.mockResolvedValue(row);
2211
+ answerEngineWrites(rawQuery, [
2212
+ { _id: row.id!.toString(), name: "MySQL orders-db.example.com:5432" },
2213
+ ]);
2214
+ const feed: jest.SpyInstance = mockFeed();
2215
+
2216
+ const result: DatabaseServer | null =
2217
+ await DatabaseServerService.findOrCreateByEndpoint({
2218
+ projectId: PROJECT_ID,
2219
+ dbSystem: "mysql",
2220
+ endpoint: ENDPOINT,
2221
+ discoverySource: DatabaseServerDiscoverySource.Collector,
2222
+ allowCreate: true,
2223
+ });
2224
+
2225
+ expect(result).toBe(row);
2226
+ expect(create).not.toHaveBeenCalled();
2227
+ expect(writes).not.toHaveBeenCalled();
2228
+ const statements: Array<RawStatement> = engineWrites(rawQuery);
2229
+ expect(statements).toHaveLength(1);
2230
+ const { sql, params } = statements[0]!;
2231
+ expect(sql).toContain('"dbSystem" = $2');
2232
+ // The name moves only while it is still the generated one that was read.
2233
+ expect(sql).toContain(
2234
+ '"name" = CASE WHEN "name" IS NOT DISTINCT FROM $3 THEN $4 ELSE "name" END',
2235
+ );
2236
+ expect(sql).toContain('"updatedAt" = CURRENT_TIMESTAMP');
2237
+ // Compare-and-set on the engine AND the evidence it was weighed against.
2238
+ expect(sql).toContain('"dbSystem" IS NOT DISTINCT FROM $7');
2239
+ expect(sql).toContain('"dbSystemSource" IS NOT DISTINCT FROM $8');
2240
+ expect(sql).toContain('"deletedAt" IS NULL');
2241
+ expect(sql).toContain('RETURNING "_id", "name"');
2242
+ expect(params).toEqual([
2243
+ "collector",
2244
+ "mysql",
2245
+ "PostgreSQL orders-db.example.com:5432",
2246
+ "MySQL orders-db.example.com:5432",
2247
+ row.id!.toString(),
2248
+ PROJECT_ID.toString(),
2249
+ "postgresql",
2250
+ null,
2251
+ ]);
2252
+ expect(row.dbSystem).toBe("mysql");
2253
+ expect(row.dbSystemSource).toBe("collector");
2254
+ expect(row.name).toBe("MySQL orders-db.example.com:5432");
2255
+ expect(feed).toHaveBeenCalledTimes(1);
2256
+ });
2257
+
2258
+ /*
2259
+ * Two writers read the row at once and both decide to change the engine:
2260
+ * only the one whose compare-and-set matched may say so. The other leaves
2261
+ * its in-memory row as read and writes no feed item.
2262
+ */
2263
+ test("a writer that loses the engine compare-and-set changes nothing and announces nothing", async () => {
2264
+ const row: DatabaseServer = databaseRow({
2265
+ name: "Redis cache.example.com:6379",
2266
+ dbSystem: "redis",
2267
+ serverAddress: "cache.example.com",
2268
+ serverPort: 6379,
2269
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2270
+ });
2271
+ findOwner.mockResolvedValue(ownedBy(row));
2272
+ findOneBy.mockResolvedValue(row);
2273
+ answerEngineWrites(rawQuery, []);
2274
+ const feed: jest.SpyInstance = mockFeed();
2275
+
2276
+ const result: DatabaseServer | null =
2277
+ await DatabaseServerService.findOrCreateByEndpoint({
2278
+ projectId: PROJECT_ID,
2279
+ dbSystem: "valkey",
2280
+ endpoint: { host: "cache.example.com", port: 6379 },
2281
+ discoverySource: DatabaseServerDiscoverySource.Collector,
2282
+ allowCreate: false,
2283
+ });
2284
+
2285
+ expect(result).toBe(row);
2286
+ expect(engineWrites(rawQuery)).toHaveLength(1);
2287
+ expect(row.dbSystem).toBe("redis");
2288
+ expect(row.dbSystemSource).toBeUndefined();
2289
+ expect(row.name).toBe("Redis cache.example.com:6379");
2290
+ expect(feed).not.toHaveBeenCalled();
2291
+ });
2292
+
2293
+ test("the compare-and-set carries the evidence as read, so a concurrent stronger report is not overwritten", async () => {
2294
+ const row: DatabaseServer = databaseRow({
2295
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2296
+ dbSystemSource: DatabaseSystemEvidence.ClientSpans,
2297
+ });
2298
+ findOwner.mockResolvedValue(ownedBy(row));
2299
+ findOneBy.mockResolvedValue(row);
2300
+ answerEngineWrites(rawQuery, [
2301
+ { _id: row.id!.toString(), name: row.name! },
2302
+ ]);
2303
+
2304
+ await DatabaseServerService.findOrCreateByEndpoint({
2305
+ projectId: PROJECT_ID,
2306
+ dbSystem: "postgres",
2307
+ endpoint: ENDPOINT,
2308
+ discoverySource: DatabaseServerDiscoverySource.Collector,
2309
+ allowCreate: false,
2310
+ });
2311
+
2312
+ const { sql, params } = engineWrites(rawQuery)[0]!;
2313
+ // Same engine, stronger evidence: bookkeeping, no rename, no updatedAt.
2314
+ expect(sql).not.toContain('"dbSystem" = $');
2315
+ expect(sql).not.toContain('"name" =');
2316
+ expect(sql).not.toContain('"updatedAt"');
2317
+ expect(params).toEqual([
2318
+ "collector",
2319
+ row.id!.toString(),
2320
+ PROJECT_ID.toString(),
2321
+ "postgresql",
2322
+ "client-spans",
2323
+ ]);
2324
+ expect(row.dbSystemSource).toBe("collector");
2325
+ });
2326
+
2327
+ test("a person's rename made after the read survives the engine change: the database answers with their name", async () => {
2328
+ const row: DatabaseServer = databaseRow({
2329
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2330
+ });
2331
+ findOwner.mockResolvedValue(ownedBy(row));
2332
+ findOneBy.mockResolvedValue(row);
2333
+ answerEngineWrites(rawQuery, [
2334
+ { _id: row.id!.toString(), name: "Orders (prod)" },
2335
+ ]);
2336
+
2337
+ await DatabaseServerService.findOrCreateByEndpoint({
2338
+ projectId: PROJECT_ID,
2339
+ dbSystem: "mysql",
2340
+ endpoint: ENDPOINT,
2341
+ discoverySource: DatabaseServerDiscoverySource.Collector,
2342
+ allowCreate: false,
2343
+ });
2344
+
2345
+ expect(row.dbSystem).toBe("mysql");
2346
+ expect(row.name).toBe("Orders (prod)");
2347
+ });
2348
+
2349
+ test("client spans reporting a different engine change nothing on a collector row", async () => {
2350
+ const row: DatabaseServer = databaseRow({
2351
+ discoverySource: DatabaseServerDiscoverySource.Collector,
2352
+ });
2353
+ findOwner.mockResolvedValue(ownedBy(row));
2354
+ findOneBy.mockResolvedValue(row);
2355
+
2356
+ await DatabaseServerService.findOrCreateByEndpoint({
2357
+ projectId: PROJECT_ID,
2358
+ dbSystem: "mysql",
2359
+ endpoint: ENDPOINT,
2360
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2361
+ allowCreate: false,
2362
+ });
2363
+
2364
+ expect(engineWrites(rawQuery)).toHaveLength(0);
2365
+ expect(row.dbSystem).toBe("postgresql");
2366
+ });
2367
+
2368
+ test("a failed engine write never fails the lookup", async () => {
2369
+ const row: DatabaseServer = databaseRow({
2370
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2371
+ });
2372
+ findOwner.mockResolvedValue(ownedBy(row));
2373
+ findOneBy.mockResolvedValue(row);
2374
+ rawQuery.mockRejectedValue(new Error("connection terminated"));
2375
+
2376
+ await expect(
2377
+ DatabaseServerService.findOrCreateByEndpoint({
2378
+ projectId: PROJECT_ID,
2379
+ dbSystem: "mysql",
2380
+ endpoint: ENDPOINT,
2381
+ discoverySource: DatabaseServerDiscoverySource.Collector,
2382
+ allowCreate: false,
2383
+ }),
2384
+ ).resolves.toBe(row);
2385
+ expect(row.dbSystem).toBe("postgresql");
2386
+ expect(logs.warn).toHaveBeenCalled();
2387
+ });
2388
+
2389
+ /*
2390
+ * The row of a workload that is gone, archived by discovery: application
2391
+ * traces never restore it, so a trace naming one of its endpoints is not
2392
+ * a sighting of it either. The trace path gets no row back - it records
2393
+ * no lastSeenAt on it and claims no sibling endpoints for it - and
2394
+ * nothing about the row changes: not its engine, not its endpoint's
2395
+ * "last matched".
2396
+ */
2397
+ test("an auto-archived row of a workload that is gone is not a trace's to sight: no row, no restore, no write", async () => {
2398
+ const row: DatabaseServer = databaseRow({
2399
+ workloadIdentifier: "redis|kubernetes:prod/cache/deployment/redis",
2400
+ workloadLastSeenAt: new Date(Date.now() - 8 * 24 * HOUR_MS),
2401
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
2402
+ isArchived: true,
2403
+ autoArchivedAt: new Date(),
2404
+ });
2405
+ findOwner.mockResolvedValue(ownedBy(row));
2406
+ findOneBy.mockResolvedValue(row);
2407
+
2408
+ const result: DatabaseServer | null =
2409
+ await DatabaseServerService.findOrCreateByEndpoint({
2410
+ projectId: PROJECT_ID,
2411
+ dbSystem: "mysql",
2412
+ endpoint: ENDPOINT,
2413
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2414
+ allowCreate: false,
2415
+ });
2416
+
2417
+ expect(result).toBeNull();
2418
+ expect(rawQuery).not.toHaveBeenCalled();
2419
+ expect(writes).not.toHaveBeenCalled();
2420
+ expect(mark).not.toHaveBeenCalled();
2421
+ expect(create).not.toHaveBeenCalled();
2422
+ expect(row.isArchived).toBe(true);
2423
+ });
2424
+
2425
+ test("a collector reporting that retired row still resolves to it - its heartbeat is what restores it", async () => {
2426
+ const row: DatabaseServer = databaseRow({
2427
+ workloadIdentifier: "postgresql|kubernetes:prod/data/statefulset/pg",
2428
+ workloadLastSeenAt: new Date(Date.now() - 8 * 24 * HOUR_MS),
2429
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
2430
+ isArchived: true,
2431
+ autoArchivedAt: new Date(),
2432
+ });
2433
+ findOwner.mockResolvedValue(ownedBy(row));
2434
+ findOneBy.mockResolvedValue(row);
2435
+
2436
+ await expect(
2437
+ DatabaseServerService.findOrCreateByEndpoint({
2438
+ projectId: PROJECT_ID,
2439
+ dbSystem: "postgresql",
2440
+ endpoint: ENDPOINT,
2441
+ discoverySource: DatabaseServerDiscoverySource.Collector,
2442
+ allowCreate: false,
2443
+ }),
2444
+ ).resolves.toBe(row);
2445
+ });
2446
+
2447
+ test("a workload row archived while its workload was still reporting is sighted and restored as before", async () => {
2448
+ const row: DatabaseServer = databaseRow({
2449
+ workloadIdentifier: "postgresql|kubernetes:prod/data/statefulset/pg",
2450
+ workloadLastSeenAt: new Date(Date.now() - 10 * 60 * 1000),
2451
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
2452
+ isArchived: true,
2453
+ autoArchivedAt: new Date(),
2454
+ });
2455
+ findOwner.mockResolvedValue(ownedBy(row));
2456
+ findOneBy.mockResolvedValue(row);
2457
+ rawQuery.mockResolvedValue([{ _id: row.id!.toString() }]);
2458
+
2459
+ await expect(
2460
+ DatabaseServerService.findOrCreateByEndpoint({
2461
+ projectId: PROJECT_ID,
2462
+ dbSystem: "postgresql",
2463
+ endpoint: ENDPOINT,
2464
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2465
+ allowCreate: false,
2466
+ }),
2467
+ ).resolves.toBe(row);
2468
+ expect(row.isArchived).toBe(false);
2469
+ });
2470
+
2471
+ test("losing the create race to a retired workload row: the trace gets no row back", async () => {
2472
+ const retired: DatabaseServer = databaseRow({
2473
+ workloadIdentifier: "postgresql|kubernetes:prod/data/statefulset/pg",
2474
+ workloadLastSeenAt: new Date(Date.now() - 8 * 24 * HOUR_MS),
2475
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
2476
+ isArchived: true,
2477
+ autoArchivedAt: new Date(),
2478
+ });
2479
+ findOwner
2480
+ .mockResolvedValueOnce(null)
2481
+ .mockResolvedValueOnce(ownedBy(retired));
2482
+ findOneBy.mockResolvedValue(retired);
2483
+ getJestSpyOn(
2484
+ DatabaseServerEndpointService,
2485
+ "claimEndpoint",
2486
+ ).mockResolvedValue("owned-by-other");
2487
+ getJestSpyOn(service, "deleteBy").mockResolvedValue(1);
2488
+
2489
+ await expect(
2490
+ DatabaseServerService.findOrCreateByEndpoint({
2491
+ projectId: PROJECT_ID,
2492
+ dbSystem: "postgresql",
2493
+ endpoint: ENDPOINT,
2494
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2495
+ allowCreate: true,
2496
+ }),
2497
+ ).resolves.toBeNull();
2498
+ expect(rawQuery).not.toHaveBeenCalled();
2499
+ expect(retired.isArchived).toBe(true);
2500
+ });
2501
+
2502
+ test("the identifier race landing on a retired workload row: the trace neither claims the endpoint for it nor restores it", async () => {
2503
+ const retired: DatabaseServer = databaseRow({
2504
+ workloadIdentifier: "postgresql|kubernetes:prod/data/statefulset/pg",
2505
+ workloadLastSeenAt: new Date(Date.now() - 8 * 24 * HOUR_MS),
2506
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2507
+ isArchived: true,
2508
+ autoArchivedAt: new Date(),
2509
+ });
2510
+ create.mockRejectedValue(new Error("duplicate key value"));
2511
+ findOneBy.mockResolvedValue(retired);
2512
+ const claim: jest.SpyInstance = getJestSpyOn(
2513
+ DatabaseServerEndpointService,
2514
+ "claimEndpoint",
2515
+ ).mockResolvedValue("claimed");
2516
+
2517
+ await expect(
2518
+ DatabaseServerService.findOrCreateByEndpoint({
2519
+ projectId: PROJECT_ID,
2520
+ dbSystem: "postgresql",
2521
+ endpoint: ENDPOINT,
2522
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2523
+ allowCreate: true,
2524
+ }),
2525
+ ).resolves.toBeNull();
2526
+ expect(claim).not.toHaveBeenCalled();
2527
+ expect(rawQuery).not.toHaveBeenCalled();
2528
+ expect(retired.isArchived).toBe(true);
2529
+ });
2530
+
2531
+ test("an auto-archived row that is not a gone workload is restored as before", async () => {
2532
+ const row: DatabaseServer = databaseRow({
2533
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2534
+ isArchived: true,
2535
+ autoArchivedAt: new Date(),
2536
+ });
2537
+ findOwner.mockResolvedValue(ownedBy(row));
2538
+ findOneBy.mockResolvedValue(row);
2539
+ rawQuery.mockResolvedValue([{ _id: row.id!.toString() }]);
2540
+
2541
+ await DatabaseServerService.findOrCreateByEndpoint({
2542
+ projectId: PROJECT_ID,
2543
+ dbSystem: "postgresql",
2544
+ endpoint: ENDPOINT,
2545
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2546
+ allowCreate: false,
2547
+ });
2548
+
2549
+ expect(row.isArchived).toBe(false);
2550
+ });
2551
+
2552
+ /*
2553
+ * Two writers saw the endpoint unowned; ours lost the claim. The row that
2554
+ * won is the owner, and our report is a sighting of it like any other: a
2555
+ * fork our client named refines the family engine it was created with.
2556
+ */
2557
+ test("losing the create race, the report still refines the winner's engine", async () => {
2558
+ const winner: DatabaseServer = databaseRow({
2559
+ name: "MySQL orders-db.example.com:5432",
2560
+ dbSystem: "mysql",
2561
+ databaseIdentifier: "mysql|orders-db.example.com:5432",
2562
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2563
+ });
2564
+ findOwner
2565
+ .mockResolvedValueOnce(null)
2566
+ .mockResolvedValueOnce(ownedBy(winner));
2567
+ findOneBy.mockResolvedValue(winner);
2568
+ getJestSpyOn(
2569
+ DatabaseServerEndpointService,
2570
+ "claimEndpoint",
2571
+ ).mockResolvedValue("owned-by-other");
2572
+ const deleteBy: jest.SpyInstance = getJestSpyOn(
2573
+ service,
2574
+ "deleteBy",
2575
+ ).mockResolvedValue(1);
2576
+ answerEngineWrites(rawQuery, [
2577
+ {
2578
+ _id: winner.id!.toString(),
2579
+ name: "MariaDB orders-db.example.com:5432",
2580
+ },
2581
+ ]);
2582
+
2583
+ const result: DatabaseServer | null =
2584
+ await DatabaseServerService.findOrCreateByEndpoint({
2585
+ projectId: PROJECT_ID,
2586
+ dbSystem: "mariadb",
2587
+ endpoint: ENDPOINT,
2588
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2589
+ allowCreate: true,
2590
+ });
2591
+
2592
+ expect(result).toBe(winner);
2593
+ // Our orphan is gone...
2594
+ expect(deleteBy).toHaveBeenCalledTimes(1);
2595
+ // ...and the winner now shows the fork, renamed from its generated name.
2596
+ const { params } = engineWrites(rawQuery)[0]!;
2597
+ expect(params).toEqual([
2598
+ "client-spans",
2599
+ "mariadb",
2600
+ "MySQL orders-db.example.com:5432",
2601
+ "MariaDB orders-db.example.com:5432",
2602
+ winner.id!.toString(),
2603
+ PROJECT_ID.toString(),
2604
+ "mysql",
2605
+ null,
2606
+ ]);
2607
+ expect(winner.dbSystem).toBe("mariadb");
2608
+ expect(winner.name).toBe("MariaDB orders-db.example.com:5432");
2609
+ });
2610
+
2611
+ test("losing the create race, a family name never undoes the winner's fork", async () => {
2612
+ const winner: DatabaseServer = databaseRow({
2613
+ dbSystem: "mariadb",
2614
+ discoverySource: DatabaseServerDiscoverySource.Collector,
2615
+ });
2616
+ findOwner
2617
+ .mockResolvedValueOnce(null)
2618
+ .mockResolvedValueOnce(ownedBy(winner));
2619
+ findOneBy.mockResolvedValue(winner);
2620
+ getJestSpyOn(
2621
+ DatabaseServerEndpointService,
2622
+ "claimEndpoint",
2623
+ ).mockResolvedValue("owned-by-other");
2624
+ getJestSpyOn(service, "deleteBy").mockResolvedValue(1);
2625
+
2626
+ await DatabaseServerService.findOrCreateByEndpoint({
2627
+ projectId: PROJECT_ID,
2628
+ dbSystem: "mysql",
2629
+ endpoint: ENDPOINT,
2630
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2631
+ allowCreate: true,
2632
+ });
2633
+
2634
+ expect(engineWrites(rawQuery)).toHaveLength(0);
2635
+ expect(winner.dbSystem).toBe("mariadb");
2636
+ });
2637
+
2638
+ describe("the collector's auto-create budget", () => {
2639
+ let restoreEnv: () => void;
2640
+
2641
+ beforeEach(() => {
2642
+ restoreEnv = withEnv("DATABASE_SERVER_AUTO_CREATE_BUDGET", "2");
2643
+ });
2644
+
2645
+ afterEach(() => {
2646
+ restoreEnv();
2647
+ });
2648
+
2649
+ function collectorCreate(host: string): Promise<DatabaseServer | null> {
2650
+ return DatabaseServerService.findOrCreateByEndpoint({
2651
+ projectId: PROJECT_ID,
2652
+ dbSystem: "postgresql",
2653
+ endpoint: { host: host, port: 5432 },
2654
+ discoverySource: DatabaseServerDiscoverySource.Collector,
2655
+ allowCreate: true,
2656
+ });
2657
+ }
2658
+
2659
+ test("under budget: created, and the count is read once for the minute", async () => {
2660
+ rawQuery.mockResolvedValue([{ count: 0 }]);
2661
+
2662
+ await expect(collectorCreate("a.example.com")).resolves.not.toBeNull();
2663
+
2664
+ expect(create).toHaveBeenCalledTimes(1);
2665
+ const countCalls: Array<any> = rawQuery.mock.calls.filter((call: any) => {
2666
+ return String(call[0]).includes(`COUNT(*)`);
2667
+ });
2668
+ expect(countCalls).toHaveLength(1);
2669
+ expect(countCalls[0][1]).toEqual([PROJECT_ID.toString(), "manual"]);
2670
+ });
2671
+
2672
+ test("over budget: nothing is created, null is returned, and one warning says why", async () => {
2673
+ rawQuery.mockResolvedValue([{ count: 2 }]);
2674
+
2675
+ await expect(collectorCreate("a.example.com")).resolves.toBeNull();
2676
+ await expect(collectorCreate("b.example.com")).resolves.toBeNull();
2677
+
2678
+ expect(create).not.toHaveBeenCalled();
2679
+ const budgetWarnings: Array<any> = logs.warn.mock.calls.filter(
2680
+ (call: any) => {
2681
+ return String(call[0]).includes("reached its auto-create budget");
2682
+ },
2683
+ );
2684
+ expect(budgetWarnings).toHaveLength(1);
2685
+ expect(budgetWarnings[0][0]).toContain(
2686
+ "DATABASE_SERVER_AUTO_CREATE_BUDGET=2",
2687
+ );
2688
+ expect(budgetWarnings[0][0]).toContain("a.example.com:5432");
2689
+ });
2690
+
2691
+ test("creates in between count against the cached number, so a burst stops at the budget", async () => {
2692
+ rawQuery.mockResolvedValue([{ count: 1 }]);
2693
+
2694
+ await expect(collectorCreate("a.example.com")).resolves.not.toBeNull();
2695
+ await expect(collectorCreate("b.example.com")).resolves.toBeNull();
2696
+
2697
+ expect(create).toHaveBeenCalledTimes(1);
2698
+ const countCalls: Array<any> = rawQuery.mock.calls.filter((call: any) => {
2699
+ return String(call[0]).includes(`COUNT(*)`);
2700
+ });
2701
+ expect(countCalls).toHaveLength(1);
2702
+ });
2703
+
2704
+ test("an unreadable count creates nothing (fail closed) and says so", async () => {
2705
+ rawQuery.mockRejectedValue(new Error("connection terminated"));
2706
+
2707
+ await expect(collectorCreate("a.example.com")).resolves.toBeNull();
2708
+
2709
+ expect(create).not.toHaveBeenCalled();
2710
+ expect(logs.error).toHaveBeenCalledWith(
2711
+ expect.stringContaining("auto-create budget check failed"),
2712
+ expect.anything(),
2713
+ );
2714
+ });
2715
+
2716
+ test("a known endpoint costs no budget read at all", async () => {
2717
+ const row: DatabaseServer = databaseRow({
2718
+ discoverySource: DatabaseServerDiscoverySource.Collector,
2719
+ });
2720
+ findOwner.mockResolvedValue(ownedBy(row));
2721
+ findOneBy.mockResolvedValue(row);
2722
+
2723
+ await collectorCreate("orders-db.example.com");
2724
+
2725
+ expect(
2726
+ rawQuery.mock.calls.filter((call: any) => {
2727
+ return String(call[0]).includes(`COUNT(*)`);
2728
+ }),
2729
+ ).toHaveLength(0);
2730
+ });
2731
+
2732
+ test("the trace path asks its own budget (AutoCreateBudget) - none is read here", async () => {
2733
+ rawQuery.mockResolvedValue([{ count: 99 }]);
2734
+
2735
+ await DatabaseServerService.findOrCreateByEndpoint({
2736
+ projectId: PROJECT_ID,
2737
+ dbSystem: "postgresql",
2738
+ endpoint: ENDPOINT,
2739
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2740
+ allowCreate: true,
2741
+ });
2742
+
2743
+ expect(create).toHaveBeenCalledTimes(1);
2744
+ });
2745
+
2746
+ test("a zero budget turns collector auto-creation off without a query", async () => {
2747
+ restoreEnv();
2748
+ restoreEnv = withEnv("DATABASE_SERVER_AUTO_CREATE_BUDGET", "0");
2749
+
2750
+ await expect(collectorCreate("a.example.com")).resolves.toBeNull();
2751
+ expect(rawQuery).not.toHaveBeenCalled();
2752
+ });
2753
+ });
2754
+ });
2755
+
2756
+ describe("DatabaseServerService.isUnderAutoCreateBudgetCached", () => {
2757
+ test("reads the count once a minute per project", async () => {
2758
+ const query: jest.Mock = mockRawQuery([{ count: 3 }]);
2759
+ const nowSpy: jest.SpyInstance = jest.spyOn(Date, "now");
2760
+ const start: number = Date.parse("2026-09-24T10:00:00Z");
2761
+ nowSpy.mockReturnValue(start);
2762
+
2763
+ await expect(
2764
+ DatabaseServerService.isUnderAutoCreateBudgetCached(PROJECT_ID),
2765
+ ).resolves.toBe(true);
2766
+ nowSpy.mockReturnValue(start + 30 * 1000);
2767
+ await DatabaseServerService.isUnderAutoCreateBudgetCached(PROJECT_ID);
2768
+ expect(query).toHaveBeenCalledTimes(1);
2769
+
2770
+ nowSpy.mockReturnValue(start + 61 * 1000);
2771
+ await DatabaseServerService.isUnderAutoCreateBudgetCached(PROJECT_ID);
2772
+ expect(query).toHaveBeenCalledTimes(2);
2773
+ });
2774
+ });
2775
+
2776
+ /*
2777
+ * ---------------------------------------------------------------------------
2778
+ * Collector heartbeat: engine evidence behind the ten-minute gate
2779
+ * ---------------------------------------------------------------------------
2780
+ */
2781
+ describe("DatabaseServerService.recordCollectorHeartbeat - engine evidence", () => {
2782
+ const DATABASE_ID: ObjectID = ObjectID.generate();
2783
+ let cache: Map<string, string>;
2784
+ let findOneById: jest.SpyInstance;
2785
+ let writes: jest.SpyInstance;
2786
+ let rawQuery: jest.Mock;
2787
+
2788
+ beforeEach(() => {
2789
+ silenceLogs();
2790
+ mockFeed();
2791
+ cache = new Map<string, string>();
2792
+ SingleFlight.clear();
2793
+ ResourceHeartbeat.clearRecentHeartbeatMemo();
2794
+ DatabaseServerService.clearAutoRestoreCheckMemo();
2795
+ getJestSpyOn(GlobalCache, "setStringIfNotExists").mockImplementation(
2796
+ async (ns: string, key: string, value: string) => {
2797
+ const full: string = `${ns}:${key}`;
2798
+ if (cache.has(full)) {
2799
+ return false;
2800
+ }
2801
+ cache.set(full, value);
2802
+ return true;
2803
+ },
2804
+ );
2805
+ getJestSpyOn(GlobalCache, "setStringIfChanged").mockResolvedValue(true);
2806
+ getJestSpyOn(
2807
+ service,
2808
+ "updateColumnsByIdIfUnlockedWithoutHooks",
2809
+ ).mockResolvedValue(true);
2810
+ writes = getJestSpyOn(
2811
+ service,
2812
+ "updateColumnsByIdWithoutHooks",
2813
+ ).mockResolvedValue(undefined);
2814
+ findOneById = getJestSpyOn(service, "findOneById");
2815
+ rawQuery = mockRawQuery([]);
2816
+ });
2817
+
2818
+ afterEach(() => {
2819
+ SingleFlight.clear();
2820
+ ResourceHeartbeat.clearRecentHeartbeatMemo();
2821
+ DatabaseServerService.clearAutoRestoreCheckMemo();
2822
+ });
2823
+
2824
+ test("the engine the receiver reports corrects a container row, once per window", async () => {
2825
+ const row: DatabaseServer = databaseRow({
2826
+ name: "PostgreSQL data/orders",
2827
+ dbSystem: "postgresql",
2828
+ workloadName: "orders",
2829
+ kubernetesNamespace: "data",
2830
+ discoverySource: DatabaseServerDiscoverySource.Kubernetes,
2831
+ });
2832
+ row._id = DATABASE_ID.toString();
2833
+ findOneById.mockResolvedValue(row);
2834
+ answerEngineWrites(rawQuery, [
2835
+ { _id: DATABASE_ID.toString(), name: "MySQL data/orders" },
2836
+ ]);
2837
+
2838
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID, {
2839
+ dbSystem: "mysql",
2840
+ });
2841
+
2842
+ expect(writes).not.toHaveBeenCalled();
2843
+ const statements: Array<RawStatement> = engineWrites(rawQuery);
2844
+ expect(statements).toHaveLength(1);
2845
+ expect(statements[0]!.params).toEqual([
2846
+ "collector",
2847
+ "mysql",
2848
+ "PostgreSQL data/orders",
2849
+ "MySQL data/orders",
2850
+ DATABASE_ID.toString(),
2851
+ PROJECT_ID.toString(),
2852
+ "postgresql",
2853
+ null,
2854
+ ]);
2855
+ expect(row.name).toBe("MySQL data/orders");
2856
+
2857
+ // Later batches in the same window never look again.
2858
+ SingleFlight.clear();
2859
+ ResourceHeartbeat.clearRecentHeartbeatMemo();
2860
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID, {
2861
+ dbSystem: "mongodb",
2862
+ });
2863
+ expect(findOneById).toHaveBeenCalledTimes(1);
2864
+ });
2865
+
2866
+ test("the same engine on stronger evidence is recorded without bumping updatedAt", async () => {
2867
+ const row: DatabaseServer = databaseRow({
2868
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2869
+ });
2870
+ row._id = DATABASE_ID.toString();
2871
+ findOneById.mockResolvedValue(row);
2872
+ answerEngineWrites(rawQuery, [
2873
+ { _id: DATABASE_ID.toString(), name: row.name! },
2874
+ ]);
2875
+
2876
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID, {
2877
+ dbSystem: "postgres",
2878
+ });
2879
+
2880
+ const { sql, params } = engineWrites(rawQuery)[0]!;
2881
+ expect(params[0]).toBe("collector");
2882
+ expect(sql).not.toContain('"updatedAt"');
2883
+ expect(row.dbSystemSource).toBe("collector");
2884
+ });
2885
+
2886
+ test("no reported engine, no engine write", async () => {
2887
+ const row: DatabaseServer = databaseRow({
2888
+ discoverySource: DatabaseServerDiscoverySource.ClientSpans,
2889
+ });
2890
+ row._id = DATABASE_ID.toString();
2891
+ findOneById.mockResolvedValue(row);
2892
+
2893
+ await DatabaseServerService.recordCollectorHeartbeat(DATABASE_ID, {});
2894
+
2895
+ expect(writes).not.toHaveBeenCalled();
2896
+ expect(engineWrites(rawQuery)).toHaveLength(0);
2897
+ });
2898
+ });
2899
+
2900
+ /*
2901
+ * ---------------------------------------------------------------------------
2902
+ * Who invested in a row: automatic labels and owners, and a person's restore
2903
+ * ---------------------------------------------------------------------------
2904
+ */
2905
+ describe("DatabaseServerService automatic assignments", () => {
2906
+ const DATABASE_ID: ObjectID = new ObjectID(
2907
+ "11111111-1111-4111-8111-111111111111",
2908
+ );
2909
+ const LABEL_ID: string = "e1e1e1e1-e1e1-4e1e-8e1e-e1e1e1e1e1e1";
2910
+
2911
+ beforeEach(() => {
2912
+ silenceLogs();
2913
+ });
2914
+
2915
+ test("records ids atomically, deduped, as lowercase UUID strings", async () => {
2916
+ const query: jest.Mock = mockRawQuery([]);
2917
+
2918
+ await DatabaseServerService.recordAutomaticAssignments({
2919
+ databaseServerId: DATABASE_ID,
2920
+ kind: "labelIds",
2921
+ ids: [LABEL_ID.toUpperCase(), new ObjectID(LABEL_ID), "not-a-uuid"],
2922
+ });
2923
+
2924
+ expect(query).toHaveBeenCalledTimes(1);
2925
+ const [sql, params] = query.mock.calls[0] as [string, Array<unknown>];
2926
+ expect(sql).toContain(`UPDATE "DatabaseServer"`);
2927
+ expect(sql).toContain(`jsonb_agg(DISTINCT assigned.id)`);
2928
+ expect(sql).toContain(`WHERE "_id" = $1`);
2929
+ expect(params).toEqual([
2930
+ DATABASE_ID.toString(),
2931
+ "labelIds",
2932
+ JSON.stringify([LABEL_ID]),
2933
+ ]);
2934
+ });
2935
+
2936
+ test("forgets ids a person added, only from a list that exists", async () => {
2937
+ const query: jest.Mock = mockRawQuery([]);
2938
+
2939
+ await DatabaseServerService.forgetAutomaticAssignments({
2940
+ databaseServerId: DATABASE_ID,
2941
+ kind: "ownerUserIds",
2942
+ ids: [USER_ID],
2943
+ });
2944
+
2945
+ const [sql, params] = query.mock.calls[0] as [string, Array<unknown>];
2946
+ expect(sql).toContain(`WHERE NOT (assigned.id = ANY($3::text[]))`);
2947
+ expect(sql).toContain(
2948
+ `jsonb_typeof("automaticAssignments" -> $2::text) = 'array'`,
2949
+ );
2950
+ expect(params).toEqual([
2951
+ DATABASE_ID.toString(),
2952
+ "ownerUserIds",
2953
+ [USER_ID.toString()],
2954
+ ]);
2955
+ });
2956
+
2957
+ test("nothing to record or forget, no statement", async () => {
2958
+ const query: jest.Mock = mockRawQuery([]);
2959
+
2960
+ await DatabaseServerService.recordAutomaticAssignments({
2961
+ databaseServerId: DATABASE_ID,
2962
+ kind: "labelIds",
2963
+ ids: [],
2964
+ });
2965
+ await DatabaseServerService.forgetAutomaticAssignments({
2966
+ databaseServerId: DATABASE_ID,
2967
+ kind: "labelIds",
2968
+ ids: ["nope"],
2969
+ });
2970
+
2971
+ expect(query).not.toHaveBeenCalled();
2972
+ });
2973
+
2974
+ test("an unknown kind is refused before any SQL", async () => {
2975
+ const query: jest.Mock = mockRawQuery([]);
2976
+
2977
+ await DatabaseServerService.recordAutomaticAssignments({
2978
+ databaseServerId: DATABASE_ID,
2979
+ kind: "monitorIds" as never,
2980
+ ids: [LABEL_ID],
2981
+ });
2982
+
2983
+ expect(query).not.toHaveBeenCalled();
2984
+ });
2985
+
2986
+ test("a failing write is logged, never thrown - it only annotates a write that happened", async () => {
2987
+ getJestSpyOn(service, "getRepository").mockReturnValue({
2988
+ manager: {
2989
+ query: jest.fn(async () => {
2990
+ throw new Error("connection terminated");
2991
+ }),
2992
+ },
2993
+ } as never);
2994
+ const warn: jest.SpyInstance = logger.warn as unknown as jest.SpyInstance;
2995
+
2996
+ await expect(
2997
+ DatabaseServerService.recordAutomaticAssignments({
2998
+ databaseServerId: DATABASE_ID,
2999
+ kind: "labelIds",
3000
+ ids: [LABEL_ID],
3001
+ }),
3002
+ ).resolves.toBeUndefined();
3003
+ await expect(
3004
+ DatabaseServerService.forgetAutomaticAssignments({
3005
+ databaseServerId: DATABASE_ID,
3006
+ kind: "labelIds",
3007
+ ids: [LABEL_ID],
3008
+ }),
3009
+ ).resolves.toBeUndefined();
3010
+ expect(warn).toHaveBeenCalledTimes(2);
3011
+ });
3012
+
3013
+ test("labels promoted from telemetry are recorded as automatic - only the ones actually added", async () => {
3014
+ const existing: Label = new Label(ObjectID.generate());
3015
+ const added: ObjectID = ObjectID.generate();
3016
+ const builder: any = {
3017
+ createQueryBuilder: () => {
3018
+ return builder;
3019
+ },
3020
+ relation: () => {
3021
+ return builder;
3022
+ },
3023
+ of: () => {
3024
+ return builder;
3025
+ },
3026
+ loadMany: async () => {
3027
+ return [existing];
3028
+ },
3029
+ add: jest.fn(async () => {
3030
+ return undefined;
3031
+ }),
3032
+ };
3033
+ const query: jest.Mock = jest.fn(async () => {
3034
+ return [];
3035
+ });
3036
+ builder.manager = { query };
3037
+ getJestSpyOn(service, "getRepository").mockReturnValue(builder as never);
3038
+ getJestSpyOn(GlobalCache, "getString").mockResolvedValue(null);
3039
+ getJestSpyOn(GlobalCache, "setString").mockResolvedValue(undefined);
3040
+
3041
+ await DatabaseServerService.attachLabels({
3042
+ databaseServerId: DATABASE_ID,
3043
+ labelIds: [existing.id!, added],
3044
+ });
3045
+
3046
+ expect(builder.add).toHaveBeenCalledWith([added.toString()]);
3047
+ expect(query).toHaveBeenCalledTimes(1);
3048
+ expect(query.mock.calls[0]![1]).toEqual([
3049
+ DATABASE_ID.toString(),
3050
+ "labelIds",
3051
+ JSON.stringify([added.toString()]),
3052
+ ]);
3053
+ });
3054
+ });
3055
+
3056
+ describe("DatabaseServerService.onUpdateSuccess - a person's edits", () => {
3057
+ let writes: jest.SpyInstance;
3058
+ let forget: jest.SpyInstance;
3059
+
3060
+ function onUpdate(data: Record<string, unknown>): any {
3061
+ return {
3062
+ updateBy: {
3063
+ query: {},
3064
+ data: data,
3065
+ props: { userId: USER_ID },
3066
+ },
3067
+ carryForward: null,
3068
+ };
3069
+ }
3070
+
3071
+ beforeEach(() => {
3072
+ silenceLogs();
3073
+ mockFeed();
3074
+ writes = getJestSpyOn(
3075
+ service,
3076
+ "updateColumnsByIdWithoutHooks",
3077
+ ).mockResolvedValue(undefined);
3078
+ forget = getJestSpyOn(
3079
+ service,
3080
+ "forgetAutomaticAssignments",
3081
+ ).mockResolvedValue(undefined);
3082
+ getJestSpyOn(service, "findOneById").mockResolvedValue(databaseRow());
3083
+ });
3084
+
3085
+ test("labels a person saves count as theirs, whichever rule first attached them", async () => {
3086
+ const id: ObjectID = ObjectID.generate();
3087
+ const a: ObjectID = ObjectID.generate();
3088
+ const b: ObjectID = ObjectID.generate();
3089
+ const label: Label = new Label(b);
3090
+
3091
+ await service.onUpdateSuccess(
3092
+ onUpdate({ labels: [{ _id: a.toString() }, label, "not-an-object"] }),
3093
+ [id],
3094
+ );
3095
+
3096
+ expect(forget).toHaveBeenCalledTimes(1);
3097
+ const call: any = forget.mock.calls[0]![0];
3098
+ expect(call.databaseServerId).toBe(id);
3099
+ expect(call.kind).toBe("labelIds");
3100
+ expect(call.ids).toEqual([a.toString(), b.toString(), "not-an-object"]);
3101
+ });
3102
+
3103
+ test("an engine a person sets is recorded as theirs", async () => {
3104
+ const id: ObjectID = ObjectID.generate();
3105
+
3106
+ await service.onUpdateSuccess(onUpdate({ dbSystem: "mysql" }), [id]);
3107
+
3108
+ expect(writes).toHaveBeenCalledWith({
3109
+ id: id,
3110
+ data: { dbSystemSource: "manual" },
3111
+ skipUpdateDateColumn: true,
3112
+ });
3113
+ });
3114
+
3115
+ test("an edit touching neither leaves both alone", async () => {
3116
+ await service.onUpdateSuccess(onUpdate({ name: "Orders" }), [
3117
+ ObjectID.generate(),
3118
+ ]);
3119
+
3120
+ expect(forget).not.toHaveBeenCalled();
3121
+ expect(writes).not.toHaveBeenCalled();
3122
+ });
3123
+ });
3124
+
3125
+ describe("DatabaseServerService.autoArchiveStaleDatabaseServers - restore grace", () => {
3126
+ beforeEach(() => {
3127
+ silenceLogs();
3128
+ mockFeed();
3129
+ });
3130
+
3131
+ test("an archive window longer than thirty days stretches a person's restore grace with it", async () => {
3132
+ const query: jest.Mock = mockRawQuery([]);
3133
+ const restore: () => void = withEnv(
3134
+ "DATABASE_SERVER_AUTO_ARCHIVE_DAYS",
3135
+ "45",
3136
+ );
3137
+
3138
+ try {
3139
+ const before: number = Date.now();
3140
+ await DatabaseServerService.autoArchiveStaleDatabaseServers();
3141
+
3142
+ const params: Array<unknown> = query.mock.calls[0]![1] as Array<unknown>;
3143
+ const grace: number = before - (params[4] as Date).getTime();
3144
+ expect(grace).toBeGreaterThanOrEqual(45 * 24 * HOUR_MS - 1000);
3145
+ expect(grace).toBeLessThanOrEqual(45 * 24 * HOUR_MS + 5000);
3146
+ } finally {
3147
+ restore();
3148
+ }
3149
+ });
3150
+
3151
+ test("the feed says which investment keeps a row, and that rule-applied labels do not count", async () => {
3152
+ mockRawQuery([
3153
+ { _id: ObjectID.generate().toString(), projectId: PROJECT_ID.toString() },
3154
+ ]);
3155
+ const feed: jest.SpyInstance =
3156
+ DatabaseServerFeedService.createDatabaseServerFeedItem as unknown as jest.SpyInstance;
3157
+
3158
+ await DatabaseServerService.autoArchiveStaleDatabaseServers();
3159
+
3160
+ const more: string = feed.mock.calls[0]![0].moreInformationInMarkdown;
3161
+ expect(more).toContain("recently restored it from the archive");
3162
+ expect(more).toContain(
3163
+ "Labels and owners that rules or telemetry attached on their own do not count.",
3164
+ );
3165
+ expect(more).toContain("kept reporting");
3166
+ });
3167
+ });
3168
+
3169
+ /*
3170
+ * ---------------------------------------------------------------------------
3171
+ * Child rows created by a caller: the database must be theirs
3172
+ * ---------------------------------------------------------------------------
3173
+ */
3174
+ describe("DatabaseServerService.assertDatabaseServerReferenceInProject", () => {
3175
+ const DATABASE_ID: ObjectID = new ObjectID(
3176
+ "11111111-1111-4111-8111-111111111111",
3177
+ );
3178
+ const OTHER_DATABASE_ID: ObjectID = new ObjectID(
3179
+ "22222222-2222-4222-8222-222222222222",
3180
+ );
3181
+
3182
+ let findOneBy: jest.SpyInstance;
3183
+
3184
+ function ownerRequest(reference: Partial<DatabaseServerOwnerUser>): {
3185
+ data: DatabaseServerOwnerUser;
3186
+ props: DatabaseCommonInteractionProps;
3187
+ } {
3188
+ const data: DatabaseServerOwnerUser = new DatabaseServerOwnerUser();
3189
+ data.userId = USER_ID;
3190
+ Object.assign(data, reference);
3191
+ return { data: data, props: memberProps() };
3192
+ }
3193
+
3194
+ beforeEach(() => {
3195
+ findOneBy = getJestSpyOn(service, "findOneBy").mockImplementation(
3196
+ async (args: any) => {
3197
+ return args.query._id === DATABASE_ID.toString() &&
3198
+ args.query.projectId.toString() === PROJECT_ID.toString()
3199
+ ? databaseRow()
3200
+ : null;
3201
+ },
3202
+ );
3203
+ });
3204
+
3205
+ test("a database of the caller's project is accepted, as the FK column only", async () => {
3206
+ const request: any = ownerRequest({
3207
+ databaseServer: new DatabaseServer(DATABASE_ID),
3208
+ });
3209
+
3210
+ await expect(
3211
+ DatabaseServerService.assertDatabaseServerReferenceInProject(request),
3212
+ ).resolves.toEqual(DATABASE_ID);
3213
+
3214
+ expect(request.data.databaseServerId.toString()).toBe(
3215
+ DATABASE_ID.toString(),
3216
+ );
3217
+ expect(request.data.databaseServer).toBeUndefined();
3218
+ expect(findOneBy.mock.calls[0]![0].props).toEqual({ isRoot: true });
3219
+ });
3220
+
3221
+ test("another project's database is refused", async () => {
3222
+ await expect(
3223
+ DatabaseServerService.assertDatabaseServerReferenceInProject(
3224
+ ownerRequest({ databaseServerId: OTHER_DATABASE_ID }) as any,
3225
+ ),
3226
+ ).rejects.toThrow("Database not found.");
3227
+ });
3228
+
3229
+ test("a relation object that disagrees with the FK column is refused before any lookup", async () => {
3230
+ await expect(
3231
+ DatabaseServerService.assertDatabaseServerReferenceInProject(
3232
+ ownerRequest({
3233
+ databaseServerId: DATABASE_ID,
3234
+ databaseServer: new DatabaseServer(OTHER_DATABASE_ID),
3235
+ }) as any,
3236
+ ),
3237
+ ).rejects.toThrow("Conflicting database references were provided.");
3238
+ expect(findOneBy).not.toHaveBeenCalled();
3239
+ });
3240
+
3241
+ test("no database at all is refused", async () => {
3242
+ await expect(
3243
+ DatabaseServerService.assertDatabaseServerReferenceInProject(
3244
+ ownerRequest({}) as any,
3245
+ ),
3246
+ ).rejects.toThrow("Select a database.");
3247
+ });
3248
+ });
3249
+
3250
+ describe("DatabaseServerService.getDatabaseServerNameIfReadable", () => {
3251
+ test("as root (or without a caller) the name is read as root", async () => {
3252
+ const rootName: jest.SpyInstance = getJestSpyOn(
3253
+ service,
3254
+ "getDatabaseServerName",
3255
+ ).mockResolvedValue("Orders");
3256
+ const id: ObjectID = ObjectID.generate();
3257
+
3258
+ await expect(
3259
+ DatabaseServerService.getDatabaseServerNameIfReadable({
3260
+ databaseServerId: id,
3261
+ }),
3262
+ ).resolves.toBe("Orders");
3263
+ await expect(
3264
+ DatabaseServerService.getDatabaseServerNameIfReadable({
3265
+ databaseServerId: id,
3266
+ props: { isRoot: true },
3267
+ }),
3268
+ ).resolves.toBe("Orders");
3269
+ expect(rootName).toHaveBeenCalledTimes(2);
3270
+ });
3271
+
3272
+ test("a caller's lookup runs with the caller's own props", async () => {
3273
+ const findOneBy: jest.SpyInstance = getJestSpyOn(
3274
+ service,
3275
+ "findOneBy",
3276
+ ).mockResolvedValue(databaseRow({ name: "Orders" }));
3277
+ const props: DatabaseCommonInteractionProps = memberProps();
3278
+
3279
+ await expect(
3280
+ DatabaseServerService.getDatabaseServerNameIfReadable({
3281
+ databaseServerId: ObjectID.generate(),
3282
+ props: props,
3283
+ }),
3284
+ ).resolves.toBe("Orders");
3285
+ expect(findOneBy.mock.calls[0]![0].props).toBe(props);
3286
+ });
3287
+
3288
+ test("a row the caller cannot read, or any error, is an empty name", async () => {
3289
+ getJestSpyOn(service, "findOneBy")
3290
+ .mockResolvedValueOnce(null)
3291
+ .mockRejectedValueOnce(new Error("not allowed"));
3292
+
3293
+ for (let i: number = 0; i < 2; i++) {
3294
+ await expect(
3295
+ DatabaseServerService.getDatabaseServerNameIfReadable({
3296
+ databaseServerId: ObjectID.generate(),
3297
+ props: memberProps(),
3298
+ }),
3299
+ ).resolves.toBe("");
3300
+ }
3301
+ });
3302
+ });