@hot-updater/server 1.0.0-rc.0 → 1.0.0-rc.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 (210) hide show
  1. package/dist/_virtual/_rolldown/runtime.cjs +1 -1
  2. package/dist/adapters/databaseConstraintErrors.cjs +0 -1
  3. package/dist/adapters/databasePluginUtils.cjs +0 -1
  4. package/dist/adapters/drizzle.cjs +11 -4
  5. package/dist/adapters/drizzle.d.cts +0 -1
  6. package/dist/adapters/drizzle.d.mts +0 -1
  7. package/dist/adapters/drizzle.mjs +12 -4
  8. package/dist/adapters/drizzleCrud.cjs +62 -1
  9. package/dist/adapters/drizzleCrud.mjs +61 -2
  10. package/dist/adapters/drizzleLazyDB.cjs +6 -1
  11. package/dist/adapters/drizzleLazyDB.mjs +6 -1
  12. package/dist/adapters/drizzleQuery.cjs +3 -2
  13. package/dist/adapters/drizzleQuery.mjs +3 -1
  14. package/dist/adapters/insightsTransaction.cjs +22 -0
  15. package/dist/adapters/insightsTransaction.mjs +22 -0
  16. package/dist/adapters/kysely.cjs +2 -1
  17. package/dist/adapters/kysely.d.cts +0 -1
  18. package/dist/adapters/kysely.d.mts +0 -1
  19. package/dist/adapters/kysely.mjs +3 -1
  20. package/dist/adapters/kyselyCrud.cjs +54 -3
  21. package/dist/adapters/kyselyCrud.mjs +54 -3
  22. package/dist/adapters/kyselyQuery.cjs +2 -2
  23. package/dist/adapters/kyselyQuery.mjs +2 -1
  24. package/dist/adapters/mongodb.cjs +27 -1
  25. package/dist/adapters/mongodb.d.cts +1 -1
  26. package/dist/adapters/mongodb.d.mts +1 -1
  27. package/dist/adapters/mongodb.mjs +28 -1
  28. package/dist/adapters/mongodbCollections.cjs +2 -0
  29. package/dist/adapters/mongodbCollections.mjs +2 -0
  30. package/dist/adapters/mongodbQuery.cjs +21 -4
  31. package/dist/adapters/mongodbQuery.mjs +21 -5
  32. package/dist/adapters/mongodbReads.cjs +91 -29
  33. package/dist/adapters/mongodbReads.mjs +92 -29
  34. package/dist/adapters/mongodbWrites.cjs +16 -2
  35. package/dist/adapters/mongodbWrites.mjs +18 -3
  36. package/dist/adapters/prisma.cjs +55 -7
  37. package/dist/adapters/prisma.d.cts +0 -1
  38. package/dist/adapters/prisma.d.mts +0 -1
  39. package/dist/adapters/prisma.mjs +56 -7
  40. package/dist/adapters/prismaQuery.cjs +0 -1
  41. package/dist/adapters/prismaRows.cjs +22 -1
  42. package/dist/adapters/prismaRows.mjs +22 -1
  43. package/dist/apiKeys.cjs +6 -2
  44. package/dist/apiKeys.d.cts +0 -1
  45. package/dist/apiKeys.d.mts +0 -1
  46. package/dist/apiKeys.mjs +3 -3
  47. package/dist/createHotUpdaterCore.cjs +27 -12
  48. package/dist/createHotUpdaterCore.d.cts +8 -8
  49. package/dist/createHotUpdaterCore.d.mts +8 -8
  50. package/dist/createHotUpdaterCore.mjs +26 -12
  51. package/dist/db/createBundleDiff.cjs +2 -2
  52. package/dist/db/createBundleDiff.d.cts +1 -5
  53. package/dist/db/createBundleDiff.d.mts +1 -5
  54. package/dist/db/databasePluginCore.cjs +0 -1
  55. package/dist/db/fixedMigrator.cjs +2 -2
  56. package/dist/db/fixedMigrator.mjs +2 -2
  57. package/dist/db/fixedMigratorMongo.cjs +6 -4
  58. package/dist/db/fixedMigratorMongo.mjs +6 -4
  59. package/dist/db/index.d.cts +0 -1
  60. package/dist/db/index.d.mts +0 -1
  61. package/dist/db/releaseCatalog.cjs +0 -1
  62. package/dist/db/releaseCatalog.d.cts +0 -1
  63. package/dist/db/releaseCatalog.d.mts +0 -1
  64. package/dist/db/schema/insightsCollation.cjs +14 -0
  65. package/dist/db/schema/insightsCollation.mjs +14 -0
  66. package/dist/db/schema/sql.cjs +11 -7
  67. package/dist/db/schema/sql.mjs +8 -8
  68. package/dist/db/schemaGenerators.cjs +28 -9
  69. package/dist/db/schemaGenerators.mjs +28 -9
  70. package/dist/db/schemaReadiness.cjs +2 -0
  71. package/dist/db/schemaReadiness.d.cts +1 -0
  72. package/dist/db/schemaReadiness.d.mts +1 -0
  73. package/dist/db/schemaReadiness.mjs +2 -0
  74. package/dist/db/sqlMigrationExecution.cjs +1 -1
  75. package/dist/db/sqlMigrationExecution.mjs +1 -0
  76. package/dist/db/types.cjs +3 -1
  77. package/dist/db/types.d.cts +3 -6
  78. package/dist/db/types.d.mts +3 -6
  79. package/dist/db/types.mjs +2 -2
  80. package/dist/db/updateArtifacts.cjs +0 -1
  81. package/dist/handler.cjs +5 -5
  82. package/dist/handler.d.cts +1 -2
  83. package/dist/handler.d.mts +1 -2
  84. package/dist/handler.mjs +5 -5
  85. package/dist/handlerReleaseCatalogRoutes.cjs +0 -1
  86. package/dist/handlerReleaseManagementRoutes.cjs +0 -1
  87. package/dist/handlerTypes.d.cts +0 -1
  88. package/dist/handlerTypes.d.mts +0 -1
  89. package/dist/handlerVersionRoutes.d.cts +1 -0
  90. package/dist/handlerVersionRoutes.d.mts +1 -0
  91. package/dist/index.cjs +2 -2
  92. package/dist/index.d.cts +4 -4
  93. package/dist/index.d.mts +4 -4
  94. package/dist/index.mjs +2 -2
  95. package/dist/insights/domain.d.cts +89 -0
  96. package/dist/insights/domain.d.mts +89 -0
  97. package/dist/insights/errors.cjs +15 -0
  98. package/dist/insights/errors.mjs +14 -0
  99. package/dist/{analytics → insights}/eventInput.cjs +58 -14
  100. package/dist/{analytics → insights}/eventInput.mjs +58 -16
  101. package/dist/insights/provider.cjs +314 -0
  102. package/dist/insights/provider.d.cts +6 -0
  103. package/dist/insights/provider.d.mts +6 -0
  104. package/dist/insights/provider.mjs +314 -0
  105. package/dist/insights/queryInput.cjs +93 -0
  106. package/dist/insights/queryInput.mjs +91 -0
  107. package/dist/insights/routes.cjs +58 -0
  108. package/dist/insights/routes.mjs +56 -0
  109. package/dist/insights/types.d.cts +32 -0
  110. package/dist/insights/types.d.mts +32 -0
  111. package/dist/node.d.cts +0 -1
  112. package/dist/node.d.mts +0 -1
  113. package/dist/package.cjs +1 -1
  114. package/dist/package.mjs +1 -1
  115. package/dist/schema/index.cjs +17 -3
  116. package/dist/schema/index.mjs +4 -4
  117. package/dist/schema/v1_0_0.cjs +72 -30
  118. package/dist/schema/v1_0_0.mjs +72 -31
  119. package/dist/storageAccess.cjs +0 -1
  120. package/dist/types/index.d.cts +0 -1
  121. package/dist/types/index.d.mts +0 -1
  122. package/package.json +7 -7
  123. package/src/adapters/databasePluginTestDatabase.ts +14 -0
  124. package/src/adapters/drizzle.spec.ts +99 -0
  125. package/src/adapters/drizzle.ts +22 -4
  126. package/src/adapters/drizzleCrud.ts +154 -2
  127. package/src/adapters/drizzleLazyDB.ts +17 -3
  128. package/src/adapters/insightsTransaction.ts +32 -0
  129. package/src/adapters/kysely.spec.ts +29 -0
  130. package/src/adapters/kysely.ts +10 -1
  131. package/src/adapters/kyselyCrud.ts +121 -0
  132. package/src/adapters/mongodb.integration.spec.ts +210 -0
  133. package/src/adapters/mongodb.spec.ts +69 -1
  134. package/src/adapters/mongodb.ts +66 -2
  135. package/src/adapters/mongodbCollections.ts +5 -0
  136. package/src/adapters/mongodbQuery.ts +32 -3
  137. package/src/adapters/mongodbReads.ts +69 -0
  138. package/src/adapters/mongodbTestClient.ts +41 -6
  139. package/src/adapters/mongodbTestFilter.ts +4 -1
  140. package/src/adapters/mongodbWrites.ts +27 -0
  141. package/src/adapters/prisma.spec.ts +97 -0
  142. package/src/adapters/prisma.ts +103 -3
  143. package/src/adapters/prismaRows.ts +37 -1
  144. package/src/adapters/prismaTestClient.ts +22 -5
  145. package/src/apiKeys.integration.spec.ts +3 -3
  146. package/src/createHotUpdaterCore.ts +30 -16
  147. package/src/db/databasePluginUpdateCheck.bench.ts +3 -0
  148. package/src/db/fixedMigrator.spec.ts +65 -0
  149. package/src/db/fixedMigratorMongo.spec.ts +56 -9
  150. package/src/db/fixedMigratorMongo.ts +10 -10
  151. package/src/db/index.spec.ts +8 -1
  152. package/src/db/schema/insightsCollation.ts +21 -0
  153. package/src/db/schema/sql.ts +8 -5
  154. package/src/db/schemaGenerators.ts +41 -13
  155. package/src/db/types.spec.ts +11 -3
  156. package/src/db/types.ts +13 -7
  157. package/src/handler-standalone.integration.spec.ts +6 -8
  158. package/src/handler.ts +12 -12
  159. package/src/index.ts +3 -3
  160. package/src/insights/domain.ts +103 -0
  161. package/src/insights/errors.ts +11 -0
  162. package/src/{analytics → insights}/eventInput.ts +71 -14
  163. package/src/insights/insights.integration.spec.ts +381 -0
  164. package/src/insights/provider.spec.ts +385 -0
  165. package/src/insights/provider.ts +591 -0
  166. package/src/insights/queryInput.ts +141 -0
  167. package/src/insights/routes.ts +104 -0
  168. package/src/insights/types.ts +55 -0
  169. package/src/schema/dsl-all-versions.spec.ts +3 -1
  170. package/src/schema/v1_0_0.spec.ts +77 -3
  171. package/src/schema/v1_0_0.ts +94 -30
  172. package/dist/analytics/bounded/activeOverview.cjs +0 -87
  173. package/dist/analytics/bounded/activeOverview.mjs +0 -85
  174. package/dist/analytics/bounded/installationSearch.cjs +0 -42
  175. package/dist/analytics/bounded/installationSearch.mjs +0 -42
  176. package/dist/analytics/bounded/persistence.cjs +0 -39
  177. package/dist/analytics/bounded/persistence.mjs +0 -38
  178. package/dist/analytics/bounded/provider.cjs +0 -170
  179. package/dist/analytics/bounded/provider.d.cts +0 -7
  180. package/dist/analytics/bounded/provider.d.mts +0 -7
  181. package/dist/analytics/bounded/provider.mjs +0 -170
  182. package/dist/analytics/bounded/scan.cjs +0 -144
  183. package/dist/analytics/bounded/scan.mjs +0 -138
  184. package/dist/analytics/domain.d.cts +0 -121
  185. package/dist/analytics/domain.d.mts +0 -121
  186. package/dist/analytics/errors.cjs +0 -22
  187. package/dist/analytics/errors.mjs +0 -20
  188. package/dist/analytics/persistence.d.cts +0 -6
  189. package/dist/analytics/persistence.d.mts +0 -6
  190. package/dist/analytics/queryInput.cjs +0 -58
  191. package/dist/analytics/queryInput.mjs +0 -55
  192. package/dist/analytics/routes.cjs +0 -63
  193. package/dist/analytics/routes.mjs +0 -61
  194. package/dist/analytics/types.d.cts +0 -17
  195. package/dist/analytics/types.d.mts +0 -17
  196. package/src/analytics/analytics.integration.spec.ts +0 -144
  197. package/src/analytics/bounded/activeOverview.spec.ts +0 -189
  198. package/src/analytics/bounded/activeOverview.ts +0 -148
  199. package/src/analytics/bounded/installationSearch.spec.ts +0 -130
  200. package/src/analytics/bounded/installationSearch.ts +0 -69
  201. package/src/analytics/bounded/persistence.ts +0 -43
  202. package/src/analytics/bounded/provider.spec.ts +0 -219
  203. package/src/analytics/bounded/provider.ts +0 -229
  204. package/src/analytics/bounded/scan.ts +0 -235
  205. package/src/analytics/domain.ts +0 -139
  206. package/src/analytics/errors.ts +0 -18
  207. package/src/analytics/persistence.ts +0 -10
  208. package/src/analytics/queryInput.ts +0 -102
  209. package/src/analytics/routes.ts +0 -123
  210. package/src/analytics/types.ts +0 -44
