@oneuptime/common 12.0.2 → 12.0.3

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 (228) hide show
  1. package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +14 -0
  2. package/Models/DatabaseModels/Index.ts +2 -0
  3. package/Models/DatabaseModels/LogDropFilter.ts +9 -20
  4. package/Models/DatabaseModels/StatusPageMonitorRule.ts +911 -0
  5. package/Models/DatabaseModels/StatusPageResource.ts +73 -0
  6. package/Models/DatabaseModels/TraceDropFilter.ts +9 -20
  7. package/Server/API/BaseAPI.ts +13 -2
  8. package/Server/API/DashboardAPI.ts +39 -0
  9. package/Server/EnvironmentConfig.ts +31 -1
  10. package/Server/Infrastructure/GlobalCache.ts +115 -0
  11. package/Server/Infrastructure/Postgres/DataSourceOptions.ts +16 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.ts +123 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  14. package/Server/Services/AlertStateTimelineService.ts +20 -0
  15. package/Server/Services/CephClusterService.ts +26 -86
  16. package/Server/Services/CloudResourceService.ts +23 -83
  17. package/Server/Services/DatabaseService.ts +111 -0
  18. package/Server/Services/DockerHostService.ts +19 -79
  19. package/Server/Services/DockerSwarmClusterService.ts +27 -88
  20. package/Server/Services/HostService.ts +46 -99
  21. package/Server/Services/IncidentStateTimelineService.ts +20 -0
  22. package/Server/Services/Index.ts +2 -0
  23. package/Server/Services/IoTFleetService.ts +19 -79
  24. package/Server/Services/KubernetesClusterService.ts +17 -78
  25. package/Server/Services/LabelService.ts +57 -10
  26. package/Server/Services/MetricTypeService.ts +84 -0
  27. package/Server/Services/MonitorService.ts +89 -14
  28. package/Server/Services/MonitorStatusTimelineService.ts +29 -0
  29. package/Server/Services/MonitorTemplateService.ts +65 -0
  30. package/Server/Services/PodmanHostService.ts +19 -79
  31. package/Server/Services/ProxmoxClusterService.ts +23 -83
  32. package/Server/Services/RumApplicationService.ts +19 -80
  33. package/Server/Services/ScheduledMaintenanceStateTimelineService.ts +21 -0
  34. package/Server/Services/ServerlessFunctionService.ts +24 -85
  35. package/Server/Services/ServiceService.ts +299 -73
  36. package/Server/Services/StatusPageMonitorRuleEngineService.ts +752 -0
  37. package/Server/Services/StatusPageMonitorRuleService.ts +471 -0
  38. package/Server/Services/TelemetryEntityRelationshipService.ts +1 -0
  39. package/Server/Services/TelemetryEntityService.ts +1 -0
  40. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +170 -39
  41. package/Server/Utils/Monitor/MonitorResource.ts +63 -36
  42. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +36 -0
  43. package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +148 -127
  44. package/Server/Utils/Monitor/MonitorStepsReferenceExtractor.ts +434 -0
  45. package/Server/Utils/SingleFlight.ts +83 -0
  46. package/Server/Utils/StatusPage/MonitorRulePatternValidator.ts +46 -0
  47. package/Server/Utils/Telemetry/EntityRegistry.ts +107 -26
  48. package/Server/Utils/Telemetry/ResourceHeartbeat.ts +314 -0
  49. package/Server/Utils/Telemetry/Telemetry.ts +351 -122
  50. package/Tests/App/Dashboard/WidgetCatalog.test.ts +2 -0
  51. package/Tests/Models/DatabaseModels/ColumnTransformerInvariants.test.ts +306 -0
  52. package/Tests/Models/DatabaseModels/NumberColumnDeserialization.test.ts +333 -0
  53. package/Tests/Models/DatabaseModels/StatusPageMonitorRuleModel.test.ts +190 -0
  54. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +26 -0
  55. package/Tests/Server/Infrastructure/GlobalCache.test.ts +226 -0
  56. package/Tests/Server/Infrastructure/PostgresLockTimeoutOptions.test.ts +181 -0
  57. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlocked.test.ts +342 -0
  58. package/Tests/Server/Services/DropFilterSaveValidation.test.ts +245 -0
  59. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +163 -15
  60. package/Tests/Server/Services/MetricTypeAttachServices.test.ts +222 -0
  61. package/Tests/Server/Services/MissingReferenceWriteGuard.test.ts +614 -0
  62. package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +23 -0
  63. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +305 -56
  64. package/Tests/Server/Services/ServiceUpdateLastSeenSplitGates.test.ts +705 -0
  65. package/Tests/Server/Services/StatusPageMonitorRuleEngineService.test.ts +1719 -0
  66. package/Tests/Server/Services/StatusPageMonitorRuleHooks.test.ts +1212 -0
  67. package/Tests/Server/Services/StatusPageMonitorRuleMonitorCreateHook.test.ts +228 -0
  68. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +267 -0
  69. package/Tests/Server/Utils/Database/ProjectScopedReferenceValidator.test.ts +260 -13
  70. package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +302 -0
  71. package/Tests/Server/Utils/MetricTypeIndexConvergence.test.ts +598 -0
  72. package/Tests/Server/Utils/Monitor/MonitorStatusTimelineUnusableStatus.test.ts +154 -0
  73. package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +638 -92
  74. package/Tests/Server/Utils/Monitor/MonitorStepsReferenceExtractor.test.ts +724 -0
  75. package/Tests/Server/Utils/SingleFlight.test.ts +221 -0
  76. package/Tests/Types/Dashboard/NetworkDashboardTemplate.test.ts +793 -0
  77. package/Tests/Types/Database/BigIntColumnTransformer.test.ts +147 -0
  78. package/Tests/Types/Database/DatabasePropertyTransformer.test.ts +140 -0
  79. package/Tests/Types/Database/NumericColumnValue.test.ts +127 -0
  80. package/Tests/Types/Decimal.test.ts +37 -0
  81. package/Tests/Types/Monitor/SnmpVersionParsing.test.ts +152 -0
  82. package/Tests/Types/Port.test.ts +38 -0
  83. package/Tests/UI/Components/LogsHistogram.test.tsx +276 -0
  84. package/Tests/UI/Components/UseLiveLogsRefresh.test.tsx +489 -0
  85. package/Tests/UI/Components/UseLogsHistogram.test.tsx +500 -0
  86. package/Tests/Utils/Dashboard/Components/DashboardComponentsUtil.test.ts +120 -0
  87. package/Tests/Utils/Dashboard/DashboardNetworkMapComponent.test.ts +327 -0
  88. package/Types/Dashboard/DashboardComponentType.ts +1 -0
  89. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  90. package/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.ts +40 -0
  91. package/Types/Dashboard/DashboardTemplates.ts +397 -0
  92. package/Types/Database/BigIntColumnTransformer.ts +74 -0
  93. package/Types/Database/DatabaseProperty.ts +26 -0
  94. package/Types/Database/NumericColumnValue.ts +128 -0
  95. package/Types/Decimal.ts +16 -4
  96. package/Types/Permission.ts +48 -0
  97. package/Types/Port.ts +16 -4
  98. package/UI/Components/LogsViewer/useLiveLogsRefresh.ts +86 -0
  99. package/UI/Components/LogsViewer/useLogsHistogram.ts +113 -0
  100. package/Utils/Dashboard/Components/DashboardNetworkMapComponent.ts +136 -0
  101. package/Utils/Dashboard/Components/Index.ts +7 -0
  102. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +12 -0
  103. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  105. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/LogDropFilter.js +9 -20
  107. package/build/dist/Models/DatabaseModels/LogDropFilter.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/StatusPageMonitorRule.js +925 -0
  109. package/build/dist/Models/DatabaseModels/StatusPageMonitorRule.js.map +1 -0
  110. package/build/dist/Models/DatabaseModels/StatusPageResource.js +72 -0
  111. package/build/dist/Models/DatabaseModels/StatusPageResource.js.map +1 -1
  112. package/build/dist/Models/DatabaseModels/TraceDropFilter.js +9 -20
  113. package/build/dist/Models/DatabaseModels/TraceDropFilter.js.map +1 -1
  114. package/build/dist/Server/API/BaseAPI.js +11 -1
  115. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  116. package/build/dist/Server/API/DashboardAPI.js +39 -0
  117. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  118. package/build/dist/Server/EnvironmentConfig.js +27 -1
  119. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  120. package/build/dist/Server/Infrastructure/GlobalCache.js +91 -0
  121. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  122. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +5 -3
  123. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.js +48 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.js.map +1 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  128. package/build/dist/Server/Services/AlertStateTimelineService.js +19 -0
  129. package/build/dist/Server/Services/AlertStateTimelineService.js.map +1 -1
  130. package/build/dist/Server/Services/CephClusterService.js +26 -76
  131. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  132. package/build/dist/Server/Services/CloudResourceService.js +23 -73
  133. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  134. package/build/dist/Server/Services/DatabaseService.js +86 -0
  135. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  136. package/build/dist/Server/Services/DockerHostService.js +19 -69
  137. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  138. package/build/dist/Server/Services/DockerSwarmClusterService.js +27 -78
  139. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  140. package/build/dist/Server/Services/HostService.js +58 -102
  141. package/build/dist/Server/Services/HostService.js.map +1 -1
  142. package/build/dist/Server/Services/IncidentStateTimelineService.js +19 -0
  143. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  144. package/build/dist/Server/Services/Index.js +2 -0
  145. package/build/dist/Server/Services/Index.js.map +1 -1
  146. package/build/dist/Server/Services/IoTFleetService.js +19 -69
  147. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  148. package/build/dist/Server/Services/KubernetesClusterService.js +17 -68
  149. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  150. package/build/dist/Server/Services/LabelService.js +42 -7
  151. package/build/dist/Server/Services/LabelService.js.map +1 -1
  152. package/build/dist/Server/Services/MetricTypeService.js +74 -0
  153. package/build/dist/Server/Services/MetricTypeService.js.map +1 -1
  154. package/build/dist/Server/Services/MonitorService.js +81 -9
  155. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  156. package/build/dist/Server/Services/MonitorStatusTimelineService.js +28 -0
  157. package/build/dist/Server/Services/MonitorStatusTimelineService.js.map +1 -1
  158. package/build/dist/Server/Services/MonitorTemplateService.js +60 -0
  159. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  160. package/build/dist/Server/Services/PodmanHostService.js +19 -69
  161. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  162. package/build/dist/Server/Services/ProxmoxClusterService.js +23 -73
  163. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  164. package/build/dist/Server/Services/RumApplicationService.js +19 -70
  165. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  166. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js +20 -0
  167. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js.map +1 -1
  168. package/build/dist/Server/Services/ServerlessFunctionService.js +24 -75
  169. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  170. package/build/dist/Server/Services/ServiceService.js +236 -61
  171. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  172. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js +603 -0
  173. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js.map +1 -0
  174. package/build/dist/Server/Services/StatusPageMonitorRuleService.js +401 -0
  175. package/build/dist/Server/Services/StatusPageMonitorRuleService.js.map +1 -0
  176. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js +1 -0
  177. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +1 -1
  178. package/build/dist/Server/Services/TelemetryEntityService.js +1 -0
  179. package/build/dist/Server/Services/TelemetryEntityService.js.map +1 -1
  180. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +105 -29
  181. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  182. package/build/dist/Server/Utils/Monitor/MonitorResource.js +56 -32
  183. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  184. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +30 -3
  185. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  186. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +110 -98
  187. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -1
  188. package/build/dist/Server/Utils/Monitor/MonitorStepsReferenceExtractor.js +312 -0
  189. package/build/dist/Server/Utils/Monitor/MonitorStepsReferenceExtractor.js.map +1 -0
  190. package/build/dist/Server/Utils/SingleFlight.js +76 -0
  191. package/build/dist/Server/Utils/SingleFlight.js.map +1 -0
  192. package/build/dist/Server/Utils/StatusPage/MonitorRulePatternValidator.js +29 -0
  193. package/build/dist/Server/Utils/StatusPage/MonitorRulePatternValidator.js.map +1 -0
  194. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +78 -19
  195. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  196. package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js +160 -0
  197. package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js.map +1 -0
  198. package/build/dist/Server/Utils/Telemetry/Telemetry.js +279 -104
  199. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  200. package/build/dist/Types/Dashboard/DashboardComponentType.js +1 -0
  201. package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
  202. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  203. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  204. package/build/dist/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.js +2 -0
  205. package/build/dist/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.js.map +1 -0
  206. package/build/dist/Types/Dashboard/DashboardTemplates.js +368 -0
  207. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  208. package/build/dist/Types/Database/BigIntColumnTransformer.js +66 -0
  209. package/build/dist/Types/Database/BigIntColumnTransformer.js.map +1 -0
  210. package/build/dist/Types/Database/DatabaseProperty.js +25 -0
  211. package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
  212. package/build/dist/Types/Database/NumericColumnValue.js +97 -0
  213. package/build/dist/Types/Database/NumericColumnValue.js.map +1 -0
  214. package/build/dist/Types/Decimal.js +14 -3
  215. package/build/dist/Types/Decimal.js.map +1 -1
  216. package/build/dist/Types/Permission.js +42 -0
  217. package/build/dist/Types/Permission.js.map +1 -1
  218. package/build/dist/Types/Port.js +15 -5
  219. package/build/dist/Types/Port.js.map +1 -1
  220. package/build/dist/UI/Components/LogsViewer/useLiveLogsRefresh.js +44 -0
  221. package/build/dist/UI/Components/LogsViewer/useLiveLogsRefresh.js.map +1 -0
  222. package/build/dist/UI/Components/LogsViewer/useLogsHistogram.js +75 -0
  223. package/build/dist/UI/Components/LogsViewer/useLogsHistogram.js.map +1 -0
  224. package/build/dist/Utils/Dashboard/Components/DashboardNetworkMapComponent.js +114 -0
  225. package/build/dist/Utils/Dashboard/Components/DashboardNetworkMapComponent.js.map +1 -0
  226. package/build/dist/Utils/Dashboard/Components/Index.js +4 -0
  227. package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
  228. package/package.json +1 -1
