@oneuptime/common 12.0.3 → 12.0.4

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 (173) hide show
  1. package/Models/DatabaseModels/CephCluster.ts +14 -0
  2. package/Models/DatabaseModels/CodeRepository.ts +14 -6
  3. package/Models/DatabaseModels/DockerHost.ts +14 -0
  4. package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
  5. package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
  6. package/Models/DatabaseModels/IoTFleet.ts +14 -0
  7. package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
  8. package/Models/DatabaseModels/NetworkDevice.ts +14 -0
  9. package/Models/DatabaseModels/NetworkInterface.ts +18 -0
  10. package/Models/DatabaseModels/Project.ts +16 -2
  11. package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
  12. package/Models/DatabaseModels/Service.ts +30 -0
  13. package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
  14. package/Models/DatabaseModels/User.ts +30 -0
  15. package/Server/API/AIAgentDataAPI.ts +31 -0
  16. package/Server/API/DashboardAPI.ts +419 -12
  17. package/Server/API/GitHubAPI.ts +119 -283
  18. package/Server/API/UserAPI.ts +263 -1
  19. package/Server/EnvironmentConfig.ts +7 -3
  20. package/Server/Infrastructure/GlobalCache.ts +37 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
  27. package/Server/Services/AnalyticsDatabaseService.ts +72 -1
  28. package/Server/Services/CodeRepositoryService.ts +105 -2
  29. package/Server/Services/DatabaseService.ts +185 -2
  30. package/Server/Services/OpenTelemetryIngestService.ts +230 -26
  31. package/Server/Services/UserService.ts +184 -0
  32. package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
  33. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
  34. package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
  35. package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
  36. package/Server/Utils/PasswordHash.ts +306 -0
  37. package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
  38. package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
  39. package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
  40. package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
  41. package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
  42. package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
  43. package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
  44. package/Tests/Server/API/Helpers.ts +6 -1
  45. package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
  46. package/Tests/Server/Infrastructure/GlobalCache.test.ts +128 -0
  47. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
  48. package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
  49. package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
  50. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
  51. package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
  52. package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
  53. package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
  54. package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
  55. package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
  56. package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
  57. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
  58. package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
  59. package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
  60. package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
  61. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
  62. package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
  63. package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
  64. package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
  65. package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
  66. package/Tests/Types/Database/ColumnLength.test.ts +7 -1
  67. package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
  68. package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
  69. package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
  70. package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
  71. package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
  72. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
  73. package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
  74. package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
  75. package/Types/Database/ColumnLength.ts +9 -1
  76. package/Types/Database/TableColumn.ts +11 -0
  77. package/Types/Database/UnsynchronizedIndex.ts +48 -0
  78. package/Types/HashedString.ts +139 -4
  79. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
  80. package/UI/Components/Navbar/NavBar.tsx +12 -2
  81. package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
  82. package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
  83. package/Utils/TeamMembersByProject.ts +237 -0
  84. package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
  85. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  86. package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
  87. package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
  88. package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
  89. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  90. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
  91. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
  92. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
  93. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
  95. package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
  97. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
  99. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
  101. package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/Project.js +16 -2
  103. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
  105. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/Service.js +28 -1
  107. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
  109. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  110. package/build/dist/Models/DatabaseModels/User.js +32 -0
  111. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  112. package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
  113. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  114. package/build/dist/Server/API/DashboardAPI.js +304 -10
  115. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  116. package/build/dist/Server/API/GitHubAPI.js +94 -167
  117. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  118. package/build/dist/Server/API/UserAPI.js +187 -2
  119. package/build/dist/Server/API/UserAPI.js.map +1 -1
  120. package/build/dist/Server/EnvironmentConfig.js +7 -3
  121. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  122. package/build/dist/Server/Infrastructure/GlobalCache.js +29 -0
  123. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  136. package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
  137. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  138. package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
  139. package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
  140. package/build/dist/Server/Services/DatabaseService.js +160 -2
  141. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  142. package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
  143. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  144. package/build/dist/Server/Services/UserService.js +165 -0
  145. package/build/dist/Server/Services/UserService.js.map +1 -1
  146. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
  147. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
  148. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
  149. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  150. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
  151. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
  152. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
  153. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
  154. package/build/dist/Server/Utils/PasswordHash.js +237 -0
  155. package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
  156. package/build/dist/Types/Database/ColumnLength.js +9 -1
  157. package/build/dist/Types/Database/ColumnLength.js.map +1 -1
  158. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  159. package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
  160. package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
  161. package/build/dist/Types/HashedString.js +95 -5
  162. package/build/dist/Types/HashedString.js.map +1 -1
  163. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
  164. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  165. package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
  166. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  167. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
  168. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  169. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
  170. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
  171. package/build/dist/Utils/TeamMembersByProject.js +145 -0
  172. package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
  173. package/package.json +1 -1
