@oneuptime/common 14.0.6 → 14.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  10. package/Server/Services/UserNotificationRuleService.ts +55 -53
  11. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  12. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  13. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  14. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  15. package/Server/Utils/Response.ts +31 -0
  16. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  17. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  18. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  19. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  20. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  21. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  22. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  23. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  24. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  25. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  26. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  27. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  28. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  29. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  30. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  31. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  32. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  33. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  34. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  35. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  36. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  37. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  38. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  39. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  40. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  41. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  42. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  43. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  44. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  45. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  46. package/Tests/Server/Services/DomainService.test.ts +525 -0
  47. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  48. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  49. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  50. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  51. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  52. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  53. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  54. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  55. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  56. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  57. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  58. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  59. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  60. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  61. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  62. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  63. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  64. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  65. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  66. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  67. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  68. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  69. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  70. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  71. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  72. package/Types/Incident/IncidentAlertLink.ts +19 -0
  73. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  74. package/Types/Topology/TopologyApi.ts +428 -0
  75. package/Types/Topology/TopologyTypeRules.ts +191 -0
  76. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  77. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  78. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  80. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  81. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  82. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  83. package/build/dist/Server/Services/AlertService.js +21 -33
  84. package/build/dist/Server/Services/AlertService.js.map +1 -1
  85. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  86. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  87. package/build/dist/Server/Services/IncidentService.js +96 -42
  88. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  89. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  90. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  91. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  92. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  93. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  94. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  95. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  96. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  97. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  98. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  99. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  100. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  101. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  102. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  103. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  104. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  105. package/build/dist/Server/Utils/Response.js +30 -0
  106. package/build/dist/Server/Utils/Response.js.map +1 -1
  107. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  108. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  109. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  110. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  111. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  112. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  113. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  114. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  115. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  116. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  117. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  118. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  119. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  120. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  121. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  122. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  123. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  124. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  125. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  126. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  127. package/package.json +1 -1
