@oneuptime/common 12.0.0 → 12.0.2

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 (237) hide show
  1. package/Models/DatabaseModels/Host.ts +12 -4
  2. package/Models/DatabaseModels/KubernetesContainer.ts +5 -4
  3. package/Models/DatabaseModels/KubernetesResource.ts +8 -7
  4. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +12 -2
  5. package/Models/DatabaseModels/PodmanResource.ts +6 -6
  6. package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
  7. package/Models/DatabaseModels/StatusPage.ts +95 -0
  8. package/Server/API/TeamMemberAPI.ts +85 -0
  9. package/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.ts +12 -2
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  15. package/Server/Middleware/ProjectAuthorization.ts +66 -4
  16. package/Server/Services/BillingService.ts +600 -51
  17. package/Server/Services/CephClusterService.ts +47 -5
  18. package/Server/Services/CephResourceService.ts +43 -4
  19. package/Server/Services/CloudResourceService.ts +47 -5
  20. package/Server/Services/DatabaseService.ts +87 -6
  21. package/Server/Services/DockerHostService.ts +47 -5
  22. package/Server/Services/DockerResourceService.ts +62 -8
  23. package/Server/Services/DockerSwarmClusterService.ts +48 -5
  24. package/Server/Services/DockerSwarmResourceService.ts +53 -4
  25. package/Server/Services/HostService.ts +48 -5
  26. package/Server/Services/IoTDeviceService.ts +1 -17
  27. package/Server/Services/IoTFleetService.ts +47 -5
  28. package/Server/Services/KubernetesClusterService.ts +48 -5
  29. package/Server/Services/KubernetesContainerService.ts +50 -9
  30. package/Server/Services/KubernetesResourceService.ts +53 -7
  31. package/Server/Services/LabelService.ts +106 -1
  32. package/Server/Services/LlmProviderService.ts +69 -9
  33. package/Server/Services/MonitorService.ts +57 -0
  34. package/Server/Services/PodmanHostService.ts +47 -5
  35. package/Server/Services/PodmanResourceService.ts +62 -8
  36. package/Server/Services/ProjectService.ts +165 -35
  37. package/Server/Services/ProxmoxClusterService.ts +47 -5
  38. package/Server/Services/ProxmoxResourceService.ts +43 -4
  39. package/Server/Services/RumApplicationService.ts +48 -5
  40. package/Server/Services/RunnerService.ts +1 -7
  41. package/Server/Services/ServerlessFunctionService.ts +48 -5
  42. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
  43. package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
  44. package/Server/Services/StatusPageService.ts +95 -36
  45. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
  46. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  47. package/Server/Types/Database/JSONColumnQuery.ts +621 -0
  48. package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
  49. package/Server/Types/Database/QueryHelper.ts +17 -32
  50. package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
  51. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +12 -0
  52. package/Tests/App/Dashboard/RunnerInstallInstructions.test.tsx +119 -0
  53. package/Tests/App/Dashboard/RunnerStatus.test.tsx +274 -0
  54. package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
  55. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
  56. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
  57. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
  58. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
  59. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
  60. package/Tests/Server/Services/BillingService.test.ts +78 -3
  61. package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
  62. package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
  63. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
  64. package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
  65. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
  66. package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
  67. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
  68. package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
  69. package/Tests/Server/Services/LlmProviderUsableByProject.test.ts +293 -0
  70. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
  71. package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
  72. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
  73. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
  74. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
  75. package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
  76. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
  77. package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
  78. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
  79. package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
  80. package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
  81. package/Tests/Server/Utils/AI/ToolArgsExtractors.test.ts +141 -0
  82. package/Tests/Server/Utils/AI/Toolbox/WidgetBuilder.test.ts +205 -0
  83. package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
  84. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +897 -0
  85. package/Tests/Types/Events/Recurring.test.ts +157 -0
  86. package/Tests/Types/Runner/RunnerLiveStatus.test.ts +320 -0
  87. package/Tests/UI/Components/AiInvestigationSettingsCard.test.tsx +285 -0
  88. package/Tests/UI/Components/Detail/EntityFields.test.tsx +166 -0
  89. package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
  90. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
  91. package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
  92. package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
  93. package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
  94. package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
  95. package/Types/CustomField/CustomFieldDefinition.ts +24 -0
  96. package/Types/Date.ts +112 -0
  97. package/Types/Events/Recurring.ts +99 -1
  98. package/Types/Runbook/RunbookStep.ts +15 -0
  99. package/Types/Runner/RunnerLiveStatus.ts +114 -0
  100. package/Types/StatusPage/StatusPageReport.ts +11 -0
  101. package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
  102. package/UI/Components/Detail/Detail.tsx +155 -0
  103. package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
  104. package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
  105. package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
  106. package/UI/Utils/TeamMembersByUser.ts +302 -0
  107. package/Utils/StatusPage/ReportPeriod.ts +236 -0
  108. package/Utils/Telemetry/HostIpAddresses.ts +98 -0
  109. package/build/dist/Models/DatabaseModels/Host.js +12 -4
  110. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  111. package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
  112. package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
  114. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +16 -4
  116. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
  118. package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
  120. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
  122. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  123. package/build/dist/Server/API/TeamMemberAPI.js +57 -2
  124. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  125. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js +11 -1
  126. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js.map +1 -1
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  136. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  137. package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
  138. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  139. package/build/dist/Server/Services/BillingService.js +466 -36
  140. package/build/dist/Server/Services/BillingService.js.map +1 -1
  141. package/build/dist/Server/Services/CephClusterService.js +42 -5
  142. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  143. package/build/dist/Server/Services/CephResourceService.js +28 -3
  144. package/build/dist/Server/Services/CephResourceService.js.map +1 -1
  145. package/build/dist/Server/Services/CloudResourceService.js +42 -5
  146. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  147. package/build/dist/Server/Services/DatabaseService.js +69 -2
  148. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  149. package/build/dist/Server/Services/DockerHostService.js +42 -5
  150. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  151. package/build/dist/Server/Services/DockerResourceService.js +34 -4
  152. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  153. package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
  154. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  155. package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
  156. package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
  157. package/build/dist/Server/Services/HostService.js +43 -5
  158. package/build/dist/Server/Services/HostService.js.map +1 -1
  159. package/build/dist/Server/Services/IoTDeviceService.js +1 -8
  160. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  161. package/build/dist/Server/Services/IoTFleetService.js +42 -5
  162. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  163. package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
  164. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  165. package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
  166. package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
  167. package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
  168. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  169. package/build/dist/Server/Services/LabelService.js +92 -0
  170. package/build/dist/Server/Services/LabelService.js.map +1 -1
  171. package/build/dist/Server/Services/LlmProviderService.js +61 -8
  172. package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
  173. package/build/dist/Server/Services/MonitorService.js +47 -0
  174. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  175. package/build/dist/Server/Services/PodmanHostService.js +42 -5
  176. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  177. package/build/dist/Server/Services/PodmanResourceService.js +34 -4
  178. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  179. package/build/dist/Server/Services/ProjectService.js +135 -21
  180. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  181. package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
  182. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  183. package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
  184. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  185. package/build/dist/Server/Services/RumApplicationService.js +43 -5
  186. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  187. package/build/dist/Server/Services/RunnerService.js +1 -6
  188. package/build/dist/Server/Services/RunnerService.js.map +1 -1
  189. package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
  190. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  191. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
  192. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
  193. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
  194. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  195. package/build/dist/Server/Services/StatusPageService.js +69 -46
  196. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  197. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
  198. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  199. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  200. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  201. package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
  202. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
  203. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
  204. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
  205. package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
  206. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  207. package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
  208. package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
  209. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +11 -0
  210. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  211. package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
  212. package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
  213. package/build/dist/Types/Date.js +68 -0
  214. package/build/dist/Types/Date.js.map +1 -1
  215. package/build/dist/Types/Events/Recurring.js +63 -0
  216. package/build/dist/Types/Events/Recurring.js.map +1 -1
  217. package/build/dist/Types/Runbook/RunbookStep.js.map +1 -1
  218. package/build/dist/Types/Runner/RunnerLiveStatus.js +69 -0
  219. package/build/dist/Types/Runner/RunnerLiveStatus.js.map +1 -0
  220. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
  221. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
  222. package/build/dist/UI/Components/Detail/Detail.js +97 -0
  223. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  224. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  225. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
  226. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  227. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
  228. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
  229. package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
  230. package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
  231. package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
  232. package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
  233. package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
  234. package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
  235. package/jest.config.json +2 -0
  236. package/package.json +1 -1
  237. package/tsconfig.json +12 -1