@@ -0,0 +1,582 @@
1
+ import AnalyticsDatabaseService from "../../../Server/Services/AnalyticsDatabaseService";
2
+ import {
3
+ SQL,
4
+ Statement,
5
+ } from "../../../Server/Utils/AnalyticsDatabase/Statement";
6
+ import logger from "../../../Server/Utils/Logger";
7
+ import "../TestingUtils/Init";
8
+ import AnalyticsBaseModel from "../../../Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
9
+ import Route from "../../../Types/API/Route";
10
+ import AnalyticsTableEngine from "../../../Types/AnalyticsDatabase/AnalyticsTableEngine";
11
+ import AnalyticsTableColumn from "../../../Types/AnalyticsDatabase/TableColumn";
12
+ import TableColumnType from "../../../Types/AnalyticsDatabase/TableColumnType";
13
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
14
+ import GenericObject from "../../../Types/GenericObject";
15
+ import {
16
+ describe,
17
+ expect,
18
+ beforeEach,
19
+ afterEach,
20
+ test,
21
+ jest,
22
+ } from "@jest/globals";
23
+
24
+ /*
25
+ * Pagination stability for analytics reads.
26
+ *
27
+ * `ORDER BY time DESC` is not a TOTAL order: rows that tie on `time` may be
28
+ * returned in any order, and ClickHouse is free to order them differently
29
+ * between two executions of the same query. Paging with LIMIT/OFFSET on top
30
+ * of a partial order silently drops rows at page boundaries and repeats
31
+ * others — a row sitting at offset 49 on page 1 can shift to offset 50
32
+ * before page 2 is fetched, so it is never rendered.
33
+ *
34
+ * Ties are the normal case, not an edge case: a service emitting a burst of
35
+ * logs writes many rows carrying an identical timestamp.
36
+ *
37
+ * The fix appends the unique, time-ordered `_id` as a final sort key.
38
+ *
39
+ * Two suites below:
40
+ * - "sort shape" pins the sort object the service hands to the generator.
41
+ * - "page boundary behaviour" simulates the engine's freedom to reorder
42
+ * ties and proves paging is lossless with the tiebreaker and lossy
43
+ * without it.
44
+ */
45
+
46
+ const TIME_COLUMN: string = "time";
47
+ const BODY_COLUMN: string = "body";
48
+
49
+ class LogLikeModel extends AnalyticsBaseModel {
50
+ public constructor() {
51
+ super({
52
+ tableName: "<log-like-table>",
53
+ singularName: "<singular-name>",
54
+ pluralName: "<plural-name>",
55
+ tableColumns: [
56
+ new AnalyticsTableColumn({
57
+ key: TIME_COLUMN,
58
+ title: "<title>",
59
+ description: "<description>",
60
+ required: true,
61
+ type: TableColumnType.DateTime64,
62
+ }),
63
+ new AnalyticsTableColumn({
64
+ key: BODY_COLUMN,
65
+ title: "<title>",
66
+ description: "<description>",
67
+ required: false,
68
+ type: TableColumnType.Text,
69
+ }),
70
+ ],
71
+ crudApiPath: new Route("route"),
72
+ primaryKeys: [TIME_COLUMN],
73
+ sortKeys: [TIME_COLUMN],
74
+ partitionKey: TIME_COLUMN,
75
+ tableEngine: AnalyticsTableEngine.MergeTree,
76
+ defaultSortColumn: TIME_COLUMN,
77
+ });
78
+ }
79
+ }
80
+
81
+ /*
82
+ * Stands in for a derived aggregate target. `includeBaseColumns: false`
83
+ * omits `_id` because the aggregation key IS the row identity there, so the
84
+ * tiebreaker has nothing to append and must not invent a column.
85
+ */
86
+ class NoIdModel extends AnalyticsBaseModel {
87
+ public constructor() {
88
+ super({
89
+ tableName: "<no-id-table>",
90
+ singularName: "<singular-name>",
91
+ pluralName: "<plural-name>",
92
+ includeBaseColumns: false,
93
+ tableColumns: [
94
+ new AnalyticsTableColumn({
95
+ key: TIME_COLUMN,
96
+ title: "<title>",
97
+ description: "<description>",
98
+ required: true,
99
+ type: TableColumnType.DateTime64,
100
+ }),
101
+ ],
102
+ crudApiPath: new Route("route"),
103
+ primaryKeys: [TIME_COLUMN],
104
+ sortKeys: [TIME_COLUMN],
105
+ partitionKey: TIME_COLUMN,
106
+ tableEngine: AnalyticsTableEngine.MergeTree,
107
+ defaultSortColumn: TIME_COLUMN,
108
+ });
109
+ }
110
+ }
111
+
112
+ describe("AnalyticsDatabaseService pagination stability", () => {
113
+ describe("sort shape", () => {
114
+ let service: AnalyticsDatabaseService<LogLikeModel>;
115
+ let capturedSorts: Array<Record<string, SortOrder>>;
116
+
117
+ beforeEach(() => {
118
+ service = new AnalyticsDatabaseService({ modelType: LogLikeModel });
119
+ capturedSorts = [];
120
+
121
+ service.statementGenerator.toSelectStatement = jest.fn(() => {
122
+ return { statement: SQL`<select>`, columns: ["<column>"] };
123
+ });
124
+ service.statementGenerator.toWhereStatement = jest.fn(() => {
125
+ return SQL`<where>`;
126
+ });
127
+ service.statementGenerator.toGroupByStatement = jest.fn(() => {
128
+ return SQL`<group-by>`;
129
+ });
130
+ service.statementGenerator.toSortStatement = jest.fn(
131
+ (sort: unknown): Statement => {
132
+ capturedSorts.push({ ...(sort as Record<string, SortOrder>) });
133
+ return SQL`<sort>`;
134
+ },
135
+ ) as never;
136
+
137
+ jest.spyOn(logger, "debug").mockImplementation(() => {
138
+ return undefined!;
139
+ });
140
+ });
141
+
142
+ afterEach(() => {
143
+ jest.restoreAllMocks();
144
+ });
145
+
146
+ type FindArgs = {
147
+ sort: GenericObject;
148
+ groupBy?: GenericObject | undefined;
149
+ };
150
+
151
+ const runFind: (args: FindArgs) => void = (args: FindArgs): void => {
152
+ service.toFindStatement({
153
+ select: {} as GenericObject,
154
+ query: {} as GenericObject,
155
+ props: {} as GenericObject,
156
+ sort: args.sort,
157
+ groupBy: args.groupBy,
158
+ limit: 50,
159
+ skip: 0,
160
+ } as never);
161
+ };
162
+
163
+ test("appends _id when the caller did not sort by it", () => {
164
+ runFind({ sort: { [TIME_COLUMN]: SortOrder.Descending } });
165
+
166
+ expect(capturedSorts[0]).toStrictEqual({
167
+ [TIME_COLUMN]: SortOrder.Descending,
168
+ _id: SortOrder.Descending,
169
+ });
170
+ });
171
+
172
+ test("_id is the LAST key, so it only ever breaks ties", () => {
173
+ runFind({ sort: { [TIME_COLUMN]: SortOrder.Descending } });
174
+
175
+ expect(Object.keys(capturedSorts[0]!)).toStrictEqual([
176
+ TIME_COLUMN,
177
+ "_id",
178
+ ]);
179
+ });
180
+
181
+ test("tiebreaker direction follows a descending sort", () => {
182
+ runFind({ sort: { [TIME_COLUMN]: SortOrder.Descending } });
183
+
184
+ expect(capturedSorts[0]!["_id"]).toBe(SortOrder.Descending);
185
+ });
186
+
187
+ test("tiebreaker direction follows an ascending sort", () => {
188
+ runFind({ sort: { [TIME_COLUMN]: SortOrder.Ascending } });
189
+
190
+ expect(capturedSorts[0]!["_id"]).toBe(SortOrder.Ascending);
191
+ });
192
+
193
+ test("follows the LAST key when several columns are sorted", () => {
194
+ runFind({
195
+ sort: {
196
+ [TIME_COLUMN]: SortOrder.Descending,
197
+ [BODY_COLUMN]: SortOrder.Ascending,
198
+ },
199
+ });
200
+
201
+ expect(Object.keys(capturedSorts[0]!)).toStrictEqual([
202
+ TIME_COLUMN,
203
+ BODY_COLUMN,
204
+ "_id",
205
+ ]);
206
+ expect(capturedSorts[0]!["_id"]).toBe(SortOrder.Ascending);
207
+ });
208
+
209
+ test("does not append a second _id when already sorted by it", () => {
210
+ runFind({ sort: { _id: SortOrder.Ascending } });
211
+
212
+ expect(capturedSorts[0]).toStrictEqual({ _id: SortOrder.Ascending });
213
+ });
214
+
215
+ test("keeps a caller's _id in its original position and direction", () => {
216
+ runFind({
217
+ sort: {
218
+ _id: SortOrder.Descending,
219
+ [TIME_COLUMN]: SortOrder.Ascending,
220
+ },
221
+ });
222
+
223
+ expect(Object.keys(capturedSorts[0]!)).toStrictEqual([
224
+ "_id",
225
+ TIME_COLUMN,
226
+ ]);
227
+ expect(capturedSorts[0]!["_id"]).toBe(SortOrder.Descending);
228
+ });
229
+
230
+ /*
231
+ * Under GROUP BY, `_id` is neither a grouping key nor an aggregate, so
232
+ * referencing it is an error rather than a tiebreak.
233
+ */
234
+ test("does not append _id to a GROUP BY query", () => {
235
+ runFind({
236
+ sort: { [TIME_COLUMN]: SortOrder.Descending },
237
+ groupBy: { [TIME_COLUMN]: true },
238
+ });
239
+
240
+ expect(capturedSorts[0]).toStrictEqual({
241
+ [TIME_COLUMN]: SortOrder.Descending,
242
+ });
243
+ });
244
+
245
+ test("falls back to descending when the sort is empty", () => {
246
+ runFind({ sort: {} });
247
+
248
+ expect(capturedSorts[0]).toStrictEqual({ _id: SortOrder.Descending });
249
+ });
250
+
251
+ test("does not mutate the caller's sort object", () => {
252
+ const callerSort: GenericObject = {
253
+ [TIME_COLUMN]: SortOrder.Descending,
254
+ };
255
+
256
+ runFind({ sort: callerSort });
257
+
258
+ expect(callerSort).toStrictEqual({ [TIME_COLUMN]: SortOrder.Descending });
259
+ expect(callerSort).not.toHaveProperty("_id");
260
+ });
261
+
262
+ test("a model without an _id column is left alone", () => {
263
+ const noIdService: AnalyticsDatabaseService<NoIdModel> =
264
+ new AnalyticsDatabaseService({ modelType: NoIdModel });
265
+
266
+ const noIdSorts: Array<Record<string, SortOrder>> = [];
267
+
268
+ noIdService.statementGenerator.toSelectStatement = jest.fn(() => {
269
+ return { statement: SQL`<select>`, columns: ["<column>"] };
270
+ });
271
+ noIdService.statementGenerator.toWhereStatement = jest.fn(() => {
272
+ return SQL`<where>`;
273
+ });
274
+ noIdService.statementGenerator.toSortStatement = jest.fn(
275
+ (sort: unknown): Statement => {
276
+ noIdSorts.push({ ...(sort as Record<string, SortOrder>) });
277
+ return SQL`<sort>`;
278
+ },
279
+ ) as never;
280
+
281
+ noIdService.toFindStatement({
282
+ select: {} as GenericObject,
283
+ query: {} as GenericObject,
284
+ props: {} as GenericObject,
285
+ sort: { [TIME_COLUMN]: SortOrder.Descending },
286
+ limit: 50,
287
+ skip: 0,
288
+ } as never);
289
+
290
+ expect(noIdSorts[0]).toStrictEqual({
291
+ [TIME_COLUMN]: SortOrder.Descending,
292
+ });
293
+ });
294
+ });
295
+
296
+ describe("rendered SQL", () => {
297
+ let service: AnalyticsDatabaseService<LogLikeModel>;
298
+
299
+ beforeEach(() => {
300
+ service = new AnalyticsDatabaseService({ modelType: LogLikeModel });
301
+
302
+ service.statementGenerator.toSelectStatement = jest.fn(() => {
303
+ return { statement: SQL`<select>`, columns: ["<column>"] };
304
+ });
305
+ service.statementGenerator.toWhereStatement = jest.fn(() => {
306
+ return SQL`<where>`;
307
+ });
308
+ jest.spyOn(logger, "debug").mockImplementation(() => {
309
+ return undefined!;
310
+ });
311
+ });
312
+
313
+ afterEach(() => {
314
+ jest.restoreAllMocks();
315
+ });
316
+
317
+ /*
318
+ * The generator is NOT mocked here, so this asserts the real ORDER BY
319
+ * text — the separator fix and the tiebreaker together.
320
+ */
321
+ test("emits a comma separated, _id terminated ORDER BY", () => {
322
+ const { statement } = service.toFindStatement({
323
+ select: {} as GenericObject,
324
+ query: {} as GenericObject,
325
+ props: {} as GenericObject,
326
+ sort: { [TIME_COLUMN]: SortOrder.Descending },
327
+ limit: 50,
328
+ skip: 100,
329
+ } as never);
330
+
331
+ expect(statement.query).toContain(
332
+ "ORDER BY {p2:Identifier} DESC, {p3:Identifier} DESC",
333
+ );
334
+ expect(statement.query_params).toMatchObject({
335
+ p2: TIME_COLUMN,
336
+ p3: "_id",
337
+ });
338
+ });
339
+
340
+ test("the ORDER BY ends with _id, immediately before LIMIT", () => {
341
+ const { statement } = service.toFindStatement({
342
+ select: {} as GenericObject,
343
+ query: {} as GenericObject,
344
+ props: {} as GenericObject,
345
+ sort: { [TIME_COLUMN]: SortOrder.Descending },
346
+ limit: 50,
347
+ skip: 0,
348
+ } as never);
349
+
350
+ const orderBy: string = statement.query
351
+ .split("ORDER BY ")[1]!
352
+ .split(" LIMIT")[0]!;
353
+ const lastTerm: string = orderBy.split(", ").pop()!;
354
+ const lastParam: string = lastTerm.split(":")[0]!.replace("{", "");
355
+
356
+ expect(statement.query_params[lastParam]).toBe("_id");
357
+ });
358
+ });
359
+
360
+ /*
361
+ * The regression itself. These tests do not touch SQL text; they model
362
+ * what the engine is ALLOWED to do with a partial order and check whether
363
+ * paging survives it.
364
+ */
365
+ describe("page boundary behaviour", () => {
366
+ interface Row {
367
+ _id: string;
368
+ time: number;
369
+ body: string;
370
+ }
371
+
372
+ /*
373
+ * Order rows the way an engine may for a given ORDER BY spec. Rows that
374
+ * tie on every sort key fall back to `tieOrder` — standing in for the
375
+ * arbitrary, between-query-unstable order the engine is free to return.
376
+ */
377
+ const orderRows: (
378
+ rows: Array<Row>,
379
+ sort: Record<string, SortOrder>,
380
+ tieOrder: Array<string>,
381
+ ) => Array<Row> = (
382
+ rows: Array<Row>,
383
+ sort: Record<string, SortOrder>,
384
+ tieOrder: Array<string>,
385
+ ): Array<Row> => {
386
+ return [...rows].sort((a: Row, b: Row): number => {
387
+ for (const key of Object.keys(sort)) {
388
+ const left: string | number = (
389
+ a as unknown as Record<string, string | number>
390
+ )[key]!;
391
+ const right: string | number = (
392
+ b as unknown as Record<string, string | number>
393
+ )[key]!;
394
+
395
+ if (left !== right) {
396
+ const ascending: number = left < right ? -1 : 1;
397
+ return sort[key] === SortOrder.Ascending ? ascending : -ascending;
398
+ }
399
+ }
400
+
401
+ return tieOrder.indexOf(a._id) - tieOrder.indexOf(b._id);
402
+ });
403
+ };
404
+
405
+ const fetchPage: (
406
+ rows: Array<Row>,
407
+ sort: Record<string, SortOrder>,
408
+ tieOrder: Array<string>,
409
+ skip: number,
410
+ limit: number,
411
+ ) => Array<Row> = (
412
+ rows: Array<Row>,
413
+ sort: Record<string, SortOrder>,
414
+ tieOrder: Array<string>,
415
+ skip: number,
416
+ limit: number,
417
+ ): Array<Row> => {
418
+ return orderRows(rows, sort, tieOrder).slice(skip, skip + limit);
419
+ };
420
+
421
+ /*
422
+ * 12 rows across 3 timestamps, 4 rows tied on each — the shape a burst
423
+ * of logs produces.
424
+ */
425
+ const buildRows: () => Array<Row> = (): Array<Row> => {
426
+ const rows: Array<Row> = [];
427
+ for (let bucket: number = 0; bucket < 3; bucket++) {
428
+ for (let index: number = 0; index < 4; index++) {
429
+ const ordinal: number = bucket * 4 + index;
430
+ rows.push({
431
+ // Zero padded so lexical order matches numeric order.
432
+ _id: `id-${String(ordinal).padStart(2, "0")}`,
433
+ time: 1000 - bucket,
434
+ body: `Solver ${ordinal}% complete`,
435
+ });
436
+ }
437
+ }
438
+ return rows;
439
+ };
440
+
441
+ /*
442
+ * Three different tie orders, one per page fetch, modelling the engine
443
+ * returning ties differently for each of the three queries the UI runs.
444
+ */
445
+ const tieOrders: Array<Array<string>> = [
446
+ buildRows().map((row: Row): string => {
447
+ return row._id;
448
+ }),
449
+ [...buildRows()].reverse().map((row: Row): string => {
450
+ return row._id;
451
+ }),
452
+ buildRows()
453
+ .map((row: Row): string => {
454
+ return row._id;
455
+ })
456
+ .sort(),
457
+ ];
458
+
459
+ /*
460
+ * Page size 5 against tie groups of 4 is deliberate: the page boundaries
461
+ * (offset 5 and 10) fall INSIDE a tie group. Aligning them with group
462
+ * boundaries would hide the bug, because a reordering that stays within
463
+ * one page is unobservable.
464
+ */
465
+ const pageThrough: (sort: Record<string, SortOrder>) => Array<string> = (
466
+ sort: Record<string, SortOrder>,
467
+ ): Array<string> => {
468
+ const pageSize: number = 5;
469
+ const rows: Array<Row> = buildRows();
470
+ const seen: Array<string> = [];
471
+
472
+ for (let page: number = 0; page < 3; page++) {
473
+ const fetched: Array<Row> = fetchPage(
474
+ rows,
475
+ sort,
476
+ tieOrders[page]!,
477
+ page * pageSize,
478
+ pageSize,
479
+ );
480
+ for (const row of fetched) {
481
+ seen.push(row._id);
482
+ }
483
+ }
484
+
485
+ return seen;
486
+ };
487
+
488
+ test("WITHOUT the tiebreaker, paging loses and repeats rows", () => {
489
+ const seen: Array<string> = pageThrough({ time: SortOrder.Descending });
490
+ const unique: Set<string> = new Set(seen);
491
+
492
+ // Rows were fetched, but not the right ones.
493
+ expect(seen).toHaveLength(12);
494
+ expect(unique.size).toBeLessThan(12);
495
+ });
496
+
497
+ test("WITH the tiebreaker, every row is returned exactly once", () => {
498
+ const seen: Array<string> = pageThrough({
499
+ time: SortOrder.Descending,
500
+ _id: SortOrder.Descending,
501
+ });
502
+
503
+ expect(seen).toHaveLength(12);
504
+ expect(new Set(seen).size).toBe(12);
505
+ expect([...seen].sort()).toStrictEqual(
506
+ buildRows()
507
+ .map((row: Row): string => {
508
+ return row._id;
509
+ })
510
+ .sort(),
511
+ );
512
+ });
513
+
514
+ test("WITH the tiebreaker, page contents do not depend on tie order", () => {
515
+ const sort: Record<string, SortOrder> = {
516
+ time: SortOrder.Descending,
517
+ _id: SortOrder.Descending,
518
+ };
519
+ const rows: Array<Row> = buildRows();
520
+
521
+ // Offset 3 / limit 5 straddles two tie groups.
522
+ const underFirstTieOrder: Array<Row> = fetchPage(
523
+ rows,
524
+ sort,
525
+ tieOrders[0]!,
526
+ 3,
527
+ 5,
528
+ );
529
+ const underSecondTieOrder: Array<Row> = fetchPage(
530
+ rows,
531
+ sort,
532
+ tieOrders[1]!,
533
+ 3,
534
+ 5,
535
+ );
536
+
537
+ expect(underFirstTieOrder).toStrictEqual(underSecondTieOrder);
538
+ });
539
+
540
+ test("WITH the tiebreaker, ordering is fully deterministic", () => {
541
+ const sort: Record<string, SortOrder> = {
542
+ time: SortOrder.Descending,
543
+ _id: SortOrder.Descending,
544
+ };
545
+ const rows: Array<Row> = buildRows();
546
+
547
+ const ordered: Array<string> = orderRows(rows, sort, tieOrders[0]!).map(
548
+ (row: Row): string => {
549
+ return row._id;
550
+ },
551
+ );
552
+
553
+ /*
554
+ * `time` is 1000 - bucket, so bucket 0 (ids 00-03) is newest and comes
555
+ * first under DESC; within each tied bucket, _id descends.
556
+ */
557
+ expect(ordered).toStrictEqual([
558
+ "id-03",
559
+ "id-02",
560
+ "id-01",
561
+ "id-00",
562
+ "id-07",
563
+ "id-06",
564
+ "id-05",
565
+ "id-04",
566
+ "id-11",
567
+ "id-10",
568
+ "id-09",
569
+ "id-08",
570
+ ]);
571
+ });
572
+
573
+ test("an ascending tiebreaker is equally stable", () => {
574
+ const seen: Array<string> = pageThrough({
575
+ time: SortOrder.Ascending,
576
+ _id: SortOrder.Ascending,
577
+ });
578
+
579
+ expect(new Set(seen).size).toBe(12);
580
+ });
581
+ });
582
+ });
@@ -206,8 +206,13 @@ describe("AnalyticsDatabaseService", () => {
206
206
  expect(service.statementGenerator.toWhereStatement).toBeCalledWith({
207
207
  field: "value",
208
208
  });
209
+ /*
210
+ * `_id` is appended as a pagination tiebreaker so the ORDER BY is a
211
+ * total order. See the PaginationStableSort suite below.
212
+ */
209
213
  expect(service.statementGenerator.toSortStatement).toBeCalledWith({
210
214
  field: "asc",
215
+ _id: "asc",
211
216
  });
212
217
 
213
218
  expect(jest.mocked(logger.debug)).toHaveBeenCalledTimes(2);