@@ -1,25 +1,36 @@
1
1
  import ProjectScopedReferenceValidator from "../../../../Server/Utils/Database/ProjectScopedReferenceValidator";
2
+ import DatabaseService from "../../../../Server/Services/DatabaseService";
2
3
  import IncidentSeverityService from "../../../../Server/Services/IncidentSeverityService";
3
4
  import IncidentStateService from "../../../../Server/Services/IncidentStateService";
4
5
  import MonitorStatusService from "../../../../Server/Services/MonitorStatusService";
6
+ import UserService from "../../../../Server/Services/UserService";
7
+ import DatabaseBaseModel from "../../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
5
8
  import IncidentSeverity from "../../../../Models/DatabaseModels/IncidentSeverity";
6
9
  import IncidentState from "../../../../Models/DatabaseModels/IncidentState";
7
10
  import MonitorStatus from "../../../../Models/DatabaseModels/MonitorStatus";
11
+ import User from "../../../../Models/DatabaseModels/User";
8
12
  import BadDataException from "../../../../Types/Exception/BadDataException";
13
+ import Name from "../../../../Types/Name";
9
14
  import ObjectID from "../../../../Types/ObjectID";
10
15
  import { afterEach, describe, expect, it, jest } from "@jest/globals";
11
16
 
12
17
  /*
13
18
  * Contract under test: an incident (or alert, or scheduled maintenance event)
14
19
  * stores the id of its state, its severity and the monitor status it switches
15
- * monitors to. Saving one that belongs to a *different* project is what leaves
16
- * the referenced project undeletable — deleting that project cascades into the
17
- * record, the row here still points at it, and the ON DELETE NO ACTION foreign
18
- * key refuses the delete with 23503.
20
+ * monitors to. Two things can be wrong with one of those ids, and the guard
21
+ * refuses both.
19
22
  *
20
- * The check has to stay narrow in the same way MonitorStepsProjectValidator
21
- * is: an id that matches no row at all must keep saving, otherwise a record
22
- * that already points at something deleted can never be saved back into shape.
23
+ * An id belonging to a *different* project is what leaves the referenced
24
+ * project undeletable deleting that project cascades into the record, the row
25
+ * here still points at it, and the ON DELETE NO ACTION foreign key refuses the
26
+ * delete with 23503.
27
+ *
28
+ * An id that exists NOWHERE is issue #3039: the REST API took any uuid for
29
+ * these, and the write then failed as a raw foreign key violation deep in
30
+ * Postgres — or, for ids stored in a JSON blob with no foreign key behind them,
31
+ * saved fine and blew up much later inside the probe worker. Rejecting it here
32
+ * names the id instead. Callers whose reference is allowed to dangle opt out
33
+ * per reference with `mustExist: false`.
23
34
  */
