@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
@@ -1,13 +1,16 @@
1
+ import { createUUIDv7, type BundleEventRow } from "@hot-updater/plugin-core";
2
+
1
3
  import type {
2
4
  CreateBundleEventRequest,
3
5
  CreateBundleEventRequestBase,
4
6
  } from "./domain";
5
7
  import {
6
- AnalyticsBadRequestError,
7
- AnalyticsPayloadTooLargeError,
8
+ InsightsBadRequestError,
9
+ InsightsPayloadTooLargeError,
8
10
  } from "./errors";
9
11
 
10
12
  const MAX_EVENT_STRING_LENGTH = 1_024;
13
+ const MAX_IDENTITY_LENGTH = 255;
11
14
  export const EVENT_BODY_MAX_BYTES = 16 * 1_024;
12
15
 
13
16
  const eventKeys = new Set([
@@ -40,9 +43,12 @@ function requireStringField(
40
43
  if (
41
44
  typeof value !== "string" ||
42
45
  value.length === 0 ||
43
- value.length > MAX_EVENT_STRING_LENGTH
46
+ value.length > MAX_EVENT_STRING_LENGTH ||
47
+ new TextDecoder("utf-8", { ignoreBOM: true }).decode(
48
+ new TextEncoder().encode(value),
49
+ ) !== value
44
50
  ) {
45
- throw new AnalyticsBadRequestError(`Invalid event field: ${key}`);
51
+ throw new InsightsBadRequestError(`Invalid event field: ${key}`);
46
52
  }
47
53
  return value;
48
54
  }
@@ -55,6 +61,17 @@ function requireNullableStringField(
55
61
  return requireStringField(payload, key);
56
62
  }
57
63
 
64
+ function requireIdentityField(
65
+ payload: Readonly<Record<string, unknown>>,
66
+ key: "installId" | "userId",
67
+ ): string {
68
+ const value = requireStringField(payload, key);
69
+ if (value.length > MAX_IDENTITY_LENGTH) {
70
+ throw new InsightsBadRequestError(`Invalid event field: ${key}`);
71
+ }
72
+ return value;
73
+ }
74
+
58
75
  async function readBoundedText(request: Request): Promise<string> {
59
76
  const contentLength = request.headers.get("content-length");
60
77
  const declaredByteLength = Number(contentLength);
@@ -63,7 +80,7 @@ async function readBoundedText(request: Request): Promise<string> {
63
80
  Number.isSafeInteger(declaredByteLength) &&
64
81
  declaredByteLength > EVENT_BODY_MAX_BYTES
65
82
  ) {
66
- throw new AnalyticsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
83
+ throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
67
84
  }
68
85
  if (request.body === null) return "";
69
86
  const reader = request.body.getReader();
@@ -76,7 +93,7 @@ async function readBoundedText(request: Request): Promise<string> {
76
93
  byteLength += result.value.byteLength;
77
94
  if (byteLength > EVENT_BODY_MAX_BYTES) {
78
95
  await reader.cancel();
79
- throw new AnalyticsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
96
+ throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
80
97
  }
81
98
  text += decoder.decode(result.value, { stream: true });
82
99
  }
@@ -89,7 +106,7 @@ async function parseJson(request: Request): Promise<unknown> {
89
106
  return JSON.parse(text);
90
107
  } catch (error) {
91
108
  if (error instanceof SyntaxError) {
92
- throw new AnalyticsBadRequestError("Invalid event payload");
109
+ throw new InsightsBadRequestError("Invalid event payload");
93
110
  }
94
111
  throw error;
95
112
  }
@@ -100,18 +117,18 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
100
117
  !isRecord(payload) ||
101
118
  Object.keys(payload).some((key) => !eventKeys.has(key))
102
119
  ) {
103
- throw new AnalyticsBadRequestError("Invalid event payload");
120
+ throw new InsightsBadRequestError("Invalid event payload");
104
121
  }
105
122
  const platform = requireStringField(payload, "platform");
106
123
  if (platform !== "ios" && platform !== "android") {
107
- throw new AnalyticsBadRequestError("Invalid event field: platform");
124
+ throw new InsightsBadRequestError("Invalid event field: platform");
108
125
  }
109
126
  const base: CreateBundleEventRequestBase = {
110
- installId: requireStringField(payload, "installId"),
127
+ installId: requireIdentityField(payload, "installId"),
111
128
  toBundleId: requireStringField(payload, "toBundleId"),
112
129
  ...(payload.userId === undefined
113
130
  ? {}
114
- : { userId: requireStringField(payload, "userId") }),
131
+ : { userId: requireIdentityField(payload, "userId") }),
115
132
  ...(payload.username === undefined
116
133
  ? {}
117
134
  : { username: requireStringField(payload, "username") }),
@@ -134,7 +151,7 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
134
151
  case "RELEASE_ADOPTED": {
135
152
  const updateStrategy = requireStringField(payload, "updateStrategy");
136
153
  if (updateStrategy !== "fingerprint" && updateStrategy !== "appVersion") {
137
- throw new AnalyticsBadRequestError(
154
+ throw new InsightsBadRequestError(
138
155
  "Invalid event field: updateStrategy",
139
156
  );
140
157
  }
@@ -147,7 +164,7 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
147
164
  }
148
165
  case "UNCHANGED":
149
166
  if (payload.fromBundleId !== null || payload.updateStrategy !== null) {
150
- throw new AnalyticsBadRequestError("Invalid unchanged event shape");
167
+ throw new InsightsBadRequestError("Invalid unchanged event shape");
151
168
  }
152
169
  return {
153
170
  ...base,
@@ -156,7 +173,7 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
156
173
  updateStrategy: null,
157
174
  };
158
175
  default:
159
- throw new AnalyticsBadRequestError("Invalid event field: type");
176
+ throw new InsightsBadRequestError("Invalid event field: type");
160
177
  }
161
178
  }
162
179
 
@@ -166,3 +183,43 @@ export async function parseBundleEventRequest(
166
183
  const payload = await parseJson(request);
167
184
  return requireEvent(payload);
168
185
  }
186
+
187
+ export function createBundleEventRow(
188
+ input: CreateBundleEventRequest,
189
+ ): BundleEventRow {
190
+ input = requireEvent(input);
191
+ const base = {
192
+ app_version: input.appVersion,
193
+ channel: input.channel,
194
+ cohort: input.cohort,
195
+ fingerprint_hash: input.fingerprintHash,
196
+ from_release_id: input.fromReleaseId,
197
+ id: createUUIDv7(),
198
+ install_id: input.installId,
199
+ platform: input.platform,
200
+ received_at_ms: Date.now(),
201
+ sdk_version: input.sdkVersion ?? null,
202
+ to_bundle_id: input.toBundleId,
203
+ to_release_id: input.toReleaseId,
204
+ user_id: input.userId ?? null,
205
+ username: input.username ?? null,
206
+ };
207
+ switch (input.type) {
208
+ case "UPDATE_APPLIED":
209
+ case "RECOVERED":
210
+ case "RELEASE_ADOPTED":
211
+ return {
212
+ ...base,
213
+ from_bundle_id: input.fromBundleId,
214
+ type: input.type,
215
+ update_strategy: input.updateStrategy,
216
+ };
217
+ case "UNCHANGED":
218
+ return {
219
+ ...base,
220
+ from_bundle_id: null,
221
+ type: input.type,
222
+ update_strategy: null,
223
+ };
224
+ }
225
+ }
@@ -0,0 +1,381 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { createInMemoryDatabasePlugin } from "../../../test-utils/test/inMemoryDatabasePlugin";
4
+ import { createHotUpdater } from "../index";
5
+ import { createSchemaManagedDatabase } from "../runtime.testFixtures";
6
+ import { HOT_UPDATER_SCHEMA_VERSION } from "../schema/types";
7
+
8
+ const event = {
9
+ appVersion: "1.0.0",
10
+ channel: "production",
11
+ cohort: "default",
12
+ fingerprintHash: null,
13
+ fromBundleId: null,
14
+ fromReleaseId: null,
15
+ installId: "install-1",
16
+ platform: "ios",
17
+ toBundleId: "bundle-1",
18
+ toReleaseId: null,
19
+ type: "UNCHANGED",
20
+ updateStrategy: null,
21
+ userId: "user-1",
22
+ username: "Jane",
23
+ sdkVersion: "2.0.0",
24
+ } as const;
25
+
26
+ const eventRequest = (body: unknown = event) =>
27
+ new Request("https://example.com/events", {
28
+ method: "POST",
29
+ headers: { "content-type": "application/json" },
30
+ body: JSON.stringify(body),
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.useRealTimers();
35
+ });
36
+
37
+ describe("createHotUpdater Insights", () => {
38
+ it("persists an event and serves the lean Insights views", async () => {
39
+ vi.useFakeTimers();
40
+ vi.setSystemTime(new Date("2026-08-12T00:00:00.000Z"));
41
+ const receivedAtMs = Date.now();
42
+ const database = createInMemoryDatabasePlugin();
43
+ const append = vi.spyOn(database.models.insights, "record");
44
+ const hotUpdater = createHotUpdater({
45
+ database,
46
+ clientAccess: { type: "public" },
47
+ });
48
+
49
+ const ingestion = await hotUpdater.handlers.client(eventRequest());
50
+ vi.advanceTimersByTime(1);
51
+ const events = await hotUpdater.handlers.admin(
52
+ new Request("https://example.com/events"),
53
+ );
54
+ const installation = await hotUpdater.handlers.admin(
55
+ new Request("https://example.com/installations/install-1"),
56
+ );
57
+ const matches = await hotUpdater.handlers.admin(
58
+ new Request("https://example.com/installations?userId=user-1"),
59
+ );
60
+ const active = await hotUpdater.handlers.admin(
61
+ new Request(
62
+ "https://example.com/overview?platform=ios&channel=production&window=24h",
63
+ ),
64
+ );
65
+
66
+ expect(ingestion.status).toBe(204);
67
+ expect(append).toHaveBeenCalledWith(
68
+ expect.objectContaining({
69
+ event: expect.objectContaining({
70
+ install_id: "install-1",
71
+ sdk_version: "2.0.0",
72
+ to_bundle_id: "bundle-1",
73
+ type: "UNCHANGED",
74
+ }),
75
+ }),
76
+ );
77
+ expect(events.status).toBe(200);
78
+ await expect(events.json()).resolves.toMatchObject({
79
+ data: [
80
+ {
81
+ id: expect.any(String),
82
+ installId: "install-1",
83
+ receivedAtMs,
84
+ type: "UNCHANGED",
85
+ userId: "user-1",
86
+ username: "Jane",
87
+ },
88
+ ],
89
+ nextCursor: null,
90
+ });
91
+ expect(installation.status).toBe(200);
92
+ await expect(installation.json()).resolves.toMatchObject({
93
+ installId: "install-1",
94
+ latestStatus: "UNCHANGED",
95
+ userId: "user-1",
96
+ });
97
+ expect(matches.status).toBe(200);
98
+ await expect(matches.json()).resolves.toMatchObject({
99
+ data: [{ installId: "install-1", userId: "user-1" }],
100
+ nextCursor: null,
101
+ });
102
+ expect(active.status).toBe(200);
103
+ await expect(active.json()).resolves.toEqual({
104
+ platform: "ios",
105
+ channel: "production",
106
+ sinceMs: Date.now() - 24 * 60 * 60 * 1_000,
107
+ beforeReceivedAtMs: Date.now(),
108
+ reportingInstallations: { count: 1, measuredAtMs: Date.now() },
109
+ window: "24h",
110
+ });
111
+ });
112
+
113
+ it("serves scoped bundle counts and the same half-open recovery drill-down", async () => {
114
+ vi.useFakeTimers();
115
+ const hotUpdater = createHotUpdater({
116
+ database: createInMemoryDatabasePlugin(),
117
+ clientAccess: { type: "public" },
118
+ });
119
+ const report = async (
120
+ receivedAtMs: number,
121
+ overrides: Record<string, unknown>,
122
+ ) => {
123
+ vi.setSystemTime(receivedAtMs);
124
+ const response = await hotUpdater.handlers.client(
125
+ eventRequest({ ...event, ...overrides }),
126
+ );
127
+ expect(response.status).toBe(204);
128
+ };
129
+ await report(100, {
130
+ type: "UPDATE_APPLIED",
131
+ fromBundleId: "A",
132
+ toBundleId: "B",
133
+ updateStrategy: "appVersion",
134
+ });
135
+ await report(200, {
136
+ type: "RECOVERED",
137
+ fromBundleId: "B",
138
+ toBundleId: "A",
139
+ updateStrategy: "appVersion",
140
+ });
141
+ await report(300, {
142
+ installId: "install-2",
143
+ type: "RELEASE_ADOPTED",
144
+ fromBundleId: "B",
145
+ toBundleId: "B",
146
+ updateStrategy: "appVersion",
147
+ });
148
+ await report(400, {
149
+ installId: "install-3",
150
+ platform: "android",
151
+ toBundleId: "B",
152
+ });
153
+ vi.setSystemTime(500);
154
+ const overview = await hotUpdater.handlers.admin(
155
+ new Request(
156
+ "https://example.com/overview?platform=ios&channel=production&window=24h&bundleId=B",
157
+ ),
158
+ );
159
+ expect(overview.status).toBe(200);
160
+ await expect(overview.json()).resolves.toMatchObject({
161
+ sinceMs: 0,
162
+ beforeReceivedAtMs: 500,
163
+ reportingInstallations: { count: 2 },
164
+ bundle: {
165
+ bundleId: "B",
166
+ reportingInstallations: { count: 1 },
167
+ appliedReports: { count: 1 },
168
+ recoveredReports: { count: 1 },
169
+ adoptedReports: { count: 1 },
170
+ },
171
+ });
172
+ const drilldown = await hotUpdater.handlers.admin(
173
+ new Request(
174
+ "https://example.com/events?platform=ios&channel=production&bundleId=B&outcome=recovered&sinceMs=200&beforeReceivedAtMs=300",
175
+ ),
176
+ );
177
+ await expect(drilldown.json()).resolves.toMatchObject({
178
+ data: [
179
+ {
180
+ type: "RECOVERED",
181
+ fromBundleId: "B",
182
+ toBundleId: "A",
183
+ receivedAtMs: 200,
184
+ },
185
+ ],
186
+ nextCursor: null,
187
+ });
188
+ const outside = await hotUpdater.handlers.admin(
189
+ new Request(
190
+ "https://example.com/events?platform=ios&channel=production&bundleId=B&outcome=recovered&sinceMs=0&beforeReceivedAtMs=200",
191
+ ),
192
+ );
193
+ await expect(outside.json()).resolves.toMatchObject({
194
+ data: [],
195
+ nextCursor: null,
196
+ });
197
+ });
198
+
199
+ it.each([
200
+ "/overview?window=24h",
201
+ "/overview?platform=ios&channel=production&platform=android",
202
+ "/events?bundleId=B",
203
+ "/events?platform=ios&channel=production&bundleId=B&outcome=UNCHANGED",
204
+ "/events?sinceMs=20&beforeReceivedAtMs=10",
205
+ "/installations/install-1/events?platform=ios&channel=production&bundleId=B&outcome=applied",
206
+ ])("rejects ambiguous or invalid Insights query %s", async (path) => {
207
+ const hotUpdater = createHotUpdater({
208
+ database: createInMemoryDatabasePlugin(),
209
+ clientAccess: { type: "public" },
210
+ });
211
+ expect(
212
+ (
213
+ await hotUpdater.handlers.admin(
214
+ new Request(`https://example.com${path}`),
215
+ )
216
+ ).status,
217
+ ).toBe(400);
218
+ });
219
+
220
+ it("checks the official schema before Insights reads and writes", async () => {
221
+ const database = createSchemaManagedDatabase(
222
+ "kysely",
223
+ HOT_UPDATER_SCHEMA_VERSION,
224
+ );
225
+ const createMigrator = vi.spyOn(database, "createMigrator");
226
+ const hotUpdater = createHotUpdater({
227
+ database,
228
+ clientAccess: { type: "public" },
229
+ });
230
+
231
+ expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
232
+ await hotUpdater.insights.listEvents({});
233
+
234
+ expect(createMigrator).toHaveBeenCalledOnce();
235
+ });
236
+
237
+ it("keeps ingestion and queries on separate handler surfaces", async () => {
238
+ const hotUpdater = createHotUpdater({
239
+ database: createInMemoryDatabasePlugin(),
240
+ clientAccess: { type: "public" },
241
+ });
242
+
243
+ expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
244
+ const clientQuery = await hotUpdater.handlers.client(
245
+ new Request("https://example.com/events"),
246
+ );
247
+ const adminIngestion = await hotUpdater.handlers.admin(eventRequest());
248
+ const adminQuery = await hotUpdater.handlers.admin(
249
+ new Request("https://example.com/events"),
250
+ );
251
+
252
+ expect(clientQuery.status).toBe(404);
253
+ expect(adminIngestion.status).toBe(404);
254
+ expect(adminQuery.status).toBe(200);
255
+ expect(adminQuery.headers.get("cache-control")).toBe("private, no-store");
256
+ });
257
+
258
+ it("returns a stable client error for malformed event payloads", async () => {
259
+ const hotUpdater = createHotUpdater({
260
+ database: createInMemoryDatabasePlugin(),
261
+ clientAccess: { type: "public" },
262
+ });
263
+
264
+ const response = await hotUpdater.handlers.client(
265
+ eventRequest({ ...event, platform: "web" }),
266
+ );
267
+
268
+ expect(response.status).toBe(400);
269
+ await expect(response.json()).resolves.toEqual({
270
+ error: "Invalid event field: platform",
271
+ });
272
+ });
273
+
274
+ it.each(["installId", "userId"] as const)(
275
+ "rejects a 256-character event %s",
276
+ async (field) => {
277
+ const hotUpdater = createHotUpdater({
278
+ database: createInMemoryDatabasePlugin(),
279
+ clientAccess: { type: "public" },
280
+ });
281
+
282
+ const response = await hotUpdater.handlers.client(
283
+ eventRequest({ ...event, [field]: "x".repeat(256) }),
284
+ );
285
+
286
+ expect(response.status).toBe(400);
287
+ await expect(response.json()).resolves.toEqual({
288
+ error: `Invalid event field: ${field}`,
289
+ });
290
+ },
291
+ );
292
+
293
+ it("preserves a leading Unicode BOM in exact installation and current-user IDs", async () => {
294
+ const hotUpdater = createHotUpdater({
295
+ database: createInMemoryDatabasePlugin(),
296
+ clientAccess: { type: "public" },
297
+ });
298
+ const installId = "\uFEFFinstall";
299
+ const userId = "\uFEFFuser";
300
+ expect(
301
+ (
302
+ await hotUpdater.handlers.client(
303
+ eventRequest({ ...event, installId, userId }),
304
+ )
305
+ ).status,
306
+ ).toBe(204);
307
+ const installation = await hotUpdater.handlers.admin(
308
+ new Request(
309
+ `https://example.com/installations/${encodeURIComponent(installId)}`,
310
+ ),
311
+ );
312
+ expect(installation.status).toBe(200);
313
+ await expect(installation.json()).resolves.toMatchObject({
314
+ installId,
315
+ userId,
316
+ });
317
+ const matches = await hotUpdater.handlers.admin(
318
+ new Request(
319
+ `https://example.com/installations?userId=${encodeURIComponent(userId)}`,
320
+ ),
321
+ );
322
+ expect(matches.status).toBe(200);
323
+ await expect(matches.json()).resolves.toMatchObject({
324
+ data: [{ installId, userId }],
325
+ });
326
+ });
327
+
328
+ it.each(["\uD800", "\uDC00"])(
329
+ "rejects an unmatched surrogate identity %j",
330
+ async (installId) => {
331
+ const hotUpdater = createHotUpdater({
332
+ database: createInMemoryDatabasePlugin(),
333
+ clientAccess: { type: "public" },
334
+ });
335
+ expect(
336
+ (
337
+ await hotUpdater.handlers.client(
338
+ eventRequest({ ...event, installId }),
339
+ )
340
+ ).status,
341
+ ).toBe(400);
342
+ },
343
+ );
344
+
345
+ it("rejects 256-character installation query identities", async () => {
346
+ const hotUpdater = createHotUpdater({
347
+ database: createInMemoryDatabasePlugin(),
348
+ clientAccess: { type: "public" },
349
+ });
350
+ const tooLong = "x".repeat(256);
351
+
352
+ const user = await hotUpdater.handlers.admin(
353
+ new Request(`https://example.com/installations?userId=${tooLong}`),
354
+ );
355
+ const installation = await hotUpdater.handlers.admin(
356
+ new Request(`https://example.com/installations/${tooLong}`),
357
+ );
358
+
359
+ expect(user.status).toBe(400);
360
+ expect(installation.status).toBe(400);
361
+ });
362
+
363
+ it.each(["fromReleaseId", "toReleaseId"] as const)(
364
+ "rejects an omitted %s instead of treating it as null",
365
+ async (field) => {
366
+ const hotUpdater = createHotUpdater({
367
+ database: createInMemoryDatabasePlugin(),
368
+ clientAccess: { type: "public" },
369
+ });
370
+ const payload: Record<string, unknown> = { ...event };
371
+ delete payload[field];
372
+
373
+ const response = await hotUpdater.handlers.client(eventRequest(payload));
374
+
375
+ expect(response.status).toBe(400);
376
+ await expect(response.json()).resolves.toEqual({
377
+ error: `Invalid event field: ${field}`,
378
+ });
379
+ },
380
+ );
381
+ });