@@ -0,0 +1,385 @@
1
+ import type {
2
+ BundleEventRow,
3
+ InsightsInstallationRow,
4
+ InsightsModel,
5
+ } from "@hot-updater/plugin-core";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+
8
+ import { InsightsBadRequestError } from "./errors";
9
+ import { createInsightsProvider } from "./provider";
10
+
11
+ const eventId = (index: number) =>
12
+ `00000000-0000-7000-8000-${String(index).padStart(12, "0")}`;
13
+
14
+ type TransitionEventRow = Extract<
15
+ BundleEventRow,
16
+ { readonly type: "UPDATE_APPLIED" | "RECOVERED" | "RELEASE_ADOPTED" }
17
+ >;
18
+
19
+ const eventRow = (
20
+ id: string,
21
+ receivedAtMs: number,
22
+ overrides: Partial<TransitionEventRow> = {},
23
+ ): TransitionEventRow => ({
24
+ app_version: "1.0.0",
25
+ channel: "production",
26
+ cohort: "default",
27
+ fingerprint_hash: null,
28
+ from_bundle_id: "bundle-before",
29
+ from_release_id: null,
30
+ id,
31
+ install_id: "install-1",
32
+ platform: "ios",
33
+ received_at_ms: receivedAtMs,
34
+ sdk_version: "2.0.0",
35
+ to_bundle_id: "bundle-after",
36
+ to_release_id: null,
37
+ type: "UPDATE_APPLIED",
38
+ update_strategy: "appVersion",
39
+ user_id: "user-1",
40
+ username: "Jane",
41
+ ...overrides,
42
+ });
43
+
44
+ const installationRow = (
45
+ installId: string,
46
+ overrides: Partial<InsightsInstallationRow> = {},
47
+ ): InsightsInstallationRow => ({
48
+ app_version: "1.0.0",
49
+ channel: "production",
50
+ cohort: "default",
51
+ id: eventId(1),
52
+ install_id: installId,
53
+ platform: "ios",
54
+ received_at_ms: 1_000,
55
+ to_bundle_id: "bundle-1",
56
+ type: "UNCHANGED",
57
+ user_id: "user-1",
58
+ username: "Jane",
59
+ ...overrides,
60
+ });
61
+
62
+ const createModel = () => {
63
+ const model = {
64
+ record: vi.fn<InsightsModel["record"]>(async () => {}),
65
+ listEvents: vi.fn<InsightsModel["listEvents"]>(async () => []),
66
+ findInstallations: vi.fn<InsightsModel["findInstallations"]>(
67
+ async () => [],
68
+ ),
69
+ countInstallations: vi.fn<InsightsModel["countInstallations"]>(
70
+ async () => 0,
71
+ ),
72
+ countEvents: vi.fn<InsightsModel["countEvents"]>(async () => 0),
73
+ } satisfies InsightsModel;
74
+ return { ...model, model };
75
+ };
76
+
77
+ afterEach(() => {
78
+ vi.useRealTimers();
79
+ });
80
+
81
+ describe("createInsightsProvider", () => {
82
+ it("pages all events with one bounded database call and a strict stable cutoff", async () => {
83
+ vi.useFakeTimers();
84
+ vi.setSystemTime(1_000);
85
+ const fixture = createModel();
86
+ fixture.listEvents
87
+ .mockResolvedValueOnce([
88
+ eventRow(eventId(3), 999),
89
+ eventRow(eventId(2), 900),
90
+ eventRow(eventId(1), 800),
91
+ ])
92
+ .mockResolvedValueOnce([eventRow(eventId(1), 800)]);
93
+ const provider = createInsightsProvider(fixture.model);
94
+
95
+ const first = await provider.listEvents({ limit: 2 });
96
+
97
+ expect(first.data.map(({ id }) => id)).toEqual([eventId(3), eventId(2)]);
98
+ expect(first.beforeReceivedAtMs).toBe(1_000);
99
+ expect(first.nextCursor).not.toBeNull();
100
+ expect(fixture.listEvents).toHaveBeenNthCalledWith(1, {
101
+ beforeReceivedAtMs: 1_000,
102
+ limit: 3,
103
+ sinceMs: 0,
104
+ filter: { kind: "all" },
105
+ });
106
+
107
+ const second = await provider.listEvents({
108
+ cursor: first.nextCursor ?? undefined,
109
+ limit: 2,
110
+ });
111
+
112
+ expect(second.beforeReceivedAtMs).toBe(1_000);
113
+ expect(second.nextCursor).toBeNull();
114
+ expect(fixture.listEvents).toHaveBeenNthCalledWith(2, {
115
+ after: { id: eventId(2), receivedAtMs: 900 },
116
+ beforeReceivedAtMs: 1_000,
117
+ limit: 3,
118
+ sinceMs: 0,
119
+ filter: { kind: "all" },
120
+ });
121
+ });
122
+
123
+ it("binds event cursors to their filter", async () => {
124
+ vi.useFakeTimers();
125
+ vi.setSystemTime(1_000);
126
+ const fixture = createModel();
127
+ fixture.listEvents.mockResolvedValue([
128
+ eventRow(eventId(2), 900),
129
+ eventRow(eventId(1), 800),
130
+ ]);
131
+ const provider = createInsightsProvider(fixture.model);
132
+ const allEvents = await provider.listEvents({ limit: 1 });
133
+
134
+ await expect(
135
+ provider.listInstallationEvents({
136
+ cursor: allEvents.nextCursor ?? undefined,
137
+ installId: "install-1",
138
+ limit: 1,
139
+ }),
140
+ ).rejects.toBeInstanceOf(InsightsBadRequestError);
141
+ expect(fixture.listEvents).toHaveBeenCalledOnce();
142
+ });
143
+
144
+ it("rejects installation and user IDs longer than the indexed key limit", async () => {
145
+ const fixture = createModel();
146
+ const provider = createInsightsProvider(fixture.model);
147
+ const tooLong = "x".repeat(256);
148
+
149
+ await expect(
150
+ provider.getInstallation({ installId: tooLong }),
151
+ ).rejects.toBeInstanceOf(InsightsBadRequestError);
152
+ await expect(
153
+ provider.listInstallationEvents({ installId: tooLong }),
154
+ ).rejects.toBeInstanceOf(InsightsBadRequestError);
155
+ await expect(
156
+ provider.pageInstallationsByCurrentUserId({ userId: tooLong }),
157
+ ).rejects.toBeInstanceOf(InsightsBadRequestError);
158
+ expect(fixture.findInstallations).not.toHaveBeenCalled();
159
+ expect(fixture.listEvents).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it("requests only movement events for one installation", async () => {
163
+ const fixture = createModel();
164
+ fixture.listEvents.mockResolvedValue([
165
+ eventRow(eventId(1), 900, {
166
+ install_id: "install-2",
167
+ type: "RECOVERED",
168
+ }),
169
+ ]);
170
+ const provider = createInsightsProvider(fixture.model);
171
+
172
+ const result = await provider.listInstallationEvents({
173
+ beforeReceivedAtMs: 1_000,
174
+ installId: "install-2",
175
+ limit: 10,
176
+ });
177
+
178
+ expect(result.data).toEqual([
179
+ expect.objectContaining({
180
+ id: eventId(1),
181
+ installId: "install-2",
182
+ type: "RECOVERED",
183
+ }),
184
+ ]);
185
+ expect(fixture.listEvents).toHaveBeenCalledWith({
186
+ beforeReceivedAtMs: 1_000,
187
+ limit: 11,
188
+ sinceMs: 0,
189
+ filter: { kind: "installationMovement", installId: "install-2" },
190
+ });
191
+ });
192
+
193
+ it("pages exact current user matches and binds the cursor to that user", async () => {
194
+ const fixture = createModel();
195
+ fixture.findInstallations.mockResolvedValue([
196
+ installationRow("install-a"),
197
+ installationRow("install-b"),
198
+ installationRow("install-c"),
199
+ ]);
200
+ const provider = createInsightsProvider(fixture.model);
201
+
202
+ const page = await provider.pageInstallationsByCurrentUserId({
203
+ limit: 2,
204
+ userId: "user-1",
205
+ });
206
+
207
+ expect(page.data.map(({ installId }) => installId)).toEqual([
208
+ "install-a",
209
+ "install-b",
210
+ ]);
211
+ expect(fixture.findInstallations).toHaveBeenCalledWith({
212
+ limit: 3,
213
+ userId: "user-1",
214
+ });
215
+ await expect(
216
+ provider.pageInstallationsByCurrentUserId({
217
+ cursor: page.nextCursor ?? undefined,
218
+ userId: "user-2",
219
+ }),
220
+ ).rejects.toBeInstanceOf(InsightsBadRequestError);
221
+ expect(fixture.findInstallations).toHaveBeenCalledOnce();
222
+ });
223
+
224
+ it("counts one explicit scope and returns independent measurement times", async () => {
225
+ vi.useFakeTimers();
226
+ vi.setSystemTime(new Date("2026-08-12T00:00:00.000Z"));
227
+ const fixture = createModel();
228
+ fixture.countInstallations.mockResolvedValue(123);
229
+ const provider = createInsightsProvider(fixture.model);
230
+ const input = {
231
+ window: "7d",
232
+ platform: "ios",
233
+ channel: "production",
234
+ } as const;
235
+ await expect(provider.getReportingOverview(input)).resolves.toEqual({
236
+ ...input,
237
+ sinceMs: Date.now() - 7 * 24 * 60 * 60 * 1_000,
238
+ beforeReceivedAtMs: Date.now(),
239
+ reportingInstallations: { count: 123, measuredAtMs: Date.now() },
240
+ });
241
+ expect(fixture.countInstallations).toHaveBeenCalledWith({
242
+ platform: "ios",
243
+ channel: "production",
244
+ sinceMs: Date.now() - 7 * 24 * 60 * 60 * 1_000,
245
+ });
246
+ expect(fixture.countEvents).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it("attributes recovery to the source bundle and reuses its count predicate for drill-down", async () => {
250
+ vi.useFakeTimers();
251
+ vi.setSystemTime(new Date("2026-08-12T00:00:00.000Z"));
252
+ const fixture = createModel();
253
+ fixture.countInstallations
254
+ .mockResolvedValueOnce(1)
255
+ .mockResolvedValueOnce(2);
256
+ fixture.countEvents
257
+ .mockResolvedValueOnce(5)
258
+ .mockResolvedValueOnce(3)
259
+ .mockResolvedValueOnce(1);
260
+ const provider = createInsightsProvider(fixture.model);
261
+ const scope = { platform: "ios", channel: "production" } as const;
262
+ const result = await provider.getReportingOverview({
263
+ ...scope,
264
+ window: "24h",
265
+ bundleId: "B",
266
+ });
267
+ expect(result.reportingInstallations.count).toBe(1);
268
+ // Independent live measurements are never clamped or turned into a share.
269
+ expect(result.bundle?.reportingInstallations.count).toBe(2);
270
+ expect(result.bundle?.appliedReports.count).toBe(5);
271
+ expect(result.bundle?.recoveredReports.count).toBe(3);
272
+ expect(result.bundle?.adoptedReports.count).toBe(1);
273
+ expect(
274
+ fixture.countEvents.mock.calls.map(([input]) => input.filter),
275
+ ).toEqual([
276
+ { ...scope, type: "UPDATE_APPLIED", toBundleId: "B" },
277
+ { ...scope, type: "RECOVERED", fromBundleId: "B" },
278
+ { ...scope, type: "RELEASE_ADOPTED", toBundleId: "B" },
279
+ ]);
280
+ await provider.listEvents({
281
+ bundle: { ...scope, bundleId: "B", outcome: "recovered" },
282
+ sinceMs: result.sinceMs,
283
+ beforeReceivedAtMs: result.beforeReceivedAtMs,
284
+ });
285
+ const counted = fixture.countEvents.mock.calls[1]![0];
286
+ expect(fixture.listEvents).toHaveBeenCalledWith({
287
+ ...counted,
288
+ filter: { kind: "bundle", ...counted.filter },
289
+ limit: 51,
290
+ });
291
+ });
292
+
293
+ it("binds bundle cursors to the outcome, scope, bundle, and both time bounds", async () => {
294
+ const fixture = createModel();
295
+ fixture.listEvents.mockResolvedValue([
296
+ eventRow(eventId(2), 900),
297
+ eventRow(eventId(1), 800),
298
+ ]);
299
+ const provider = createInsightsProvider(fixture.model);
300
+ const bundle = {
301
+ platform: "ios",
302
+ channel: "production",
303
+ bundleId: "bundle-after",
304
+ outcome: "applied",
305
+ } as const;
306
+ const first = await provider.listEvents({
307
+ bundle,
308
+ sinceMs: 100,
309
+ beforeReceivedAtMs: 1_000,
310
+ limit: 1,
311
+ });
312
+ const cursor = first.nextCursor ?? undefined;
313
+ for (const change of [
314
+ { bundle: { ...bundle, outcome: "recovered" as const } },
315
+ { bundle: { ...bundle, channel: "beta" } },
316
+ { bundle: { ...bundle, bundleId: "another" } },
317
+ { sinceMs: 101 },
318
+ { beforeReceivedAtMs: 1_001 },
319
+ ]) {
320
+ await expect(
321
+ provider.listEvents({ bundle, cursor, ...change }),
322
+ ).rejects.toBeInstanceOf(InsightsBadRequestError);
323
+ }
324
+ expect(fixture.listEvents).toHaveBeenCalledOnce();
325
+ });
326
+
327
+ it("rejects forged event keys before they reach the database boundary", async () => {
328
+ const fixture = createModel();
329
+ fixture.listEvents.mockResolvedValue([
330
+ eventRow(eventId(2), 900),
331
+ eventRow(eventId(1), 800),
332
+ ]);
333
+ const provider = createInsightsProvider(fixture.model);
334
+ const first = await provider.listEvents({
335
+ sinceMs: 100,
336
+ beforeReceivedAtMs: 1_000,
337
+ limit: 1,
338
+ });
339
+ const payload = JSON.parse(
340
+ Buffer.from(first.nextCursor!, "base64url").toString("utf8"),
341
+ );
342
+ for (const after of [
343
+ { id: "invalid", receivedAtMs: 900 },
344
+ { id: eventId(1), receivedAtMs: 1_000 },
345
+ { id: eventId(1), receivedAtMs: 99 },
346
+ ]) {
347
+ const cursor = Buffer.from(
348
+ JSON.stringify({ ...payload, after }),
349
+ ).toString("base64url");
350
+ await expect(provider.listEvents({ cursor })).rejects.toBeInstanceOf(
351
+ InsightsBadRequestError,
352
+ );
353
+ }
354
+ expect(fixture.listEvents).toHaveBeenCalledOnce();
355
+ });
356
+
357
+ it("propagates count failures without reporting a partial overview", async () => {
358
+ const fixture = createModel();
359
+ fixture.countEvents.mockRejectedValue(new Error("database unavailable"));
360
+ const provider = createInsightsProvider(fixture.model);
361
+ await expect(
362
+ provider.getReportingOverview({
363
+ platform: "ios",
364
+ channel: "production",
365
+ window: "24h",
366
+ bundleId: "B",
367
+ }),
368
+ ).rejects.toThrow("database unavailable");
369
+ });
370
+
371
+ it("uses UTF-8 order when checking exact current-user pages", async () => {
372
+ const fixture = createModel();
373
+ fixture.findInstallations.mockResolvedValue([
374
+ installationRow("\uE000"),
375
+ installationRow("\u{10000}"),
376
+ ]);
377
+ const result = await createInsightsProvider(
378
+ fixture.model,
379
+ ).pageInstallationsByCurrentUserId({ userId: "user-1" });
380
+ expect(result.data.map(({ installId }) => installId)).toEqual([
381
+ "\uE000",
382
+ "\u{10000}",
383
+ ]);
384
+ });
385
+ });