@@ -0,0 +1,1242 @@
1
+ import CephResourceService, {
2
+ ParsedCephResource,
3
+ } from "../../../Server/Services/CephResourceService";
4
+ import DockerResourceService, {
5
+ ParsedDockerContainer,
6
+ ParsedDockerResource,
7
+ } from "../../../Server/Services/DockerResourceService";
8
+ import DockerSwarmResourceService, {
9
+ ParsedDockerSwarmResource,
10
+ } from "../../../Server/Services/DockerSwarmResourceService";
11
+ import KubernetesContainerService, {
12
+ ContainerLatestMetric,
13
+ ParsedKubernetesContainer,
14
+ } from "../../../Server/Services/KubernetesContainerService";
15
+ import KubernetesResourceService, {
16
+ ParsedKubernetesResource,
17
+ ResourceLatestMetric,
18
+ } from "../../../Server/Services/KubernetesResourceService";
19
+ import PodmanResourceService, {
20
+ ParsedPodmanContainer,
21
+ ParsedPodmanResource,
22
+ } from "../../../Server/Services/PodmanResourceService";
23
+ import ProxmoxResourceService, {
24
+ ParsedProxmoxResource,
25
+ } from "../../../Server/Services/ProxmoxResourceService";
26
+ import logger from "../../../Server/Utils/Logger";
27
+ import ColumnLength from "../../../Types/Database/ColumnLength";
28
+ import ObjectID from "../../../Types/ObjectID";
29
+ import { describe, expect, test } from "@jest/globals";
30
+
31
+ /*
32
+ * Follow-up to issue #3006. The inventory services hand-build
33
+ * `INSERT ... ON CONFLICT` strings and flush them through
34
+ * `getRepository().manager.query()` in chunks of 500 rows, which
35
+ * bypasses the length validation DatabaseService applies to ordinary
36
+ * CRUD writes. A single value past its column limit raises 22001
37
+ * ("value too long for type character varying(N)") and aborts the WHOLE
38
+ * multi-row INSERT — one malformed resource silently drops up to 499
39
+ * healthy inventory rows with it.
40
+ *
41
+ * Widening the columns (see InventoryResourceNameColumnWidth.test.ts)
42
+ * removes the overflow that actually happens in the field; these clamps
43
+ * are the backstop that keeps ANY future overflow from taking the batch
44
+ * down with it. Every test here asserts the same two things: the
45
+ * oversized value is clipped to its column bound, and the healthy row
46
+ * riding in the same chunk still makes it into the statement.
47
+ */
48
+
49
+ const PROJECT_ID: ObjectID = ObjectID.generate();
50
+ const PARENT_ID: ObjectID = ObjectID.generate();
51
+ const OBSERVED_AT: Date = new Date("2026-08-05T00:00:00.000Z");
52
+
53
+ const OVERSIZED: string = "x".repeat(900);
54
+ const CLAMPED_TO_SHORT_TEXT: string = OVERSIZED.substring(
55
+ 0,
56
+ ColumnLength.ShortText,
57
+ );
58
+ const CLAMPED_TO_LONG_TEXT: string = OVERSIZED.substring(
59
+ 0,
60
+ ColumnLength.LongText,
61
+ );
62
+
63
+ type QueryCall = [string, Array<unknown>];
64
+
65
+ interface RepositoryHolder {
66
+ getRepository: () => unknown;
67
+ }
68
+
69
+ function mockQueryRunner(service: RepositoryHolder): jest.Mock {
70
+ const query: jest.Mock = jest.fn().mockResolvedValue([]);
71
+ jest
72
+ .spyOn(service, "getRepository")
73
+ .mockReturnValue({ manager: { query } } as any);
74
+ return query;
75
+ }
76
+
77
+ function paramsOf(query: jest.Mock, callIndex: number = 0): Array<unknown> {
78
+ const [, params] = query.mock.calls[callIndex] as QueryCall;
79
+ return params;
80
+ }
81
+
82
+ /**
83
+ * Every string parameter in the statement fits inside the widest
84
+ * bounded varchar this codebase uses, so nothing in the tuple can
85
+ * trigger 22001.
86
+ */
87
+ function expectNoParamExceedsLongText(params: Array<unknown>): void {
88
+ for (const value of params) {
89
+ if (typeof value === "string") {
90
+ expect(value.length).toBeLessThanOrEqual(ColumnLength.LongText);
91
+ }
92
+ }
93
+ }
94
+
95
+ afterEach(() => {
96
+ jest.restoreAllMocks();
97
+ });
98
+
99
+ describe("KubernetesResourceService — oversized values can't drop the chunk", () => {
100
+ function resource(
101
+ overrides: Partial<ParsedKubernetesResource> = {},
102
+ ): ParsedKubernetesResource {
103
+ return {
104
+ kind: "Pod",
105
+ namespaceKey: "default",
106
+ name: "api-7d9f8c6b5d-x2k9p",
107
+ uid: "9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8",
108
+ phase: "Running",
109
+ isReady: true,
110
+ hasMemoryPressure: null,
111
+ hasDiskPressure: null,
112
+ hasPidPressure: null,
113
+ labels: null,
114
+ annotations: null,
115
+ ownerReferences: null,
116
+ spec: null,
117
+ containerCount: 1,
118
+ status: null,
119
+ lastSeenAt: OBSERVED_AT,
120
+ resourceCreationTimestamp: null,
121
+ ...overrides,
122
+ };
123
+ }
124
+
125
+ test("bulkUpsert clamps an oversized name and keeps its sibling row", async () => {
126
+ const query: jest.Mock = mockQueryRunner(KubernetesResourceService);
127
+
128
+ await KubernetesResourceService.bulkUpsert({
129
+ projectId: PROJECT_ID,
130
+ kubernetesClusterId: PARENT_ID,
131
+ resources: [resource({ name: OVERSIZED }), resource()],
132
+ });
133
+
134
+ expect(query).toHaveBeenCalledTimes(1);
135
+ const params: Array<unknown> = paramsOf(query);
136
+
137
+ expect(params).toContain(CLAMPED_TO_LONG_TEXT);
138
+ expect(params).not.toContain(OVERSIZED);
139
+ expect(params).toContain("api-7d9f8c6b5d-x2k9p");
140
+ expectNoParamExceedsLongText(params);
141
+ });
142
+
143
+ test("bulkUpsert clamps the ShortText identity columns at 100", async () => {
144
+ const query: jest.Mock = mockQueryRunner(KubernetesResourceService);
145
+
146
+ await KubernetesResourceService.bulkUpsert({
147
+ projectId: PROJECT_ID,
148
+ kubernetesClusterId: PARENT_ID,
149
+ resources: [
150
+ resource({
151
+ kind: OVERSIZED,
152
+ namespaceKey: OVERSIZED,
153
+ uid: OVERSIZED,
154
+ phase: OVERSIZED,
155
+ }),
156
+ ],
157
+ });
158
+
159
+ const params: Array<unknown> = paramsOf(query);
160
+ const clamped: Array<unknown> = params.filter((value: unknown) => {
161
+ return value === CLAMPED_TO_SHORT_TEXT;
162
+ });
163
+
164
+ expect(clamped).toHaveLength(4);
165
+ expect(params).not.toContain(OVERSIZED);
166
+ });
167
+
168
+ test("bulkUpdateLatestMetrics clamps controller names, which are assigned columns", async () => {
169
+ const query: jest.Mock = mockQueryRunner(KubernetesResourceService);
170
+
171
+ const metric: ResourceLatestMetric = {
172
+ kind: "Pod",
173
+ namespaceKey: "default",
174
+ name: "api-7d9f8c6b5d-x2k9p",
175
+ cpuPercent: 12,
176
+ memoryBytes: 1024,
177
+ memoryPercent: 4,
178
+ observedAt: OBSERVED_AT,
179
+ controllerDeploymentName: OVERSIZED,
180
+ controllerCronJobName: OVERSIZED,
181
+ };
182
+
183
+ await KubernetesResourceService.bulkUpdateLatestMetrics({
184
+ projectId: PROJECT_ID,
185
+ kubernetesClusterId: PARENT_ID,
186
+ metrics: [metric],
187
+ });
188
+
189
+ const params: Array<unknown> = paramsOf(query);
190
+
191
+ // controllerDeploymentName is LongText, controllerCronJobName is ShortText.
192
+ expect(params).toContain(CLAMPED_TO_LONG_TEXT);
193
+ expect(params).toContain(CLAMPED_TO_SHORT_TEXT);
194
+ expect(params).not.toContain(OVERSIZED);
195
+ expectNoParamExceedsLongText(params);
196
+ });
197
+
198
+ test("bulkUpdateLatestMetrics clamps identity the same way bulkUpsert does", async () => {
199
+ /*
200
+ * If the two paths clamped differently, the mirror UPDATE would
201
+ * silently miss the row the upsert just wrote under the clipped name.
202
+ */
203
+ const upsertQuery: jest.Mock = mockQueryRunner(KubernetesResourceService);
204
+ await KubernetesResourceService.bulkUpsert({
205
+ projectId: PROJECT_ID,
206
+ kubernetesClusterId: PARENT_ID,
207
+ resources: [resource({ name: OVERSIZED })],
208
+ });
209
+ const upsertParams: Array<unknown> = paramsOf(upsertQuery);
210
+
211
+ jest.restoreAllMocks();
212
+
213
+ const metricQuery: jest.Mock = mockQueryRunner(KubernetesResourceService);
214
+ await KubernetesResourceService.bulkUpdateLatestMetrics({
215
+ projectId: PROJECT_ID,
216
+ kubernetesClusterId: PARENT_ID,
217
+ metrics: [
218
+ {
219
+ kind: "Pod",
220
+ namespaceKey: "default",
221
+ name: OVERSIZED,
222
+ cpuPercent: null,
223
+ memoryBytes: null,
224
+ memoryPercent: null,
225
+ observedAt: OBSERVED_AT,
226
+ },
227
+ ],
228
+ });
229
+ const metricParams: Array<unknown> = paramsOf(metricQuery);
230
+
231
+ /*
232
+ * name is written at index 4 of the INSERT tuple, index 4 of the
233
+ * metric tuple (which is offset by the two leading scope params).
234
+ */
235
+ expect(upsertParams[4]).toBe(CLAMPED_TO_LONG_TEXT);
236
+ expect(metricParams[4]).toBe(CLAMPED_TO_LONG_TEXT);
237
+ });
238
+ });
239
+
240
+ describe("KubernetesContainerService — oversized values can't drop the chunk", () => {
241
+ function container(
242
+ overrides: Partial<ParsedKubernetesContainer> = {},
243
+ ): ParsedKubernetesContainer {
244
+ return {
245
+ podNamespaceKey: "default",
246
+ podName: "api-7d9f8c6b5d-x2k9p",
247
+ name: "app",
248
+ image: "ghcr.io/acme/api:1.2.3",
249
+ state: "running",
250
+ reason: null,
251
+ isReady: true,
252
+ restartCount: 0,
253
+ memoryLimitBytes: 1024,
254
+ lastSeenAt: OBSERVED_AT,
255
+ ...overrides,
256
+ };
257
+ }
258
+
259
+ test("bulkUpsert clamps an oversized podName and keeps its sibling row", async () => {
260
+ const query: jest.Mock = mockQueryRunner(KubernetesContainerService);
261
+
262
+ await KubernetesContainerService.bulkUpsert({
263
+ projectId: PROJECT_ID,
264
+ kubernetesClusterId: PARENT_ID,
265
+ containers: [container({ podName: OVERSIZED }), container()],
266
+ });
267
+
268
+ expect(query).toHaveBeenCalledTimes(1);
269
+ const params: Array<unknown> = paramsOf(query);
270
+
271
+ expect(params).toContain(CLAMPED_TO_LONG_TEXT);
272
+ expect(params).not.toContain(OVERSIZED);
273
+ expect(params).toContain("api-7d9f8c6b5d-x2k9p");
274
+ expectNoParamExceedsLongText(params);
275
+ });
276
+
277
+ test("bulkUpsert clamps image at 500 and the ShortText columns at 100", async () => {
278
+ const query: jest.Mock = mockQueryRunner(KubernetesContainerService);
279
+
280
+ await KubernetesContainerService.bulkUpsert({
281
+ projectId: PROJECT_ID,
282
+ kubernetesClusterId: PARENT_ID,
283
+ containers: [
284
+ container({
285
+ image: OVERSIZED,
286
+ name: OVERSIZED,
287
+ state: OVERSIZED,
288
+ reason: OVERSIZED,
289
+ }),
290
+ ],
291
+ });
292
+
293
+ const params: Array<unknown> = paramsOf(query);
294
+
295
+ expect(params).toContain(CLAMPED_TO_LONG_TEXT);
296
+ expect(
297
+ params.filter((value: unknown) => {
298
+ return value === CLAMPED_TO_SHORT_TEXT;
299
+ }),
300
+ ).toHaveLength(3);
301
+ expectNoParamExceedsLongText(params);
302
+ });
303
+
304
+ test("bulkUpdateLatestMetrics clamps identity the same way bulkUpsert does", async () => {
305
+ const query: jest.Mock = mockQueryRunner(KubernetesContainerService);
306
+
307
+ const metric: ContainerLatestMetric = {
308
+ podNamespaceKey: "default",
309
+ podName: OVERSIZED,
310
+ name: "app",
311
+ cpuPercent: 1,
312
+ memoryBytes: 2,
313
+ observedAt: OBSERVED_AT,
314
+ };
315
+
316
+ await KubernetesContainerService.bulkUpdateLatestMetrics({
317
+ projectId: PROJECT_ID,
318
+ kubernetesClusterId: PARENT_ID,
319
+ metrics: [metric],
320
+ });
321
+
322
+ const params: Array<unknown> = paramsOf(query);
323
+ expect(params).toContain(CLAMPED_TO_LONG_TEXT);
324
+ expect(params).not.toContain(OVERSIZED);
325
+ });
326
+ });
327
+
328
+ describe("DockerResourceService — oversized values can't drop the chunk", () => {
329
+ function dockerContainer(
330
+ overrides: Partial<ParsedDockerContainer> = {},
331
+ ): ParsedDockerContainer {
332
+ return {
333
+ containerName: "api",
334
+ containerId: "0123456789ab",
335
+ imageName: "ghcr.io/acme/api:1.2.3",
336
+ state: "running",
337
+ cpuPercent: 5,
338
+ memoryBytes: 1024,
339
+ observedAt: OBSERVED_AT,
340
+ ...overrides,
341
+ };
342
+ }
343
+
344
+ function dockerResource(
345
+ overrides: Partial<ParsedDockerResource> = {},
346
+ ): ParsedDockerResource {
347
+ return {
348
+ kind: "Image",
349
+ name: "ghcr.io/acme/api:1.2.3",
350
+ containerId: null,
351
+ imageName: null,
352
+ state: null,
353
+ labels: null,
354
+ resourceCreationTimestamp: null,
355
+ lastSeenAt: OBSERVED_AT,
356
+ ...overrides,
357
+ };
358
+ }
359
+
360
+ test("bulkUpsertContainers clamps an oversized image reference", async () => {
361
+ const query: jest.Mock = mockQueryRunner(DockerResourceService);
362
+
363
+ await DockerResourceService.bulkUpsertContainers({
364
+ projectId: PROJECT_ID,
365
+ dockerHostId: PARENT_ID,
366
+ containers: [
367
+ dockerContainer({ imageName: OVERSIZED }),
368
+ dockerContainer(),
369
+ ],
370
+ });
371
+
372
+ expect(query).toHaveBeenCalledTimes(1);
373
+ const params: Array<unknown> = paramsOf(query);
374
+
375
+ expect(params).toContain(CLAMPED_TO_LONG_TEXT);
376
+ expect(params).not.toContain(OVERSIZED);
377
+ expect(params).toContain("ghcr.io/acme/api:1.2.3");
378
+ expectNoParamExceedsLongText(params);
379
+ });
380
+
381
+ test("bulkUpsert clamps an oversized name and keeps its sibling row", async () => {
382
+ const query: jest.Mock = mockQueryRunner(DockerResourceService);
383
+
384
+ await DockerResourceService.bulkUpsert({
385
+ projectId: PROJECT_ID,
386
+ dockerHostId: PARENT_ID,
387
+ resources: [dockerResource({ name: OVERSIZED }), dockerResource()],
388
+ });
389
+
390
+ const params: Array<unknown> = paramsOf(query);
391
+ expect(params).toContain(CLAMPED_TO_LONG_TEXT);
392
+ expect(params).toContain("ghcr.io/acme/api:1.2.3");
393
+ expectNoParamExceedsLongText(params);
394
+ });
395
+ });
396
+
397
+ describe("PodmanResourceService — oversized values can't drop the chunk", () => {
398
+ function podmanContainer(
399
+ overrides: Partial<ParsedPodmanContainer> = {},
400
+ ): ParsedPodmanContainer {
401
+ return {
402
+ containerName: "api",
403
+ containerId: "0123456789ab",
404
+ imageName: "ghcr.io/acme/api:1.2.3",
405
+ state: "running",
406
+ cpuPercent: 5,
407
+ memoryBytes: 1024,
408
+ observedAt: OBSERVED_AT,
409
+ ...overrides,
410
+ };
411
+ }
412
+
413
+ function podmanResource(
414
+ overrides: Partial<ParsedPodmanResource> = {},
415
+ ): ParsedPodmanResource {
416
+ return {
417
+ kind: "Image",
418
+ name: "ghcr.io/acme/api:1.2.3",
419
+ containerId: null,
420
+ imageName: null,
421
+ state: null,
422
+ labels: null,
423
+ resourceCreationTimestamp: null,
424
+ lastSeenAt: OBSERVED_AT,
425
+ ...overrides,
426
+ };
427
+ }
428
+
429
+ test("bulkUpsertContainers clamps an oversized image reference", async () => {
430
+ const query: jest.Mock = mockQueryRunner(PodmanResourceService);
431
+
432
+ await PodmanResourceService.bulkUpsertContainers({
433
+ projectId: PROJECT_ID,
434
+ podmanHostId: PARENT_ID,
435
+ containers: [
436
+ podmanContainer({ imageName: OVERSIZED }),
437
+ podmanContainer(),
438
+ ],
439
+ });
440
+
441
+ expect(query).toHaveBeenCalledTimes(1);
442
+ const params: Array<unknown> = paramsOf(query);
443
+
444
+ expect(params).toContain(CLAMPED_TO_LONG_TEXT);
445
+ expect(params).not.toContain(OVERSIZED);
446
+ expect(params).toContain("ghcr.io/acme/api:1.2.3");
447
+ expectNoParamExceedsLongText(params);
448
+ });
449
+
450
+ test("bulkUpsert clamps an oversized Repository:Tag name from `image ls`", async () => {
451
+ const query: jest.Mock = mockQueryRunner(PodmanResourceService);
452
+
453
+ await PodmanResourceService.bulkUpsert({
454
+ projectId: PROJECT_ID,
455
+ podmanHostId: PARENT_ID,
456
+ resources: [podmanResource({ name: OVERSIZED }), podmanResource()],
457
+ });
458
+
459
+ const params: Array<unknown> = paramsOf(query);
460
+ expect(params).toContain(CLAMPED_TO_LONG_TEXT);
461
+ expect(params).toContain("ghcr.io/acme/api:1.2.3");
462
+ expectNoParamExceedsLongText(params);
463
+ });
464
+
465
+ test("bulkUpsert clamps the ShortText columns at 100, not 500", async () => {
466
+ const query: jest.Mock = mockQueryRunner(PodmanResourceService);
467
+
468
+ await PodmanResourceService.bulkUpsert({
469
+ projectId: PROJECT_ID,
470
+ podmanHostId: PARENT_ID,
471
+ resources: [
472
+ podmanResource({
473
+ kind: OVERSIZED,
474
+ containerId: OVERSIZED,
475
+ state: OVERSIZED,
476
+ }),
477
+ ],
478
+ });
479
+
480
+ const params: Array<unknown> = paramsOf(query);
481
+ expect(
482
+ params.filter((value: unknown) => {
483
+ return value === CLAMPED_TO_SHORT_TEXT;
484
+ }),
485
+ ).toHaveLength(3);
486
+ });
487
+ });
488
+
489
+ describe("DockerSwarmResourceService — oversized values can't drop the chunk", () => {
490
+ function swarmResource(
491
+ overrides: Partial<ParsedDockerSwarmResource> = {},
492
+ ): ParsedDockerSwarmResource {
493
+ return {
494
+ kind: "Task",
495
+ externalId: "task/abc123",
496
+ name: "api.1",
497
+ state: "running",
498
+ role: null,
499
+ serviceMode: null,
500
+ desiredReplicas: null,
501
+ runningReplicas: null,
502
+ image: "ghcr.io/acme/api:1.2.3",
503
+ stackName: "acme",
504
+ serviceName: "api",
505
+ nodeHostname: "swarm-1",
506
+ driver: null,
507
+ isReady: true,
508
+ attributes: null,
509
+ lastSeenAt: OBSERVED_AT,
510
+ ...overrides,
511
+ };
512
+ }
513
+
514
+ test("bulkUpsert clamps an oversized externalId and keeps its sibling row", async () => {
515
+ const query: jest.Mock = mockQueryRunner(DockerSwarmResourceService);
516
+
517
+ await DockerSwarmResourceService.bulkUpsert({
518
+ projectId: PROJECT_ID,
519
+ dockerSwarmClusterId: PARENT_ID,
520
+ resources: [swarmResource({ externalId: OVERSIZED }), swarmResource()],
521
+ });
522
+
523
+ expect(query).toHaveBeenCalledTimes(1);
524
+ const params: Array<unknown> = paramsOf(query);
525
+
526
+ expect(params).toContain(CLAMPED_TO_SHORT_TEXT);
527
+ expect(params).not.toContain(OVERSIZED);
528
+ expect(params).toContain("task/abc123");
529
+ expectNoParamExceedsLongText(params);
530
+ });
531
+
532
+ test("bulkUpsert clamps image at 500, since it is the one LongText column", async () => {
533
+ const query: jest.Mock = mockQueryRunner(DockerSwarmResourceService);
534
+
535
+ await DockerSwarmResourceService.bulkUpsert({
536
+ projectId: PROJECT_ID,
537
+ dockerSwarmClusterId: PARENT_ID,
538
+ resources: [swarmResource({ image: OVERSIZED })],
539
+ });
540
+
541
+ const params: Array<unknown> = paramsOf(query);
542
+ expect(params).toContain(CLAMPED_TO_LONG_TEXT);
543
+ expect(params).not.toContain(CLAMPED_TO_SHORT_TEXT);
544
+ });
545
+
546
+ test("bulkUpdateLatestMetrics clamps identity the same way bulkUpsert does", async () => {
547
+ const query: jest.Mock = mockQueryRunner(DockerSwarmResourceService);
548
+
549
+ await DockerSwarmResourceService.bulkUpdateLatestMetrics({
550
+ projectId: PROJECT_ID,
551
+ dockerSwarmClusterId: PARENT_ID,
552
+ metrics: [
553
+ {
554
+ kind: "Task",
555
+ externalId: OVERSIZED,
556
+ cpuPercent: null,
557
+ memoryBytes: null,
558
+ maxMemoryBytes: null,
559
+ memoryPercent: null,
560
+ observedAt: OBSERVED_AT,
561
+ },
562
+ ],
563
+ });
564
+
565
+ const params: Array<unknown> = paramsOf(query);
566
+ expect(params).toContain(CLAMPED_TO_SHORT_TEXT);
567
+ expect(params).not.toContain(OVERSIZED);
568
+ });
569
+ });
570
+
571
+ describe("ProxmoxResourceService — oversized values can't drop the chunk", () => {
572
+ function proxmoxResource(
573
+ overrides: Partial<ParsedProxmoxResource> = {},
574
+ ): ParsedProxmoxResource {
575
+ return {
576
+ kind: "Guest",
577
+ externalId: "qemu/100",
578
+ name: "web-01",
579
+ vmid: 100,
580
+ guestType: "qemu",
581
+ parentNodeName: "pve1",
582
+ isUp: true,
583
+ haState: null,
584
+ onboot: null,
585
+ isBackedUp: null,
586
+ uptimeSeconds: 3600,
587
+ lastSeenAt: OBSERVED_AT,
588
+ ...overrides,
589
+ };
590
+ }
591
+
592
+ test("bulkUpsert clamps an oversized externalId and keeps its sibling row", async () => {
593
+ const query: jest.Mock = mockQueryRunner(ProxmoxResourceService);
594
+
595
+ await ProxmoxResourceService.bulkUpsert({
596
+ projectId: PROJECT_ID,
597
+ proxmoxClusterId: PARENT_ID,
598
+ resources: [
599
+ proxmoxResource({ externalId: OVERSIZED }),
600
+ proxmoxResource(),
601
+ ],
602
+ });
603
+
604
+ expect(query).toHaveBeenCalledTimes(1);
605
+ const params: Array<unknown> = paramsOf(query);
606
+
607
+ expect(params).toContain(CLAMPED_TO_SHORT_TEXT);
608
+ expect(params).not.toContain(OVERSIZED);
609
+ expect(params).toContain("qemu/100");
610
+ expectNoParamExceedsLongText(params);
611
+ });
612
+
613
+ test("bulkUpdateLatestMetrics clamps identity the same way bulkUpsert does", async () => {
614
+ const query: jest.Mock = mockQueryRunner(ProxmoxResourceService);
615
+
616
+ await ProxmoxResourceService.bulkUpdateLatestMetrics({
617
+ projectId: PROJECT_ID,
618
+ proxmoxClusterId: PARENT_ID,
619
+ metrics: [
620
+ {
621
+ kind: "Guest",
622
+ externalId: OVERSIZED,
623
+ cpuPercent: null,
624
+ memoryBytes: null,
625
+ maxMemoryBytes: null,
626
+ memoryPercent: null,
627
+ diskBytes: null,
628
+ maxDiskBytes: null,
629
+ observedAt: OBSERVED_AT,
630
+ },
631
+ ],
632
+ });
633
+
634
+ const params: Array<unknown> = paramsOf(query);
635
+ expect(params).toContain(CLAMPED_TO_SHORT_TEXT);
636
+ expect(params).not.toContain(OVERSIZED);
637
+ });
638
+ });
639
+
640
+ describe("CephResourceService — oversized values can't drop the chunk", () => {
641
+ function cephResource(
642
+ overrides: Partial<ParsedCephResource> = {},
643
+ ): ParsedCephResource {
644
+ return {
645
+ kind: "Osd",
646
+ externalId: "osd.3",
647
+ name: null,
648
+ hostname: "ceph-node-1",
649
+ daemonVersion: "ceph version 18.2.2 reef",
650
+ deviceClass: "ssd",
651
+ isUp: true,
652
+ isIn: true,
653
+ inQuorum: null,
654
+ lastSeenAt: OBSERVED_AT,
655
+ ...overrides,
656
+ };
657
+ }
658
+
659
+ test("bulkUpsert clamps an oversized externalId and keeps its sibling row", async () => {
660
+ const query: jest.Mock = mockQueryRunner(CephResourceService);
661
+
662
+ await CephResourceService.bulkUpsert({
663
+ projectId: PROJECT_ID,
664
+ cephClusterId: PARENT_ID,
665
+ resources: [cephResource({ externalId: OVERSIZED }), cephResource()],
666
+ });
667
+
668
+ expect(query).toHaveBeenCalledTimes(1);
669
+ const params: Array<unknown> = paramsOf(query);
670
+
671
+ expect(params).toContain(CLAMPED_TO_SHORT_TEXT);
672
+ expect(params).not.toContain(OVERSIZED);
673
+ expect(params).toContain("osd.3");
674
+ expectNoParamExceedsLongText(params);
675
+ });
676
+
677
+ test("bulkUpsert leaves NULL identity columns alone", async () => {
678
+ /*
679
+ * CephResource.name is pool-only — clamping must not turn a
680
+ * legitimate NULL into an empty string, or the COALESCE in the
681
+ * upsert would stop protecting the last-known value.
682
+ */
683
+ const query: jest.Mock = mockQueryRunner(CephResourceService);
684
+
685
+ await CephResourceService.bulkUpsert({
686
+ projectId: PROJECT_ID,
687
+ cephClusterId: PARENT_ID,
688
+ resources: [cephResource()],
689
+ });
690
+
691
+ const params: Array<unknown> = paramsOf(query);
692
+ expect(params[4]).toBeNull();
693
+ });
694
+
695
+ test("bulkUpdateLatestMetrics clamps identity the same way bulkUpsert does", async () => {
696
+ const query: jest.Mock = mockQueryRunner(CephResourceService);
697
+
698
+ await CephResourceService.bulkUpdateLatestMetrics({
699
+ projectId: PROJECT_ID,
700
+ cephClusterId: PARENT_ID,
701
+ metrics: [
702
+ {
703
+ kind: "Osd",
704
+ externalId: OVERSIZED,
705
+ statBytes: null,
706
+ statBytesUsed: null,
707
+ applyLatencyMs: null,
708
+ commitLatencyMs: null,
709
+ pgCount: null,
710
+ storedBytes: null,
711
+ maxAvailBytes: null,
712
+ objects: null,
713
+ readOpsCounter: null,
714
+ writeOpsCounter: null,
715
+ observedAt: OBSERVED_AT,
716
+ },
717
+ ],
718
+ });
719
+
720
+ const params: Array<unknown> = paramsOf(query);
721
+ expect(params).toContain(CLAMPED_TO_SHORT_TEXT);
722
+ expect(params).not.toContain(OVERSIZED);
723
+ });
724
+ });
725
+
726
+ describe("a full 500-row chunk survives one oversized row", () => {
727
+ test("every row still rides in the single statement", async () => {
728
+ const query: jest.Mock = mockQueryRunner(KubernetesContainerService);
729
+
730
+ const containers: Array<ParsedKubernetesContainer> = [];
731
+ for (let i: number = 0; i < 500; i++) {
732
+ containers.push({
733
+ podNamespaceKey: "default",
734
+ podName: i === 0 ? OVERSIZED : `api-7d9f8c6b5d-${i}`,
735
+ name: "app",
736
+ image: "ghcr.io/acme/api:1.2.3",
737
+ state: "running",
738
+ reason: null,
739
+ isReady: true,
740
+ restartCount: 0,
741
+ memoryLimitBytes: null,
742
+ lastSeenAt: OBSERVED_AT,
743
+ });
744
+ }
745
+
746
+ await KubernetesContainerService.bulkUpsert({
747
+ projectId: PROJECT_ID,
748
+ kubernetesClusterId: PARENT_ID,
749
+ containers,
750
+ });
751
+
752
+ // One chunk, 13 columns per row — nothing dropped by the clamp.
753
+ expect(query).toHaveBeenCalledTimes(1);
754
+ const params: Array<unknown> = paramsOf(query);
755
+ expect(params).toHaveLength(500 * 13);
756
+ expect(params).toContain(CLAMPED_TO_LONG_TEXT);
757
+ expect(params).toContain("api-7d9f8c6b5d-499");
758
+ expectNoParamExceedsLongText(params);
759
+ });
760
+
761
+ test("an oversized row in a later chunk doesn't disturb the earlier one", async () => {
762
+ const query: jest.Mock = mockQueryRunner(KubernetesContainerService);
763
+
764
+ const containers: Array<ParsedKubernetesContainer> = [];
765
+ for (let i: number = 0; i < 501; i++) {
766
+ containers.push({
767
+ podNamespaceKey: "default",
768
+ podName: i === 500 ? OVERSIZED : `api-7d9f8c6b5d-${i}`,
769
+ name: "app",
770
+ image: "ghcr.io/acme/api:1.2.3",
771
+ state: "running",
772
+ reason: null,
773
+ isReady: true,
774
+ restartCount: 0,
775
+ memoryLimitBytes: null,
776
+ lastSeenAt: OBSERVED_AT,
777
+ });
778
+ }
779
+
780
+ await KubernetesContainerService.bulkUpsert({
781
+ projectId: PROJECT_ID,
782
+ kubernetesClusterId: PARENT_ID,
783
+ containers,
784
+ });
785
+
786
+ expect(query).toHaveBeenCalledTimes(2);
787
+ expect(paramsOf(query, 0)).toHaveLength(500 * 13);
788
+ expect(paramsOf(query, 1)).toHaveLength(13);
789
+ expect(paramsOf(query, 0)).not.toContain(CLAMPED_TO_LONG_TEXT);
790
+ expect(paramsOf(query, 1)).toContain(CLAMPED_TO_LONG_TEXT);
791
+ });
792
+ });
793
+
794
+ describe("values that already fit are never touched", () => {
795
+ /*
796
+ * The other half of the contract. A clamp that fires one character
797
+ * early would silently corrupt legitimate inventory names — and
798
+ * because the clamped columns are conflict targets, a corrupted name
799
+ * mints a duplicate row rather than updating the real one.
800
+ */
801
+ const AT_SHORT_TEXT_BOUND: string = "s".repeat(ColumnLength.ShortText);
802
+ const AT_LONG_TEXT_BOUND: string = "l".repeat(ColumnLength.LongText);
803
+ const KUBERNETES_LONGEST_NAME: string = "k".repeat(253);
804
+
805
+ test("KubernetesResource keeps a name sitting exactly on the 500 bound", async () => {
806
+ const query: jest.Mock = mockQueryRunner(KubernetesResourceService);
807
+
808
+ await KubernetesResourceService.bulkUpsert({
809
+ projectId: PROJECT_ID,
810
+ kubernetesClusterId: PARENT_ID,
811
+ resources: [
812
+ {
813
+ kind: AT_SHORT_TEXT_BOUND,
814
+ namespaceKey: "default",
815
+ name: AT_LONG_TEXT_BOUND,
816
+ uid: null,
817
+ phase: "Running",
818
+ isReady: true,
819
+ hasMemoryPressure: null,
820
+ hasDiskPressure: null,
821
+ hasPidPressure: null,
822
+ labels: null,
823
+ annotations: null,
824
+ ownerReferences: null,
825
+ spec: null,
826
+ containerCount: null,
827
+ status: null,
828
+ lastSeenAt: OBSERVED_AT,
829
+ resourceCreationTimestamp: null,
830
+ },
831
+ ],
832
+ });
833
+
834
+ const params: Array<unknown> = paramsOf(query);
835
+ expect(params[2]).toBe(AT_SHORT_TEXT_BOUND);
836
+ expect(params[4]).toBe(AT_LONG_TEXT_BOUND);
837
+ });
838
+
839
+ test("KubernetesContainer keeps the longest name Kubernetes can produce", async () => {
840
+ const query: jest.Mock = mockQueryRunner(KubernetesContainerService);
841
+
842
+ await KubernetesContainerService.bulkUpsert({
843
+ projectId: PROJECT_ID,
844
+ kubernetesClusterId: PARENT_ID,
845
+ containers: [
846
+ {
847
+ podNamespaceKey: "default",
848
+ podName: KUBERNETES_LONGEST_NAME,
849
+ name: "app",
850
+ image: "ghcr.io/acme/api:1.2.3",
851
+ state: "running",
852
+ reason: null,
853
+ isReady: true,
854
+ restartCount: 0,
855
+ memoryLimitBytes: null,
856
+ lastSeenAt: OBSERVED_AT,
857
+ },
858
+ ],
859
+ });
860
+
861
+ // The whole point of the widening: 253 chars must survive intact.
862
+ expect(paramsOf(query)[3]).toBe(KUBERNETES_LONGEST_NAME);
863
+ });
864
+
865
+ test("PodmanResource keeps a long-but-legal image reference intact", async () => {
866
+ const query: jest.Mock = mockQueryRunner(PodmanResourceService);
867
+ const imageReference: string = `ghcr.io/${"o".repeat(60)}/${"r".repeat(
868
+ 60,
869
+ )}:${"t".repeat(60)}`;
870
+
871
+ await PodmanResourceService.bulkUpsert({
872
+ projectId: PROJECT_ID,
873
+ podmanHostId: PARENT_ID,
874
+ resources: [
875
+ {
876
+ kind: "Image",
877
+ name: imageReference,
878
+ containerId: null,
879
+ imageName: null,
880
+ state: null,
881
+ labels: null,
882
+ resourceCreationTimestamp: null,
883
+ lastSeenAt: OBSERVED_AT,
884
+ },
885
+ ],
886
+ });
887
+
888
+ expect(imageReference.length).toBeGreaterThan(ColumnLength.ShortText);
889
+ expect(paramsOf(query)[3]).toBe(imageReference);
890
+ });
891
+
892
+ test("CephResource keeps every identity value that already fits", async () => {
893
+ const query: jest.Mock = mockQueryRunner(CephResourceService);
894
+
895
+ await CephResourceService.bulkUpsert({
896
+ projectId: PROJECT_ID,
897
+ cephClusterId: PARENT_ID,
898
+ resources: [
899
+ {
900
+ kind: "Osd",
901
+ externalId: "osd.3",
902
+ name: null,
903
+ hostname: "ceph-node-1",
904
+ daemonVersion: "ceph version 18.2.2 reef",
905
+ deviceClass: "ssd",
906
+ isUp: true,
907
+ isIn: true,
908
+ inQuorum: null,
909
+ lastSeenAt: OBSERVED_AT,
910
+ },
911
+ ],
912
+ });
913
+
914
+ expect(paramsOf(query)).toEqual([
915
+ PROJECT_ID.toString(),
916
+ PARENT_ID.toString(),
917
+ "Osd",
918
+ "osd.3",
919
+ null,
920
+ "ceph-node-1",
921
+ "ceph version 18.2.2 reef",
922
+ "ssd",
923
+ true,
924
+ true,
925
+ null,
926
+ OBSERVED_AT,
927
+ 0,
928
+ ]);
929
+ });
930
+ });
931
+
932
+ describe("NULL stays NULL through the clamp", () => {
933
+ /*
934
+ * Every one of these upserts COALESCEs against the existing row, so a
935
+ * NULL means "this batch didn't carry the field, keep what's stored."
936
+ * A clamp that coerced NULL to "" would blank real data on every
937
+ * partial scrape.
938
+ */
939
+ test("DockerSwarmResource passes its nullable identity columns through as null", async () => {
940
+ const query: jest.Mock = mockQueryRunner(DockerSwarmResourceService);
941
+
942
+ await DockerSwarmResourceService.bulkUpsert({
943
+ projectId: PROJECT_ID,
944
+ dockerSwarmClusterId: PARENT_ID,
945
+ resources: [
946
+ {
947
+ kind: "Network",
948
+ externalId: "network/abc",
949
+ name: null,
950
+ state: null,
951
+ role: null,
952
+ serviceMode: null,
953
+ desiredReplicas: null,
954
+ runningReplicas: null,
955
+ image: null,
956
+ stackName: null,
957
+ serviceName: null,
958
+ nodeHostname: null,
959
+ driver: null,
960
+ isReady: null,
961
+ attributes: null,
962
+ lastSeenAt: OBSERVED_AT,
963
+ },
964
+ ],
965
+ });
966
+
967
+ const params: Array<unknown> = paramsOf(query);
968
+ for (const index of [4, 5, 6, 7, 10, 11, 12, 13, 14]) {
969
+ expect(params[index]).toBeNull();
970
+ }
971
+ expect(params).not.toContain("");
972
+ });
973
+
974
+ test("ProxmoxResource passes its nullable identity columns through as null", async () => {
975
+ const query: jest.Mock = mockQueryRunner(ProxmoxResourceService);
976
+
977
+ await ProxmoxResourceService.bulkUpsert({
978
+ projectId: PROJECT_ID,
979
+ proxmoxClusterId: PARENT_ID,
980
+ resources: [
981
+ {
982
+ kind: "Node",
983
+ externalId: "node/pve1",
984
+ name: null,
985
+ vmid: null,
986
+ guestType: null,
987
+ parentNodeName: null,
988
+ isUp: true,
989
+ haState: null,
990
+ onboot: null,
991
+ isBackedUp: null,
992
+ uptimeSeconds: null,
993
+ lastSeenAt: OBSERVED_AT,
994
+ },
995
+ ],
996
+ });
997
+
998
+ const params: Array<unknown> = paramsOf(query);
999
+ for (const index of [4, 6, 7, 9]) {
1000
+ expect(params[index]).toBeNull();
1001
+ }
1002
+ });
1003
+
1004
+ test("PodmanResource keeps a null imageName null rather than empty", async () => {
1005
+ const query: jest.Mock = mockQueryRunner(PodmanResourceService);
1006
+
1007
+ await PodmanResourceService.bulkUpsertContainers({
1008
+ projectId: PROJECT_ID,
1009
+ podmanHostId: PARENT_ID,
1010
+ containers: [
1011
+ {
1012
+ containerName: "api",
1013
+ containerId: null,
1014
+ imageName: null,
1015
+ state: "running",
1016
+ cpuPercent: null,
1017
+ memoryBytes: null,
1018
+ observedAt: OBSERVED_AT,
1019
+ },
1020
+ ],
1021
+ });
1022
+
1023
+ const params: Array<unknown> = paramsOf(query);
1024
+ expect(params[4]).toBeNull();
1025
+ expect(params[5]).toBeNull();
1026
+ });
1027
+ });
1028
+
1029
+ describe("the clamp announces itself", () => {
1030
+ /*
1031
+ * Truncation is data loss, however small. It has to be visible in the
1032
+ * logs or an operator debugging a "missing container" has nothing to
1033
+ * go on.
1034
+ */
1035
+ test("warns once per offending row, naming the parent resource", async () => {
1036
+ const warn: jest.SpyInstance = jest
1037
+ .spyOn(logger, "warn")
1038
+ .mockImplementation(() => {});
1039
+ mockQueryRunner(PodmanResourceService);
1040
+
1041
+ await PodmanResourceService.bulkUpsert({
1042
+ projectId: PROJECT_ID,
1043
+ podmanHostId: PARENT_ID,
1044
+ resources: [
1045
+ {
1046
+ kind: "Image",
1047
+ name: OVERSIZED,
1048
+ containerId: null,
1049
+ imageName: null,
1050
+ state: null,
1051
+ labels: null,
1052
+ resourceCreationTimestamp: null,
1053
+ lastSeenAt: OBSERVED_AT,
1054
+ },
1055
+ ],
1056
+ });
1057
+
1058
+ expect(warn).toHaveBeenCalledTimes(1);
1059
+ const message: string = String(warn.mock.calls[0]?.[0]);
1060
+ expect(message).toContain("PodmanResource name exceeds");
1061
+ expect(message).toContain(String(ColumnLength.LongText));
1062
+ expect(message).toContain(PARENT_ID.toString());
1063
+ });
1064
+
1065
+ test("stays quiet for a batch that needs no clamping", async () => {
1066
+ const warn: jest.SpyInstance = jest
1067
+ .spyOn(logger, "warn")
1068
+ .mockImplementation(() => {});
1069
+ mockQueryRunner(KubernetesResourceService);
1070
+
1071
+ await KubernetesResourceService.bulkUpsert({
1072
+ projectId: PROJECT_ID,
1073
+ kubernetesClusterId: PARENT_ID,
1074
+ resources: [
1075
+ {
1076
+ kind: "Pod",
1077
+ namespaceKey: "default",
1078
+ name: "api-7d9f8c6b5d-x2k9p",
1079
+ uid: null,
1080
+ phase: "Running",
1081
+ isReady: true,
1082
+ hasMemoryPressure: null,
1083
+ hasDiskPressure: null,
1084
+ hasPidPressure: null,
1085
+ labels: null,
1086
+ annotations: null,
1087
+ ownerReferences: null,
1088
+ spec: null,
1089
+ containerCount: null,
1090
+ status: null,
1091
+ lastSeenAt: OBSERVED_AT,
1092
+ resourceCreationTimestamp: null,
1093
+ },
1094
+ ],
1095
+ });
1096
+
1097
+ expect(warn).not.toHaveBeenCalled();
1098
+ });
1099
+
1100
+ test("warns per offending row, not once for the whole batch", async () => {
1101
+ const warn: jest.SpyInstance = jest
1102
+ .spyOn(logger, "warn")
1103
+ .mockImplementation(() => {});
1104
+ mockQueryRunner(CephResourceService);
1105
+
1106
+ await CephResourceService.bulkUpsert({
1107
+ projectId: PROJECT_ID,
1108
+ cephClusterId: PARENT_ID,
1109
+ /*
1110
+ * The two ids must differ WITHIN the clamp bound, or they collapse
1111
+ * onto the same ON CONFLICT target and the statement Postgres gets
1112
+ * is one the clamp itself made unusable — not what this test is
1113
+ * about, and a misleading fixture to leave lying around.
1114
+ */
1115
+ resources: [
1116
+ {
1117
+ kind: "Osd",
1118
+ externalId: `a${OVERSIZED}`,
1119
+ name: null,
1120
+ hostname: null,
1121
+ daemonVersion: null,
1122
+ deviceClass: null,
1123
+ isUp: true,
1124
+ isIn: true,
1125
+ inQuorum: null,
1126
+ lastSeenAt: OBSERVED_AT,
1127
+ },
1128
+ {
1129
+ kind: "Osd",
1130
+ externalId: `b${OVERSIZED}`,
1131
+ name: null,
1132
+ hostname: null,
1133
+ daemonVersion: null,
1134
+ deviceClass: null,
1135
+ isUp: true,
1136
+ isIn: true,
1137
+ inQuorum: null,
1138
+ lastSeenAt: OBSERVED_AT,
1139
+ },
1140
+ ],
1141
+ });
1142
+
1143
+ expect(warn).toHaveBeenCalledTimes(2);
1144
+ });
1145
+ });
1146
+
1147
+ describe("an empty batch still short-circuits", () => {
1148
+ /*
1149
+ * The sanitizing map runs before the chunk loop, so the early return
1150
+ * has to stay ahead of it — an empty batch must not reach Postgres.
1151
+ */
1152
+ test.each([
1153
+ [
1154
+ "KubernetesResourceService.bulkUpsert",
1155
+ KubernetesResourceService,
1156
+ async (): Promise<void> => {
1157
+ await KubernetesResourceService.bulkUpsert({
1158
+ projectId: PROJECT_ID,
1159
+ kubernetesClusterId: PARENT_ID,
1160
+ resources: [],
1161
+ });
1162
+ },
1163
+ ],
1164
+ [
1165
+ "KubernetesContainerService.bulkUpsert",
1166
+ KubernetesContainerService,
1167
+ async (): Promise<void> => {
1168
+ await KubernetesContainerService.bulkUpsert({
1169
+ projectId: PROJECT_ID,
1170
+ kubernetesClusterId: PARENT_ID,
1171
+ containers: [],
1172
+ });
1173
+ },
1174
+ ],
1175
+ [
1176
+ "DockerResourceService.bulkUpsertContainers",
1177
+ DockerResourceService,
1178
+ async (): Promise<void> => {
1179
+ await DockerResourceService.bulkUpsertContainers({
1180
+ projectId: PROJECT_ID,
1181
+ dockerHostId: PARENT_ID,
1182
+ containers: [],
1183
+ });
1184
+ },
1185
+ ],
1186
+ [
1187
+ "PodmanResourceService.bulkUpsert",
1188
+ PodmanResourceService,
1189
+ async (): Promise<void> => {
1190
+ await PodmanResourceService.bulkUpsert({
1191
+ projectId: PROJECT_ID,
1192
+ podmanHostId: PARENT_ID,
1193
+ resources: [],
1194
+ });
1195
+ },
1196
+ ],
1197
+ [
1198
+ "DockerSwarmResourceService.bulkUpsert",
1199
+ DockerSwarmResourceService,
1200
+ async (): Promise<void> => {
1201
+ await DockerSwarmResourceService.bulkUpsert({
1202
+ projectId: PROJECT_ID,
1203
+ dockerSwarmClusterId: PARENT_ID,
1204
+ resources: [],
1205
+ });
1206
+ },
1207
+ ],
1208
+ [
1209
+ "ProxmoxResourceService.bulkUpsert",
1210
+ ProxmoxResourceService,
1211
+ async (): Promise<void> => {
1212
+ await ProxmoxResourceService.bulkUpsert({
1213
+ projectId: PROJECT_ID,
1214
+ proxmoxClusterId: PARENT_ID,
1215
+ resources: [],
1216
+ });
1217
+ },
1218
+ ],
1219
+ [
1220
+ "CephResourceService.bulkUpsert",
1221
+ CephResourceService,
1222
+ async (): Promise<void> => {
1223
+ await CephResourceService.bulkUpsert({
1224
+ projectId: PROJECT_ID,
1225
+ cephClusterId: PARENT_ID,
1226
+ resources: [],
1227
+ });
1228
+ },
1229
+ ],
1230
+ ])(
1231
+ "%s issues no statement",
1232
+ async (
1233
+ _label: string,
1234
+ service: RepositoryHolder,
1235
+ invoke: () => Promise<void>,
1236
+ ) => {
1237
+ const query: jest.Mock = mockQueryRunner(service);
1238
+ await invoke();
1239
+ expect(query).not.toHaveBeenCalled();
1240
+ },
1241
+ );
1242
+ });