24
35
 
25
36
  const PROJECT_ID: ObjectID = new ObjectID(
@@ -34,6 +45,7 @@ const FOREIGN_SEVERITY_ID: string = "cfc2f04f-79cb-4344-8c54-dafe5e3a290c";
34
45
  const FOREIGN_STATE_ID: string = "a2eb67d4-bd2e-4186-9187-dad799c9316c";
35
46
  const FOREIGN_STATUS_ID: string = "1a1cf3f2-0e35-4a1e-98a1-3f0f8b5f7f9e";
36
47
  const UNKNOWN_ID: string = "9c0ba0b3-2f8e-4c02-a8d5-6a4d2f5b9c11";
48
+ const OWN_USER_ID: string = "7c2f6b40-6a1b-4f18-9d0e-2c5ba2d3f0a7";
37
49
 
38
50
  const incidentSeverity: (
39
51
  id: string,
@@ -172,17 +184,200 @@ describe("ProjectScopedReferenceValidator", () => {
172
184
  ).resolves.toBeUndefined();
173
185
  });
174
186
 
175
- it("leaves an id that matches no record alone", async () => {
187
+ it("rejects an id that matches no record at all", async () => {
188
+ /*
189
+ * Issue #3039. This used to be allowed through on purpose, and the write
190
+ * then either came back as an opaque foreign key violation from Postgres or
191
+ * — in a JSON column with no foreign key — saved and failed later, at run
192
+ * time, with nothing reported to whoever supplied the id.
193
+ */
194
+ mockLookups({ incidentSeverities: [] });
195
+
196
+ await expect(
197
+ ProjectScopedReferenceValidator.validateReferencesBelongToProject({
198
+ projectId: PROJECT_ID,
199
+ subject: "incident",
200
+ references: [
201
+ {
202
+ modelName: "Incident Severity",
203
+ id: UNKNOWN_ID,
204
+ service: IncidentSeverityService,
205
+ },
206
+ ],
207
+ }),
208
+ ).rejects.toThrow(BadDataException);
209
+ });
210
+
211
+ it("accepts an UPPERCASE uuid for a record Postgres reads back lower-cased", async () => {
212
+ /*
213
+ * ObjectID keeps whatever case it was handed — its validation regex is
214
+ * case-insensitive — and Postgres compares `uuid` by parsed value, so an
215
+ * uppercase id in the payload selects the row fine but reads back
216
+ * lower-cased. Comparing the two verbatim reported a record that plainly
217
+ * exists as missing, and the API answered "does not exist" for an id the
218
+ * caller had just read out of a GET response.
219
+ */
220
+ mockLookups({
221
+ incidentSeverities: [incidentSeverity(OWN_SEVERITY_ID, PROJECT_ID)],
222
+ });
223
+
224
+ await expect(
225
+ ProjectScopedReferenceValidator.validateReferencesBelongToProject({
226
+ projectId: PROJECT_ID,
227
+ references: [
228
+ {
229
+ modelName: "Incident Severity",
230
+ id: OWN_SEVERITY_ID.toUpperCase(),
231
+ service: IncidentSeverityService,
232
+ },
233
+ ],
234
+ }),
235
+ ).resolves.toBeUndefined();
236
+ });
237
+
238
+ it("echoes the id as the caller wrote it when reporting it missing", async () => {
239
+ mockLookups({ incidentSeverities: [] });
240
+
241
+ await expect(
242
+ ProjectScopedReferenceValidator.validateReferencesBelongToProject({
243
+ projectId: PROJECT_ID,
244
+ references: [
245
+ {
246
+ modelName: "Incident Severity",
247
+ id: UNKNOWN_ID.toUpperCase(),
248
+ service: IncidentSeverityService,
249
+ },
250
+ ],
251
+ }),
252
+ ).rejects.toThrow(`Incident Severity "${UNKNOWN_ID.toUpperCase()}"`);
253
+ });
254
+
255
+ it("names the missing id in the rejection so the caller can find it", async () => {
256
+ mockLookups({ incidentSeverities: [] });
257
+
258
+ await expect(
259
+ ProjectScopedReferenceValidator.validateReferencesBelongToProject({
260
+ projectId: PROJECT_ID,
261
+ subject: "incident",
262
+ references: [
263
+ {
264
+ modelName: "Incident Severity",
265
+ id: UNKNOWN_ID,
266
+ service: IncidentSeverityService,
267
+ },
268
+ ],
269
+ }),
270
+ ).rejects.toThrow(
271
+ `This incident references records that do not exist: Incident Severity "${UNKNOWN_ID}". Please pick values that exist in this project and try again.`,
272
+ );
273
+ });
274
+
275
+ it("leaves a missing id alone when the caller opts out with mustExist false", async () => {
276
+ /*
277
+ * The opt-out exists for references with no foreign key behind them, where
278
+ * a stored id really can dangle (deleting a monitor status does not rewrite
279
+ * the monitor criteria that name it). Refusing those would stop a user
280
+ * saving their way out of a record that was already broken.
281
+ */
282
+ mockLookups({ incidentSeverities: [] });
283
+
284
+ await expect(
285
+ ProjectScopedReferenceValidator.validateReferencesBelongToProject({
286
+ projectId: PROJECT_ID,
287
+ references: [
288
+ {
289
+ modelName: "Incident Severity",
290
+ id: UNKNOWN_ID,
291
+ service: IncidentSeverityService,
292
+ mustExist: false,
293
+ },
294
+ ],
295
+ }),
296
+ ).resolves.toBeUndefined();
297
+ });
298
+
299
+ it("keeps the strictest requirement when the same id arrives twice", async () => {
176
300
  /*
177
- * A record that already points at something deleted must stay saveable
178
- * refusing unknown ids would trap the user. The not-null and foreign key
179
- * checks report that case on their own.
301
+ * Two monitor criteria can name the same status, one operative and one not.
302
+ * They collapse to a single lookup, and the one that requires the record to
303
+ * exist must not be dropped by the one that does not.
180
304
  */
181
305
  mockLookups({ incidentSeverities: [] });
182
306
 
183
307
  await expect(
184
308
  ProjectScopedReferenceValidator.validateReferencesBelongToProject({
185
309
  projectId: PROJECT_ID,
310
+ references: [
311
+ {
312
+ modelName: "Incident Severity",
313
+ id: UNKNOWN_ID,
314
+ service: IncidentSeverityService,
315
+ mustExist: false,
316
+ },
317
+ {
318
+ modelName: "Incident Severity",
319
+ id: UNKNOWN_ID,
320
+ service: IncidentSeverityService,
321
+ mustExist: true,
322
+ },
323
+ ],
324
+ }),
325
+ ).rejects.toThrow("do not exist");
326
+ });
327
+
328
+ it("reports a foreign reference and a missing one in the same message", async () => {
329
+ /*
330
+ * A payload with several bad ids should not have to be fixed one
331
+ * round-trip at a time.
332
+ */
333
+ mockLookups({
334
+ incidentSeverities: [
335
+ incidentSeverity(FOREIGN_SEVERITY_ID, OTHER_PROJECT_ID),
336
+ ],
337
+ });
338
+
339
+ let message: string = "";
340
+
341
+ try {
342
+ await ProjectScopedReferenceValidator.validateReferencesBelongToProject({
343
+ projectId: PROJECT_ID,
344
+ subject: "incident",
345
+ references: [
346
+ {
347
+ modelName: "Incident Severity",
348
+ id: FOREIGN_SEVERITY_ID,
349
+ service: IncidentSeverityService,
350
+ },
351
+ {
352
+ modelName: "Incident Severity",
353
+ id: UNKNOWN_ID,
354
+ service: IncidentSeverityService,
355
+ },
356
+ ],
357
+ });
358
+ } catch (error) {
359
+ message = (error as BadDataException).message;
360
+ }
361
+
362
+ expect(message).toContain(
363
+ 'belong to a different project: Incident Severity "Critical"',
364
+ );
365
+ expect(message).toContain(
366
+ `do not exist: Incident Severity "${UNKNOWN_ID}"`,
367
+ );
368
+ });
369
+
370
+ it("does not require a record to exist when its project is unknown", async () => {
371
+ /*
372
+ * Same reason the cross-project half is skipped: root and internal writes
373
+ * do not always carry a project, and the guard must stay out of the way
374
+ * rather than guess.
375
+ */
376
+ const { severityFindBy } = mockLookups({ incidentSeverities: [] });
377
+
378
+ await expect(
379
+ ProjectScopedReferenceValidator.validateReferencesBelongToProject({
380
+ projectId: undefined,
186
381
  references: [
187
382
  {
188
383
  modelName: "Incident Severity",
@@ -192,6 +387,52 @@ describe("ProjectScopedReferenceValidator", () => {
192
387
  ],
193
388
  }),
194
389
  ).resolves.toBeUndefined();
390
+
391
+ expect(severityFindBy).not.toHaveBeenCalled();
392
+ });
393
+
394
+ it("checks existence against a model that has no project of its own", async () => {
395
+ /*
396
+ * Monitor criteria can name owner *users*, and User has no tenant column.
397
+ * The cross-project comparison has to be skipped for those — comparing an
398
+ * absent projectId against the write's project would report every user as
399
+ * belonging to a different project — while the existence check still runs.
400
+ */
401
+ const user: User = new User();
402
+ user._id = OWN_USER_ID;
403
+ user.name = new Name("Alice");
404
+
405
+ jest.spyOn(UserService, "findBy").mockResolvedValue([user] as never);
406
+
407
+ await expect(
408
+ ProjectScopedReferenceValidator.validateReferencesBelongToProject({
409
+ projectId: PROJECT_ID,
410
+ references: [
411
+ {
412
+ modelName: "User",
413
+ id: OWN_USER_ID,
414
+ service:
415
+ UserService as unknown as DatabaseService<DatabaseBaseModel>,
416
+ },
417
+ ],
418
+ }),
419
+ ).resolves.toBeUndefined();
420
+
421
+ jest.spyOn(UserService, "findBy").mockResolvedValue([] as never);
422
+
423
+ await expect(
424
+ ProjectScopedReferenceValidator.validateReferencesBelongToProject({
425
+ projectId: PROJECT_ID,
426
+ references: [
427
+ {
428
+ modelName: "User",
429
+ id: UNKNOWN_ID,
430
+ service:
431
+ UserService as unknown as DatabaseService<DatabaseBaseModel>,
432
+ },
433
+ ],
434
+ }),
435
+ ).rejects.toThrow(`User "${UNKNOWN_ID}"`);
195
436
  });
196
437
 
197
438
  it("reports every foreign reference in a single message", async () => {
@@ -272,9 +513,15 @@ describe("ProjectScopedReferenceValidator", () => {
272
513
  it("looks each model up once, with every id it was asked about", async () => {
273
514
  /*
274
515
  * These run on the incident create path, so three references must not mean
275
- * three round trips per model.
516
+ * three round trips per model. Both ids are returned so the existence check
517
+ * has nothing to complain about and the call reaches its end.
276
518
  */
277
- const { severityFindBy } = mockLookups({ incidentSeverities: [] });
519
+ const { severityFindBy } = mockLookups({
520
+ incidentSeverities: [
521
+ incidentSeverity(OWN_SEVERITY_ID, PROJECT_ID),
522
+ incidentSeverity(FOREIGN_SEVERITY_ID, PROJECT_ID),
523
+ ],
524
+ });
278
525
 
279
526
  await ProjectScopedReferenceValidator.validateReferencesBelongToProject({
280
527
  projectId: PROJECT_ID,
@@ -0,0 +1,302 @@
1
+ import GlobalCache from "../../../Server/Infrastructure/GlobalCache";
2
+ import TelemetryEntityService from "../../../Server/Services/TelemetryEntityService";
3
+ import { reconcileByNaturalKey } from "../../../Server/Utils/Telemetry/EntityRegistry";
4
+ import TelemetryEntity from "../../../Models/DatabaseModels/TelemetryEntity";
5
+ import ObjectID from "../../../Types/ObjectID";
6
+ import {
7
+ afterEach,
8
+ beforeEach,
9
+ describe,
10
+ expect,
11
+ jest,
12
+ test,
13
+ } from "@jest/globals";
14
+
15
+ /*
16
+ * The entity registry had the same defect as the Service heartbeat, one layer
17
+ * down and harder to see.
18
+ *
19
+ * Its fence keys on (project + the whole set of promoted entity keys), which
20
+ * in practice is unique per POD — a pod's own key is in the set. But the
21
+ * writes it gates are per ROW: the single TelemetryEntity row for a Kubernetes
22
+ * cluster receives one UPDATE per pod in that cluster per window, the
23
+ * namespace row one per pod in the namespace. When throttle granularity is
24
+ * finer than write granularity, the throttle does not bound the writes at all,
25
+ * and the mismatch factor is the pod count.
26
+ *
27
+ * So there are now two fences: the cheap set-level one, and a per-row one
28
+ * claimed atomically before the lookup — which also removes the redundant
29
+ * SELECT, not just the redundant UPDATE. The bump itself is the SKIP LOCKED
30
+ * write, so shared rows (a cluster, a namespace) cannot convoy the way Service
31
+ * did.
32
+ *
33
+ * Everything external is mocked — no Postgres, no Redis.
34
+ */
35
+
36
+ const PROJECT_ID: ObjectID = ObjectID.generate();
37
+ const ROW_ID: ObjectID = ObjectID.generate();
38
+
39
+ type BumpCall = { id: ObjectID; data: Record<string, unknown> };
40
+
41
+ let cache: Map<string, string>;
42
+ let deletedKeys: Array<string>;
43
+ let bumps: Array<BumpCall>;
44
+ let reads: number;
45
+ let existing: TelemetryEntity | null;
46
+ let bumpLands: boolean;
47
+
48
+ function existingRow(): TelemetryEntity {
49
+ const row: TelemetryEntity = new TelemetryEntity();
50
+ row.id = ROW_ID;
51
+ row._id = ROW_ID.toString();
52
+ return row;
53
+ }
54
+
55
+ function reconcile(rowFenceId: string): Promise<void> {
56
+ return reconcileByNaturalKey<TelemetryEntity>({
57
+ service: TelemetryEntityService,
58
+ query: { projectId: PROJECT_ID, entityKey: "k8s-cluster/prod" },
59
+ lastSeenAt: new Date(),
60
+ describe: "entity k8s-cluster/prod",
61
+ rowFenceId: rowFenceId,
62
+ buildModel: (): TelemetryEntity => {
63
+ return existingRow();
64
+ },
65
+ });
66
+ }
67
+
68
+ beforeEach(() => {
69
+ cache = new Map<string, string>();
70
+ deletedKeys = [];
71
+ bumps = [];
72
+ reads = 0;
73
+ existing = existingRow();
74
+ bumpLands = true;
75
+
76
+ jest
77
+ .spyOn(GlobalCache, "setStringIfNotExists")
78
+ .mockImplementation(async (namespace: string, key: string) => {
79
+ const full: string = `${namespace}:${key}`;
80
+ if (cache.has(full)) {
81
+ return false;
82
+ }
83
+ cache.set(full, "1");
84
+ return true;
85
+ });
86
+
87
+ jest
88
+ .spyOn(GlobalCache, "deleteKey")
89
+ .mockImplementation(async (namespace: string, key: string) => {
90
+ const full: string = `${namespace}:${key}`;
91
+ deletedKeys.push(full);
92
+ cache.delete(full);
93
+ });
94
+
95
+ jest
96
+ .spyOn(TelemetryEntityService, "findOneBy")
97
+ .mockImplementation(async () => {
98
+ reads++;
99
+ return existing;
100
+ });
101
+
102
+ jest
103
+ .spyOn(TelemetryEntityService, "updateColumnsByIdIfUnlockedWithoutHooks")
104
+ .mockImplementation(async (input: { id: ObjectID; data: unknown }) => {
105
+ bumps.push({
106
+ id: input.id,
107
+ data: { ...(input.data as Record<string, unknown>) },
108
+ });
109
+ return bumpLands;
110
+ });
111
+
112
+ jest
113
+ .spyOn(TelemetryEntityService, "create")
114
+ .mockImplementation(async (input: { data: TelemetryEntity }) => {
115
+ return input.data;
116
+ });
117
+ });
118
+
119
+ afterEach(() => {
120
+ jest.restoreAllMocks();
121
+ });
122
+
123
+ describe("reconcileByNaturalKey — the per-row fence", () => {
124
+ test("bumps the row on first contact", async () => {
125
+ await reconcile("project:k8s-cluster:prod");
126
+
127
+ expect(bumps).toHaveLength(1);
128
+ expect(bumps[0]!.id.toString()).toBe(ROW_ID.toString());
129
+ expect(bumps[0]!.data["lastSeenAt"]).toBeInstanceOf(Date);
130
+ });
131
+
132
+ /*
133
+ * The defect, reproduced: many pods, one shared row. Before the per-row
134
+ * fence, each pod's distinct set-level fence let it straight through to the
135
+ * same cluster row.
136
+ */
137
+ test("many callers for the same row produce one bump per window", async () => {
138
+ for (let pod: number = 0; pod < 50; pod++) {
139
+ await reconcile("project:k8s-cluster:prod");
140
+ }
141
+
142
+ expect(bumps).toHaveLength(1);
143
+ });
144
+
145
+ /*
146
+ * Claimed BEFORE the lookup. The redundant SELECTs were as much of the pool
147
+ * pressure as the redundant UPDATEs.
148
+ */
149
+ test("a fenced caller does not even read the row", async () => {
150
+ await reconcile("project:k8s-cluster:prod");
151
+ const readsAfterFirst: number = reads;
152
+
153
+ await reconcile("project:k8s-cluster:prod");
154
+
155
+ expect(reads).toBe(readsAfterFirst);
156
+ });
157
+
158
+ test("different rows are fenced independently", async () => {
159
+ await reconcile("project:k8s-cluster:prod");
160
+ await reconcile("project:k8s-namespace:default");
161
+
162
+ expect(bumps).toHaveLength(2);
163
+ });
164
+
165
+ test("claims the fence atomically, never read-then-write", async () => {
166
+ jest.spyOn(GlobalCache, "getString");
167
+ jest.spyOn(GlobalCache, "setString");
168
+
169
+ await reconcile("project:k8s-cluster:prod");
170
+
171
+ expect(GlobalCache.setStringIfNotExists).toHaveBeenCalled();
172
+ expect(GlobalCache.getString).not.toHaveBeenCalled();
173
+ expect(GlobalCache.setString).not.toHaveBeenCalled();
174
+ });
175
+
176
+ /*
177
+ * The fence id concatenates entity keys and is unbounded in length. Hashing
178
+ * keeps the Redis key fixed-width — a pod reporting hundreds of entities
179
+ * would otherwise produce a multi-kilobyte key.
180
+ */
181
+ test("hashes the fence id to a fixed-width key", async () => {
182
+ const longId: string = `project:${"k".repeat(5000)}`;
183
+
184
+ await reconcile(longId);
185
+
186
+ const key: string = (GlobalCache.setStringIfNotExists as jest.Mock).mock
187
+ .calls[0]![1] as string;
188
+
189
+ expect(key.length).toBeLessThan(100);
190
+ expect(key).not.toContain("kkkk");
191
+ });
192
+
193
+ test("a cache outage fails open so liveness still advances", async () => {
194
+ jest
195
+ .spyOn(GlobalCache, "setStringIfNotExists")
196
+ .mockRejectedValue(new Error("redis down"));
197
+
198
+ await reconcile("project:k8s-cluster:prod");
199
+
200
+ expect(bumps).toHaveLength(1);
201
+ });
202
+ });
203
+
204
+ describe("reconcileByNaturalKey — contention and failure re-open the window", () => {
205
+ test("uses the non-blocking write primitive", async () => {
206
+ await reconcile("project:k8s-cluster:prod");
207
+
208
+ expect(
209
+ TelemetryEntityService.updateColumnsByIdIfUnlockedWithoutHooks,
210
+ ).toHaveBeenCalled();
211
+ });
212
+
213
+ /*
214
+ * A skipped bump means the row was locked, so nothing was written — but the
215
+ * fence is already claimed and would suppress the next attempt for the whole
216
+ * window. Release it so the next batch retries; each attempt is
217
+ * non-blocking, so this cannot become a retry storm.
218
+ */
219
+ test("a skipped bump releases the row fence", async () => {
220
+ bumpLands = false;
221
+
222
+ await reconcile("project:k8s-cluster:prod");
223
+
224
+ expect(deletedKeys).toHaveLength(1);
225
+ });
226
+
227
+ test("the released fence actually lets the next attempt through", async () => {
228
+ bumpLands = false;
229
+ await reconcile("project:k8s-cluster:prod");
230
+
231
+ bumpLands = true;
232
+ await reconcile("project:k8s-cluster:prod");
233
+
234
+ expect(bumps).toHaveLength(2);
235
+ });
236
+
237
+ /*
238
+ * An over-budget skip writes nothing, so holding the window would suppress
239
+ * the gate's own re-evaluation for a decision that had no effect.
240
+ */
241
+ test("an over-budget create releases the row fence", async () => {
242
+ existing = null;
243
+
244
+ await reconcileByNaturalKey<TelemetryEntity>({
245
+ service: TelemetryEntityService,
246
+ query: { projectId: PROJECT_ID, entityKey: "k8s-cluster/prod" },
247
+ lastSeenAt: new Date(),
248
+ describe: "entity k8s-cluster/prod",
249
+ rowFenceId: "project:k8s-cluster:prod",
250
+ buildModel: (): TelemetryEntity => {
251
+ return existingRow();
252
+ },
253
+ beforeCreate: async (): Promise<boolean> => {
254
+ return false;
255
+ },
256
+ });
257
+
258
+ expect(deletedKeys).toHaveLength(1);
259
+ });
260
+
261
+ test("an invalid create releases the row fence so the warning keeps surfacing", async () => {
262
+ existing = null;
263
+
264
+ jest
265
+ .spyOn(TelemetryEntityService, "create")
266
+ .mockRejectedValue(new Error("null value in column violates not-null"));
267
+
268
+ await reconcile("project:k8s-cluster:prod");
269
+
270
+ expect(deletedKeys).toHaveLength(1);
271
+ });
272
+
273
+ test("a lost create race bumps the winner instead of failing", async () => {
274
+ existing = null;
275
+
276
+ jest
277
+ .spyOn(TelemetryEntityService, "create")
278
+ .mockRejectedValue(new Error("duplicate key value violates unique"));
279
+
280
+ jest
281
+ .spyOn(TelemetryEntityService, "findOneBy")
282
+ .mockImplementationOnce(async () => {
283
+ reads++;
284
+ return null;
285
+ })
286
+ .mockImplementation(async () => {
287
+ reads++;
288
+ return existingRow();
289
+ });
290
+
291
+ await reconcile("project:k8s-cluster:prod");
292
+
293
+ expect(bumps).toHaveLength(1);
294
+ expect(deletedKeys).toHaveLength(0);
295
+ });
296
+
297
+ test("a successful bump leaves the fence claimed", async () => {
298
+ await reconcile("project:k8s-cluster:prod");
299
+
300
+ expect(deletedKeys).toHaveLength(0);
301
+ });
302
+ });