@@ -0,0 +1,1024 @@
1
+ import EntityRelationshipType from "../../../Types/Telemetry/EntityRelationshipType";
2
+ import EntitySource from "../../../Types/Telemetry/EntitySource";
3
+ import EntityType from "../../../Types/Telemetry/EntityType";
4
+ import {
5
+ CONTAINER_SPECIFICITY,
6
+ NESTABLE_CHILD_TYPES,
7
+ NESTING_RELATIONSHIP_PRIORITY,
8
+ PLACEMENT_RELATIONSHIP_TYPES,
9
+ SERVICE_MAP_DETAIL_ATTRIBUTE_KEYS,
10
+ } from "../../../Types/Topology/TopologyTypeRules";
11
+ import {
12
+ TopologyCollectionCursorJSON,
13
+ TopologyConnectionSection,
14
+ } from "../../../Types/Topology/TopologyApi";
15
+
16
+ /*
17
+ * The SQL behind the Topology API (see Common/Types/Topology/TopologyApi.ts),
18
+ * as pure statement builders so the text and its parameters can be checked
19
+ * without a database.
20
+ *
21
+ * Rules every statement here follows:
22
+ *
23
+ * - Everything a caller or the database supplied is a bound parameter.
24
+ * The only text interpolated into SQL is table aliases and constants from
25
+ * this module and Common (entity types, relationship types, section
26
+ * names), never a request value.
27
+ * - The project is always bound and every table reference carries
28
+ * `"deletedAt" IS NULL`. Items are additionally `"isArchived" = false`
29
+ * (except in the project-types probe, which only widens a filter).
30
+ * - Lookups by entity key also constrain `"entityType" = ANY(<the project's
31
+ * types>)`, so the unique (projectId, entityType, entityKey) index serves
32
+ * them on installs where the (projectId, entityKey) index has not been
33
+ * built yet (Helm runs migrations asynchronously). Many keys travel as
34
+ * one array parameter, never as a sub-select over a CTE, so the planner
35
+ * knows how many there are (see itemsByKeysStatement).
36
+ * - Relationship end keys and tie-breaks compare with COLLATE "C": byte
37
+ * order of the UTF-8 encoding, which is code-POINT order — far cheaper
38
+ * to sort than the database collation, and what the Dashboard's
39
+ * code-point comparator (TopologyTypeRules.compareCodePoints) matches.
40
+ * Note it is not JavaScript's `<` (UTF-16 code-unit order), which
41
+ * disagrees where a character outside the Basic Multilingual Plane meets
42
+ * one in U+E000..U+FFFF.
43
+ * - Counts are cast to int (node-postgres returns bigint as a string) and
44
+ * timestamps leave the database as epoch milliseconds (float8), so hot
45
+ * result sets never allocate a Date per row.
46
+ */
47
+
48
+ export interface TopologySqlStatement {
49
+ sql: string;
50
+ params: Array<unknown>;
51
+ }
52
+
53
+ /* Collects bound parameters and hands out their placeholders in order. */
54
+ export class TopologySqlParams {
55
+ private values: Array<unknown> = [];
56
+
57
+ public add(value: unknown): string {
58
+ this.values.push(value);
59
+ return `$${this.values.length}`;
60
+ }
61
+
62
+ public list(): Array<unknown> {
63
+ return this.values;
64
+ }
65
+ }
66
+
67
+ /*
68
+ * A parameter bound the first time a statement's text needs it. Postgres
69
+ * refuses a parameter the text never references ("could not determine data
70
+ * type"), and several are only needed on some paths — the range start when
71
+ * inactive items are filtered out, the project's types when a duplicated key
72
+ * has to be resolved.
73
+ */
74
+ export class TopologyLazyParam {
75
+ private placeholder: string | null = null;
76
+
77
+ public constructor(
78
+ private params: TopologySqlParams,
79
+ private value: unknown,
80
+ ) {}
81
+
82
+ public get(): string {
83
+ if (!this.placeholder) {
84
+ this.placeholder = this.params.add(this.value);
85
+ }
86
+ return this.placeholder;
87
+ }
88
+ }
89
+
90
+ /*
91
+ * What the winner rule (winnerSql) needs: the project's types, so the
92
+ * duplicate probe can use the unique (projectId, entityType, entityKey)
93
+ * index, and the keys more than one live row carries (duplicateKeysStatement).
94
+ */
95
+ export interface TopologyWinnerScope {
96
+ projectTypes: Array<string>;
97
+ duplicateKeys: Array<string>;
98
+ }
99
+
100
+ /*
101
+ * Ranking tables for the nesting choice, bound as parallel arrays so the SQL
102
+ * can never disagree with the Dashboard's own copy of the rules (both read
103
+ * TopologyTypeRules).
104
+ */
105
+ export interface TopologyRankTable {
106
+ keys: Array<string>;
107
+ ranks: Array<number>;
108
+ }
109
+
110
+ function toRankTable(
111
+ table: Partial<Record<string, number | undefined>>,
112
+ ): TopologyRankTable {
113
+ const keys: Array<string> = [];
114
+ const ranks: Array<number> = [];
115
+ for (const key of Object.keys(table).sort()) {
116
+ const rank: number | undefined = table[key];
117
+ if (typeof rank === "number") {
118
+ keys.push(key);
119
+ ranks.push(rank);
120
+ }
121
+ }
122
+ return { keys, ranks };
123
+ }
124
+
125
+ export const NESTING_RELATIONSHIP_RANKS: TopologyRankTable = toRankTable(
126
+ NESTING_RELATIONSHIP_PRIORITY,
127
+ );
128
+
129
+ export const CONTAINER_SPECIFICITY_RANKS: TopologyRankTable = toRankTable(
130
+ CONTAINER_SPECIFICITY,
131
+ );
132
+
133
+ export const NESTABLE_CHILD_TYPE_LIST: Array<string> =
134
+ Array.from(NESTABLE_CHILD_TYPES).sort();
135
+
136
+ export const PLACEMENT_RELATIONSHIP_TYPE_LIST: Array<string> = Array.from(
137
+ PLACEMENT_RELATIONSHIP_TYPES,
138
+ ).sort();
139
+
140
+ export const SERVICE_MAP_ATTRIBUTE_KEY_LIST: Array<string> = [
141
+ ...SERVICE_MAP_DETAIL_ATTRIBUTE_KEYS,
142
+ ];
143
+
144
+ /* Labels the drawer orders its rows by; the browser translates its own. */
145
+ export const UNNAMED_RESOURCE_LABEL: string = "Unnamed resource";
146
+ export const UNDISCOVERED_RESOURCE_LABEL: string = "Undiscovered resource";
147
+
148
+ // ------------------------------------------------------------- fragments
149
+
150
+ /*
151
+ * "Did this resource report in the range?", the SQL twin of the Dashboard's
152
+ * TopologyActivity.isEntityActive. Only discovered rows have a heartbeat:
153
+ * manual and inventory-mirrored rows (any other non-blank source) are always
154
+ * active, and so is a row that has never reported. Change the two together.
155
+ */
156
+ export function activeSql(alias: string, rangeStart: string): string {
157
+ return (
158
+ `CASE WHEN ${alias}."source" IS NOT NULL AND ${alias}."source" <> '' ` +
159
+ `AND ${alias}."source" <> '${EntitySource.Discovered}' THEN TRUE ` +
160
+ `WHEN ${alias}."lastSeenAt" IS NULL THEN TRUE ` +
161
+ `ELSE ${alias}."lastSeenAt" >= ${rangeStart} END`
162
+ );
163
+ }
164
+
165
+ /* Epoch milliseconds, floored, as float8 (a JavaScript number). */
166
+ export function epochMsSql(expression: string): string {
167
+ return `floor(extract(epoch from ${expression}) * 1000)::float8`;
168
+ }
169
+
170
+ /* A non-archived, non-deleted item of the project. */
171
+ export function liveItemSql(alias: string, projectId: string): string {
172
+ return (
173
+ `${alias}."projectId" = ${projectId} AND ${alias}."isArchived" = false ` +
174
+ `AND ${alias}."deletedAt" IS NULL`
175
+ );
176
+ }
177
+
178
+ /*
179
+ * A non-deleted row of the project, for the all-time reads: an item archived
180
+ * or not, or a relationship however long ago it was seen.
181
+ */
182
+ export function storedRowSql(alias: string, projectId: string): string {
183
+ return `${alias}."projectId" = ${projectId} AND ${alias}."deletedAt" IS NULL`;
184
+ }
185
+
186
+ /* A non-deleted relationship of the project that was seen in the range. */
187
+ export function inRangeRelationshipSql(
188
+ alias: string,
189
+ projectId: string,
190
+ rangeStart: string,
191
+ ): string {
192
+ return (
193
+ `${alias}."projectId" = ${projectId} AND ${alias}."deletedAt" IS NULL ` +
194
+ `AND ${alias}."lastSeenAt" >= ${rangeStart}`
195
+ );
196
+ }
197
+
198
+ /*
199
+ * The row that represents its entity key. Keys are unique per type (the
200
+ * unique index) and hash the type into their preimage, so two live rows share
201
+ * a key only if something wrote one by hand — but when it happens every
202
+ * endpoint must pick the same row: the first by (createdAt ASC, _id DESC),
203
+ * which is the row the old client-side list kept (it iterated createdAt DESC,
204
+ * _id ASC and let the last write win).
205
+ *
206
+ * The duplicated keys are found once per request (duplicateKeysStatement) and
207
+ * the rule is applied to them alone. For every other key the row is its own
208
+ * winner, so the common case adds no predicate at all — an anti-join against
209
+ * the whole project would cost a full pass over it in every statement that
210
+ * lists items.
211
+ */
212
+ export function winnerSql(
213
+ alias: string,
214
+ projectId: string,
215
+ projectTypes: string,
216
+ duplicateKeys: string,
217
+ ): string {
218
+ return (
219
+ `(NOT (${alias}."entityKey" = ANY(${duplicateKeys}::text[])) ` +
220
+ `OR NOT EXISTS (SELECT 1 FROM "InventoryItem" dup ` +
221
+ `WHERE dup."projectId" = ${projectId} ` +
222
+ `AND dup."entityType" = ANY(${projectTypes}::text[]) ` +
223
+ `AND dup."entityKey" = ${alias}."entityKey" ` +
224
+ `AND dup."isArchived" = false AND dup."deletedAt" IS NULL ` +
225
+ `AND dup."_id" <> ${alias}."_id" ` +
226
+ `AND (dup."createdAt" < ${alias}."createdAt" ` +
227
+ `OR (dup."createdAt" = ${alias}."createdAt" AND dup."_id" > ${alias}."_id"))))`
228
+ );
229
+ }
230
+
231
+ /* The winner predicate for `alias`, or nothing when no key is duplicated. */
232
+ function winnerPredicates(data: {
233
+ params: TopologySqlParams;
234
+ alias: string;
235
+ projectId: string;
236
+ projectTypes: TopologyLazyParam;
237
+ winner: TopologyWinnerScope;
238
+ }): Array<string> {
239
+ if (data.winner.duplicateKeys.length === 0) {
240
+ return [];
241
+ }
242
+ return [
243
+ winnerSql(
244
+ data.alias,
245
+ data.projectId,
246
+ data.projectTypes.get(),
247
+ data.params.add(data.winner.duplicateKeys),
248
+ ),
249
+ ];
250
+ }
251
+
252
+ /* Escapes LIKE metacharacters so a search term only ever matches literally. */
253
+ export function escapeLikeTerm(term: string): string {
254
+ return term.replace(/[\\%_]/g, (character: string): string => {
255
+ return `\\${character}`;
256
+ });
257
+ }
258
+
259
+ /* Every term must appear in the display name (case-insensitively). */
260
+ function nameTermsSql(
261
+ alias: string,
262
+ params: TopologySqlParams,
263
+ nameTerms: Array<string>,
264
+ ): Array<string> {
265
+ return nameTerms.map((term: string): string => {
266
+ return `${alias}."displayName" ILIKE '%' || ${params.add(escapeLikeTerm(term))} || '%' ESCAPE '\\'`;
267
+ });
268
+ }
269
+
270
+ /* The lean entity columns every map row is built from. */
271
+ function leanEntityColumnsSql(alias: string): string {
272
+ return (
273
+ `${alias}."entityKey" AS "key", ${alias}."entityType" AS "type", ` +
274
+ `${alias}."displayName" AS "name", ${alias}."source" AS "source", ` +
275
+ `${epochMsSql(`${alias}."lastSeenAt"`)} AS "lastSeenAt"`
276
+ );
277
+ }
278
+
279
+ /*
280
+ * Only string-valued detail attributes, per bag: the Service Map's reader
281
+ * needs a non-blank string and falls through to the next key otherwise, so
282
+ * anything else would change which label it shows.
283
+ */
284
+ function detailAttributesSql(
285
+ alias: string,
286
+ column: string,
287
+ keys: string,
288
+ ): string {
289
+ return (
290
+ `(SELECT jsonb_object_agg(k.key, ${alias}."${column}" -> k.key) ` +
291
+ `FROM unnest(${keys}::text[]) AS k(key) ` +
292
+ `WHERE jsonb_typeof(${alias}."${column}" -> k.key) = 'string')`
293
+ );
294
+ }
295
+
296
+ // ----------------------------------------------------------- statements
297
+
298
+ /*
299
+ * The project's distinct entity types, read by skipping through the
300
+ * (projectId, entityType) index one type at a time (a "loose index scan"), so
301
+ * it costs one index probe per type however many items there are.
302
+ */
303
+ export function projectTypesStatement(data: {
304
+ projectId: string;
305
+ }): TopologySqlStatement {
306
+ const params: TopologySqlParams = new TopologySqlParams();
307
+ const projectId: string = params.add(data.projectId);
308
+ return {
309
+ sql:
310
+ `WITH RECURSIVE project_types AS (` +
311
+ `(SELECT i."entityType" FROM "InventoryItem" i ` +
312
+ `WHERE i."projectId" = ${projectId} AND i."deletedAt" IS NULL ` +
313
+ `ORDER BY i."entityType" LIMIT 1) ` +
314
+ `UNION ALL ` +
315
+ `SELECT (SELECT i."entityType" FROM "InventoryItem" i ` +
316
+ `WHERE i."projectId" = ${projectId} AND i."deletedAt" IS NULL ` +
317
+ `AND i."entityType" > t."entityType" ORDER BY i."entityType" LIMIT 1) ` +
318
+ `FROM project_types t WHERE t."entityType" IS NOT NULL) ` +
319
+ `SELECT pt."entityType" AS "entityType" FROM project_types pt ` +
320
+ `WHERE pt."entityType" IS NOT NULL`,
321
+ params: params.list(),
322
+ };
323
+ }
324
+
325
+ /*
326
+ * Keys that more than one live row of the project carries (normally none).
327
+ * One pass over the project; with the (projectId, entityKey) index it streams
328
+ * in key order without holding the keys in memory.
329
+ */
330
+ export function duplicateKeysStatement(data: {
331
+ projectId: string;
332
+ }): TopologySqlStatement {
333
+ const params: TopologySqlParams = new TopologySqlParams();
334
+ const projectId: string = params.add(data.projectId);
335
+ return {
336
+ sql:
337
+ `SELECT i."entityKey" AS "key" FROM "InventoryItem" i ` +
338
+ `WHERE ${liveItemSql("i", projectId)} ` +
339
+ `GROUP BY i."entityKey" HAVING COUNT(*) > 1`,
340
+ params: params.list(),
341
+ };
342
+ }
343
+
344
+ export type TopologyStatementMode = "rows" | "count";
345
+
346
+ /*
347
+ * Service Map entities of one kind: every non-archived service
348
+ * (`serviceKeys` absent), or the non-service items with the given keys (the
349
+ * things services call). Ordered by key so a truncated list is stable.
350
+ */
351
+ export function serviceMapEntitiesStatement(data: {
352
+ projectId: string;
353
+ winner: TopologyWinnerScope;
354
+ /* Absent: the services. Present: these keys, services excluded. */
355
+ keys?: Array<string> | undefined;
356
+ mode: TopologyStatementMode;
357
+ limit: number;
358
+ }): TopologySqlStatement {
359
+ const params: TopologySqlParams = new TopologySqlParams();
360
+ const projectId: string = params.add(data.projectId);
361
+ const projectTypes: TopologyLazyParam = new TopologyLazyParam(
362
+ params,
363
+ data.winner.projectTypes,
364
+ );
365
+
366
+ const where: Array<string> = [liveItemSql("i", projectId)];
367
+ if (data.keys) {
368
+ where.push(`i."entityType" = ANY(${projectTypes.get()}::text[])`);
369
+ where.push(`i."entityKey" = ANY(${params.add(data.keys)}::text[])`);
370
+ where.push(`i."entityType" <> ${params.add(EntityType.Service)}`);
371
+ } else {
372
+ where.push(`i."entityType" = ${params.add(EntityType.Service)}`);
373
+ }
374
+ where.push(
375
+ ...winnerPredicates({
376
+ params,
377
+ alias: "i",
378
+ projectId,
379
+ projectTypes,
380
+ winner: data.winner,
381
+ }),
382
+ );
383
+
384
+ if (data.mode === "count") {
385
+ return {
386
+ sql: `SELECT COUNT(*)::int AS "total" FROM "InventoryItem" i WHERE ${where.join(" AND ")}`,
387
+ params: params.list(),
388
+ };
389
+ }
390
+
391
+ const attributeKeys: string = params.add(SERVICE_MAP_ATTRIBUTE_KEY_LIST);
392
+ return {
393
+ sql:
394
+ `SELECT ${leanEntityColumnsSql("i")}, ` +
395
+ `${detailAttributesSql("i", "descriptiveAttributes", attributeKeys)} AS "descriptiveAttributes", ` +
396
+ `${detailAttributesSql("i", "identifyingAttributes", attributeKeys)} AS "identifyingAttributes" ` +
397
+ `FROM "InventoryItem" i WHERE ${where.join(" AND ")} ` +
398
+ `ORDER BY i."entityKey" COLLATE "C" ASC ` +
399
+ `LIMIT ${params.add(data.limit)}`,
400
+ params: params.list(),
401
+ };
402
+ }
403
+
404
+ /*
405
+ * In-range `depends-on` relationships whose caller is one of the services, in
406
+ * the order the old list API returned them (createdAt DESC, _id ASC) so the
407
+ * browser sums traffic in the same order and averages come out bit-identical.
408
+ * `calleeKeys` narrows them to calls into those keys (Infrastructure asks
409
+ * only for calls between services it can place).
410
+ */
411
+ export function serviceMapDependenciesStatement(data: {
412
+ projectId: string;
413
+ rangeStart: string;
414
+ serviceKeys: Array<string>;
415
+ calleeKeys?: Array<string> | undefined;
416
+ mode: TopologyStatementMode;
417
+ limit: number;
418
+ }): TopologySqlStatement {
419
+ const params: TopologySqlParams = new TopologySqlParams();
420
+ const projectId: string = params.add(data.projectId);
421
+ const rangeStart: string = params.add(data.rangeStart);
422
+ const where: string =
423
+ `${inRangeRelationshipSql("r", projectId, rangeStart)} ` +
424
+ `AND r."relationshipType" = ${params.add(EntityRelationshipType.DependsOn)} ` +
425
+ `AND r."fromEntityKey" <> r."toEntityKey" ` +
426
+ `AND r."fromEntityKey" = ANY(${params.add(data.serviceKeys)}::text[])` +
427
+ (data.calleeKeys
428
+ ? ` AND r."toEntityKey" = ANY(${params.add(data.calleeKeys)}::text[])`
429
+ : "");
430
+
431
+ if (data.mode === "count") {
432
+ return {
433
+ sql: `SELECT COUNT(*)::int AS "total" FROM "InventoryItemRelationship" r WHERE ${where}`,
434
+ params: params.list(),
435
+ };
436
+ }
437
+
438
+ return {
439
+ sql:
440
+ `SELECT r."fromEntityKey" AS "from", r."toEntityKey" AS "to", ` +
441
+ `r."callCount" AS "callCount", r."errorCount" AS "errorCount", ` +
442
+ `r."avgDurationMs" AS "avgDurationMs" ` +
443
+ `FROM "InventoryItemRelationship" r WHERE ${where} ` +
444
+ `ORDER BY r."createdAt" DESC, r."_id" ASC ` +
445
+ `LIMIT ${params.add(data.limit)}`,
446
+ params: params.list(),
447
+ };
448
+ }
449
+
450
+ /*
451
+ * Where services run: distinct (service, resource) over in-range runs-on /
452
+ * hosted-on from the given services. The Service Map counts these per target
453
+ * type; Infrastructure keeps the ones that land on a shipped node.
454
+ */
455
+ export function placementsStatement(data: {
456
+ projectId: string;
457
+ rangeStart: string;
458
+ serviceKeys: Array<string>;
459
+ }): TopologySqlStatement {
460
+ const params: TopologySqlParams = new TopologySqlParams();
461
+ const projectId: string = params.add(data.projectId);
462
+ const rangeStart: string = params.add(data.rangeStart);
463
+ return {
464
+ sql:
465
+ `SELECT DISTINCT r."fromEntityKey" AS "service", r."toEntityKey" AS "target" ` +
466
+ `FROM "InventoryItemRelationship" r ` +
467
+ `WHERE ${inRangeRelationshipSql("r", projectId, rangeStart)} ` +
468
+ `AND r."relationshipType" = ANY(${params.add(PLACEMENT_RELATIONSHIP_TYPE_LIST)}::text[]) ` +
469
+ `AND r."fromEntityKey" = ANY(${params.add(data.serviceKeys)}::text[])`,
470
+ params: params.list(),
471
+ };
472
+ }
473
+
474
+ /*
475
+ * The live items (winning rows) with the given keys: their type and whether
476
+ * they reported in range. The keys are an array rather than a sub-select so
477
+ * the planner knows how many there are — a handful become index probes, tens
478
+ * of thousands one pass over the project (a sub-select over a CTE is
479
+ * estimated at a couple of hundred rows and gets probed key by key, once per
480
+ * project type, which is seconds on a large estate).
481
+ */
482
+ export function itemsByKeysStatement(data: {
483
+ projectId: string;
484
+ rangeStart: string;
485
+ winner: TopologyWinnerScope;
486
+ keys: Array<string>;
487
+ }): TopologySqlStatement {
488
+ const params: TopologySqlParams = new TopologySqlParams();
489
+ const projectId: string = params.add(data.projectId);
490
+ const rangeStart: string = params.add(data.rangeStart);
491
+ const projectTypes: TopologyLazyParam = new TopologyLazyParam(
492
+ params,
493
+ data.winner.projectTypes,
494
+ );
495
+ const where: Array<string> = [
496
+ liveItemSql("i", projectId),
497
+ `i."entityType" = ANY(${projectTypes.get()}::text[])`,
498
+ `i."entityKey" = ANY(${params.add(data.keys)}::text[])`,
499
+ ...winnerPredicates({
500
+ params,
501
+ alias: "i",
502
+ projectId,
503
+ projectTypes,
504
+ winner: data.winner,
505
+ }),
506
+ ];
507
+ return {
508
+ sql:
509
+ `SELECT i."entityKey" AS "key", i."entityType" AS "type", ` +
510
+ `${activeSql("i", rangeStart)} AS "active" ` +
511
+ `FROM "InventoryItem" i WHERE ${where.join(" AND ")}`,
512
+ params: params.list(),
513
+ };
514
+ }
515
+
516
+ /*
517
+ * Every live service's key and name (Infrastructure resolves placements and
518
+ * service labels from these).
519
+ */
520
+ export function servicesStatement(data: {
521
+ projectId: string;
522
+ winner: TopologyWinnerScope;
523
+ }): TopologySqlStatement {
524
+ const params: TopologySqlParams = new TopologySqlParams();
525
+ const projectId: string = params.add(data.projectId);
526
+ const projectTypes: TopologyLazyParam = new TopologyLazyParam(
527
+ params,
528
+ data.winner.projectTypes,
529
+ );
530
+ const where: Array<string> = [
531
+ liveItemSql("i", projectId),
532
+ `i."entityType" = ${params.add(EntityType.Service)}`,
533
+ ...winnerPredicates({
534
+ params,
535
+ alias: "i",
536
+ projectId,
537
+ projectTypes,
538
+ winner: data.winner,
539
+ }),
540
+ ];
541
+ return {
542
+ sql:
543
+ `SELECT i."entityKey" AS "key", i."displayName" AS "name" ` +
544
+ `FROM "InventoryItem" i WHERE ${where.join(" AND ")} ` +
545
+ `ORDER BY i."entityKey" COLLATE "C" ASC`,
546
+ params: params.list(),
547
+ };
548
+ }
549
+
550
+ /*
551
+ * Exact item counts per infrastructure type, in one pass: how many there
552
+ * are, how many reported in range, and the latest report over each. Decides
553
+ * which flat types become collections and feeds the totals.
554
+ */
555
+ export function infrastructureTypeCountsStatement(data: {
556
+ projectId: string;
557
+ rangeStart: string;
558
+ infrastructureTypes: Array<string>;
559
+ }): TopologySqlStatement {
560
+ const params: TopologySqlParams = new TopologySqlParams();
561
+ const projectId: string = params.add(data.projectId);
562
+ const rangeStart: string = params.add(data.rangeStart);
563
+ const active: string = activeSql("i", rangeStart);
564
+ return {
565
+ sql:
566
+ `SELECT i."entityType" AS "type", COUNT(*)::int AS "total", ` +
567
+ `COUNT(*) FILTER (WHERE ${active})::int AS "active", ` +
568
+ `${epochMsSql(`MAX(i."lastSeenAt")`)} AS "lastSeenAt", ` +
569
+ `${epochMsSql(`MAX(i."lastSeenAt") FILTER (WHERE ${active})`)} AS "activeLastSeenAt" ` +
570
+ `FROM "InventoryItem" i ` +
571
+ `WHERE ${liveItemSql("i", projectId)} ` +
572
+ `AND i."entityType" = ANY(${params.add(data.infrastructureTypes)}::text[]) ` +
573
+ `GROUP BY i."entityType" ` +
574
+ `ORDER BY i."entityType" COLLATE "C" ASC`,
575
+ params: params.list(),
576
+ };
577
+ }
578
+
579
+ /* The Infrastructure node set: live winners of the given (node) types. */
580
+ function infrastructureNodesCteSql(data: {
581
+ params: TopologySqlParams;
582
+ projectId: string;
583
+ rangeStart: string;
584
+ winner: TopologyWinnerScope;
585
+ nodeTypes: string;
586
+ }): string {
587
+ const where: Array<string> = [
588
+ liveItemSql("i", data.projectId),
589
+ `i."entityType" = ANY(${data.nodeTypes}::text[])`,
590
+ ...winnerPredicates({
591
+ params: data.params,
592
+ alias: "i",
593
+ projectId: data.projectId,
594
+ projectTypes: new TopologyLazyParam(
595
+ data.params,
596
+ data.winner.projectTypes,
597
+ ),
598
+ winner: data.winner,
599
+ }),
600
+ ];
601
+ return (
602
+ `nodes AS MATERIALIZED (` +
603
+ `SELECT i."entityKey" AS "key", i."entityType" AS "type", ` +
604
+ `i."displayName" AS "name", i."source" AS "source", ` +
605
+ `i."lastSeenAt" AS "lastSeenAt", ` +
606
+ `${activeSql("i", data.rangeStart)} AS "active" ` +
607
+ `FROM "InventoryItem" i WHERE ${where.join(" AND ")})`
608
+ );
609
+ }
610
+
611
+ /*
612
+ * One row per infrastructure resource with its container chosen here, using
613
+ * the Dashboard's nesting rules over EVERY in-range relationship:
614
+ *
615
+ * candidates: C -> P where C's type nests, the relationship type ranks,
616
+ * P is another node and P's type is a container.
617
+ * rank: relationship priority DESC, container specificity DESC,
618
+ * P's key (code-point order, COLLATE "C") ASC.
619
+ *
620
+ * `parent` is the best candidate over all nodes; `activeParent` the best
621
+ * over active nodes, which the browser falls back to when inactive resources
622
+ * are hidden and `parent` did not report in range. Taking the argmax over a
623
+ * subset that contains the global argmax gives the global argmax, so the
624
+ * browser builds exactly the tree all relationships would give it.
625
+ */
626
+ export function infrastructureNodesStatement(data: {
627
+ projectId: string;
628
+ rangeStart: string;
629
+ winner: TopologyWinnerScope;
630
+ nodeTypes: Array<string>;
631
+ mode: TopologyStatementMode;
632
+ limit: number;
633
+ }): TopologySqlStatement {
634
+ const params: TopologySqlParams = new TopologySqlParams();
635
+ const projectId: string = params.add(data.projectId);
636
+ const rangeStart: string = params.add(data.rangeStart);
637
+ const nodeTypes: string = params.add(data.nodeTypes);
638
+ const nodesCte: string = infrastructureNodesCteSql({
639
+ params,
640
+ projectId,
641
+ rangeStart,
642
+ winner: data.winner,
643
+ nodeTypes,
644
+ });
645
+
646
+ if (data.mode === "count") {
647
+ return {
648
+ sql:
649
+ `WITH ${nodesCte} ` +
650
+ `SELECT COUNT(*)::int AS "total", ` +
651
+ `COUNT(*) FILTER (WHERE n."active")::int AS "active" FROM nodes n`,
652
+ params: params.list(),
653
+ };
654
+ }
655
+
656
+ const relationshipTypes: string = params.add(NESTING_RELATIONSHIP_RANKS.keys);
657
+ const relationshipPriorities: string = params.add(
658
+ NESTING_RELATIONSHIP_RANKS.ranks,
659
+ );
660
+ const containerTypes: string = params.add(CONTAINER_SPECIFICITY_RANKS.keys);
661
+ const containerSpecificities: string = params.add(
662
+ CONTAINER_SPECIFICITY_RANKS.ranks,
663
+ );
664
+ const nestableTypes: string = params.add(NESTABLE_CHILD_TYPE_LIST);
665
+ /*
666
+ * Keys sort with COLLATE "C" (code-point order) here for two reasons: the
667
+ * Dashboard breaks the same tie with a code-point comparator, and a byte
668
+ * comparison is an order of magnitude cheaper than the database collation
669
+ * over a few hundred thousand candidates.
670
+ */
671
+ const ranking: string =
672
+ `ORDER BY c."child" COLLATE "C", c."priority" DESC, c."specificity" DESC, ` +
673
+ `c."parent" COLLATE "C" ASC`;
674
+
675
+ return {
676
+ sql:
677
+ `WITH ${nodesCte}, ` +
678
+ `candidates AS MATERIALIZED (` +
679
+ `SELECT r."fromEntityKey" AS "child", r."toEntityKey" AS "parent", ` +
680
+ `r."relationshipType" AS "via", ` +
681
+ `(${relationshipPriorities}::int[])[array_position(${relationshipTypes}::text[], r."relationshipType"::text)] AS "priority", ` +
682
+ `(${containerSpecificities}::int[])[array_position(${containerTypes}::text[], p."type"::text)] AS "specificity", ` +
683
+ `p."active" AS "parentActive" ` +
684
+ `FROM "InventoryItemRelationship" r ` +
685
+ `JOIN nodes child ON child."key" = r."fromEntityKey" ` +
686
+ `JOIN nodes p ON p."key" = r."toEntityKey" ` +
687
+ `WHERE ${inRangeRelationshipSql("r", projectId, rangeStart)} ` +
688
+ `AND r."relationshipType" = ANY(${relationshipTypes}::text[]) ` +
689
+ `AND r."fromEntityKey" <> r."toEntityKey" ` +
690
+ `AND child."type" = ANY(${nestableTypes}::text[]) ` +
691
+ `AND p."type" = ANY(${containerTypes}::text[])), ` +
692
+ `best AS (SELECT DISTINCT ON (c."child" COLLATE "C") c."child", c."parent", c."via", c."parentActive" ` +
693
+ `FROM candidates c ${ranking}), ` +
694
+ `best_active AS (SELECT DISTINCT ON (c."child" COLLATE "C") c."child", c."parent", c."via" ` +
695
+ `FROM candidates c WHERE c."parentActive" ${ranking}) ` +
696
+ `SELECT n."key" AS "key", n."type" AS "type", n."name" AS "name", ` +
697
+ `n."source" AS "source", ${epochMsSql(`n."lastSeenAt"`)} AS "lastSeenAt", ` +
698
+ `n."active" AS "active", ` +
699
+ `b."parent" AS "parent", b."via" AS "parentVia", ` +
700
+ `(b."parent" IS NOT NULL AND NOT b."parentActive") AS "parentInactive", ` +
701
+ `ba."parent" AS "activeParent", ba."via" AS "activeParentVia" ` +
702
+ `FROM nodes n ` +
703
+ `LEFT JOIN best b ON b."child" = n."key" ` +
704
+ `LEFT JOIN best_active ba ON ba."child" = n."key" ` +
705
+ `ORDER BY n."type" COLLATE "C" ASC, n."key" COLLATE "C" ASC ` +
706
+ `LIMIT ${params.add(data.limit)}`,
707
+ params: params.list(),
708
+ };
709
+ }
710
+
711
+ /* Filters shared by a collection's page, its total and its search count. */
712
+ function collectionWhereSql(data: {
713
+ params: TopologySqlParams;
714
+ projectId: string;
715
+ rangeStart: TopologyLazyParam;
716
+ entityType: string;
717
+ includeInactive: boolean;
718
+ nameTerms: Array<string>;
719
+ }): Array<string> {
720
+ const where: Array<string> = [
721
+ liveItemSql("i", data.projectId),
722
+ `i."entityType" = ${data.params.add(data.entityType)}`,
723
+ ];
724
+ if (!data.includeInactive) {
725
+ where.push(`(${activeSql("i", data.rangeStart.get())})`);
726
+ }
727
+ where.push(...nameTermsSql("i", data.params, data.nameTerms));
728
+ return where;
729
+ }
730
+
731
+ /*
732
+ * One page of a collection, keyset-paged on (display name, key). The name
733
+ * sorts in the database's collation (what a person expects), the key with
734
+ * COLLATE "C" (code points); both orders are total, so a cursor never skips
735
+ * or repeats a row.
736
+ */
737
+ export function collectionPageStatement(data: {
738
+ projectId: string;
739
+ rangeStart: string;
740
+ entityType: string;
741
+ includeInactive: boolean;
742
+ nameTerms: Array<string>;
743
+ cursor: TopologyCollectionCursorJSON | null;
744
+ mode: TopologyStatementMode;
745
+ limit: number;
746
+ }): TopologySqlStatement {
747
+ const params: TopologySqlParams = new TopologySqlParams();
748
+ const projectId: string = params.add(data.projectId);
749
+ const rangeStart: TopologyLazyParam = new TopologyLazyParam(
750
+ params,
751
+ data.rangeStart,
752
+ );
753
+ const where: Array<string> = collectionWhereSql({
754
+ params,
755
+ projectId,
756
+ rangeStart,
757
+ entityType: data.entityType,
758
+ includeInactive: data.includeInactive,
759
+ nameTerms: data.nameTerms,
760
+ });
761
+
762
+ if (data.mode === "count") {
763
+ return {
764
+ sql: `SELECT COUNT(*)::int AS "total" FROM "InventoryItem" i WHERE ${where.join(" AND ")}`,
765
+ params: params.list(),
766
+ };
767
+ }
768
+
769
+ if (data.cursor) {
770
+ const name: string = params.add(data.cursor.name);
771
+ const key: string = params.add(data.cursor.key);
772
+ where.push(
773
+ `(COALESCE(i."displayName", '') > ${name} ` +
774
+ `OR (COALESCE(i."displayName", '') = ${name} ` +
775
+ `AND i."entityKey" COLLATE "C" > ${key}))`,
776
+ );
777
+ }
778
+
779
+ return {
780
+ sql:
781
+ `SELECT ${leanEntityColumnsSql("i")} ` +
782
+ `FROM "InventoryItem" i WHERE ${where.join(" AND ")} ` +
783
+ `ORDER BY COALESCE(i."displayName", '') ASC, i."entityKey" COLLATE "C" ASC ` +
784
+ `LIMIT ${params.add(data.limit)}`,
785
+ params: params.list(),
786
+ };
787
+ }
788
+
789
+ /* How many items of each requested collection match its search terms. */
790
+ export function collectionSearchStatement(data: {
791
+ projectId: string;
792
+ rangeStart: string;
793
+ includeInactive: boolean;
794
+ types: Array<{ entityType: string; nameTerms: Array<string> }>;
795
+ }): TopologySqlStatement {
796
+ const params: TopologySqlParams = new TopologySqlParams();
797
+ const projectId: string = params.add(data.projectId);
798
+ const rangeStart: TopologyLazyParam = new TopologyLazyParam(
799
+ params,
800
+ data.rangeStart,
801
+ );
802
+ const branches: Array<string> = data.types.map(
803
+ (type: { entityType: string; nameTerms: Array<string> }): string => {
804
+ const where: Array<string> = collectionWhereSql({
805
+ params,
806
+ projectId,
807
+ rangeStart,
808
+ entityType: type.entityType,
809
+ includeInactive: data.includeInactive,
810
+ nameTerms: type.nameTerms,
811
+ });
812
+ return (
813
+ `(SELECT ${params.add(type.entityType)}::text AS "entityType", ` +
814
+ `COUNT(*)::int AS "count" FROM "InventoryItem" i ` +
815
+ `WHERE ${where.join(" AND ")})`
816
+ );
817
+ },
818
+ );
819
+ return {
820
+ sql: branches.join(" UNION ALL "),
821
+ params: params.list(),
822
+ };
823
+ }
824
+
825
+ /*
826
+ * The item behind a drawer or an inventory read. The key alone identifies it
827
+ * (the winning row); a known type narrows the choice when two rows share the
828
+ * key.
829
+ */
830
+ export function entityStatement(data: {
831
+ projectId: string;
832
+ projectTypes: Array<string>;
833
+ entityKey: string;
834
+ entityType: string | null;
835
+ /*
836
+ * The all-time reads find archived items too; a live row still wins over
837
+ * an archived one that shares its key.
838
+ */
839
+ includeArchived?: boolean | undefined;
840
+ }): TopologySqlStatement {
841
+ const params: TopologySqlParams = new TopologySqlParams();
842
+ const projectId: string = params.add(data.projectId);
843
+ const projectTypes: string = params.add(data.projectTypes);
844
+ return {
845
+ sql:
846
+ `SELECT i."_id"::text AS "id", ${leanEntityColumnsSql("i")}, ` +
847
+ `${epochMsSql(`i."firstSeenAt"`)} AS "firstSeenAt", ` +
848
+ `i."resourceType" AS "resourceType", i."resourceId"::text AS "resourceId", ` +
849
+ `i."identifyingAttributes" AS "identifyingAttributes", ` +
850
+ `i."descriptiveAttributes" AS "descriptiveAttributes" ` +
851
+ `FROM "InventoryItem" i ` +
852
+ `WHERE ${data.includeArchived ? storedRowSql("i", projectId) : liveItemSql("i", projectId)} ` +
853
+ `AND i."entityType" = ANY(${projectTypes}::text[]) ` +
854
+ `AND i."entityKey" = ${params.add(data.entityKey)} ` +
855
+ `ORDER BY (i."entityType" = ${params.add(data.entityType)}::text) DESC NULLS LAST, ` +
856
+ `${data.includeArchived ? `i."isArchived" ASC, ` : ""}` +
857
+ `i."createdAt" ASC, i."_id" DESC ` +
858
+ `LIMIT 1`,
859
+ params: params.list(),
860
+ };
861
+ }
862
+
863
+ /*
864
+ * Which relationships and items an entity's sections cover: the drawer's
865
+ * range over live items, or — for the inventory item page, which has no
866
+ * range — everything the inventory holds: every non-deleted relationship
867
+ * however long ago it was seen, with archived items as known other ends.
868
+ */
869
+ export type TopologyEntityScope =
870
+ | { kind: "range"; rangeStart: string }
871
+ | { kind: "allTime" };
872
+
873
+ export type TopologyEntitySectionWindow =
874
+ | {
875
+ /* The drawer's first view: the top rows of every section. */
876
+ kind: "top";
877
+ dependencyRows: number;
878
+ otherRows: number;
879
+ }
880
+ | {
881
+ /* "Show more": one page of one section. */
882
+ kind: "page";
883
+ section: TopologyConnectionSection;
884
+ offset: number;
885
+ limit: number;
886
+ };
887
+
888
+ /*
889
+ * An entity's relationships in `scope`, classified into the drawer's four
890
+ * sections, with exact per-section totals and the requested rows.
891
+ *
892
+ * At most `scanLimit` relationships are read — outbound first, then inbound,
893
+ * each newest first — so a cluster with hundreds of thousands of pods in
894
+ * range answers with lower bounds instead of timing out. A self-loop is read
895
+ * once, as outbound. The other end is looked up among live items (the key's
896
+ * winning row); a relationship to anything else stays in its section as an
897
+ * unknown row, and is counted in `unknownTotal`.
898
+ *
899
+ * All time, the other end is looked up among archived items too (a live row
900
+ * still wins a shared key) and each row carries its `otherId`. A relationship
901
+ * that never reported a lastSeenAt sorts first under DESC, so the scan limit
902
+ * drops the oldest discovered edges before it drops one of those.
903
+ *
904
+ * Returns one row: `scanned` (relationships read), `sections` (JSON array of
905
+ * {section, total, unknownTotal}) and `rows` (JSON array, section then rank).
906
+ */
907
+ export function entitySectionsStatement(data: {
908
+ projectId: string;
909
+ scope: TopologyEntityScope;
910
+ projectTypes: Array<string>;
911
+ entityKey: string;
912
+ isService: boolean;
913
+ scanLimit: number;
914
+ window: TopologyEntitySectionWindow;
915
+ }): TopologySqlStatement {
916
+ const params: TopologySqlParams = new TopologySqlParams();
917
+ const projectId: string = params.add(data.projectId);
918
+ const allTime: boolean = data.scope.kind === "allTime";
919
+ const relationshipScope: string =
920
+ data.scope.kind === "range"
921
+ ? inRangeRelationshipSql(
922
+ "r",
923
+ projectId,
924
+ params.add(data.scope.rangeStart),
925
+ )
926
+ : storedRowSql("r", projectId);
927
+ const projectTypes: string = params.add(data.projectTypes);
928
+ const entityKey: string = params.add(data.entityKey);
929
+ const scanLimit: string = params.add(data.scanLimit);
930
+ const dependsOn: string = params.add(EntityRelationshipType.DependsOn);
931
+ const isService: string = params.add(data.isService);
932
+ const placementTypes: string = params.add(PLACEMENT_RELATIONSHIP_TYPE_LIST);
933
+
934
+ const scanColumns: (direction: "out" | "in", other: string) => string = (
935
+ direction: "out" | "in",
936
+ other: string,
937
+ ): string => {
938
+ return (
939
+ `r."relationshipType" AS "relationshipType", '${direction}'::text AS "direction", ` +
940
+ `r.${other} AS "otherKey", r."callCount" AS "callCount", ` +
941
+ `r."errorCount" AS "errorCount", r."avgDurationMs" AS "avgDurationMs", ` +
942
+ `r."lastSeenAt" AS "lastSeenAt"`
943
+ );
944
+ };
945
+
946
+ let rowWindow: string;
947
+ if (data.window.kind === "top") {
948
+ rowWindow =
949
+ `r."rank" <= CASE WHEN r."section" IN ('calls', 'calledBy') ` +
950
+ `THEN ${params.add(data.window.dependencyRows)}::int ` +
951
+ `ELSE ${params.add(data.window.otherRows)}::int END`;
952
+ } else {
953
+ const offset: string = params.add(data.window.offset);
954
+ rowWindow =
955
+ `r."section" = ${params.add(data.window.section)}::text ` +
956
+ `AND r."rank" > ${offset}::int ` +
957
+ `AND r."rank" <= ${offset}::int + ${params.add(data.window.limit)}::int`;
958
+ }
959
+
960
+ return {
961
+ sql:
962
+ `WITH scan_out AS MATERIALIZED (` +
963
+ `SELECT ${scanColumns("out", `"toEntityKey"`)} ` +
964
+ `FROM "InventoryItemRelationship" r ` +
965
+ `WHERE ${relationshipScope} ` +
966
+ `AND r."fromEntityKey" = ${entityKey} ` +
967
+ `ORDER BY r."lastSeenAt" DESC, r."_id" ASC LIMIT ${scanLimit}::int), ` +
968
+ `scan_in AS MATERIALIZED (` +
969
+ `SELECT ${scanColumns("in", `"fromEntityKey"`)} ` +
970
+ `FROM "InventoryItemRelationship" r ` +
971
+ `WHERE ${relationshipScope} ` +
972
+ `AND r."toEntityKey" = ${entityKey} AND r."fromEntityKey" <> ${entityKey} ` +
973
+ `ORDER BY r."lastSeenAt" DESC, r."_id" ASC ` +
974
+ `LIMIT GREATEST(0, ${scanLimit}::int - (SELECT COUNT(*)::int FROM scan_out))), ` +
975
+ `scan AS (SELECT * FROM scan_out UNION ALL SELECT * FROM scan_in), ` +
976
+ `others AS MATERIALIZED (` +
977
+ `SELECT DISTINCT ON (i."entityKey") i."entityKey" AS "key", ` +
978
+ `${allTime ? `i."_id"::text AS "id", ` : ""}` +
979
+ `i."entityType" AS "type", i."displayName" AS "name" ` +
980
+ `FROM "InventoryItem" i ` +
981
+ `WHERE ${allTime ? storedRowSql("i", projectId) : liveItemSql("i", projectId)} ` +
982
+ `AND i."entityType" = ANY(${projectTypes}::text[]) ` +
983
+ `AND i."entityKey" IN (SELECT s."otherKey" FROM scan s) ` +
984
+ `ORDER BY i."entityKey", ${allTime ? `i."isArchived" ASC, ` : ""}` +
985
+ `i."createdAt" ASC, i."_id" DESC), ` +
986
+ `classified AS MATERIALIZED (` +
987
+ `SELECT s.*, (o."key" IS NOT NULL) AS "otherKnown", ` +
988
+ `${allTime ? `o."id" AS "otherId", ` : ""}` +
989
+ `o."type" AS "otherType", o."name" AS "otherName", ` +
990
+ `CASE WHEN s."relationshipType" = ${dependsOn} AND s."direction" = 'out' THEN 'calls' ` +
991
+ `WHEN s."relationshipType" = ${dependsOn} THEN 'calledBy' ` +
992
+ `WHEN s."direction" = 'out' AND ${isService}::boolean ` +
993
+ `AND s."relationshipType" = ANY(${placementTypes}::text[]) THEN 'runsOn' ` +
994
+ `ELSE 'related' END AS "section", ` +
995
+ `COALESCE(NULLIF(o."name", ''), CASE WHEN o."key" IS NOT NULL ` +
996
+ `THEN '${UNNAMED_RESOURCE_LABEL}' ELSE '${UNDISCOVERED_RESOURCE_LABEL}' END) AS "label" ` +
997
+ `FROM scan s LEFT JOIN others o ON o."key" = s."otherKey"), ` +
998
+ `ranked AS (` +
999
+ `SELECT c.*, ROW_NUMBER() OVER (PARTITION BY c."section" ORDER BY ` +
1000
+ `CASE WHEN c."section" IN ('calls', 'calledBy') THEN c."callCount" END DESC NULLS LAST, ` +
1001
+ `CASE WHEN c."section" IN ('runsOn', 'related') THEN c."otherKnown" END DESC NULLS LAST, ` +
1002
+ `c."label" ASC, c."otherKey" COLLATE "C" ASC, ` +
1003
+ `c."relationshipType" COLLATE "C" ASC, c."direction" ASC) AS "rank" ` +
1004
+ `FROM classified c) ` +
1005
+ `SELECT (SELECT COUNT(*)::int FROM scan) AS "scanned", ` +
1006
+ `(SELECT COALESCE(json_agg(json_build_object(` +
1007
+ `'section', t."section", 'total', t."total", 'unknownTotal', t."unknownTotal")), '[]'::json) ` +
1008
+ `FROM (SELECT c."section" AS "section", COUNT(*)::int AS "total", ` +
1009
+ `COUNT(*) FILTER (WHERE NOT c."otherKnown")::int AS "unknownTotal" ` +
1010
+ `FROM classified c GROUP BY c."section") t) AS "sections", ` +
1011
+ `(SELECT COALESCE(json_agg(json_build_object(` +
1012
+ `'section', r."section", 'relationshipType', r."relationshipType", ` +
1013
+ `'direction', r."direction", 'otherKey', r."otherKey", ` +
1014
+ `'otherKnown', r."otherKnown", ` +
1015
+ `${allTime ? `'otherId', r."otherId", ` : ""}` +
1016
+ `'otherName', r."otherName", ` +
1017
+ `'otherType', r."otherType", 'callCount', r."callCount", ` +
1018
+ `'errorCount', r."errorCount", 'avgDurationMs', r."avgDurationMs", ` +
1019
+ `'lastSeenAt', ${epochMsSql(`r."lastSeenAt"`)}) ` +
1020
+ `ORDER BY r."section", r."rank"), '[]'::json) ` +
1021
+ `FROM ranked r WHERE ${rowWindow}) AS "rows"`,
1022
+ params: params.list(),
1023
+ };
1024
+ }