@hot-updater/server 1.0.0-rc.1 → 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 (191) 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 +10 -3
  5. package/dist/adapters/drizzle.d.cts +0 -1
  6. package/dist/adapters/drizzle.d.mts +0 -1
  7. package/dist/adapters/drizzle.mjs +11 -3
  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 +24 -9
  48. package/dist/createHotUpdaterCore.d.cts +1 -1
  49. package/dist/createHotUpdaterCore.d.mts +1 -1
  50. package/dist/createHotUpdaterCore.mjs +23 -9
  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.d.cts +1 -2
  82. package/dist/handler.d.mts +1 -2
  83. package/dist/handlerReleaseCatalogRoutes.cjs +0 -1
  84. package/dist/handlerReleaseManagementRoutes.cjs +0 -1
  85. package/dist/handlerTypes.d.cts +0 -1
  86. package/dist/handlerTypes.d.mts +0 -1
  87. package/dist/handlerVersionRoutes.d.cts +1 -0
  88. package/dist/handlerVersionRoutes.d.mts +1 -0
  89. package/dist/index.cjs +1 -1
  90. package/dist/index.d.cts +4 -4
  91. package/dist/index.d.mts +4 -4
  92. package/dist/index.mjs +1 -1
  93. package/dist/insights/domain.d.cts +37 -69
  94. package/dist/insights/domain.d.mts +37 -69
  95. package/dist/insights/errors.cjs +1 -8
  96. package/dist/insights/errors.mjs +2 -8
  97. package/dist/insights/eventInput.cjs +49 -5
  98. package/dist/insights/eventInput.mjs +48 -6
  99. package/dist/insights/provider.cjs +314 -0
  100. package/dist/insights/provider.d.cts +6 -0
  101. package/dist/insights/provider.d.mts +6 -0
  102. package/dist/insights/provider.mjs +314 -0
  103. package/dist/insights/queryInput.cjs +78 -43
  104. package/dist/insights/queryInput.mjs +76 -40
  105. package/dist/insights/routes.cjs +20 -25
  106. package/dist/insights/routes.mjs +22 -27
  107. package/dist/insights/types.d.cts +27 -12
  108. package/dist/insights/types.d.mts +27 -12
  109. package/dist/node.d.cts +0 -1
  110. package/dist/node.d.mts +0 -1
  111. package/dist/package.cjs +1 -1
  112. package/dist/package.mjs +1 -1
  113. package/dist/schema/index.cjs +17 -3
  114. package/dist/schema/index.mjs +4 -4
  115. package/dist/schema/v1_0_0.cjs +72 -30
  116. package/dist/schema/v1_0_0.mjs +72 -31
  117. package/dist/storageAccess.cjs +0 -1
  118. package/dist/types/index.d.cts +0 -1
  119. package/dist/types/index.d.mts +0 -1
  120. package/package.json +7 -7
  121. package/src/adapters/databasePluginTestDatabase.ts +14 -0
  122. package/src/adapters/drizzle.spec.ts +99 -0
  123. package/src/adapters/drizzle.ts +21 -3
  124. package/src/adapters/drizzleCrud.ts +154 -2
  125. package/src/adapters/drizzleLazyDB.ts +17 -3
  126. package/src/adapters/insightsTransaction.ts +32 -0
  127. package/src/adapters/kysely.spec.ts +29 -0
  128. package/src/adapters/kysely.ts +10 -1
  129. package/src/adapters/kyselyCrud.ts +121 -0
  130. package/src/adapters/mongodb.integration.spec.ts +210 -0
  131. package/src/adapters/mongodb.spec.ts +69 -1
  132. package/src/adapters/mongodb.ts +66 -2
  133. package/src/adapters/mongodbCollections.ts +5 -0
  134. package/src/adapters/mongodbQuery.ts +32 -3
  135. package/src/adapters/mongodbReads.ts +69 -0
  136. package/src/adapters/mongodbTestClient.ts +41 -6
  137. package/src/adapters/mongodbTestFilter.ts +4 -1
  138. package/src/adapters/mongodbWrites.ts +27 -0
  139. package/src/adapters/prisma.spec.ts +97 -0
  140. package/src/adapters/prisma.ts +103 -3
  141. package/src/adapters/prismaRows.ts +37 -1
  142. package/src/adapters/prismaTestClient.ts +22 -5
  143. package/src/apiKeys.integration.spec.ts +1 -1
  144. package/src/createHotUpdaterCore.ts +21 -7
  145. package/src/db/databasePluginUpdateCheck.bench.ts +3 -0
  146. package/src/db/fixedMigrator.spec.ts +65 -0
  147. package/src/db/fixedMigratorMongo.spec.ts +56 -9
  148. package/src/db/fixedMigratorMongo.ts +10 -10
  149. package/src/db/index.spec.ts +8 -1
  150. package/src/db/schema/insightsCollation.ts +21 -0
  151. package/src/db/schema/sql.ts +8 -5
  152. package/src/db/schemaGenerators.ts +41 -13
  153. package/src/db/types.spec.ts +8 -0
  154. package/src/db/types.ts +10 -4
  155. package/src/handler-standalone.integration.spec.ts +5 -7
  156. package/src/index.ts +2 -2
  157. package/src/insights/domain.ts +39 -75
  158. package/src/insights/errors.ts +0 -7
  159. package/src/insights/eventInput.ts +60 -3
  160. package/src/insights/insights.integration.spec.ts +254 -17
  161. package/src/insights/provider.spec.ts +385 -0
  162. package/src/insights/provider.ts +591 -0
  163. package/src/insights/queryInput.ts +112 -73
  164. package/src/insights/routes.ts +37 -56
  165. package/src/insights/types.ts +47 -36
  166. package/src/schema/dsl-all-versions.spec.ts +3 -1
  167. package/src/schema/v1_0_0.spec.ts +77 -3
  168. package/src/schema/v1_0_0.ts +94 -30
  169. package/dist/insights/bounded/activeOverview.cjs +0 -87
  170. package/dist/insights/bounded/activeOverview.mjs +0 -85
  171. package/dist/insights/bounded/installationSearch.cjs +0 -42
  172. package/dist/insights/bounded/installationSearch.mjs +0 -42
  173. package/dist/insights/bounded/persistence.cjs +0 -39
  174. package/dist/insights/bounded/persistence.mjs +0 -38
  175. package/dist/insights/bounded/provider.cjs +0 -170
  176. package/dist/insights/bounded/provider.d.cts +0 -7
  177. package/dist/insights/bounded/provider.d.mts +0 -7
  178. package/dist/insights/bounded/provider.mjs +0 -170
  179. package/dist/insights/bounded/scan.cjs +0 -144
  180. package/dist/insights/bounded/scan.mjs +0 -138
  181. package/dist/insights/persistence.d.cts +0 -6
  182. package/dist/insights/persistence.d.mts +0 -6
  183. package/src/insights/bounded/activeOverview.spec.ts +0 -189
  184. package/src/insights/bounded/activeOverview.ts +0 -148
  185. package/src/insights/bounded/installationSearch.spec.ts +0 -130
  186. package/src/insights/bounded/installationSearch.ts +0 -69
  187. package/src/insights/bounded/persistence.ts +0 -43
  188. package/src/insights/bounded/provider.spec.ts +0 -219
  189. package/src/insights/bounded/provider.ts +0 -229
  190. package/src/insights/bounded/scan.ts +0 -235
  191. package/src/insights/persistence.ts +0 -10
@@ -15,17 +15,7 @@ export type CreateBundleEventRequestBase = {
15
15
 
16
16
  export type CreateBundleEventRequest =
17
17
  | (CreateBundleEventRequestBase & {
18
- readonly type: "UPDATE_APPLIED";
19
- readonly fromBundleId: string;
20
- readonly updateStrategy: "fingerprint" | "appVersion";
21
- })
22
- | (CreateBundleEventRequestBase & {
23
- readonly type: "RECOVERED";
24
- readonly fromBundleId: string;
25
- readonly updateStrategy: "fingerprint" | "appVersion";
26
- })
27
- | (CreateBundleEventRequestBase & {
28
- readonly type: "RELEASE_ADOPTED";
18
+ readonly type: "UPDATE_APPLIED" | "RECOVERED" | "RELEASE_ADOPTED";
29
19
  readonly fromBundleId: string;
30
20
  readonly updateStrategy: "fingerprint" | "appVersion";
31
21
  })
@@ -35,28 +25,20 @@ export type CreateBundleEventRequest =
35
25
  readonly updateStrategy: null;
36
26
  });
37
27
 
38
- export type BundleEventSummary = {
39
- readonly installed: number;
40
- readonly recovered: number;
41
- };
42
-
43
- export type BundleEventSummaryByBundle = BundleEventSummary & {
44
- readonly bundleId: string;
45
- };
46
-
47
- export type BundleEventInsightsWindow = "24h" | "7d" | "30d" | "all";
48
28
  export type ActiveInstallationWindow = "24h" | "7d" | "30d";
49
29
 
50
- export type InstallationSearchRow = {
30
+ export type EventHistoryRow = {
31
+ readonly id: string;
51
32
  readonly installId: string;
52
- readonly username: string | null;
53
- readonly userId: string | null;
54
- readonly lastKnownBundleId: string;
55
- readonly latestStatus:
33
+ readonly type:
56
34
  | "UPDATE_APPLIED"
57
35
  | "RECOVERED"
58
36
  | "RELEASE_ADOPTED"
59
37
  | "UNCHANGED";
38
+ readonly fromBundleId: string | null;
39
+ readonly toBundleId: string;
40
+ readonly username: string | null;
41
+ readonly userId: string | null;
60
42
  readonly platform: "ios" | "android";
61
43
  readonly appVersion: string;
62
44
  readonly channel: string;
@@ -64,13 +46,17 @@ export type InstallationSearchRow = {
64
46
  readonly receivedAtMs: number;
65
47
  };
66
48
 
67
- export type InstallationHistoryRow = {
68
- readonly id: string;
49
+ export type InstallationHistoryRow = EventHistoryRow & {
69
50
  readonly type: "UPDATE_APPLIED" | "RECOVERED";
70
51
  readonly fromBundleId: string;
71
- readonly toBundleId: string;
52
+ };
53
+
54
+ export type InstallationRow = {
55
+ readonly installId: string;
72
56
  readonly username: string | null;
73
57
  readonly userId: string | null;
58
+ readonly lastKnownBundleId: string;
59
+ readonly latestStatus: EventHistoryRow["type"];
74
60
  readonly platform: "ios" | "android";
75
61
  readonly appVersion: string;
76
62
  readonly channel: string;
@@ -78,62 +64,40 @@ export type InstallationHistoryRow = {
78
64
  readonly receivedAtMs: number;
79
65
  };
80
66
 
81
- export type OffsetPaginationResult<TData> = {
82
- readonly data: readonly TData[];
83
- readonly pagination: {
84
- readonly total: number;
85
- readonly limit: number;
86
- readonly offset: number;
87
- };
67
+ export type CursorPage<T> = {
68
+ readonly data: readonly T[];
69
+ readonly nextCursor: string | null;
88
70
  };
89
71
 
90
- export type InsightsSeriesPoint = {
91
- readonly bucketStartMs: number;
92
- readonly value: number;
72
+ export type EventCursorPage<T extends EventHistoryRow> = CursorPage<T> & {
73
+ readonly beforeReceivedAtMs: number;
93
74
  };
94
75
 
95
- export type InsightsCohortPoint = {
96
- readonly cohort: string;
97
- readonly value: number;
76
+ export type InsightsScope = {
77
+ readonly platform: "ios" | "android";
78
+ readonly channel: string;
98
79
  };
99
80
 
100
- export type BundleEventInsightsResult = {
101
- readonly summary: BundleEventSummary;
102
- readonly series: {
103
- readonly installed: readonly InsightsSeriesPoint[];
104
- readonly recovered: readonly InsightsSeriesPoint[];
105
- };
106
- readonly cohorts: {
107
- readonly installed: readonly InsightsCohortPoint[];
108
- readonly recovered: readonly InsightsCohortPoint[];
109
- };
110
- readonly recentEvents: OffsetPaginationResult<InstallationHistoryRow>;
81
+ export type InsightsBundleSelection = InsightsScope & {
82
+ readonly bundleId: string;
83
+ readonly outcome: "applied" | "recovered" | "adopted";
111
84
  };
112
85
 
113
- export type BundleEventOverview = {
114
- readonly trackedInstallations: number;
115
- readonly bundles: readonly {
116
- readonly bundleId: string;
117
- readonly installations: number;
118
- }[];
86
+ export type InsightsCountMeasurement = {
87
+ readonly count: number;
88
+ readonly measuredAtMs: number;
119
89
  };
120
90
 
121
- export type ActiveInstallationOverview = {
122
- readonly asOfMs: number;
91
+ export type ReportingOverview = InsightsScope & {
123
92
  readonly window: ActiveInstallationWindow;
124
- readonly activeInstallations: number;
125
- readonly series: readonly InsightsSeriesPoint[];
126
- readonly bundleSeries: readonly {
127
- readonly bundleId: string;
128
- readonly series: readonly InsightsSeriesPoint[];
129
- }[];
130
- readonly bundles: readonly {
93
+ readonly sinceMs: number;
94
+ readonly beforeReceivedAtMs: number;
95
+ readonly reportingInstallations: InsightsCountMeasurement;
96
+ readonly bundle?: {
131
97
  readonly bundleId: string;
132
- readonly installations: number;
133
- }[];
134
- };
135
-
136
- export type ActiveInstallationInput = {
137
- readonly window: ActiveInstallationWindow;
138
- readonly userId?: string;
98
+ readonly reportingInstallations: InsightsCountMeasurement;
99
+ readonly appliedReports: InsightsCountMeasurement;
100
+ readonly recoveredReports: InsightsCountMeasurement;
101
+ readonly adoptedReports: InsightsCountMeasurement;
102
+ };
139
103
  };
@@ -1,10 +1,3 @@
1
- export class InsightsScanLimitExceededError extends Error {
2
- constructor(readonly limit: number) {
3
- super(`Insights event scan exceeded ${limit} rows.`);
4
- this.name = "InsightsScanLimitExceededError";
5
- }
6
- }
7
-
8
1
  export class InsightsBadRequestError extends Error {
9
2
  readonly name = "InsightsBadRequestError";
10
3
  }
@@ -1,3 +1,5 @@
1
+ import { createUUIDv7, type BundleEventRow } from "@hot-updater/plugin-core";
2
+
1
3
  import type {
2
4
  CreateBundleEventRequest,
3
5
  CreateBundleEventRequestBase,
@@ -8,6 +10,7 @@ import {
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,7 +43,10 @@ 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
51
  throw new InsightsBadRequestError(`Invalid event field: ${key}`);
46
52
  }
@@ -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);
@@ -107,11 +124,11 @@ function requireEvent(payload: unknown): CreateBundleEventRequest {
107
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") }),
@@ -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
+ }
@@ -18,6 +18,8 @@ const event = {
18
18
  toReleaseId: null,
19
19
  type: "UNCHANGED",
20
20
  updateStrategy: null,
21
+ userId: "user-1",
22
+ username: "Jane",
21
23
  sdkVersion: "2.0.0",
22
24
  } as const;
23
25
 
@@ -33,11 +35,12 @@ afterEach(() => {
33
35
  });
34
36
 
35
37
  describe("createHotUpdater Insights", () => {
36
- it("always persists events and serves queries without a server Insights flag", async () => {
38
+ it("persists an event and serves the lean Insights views", async () => {
37
39
  vi.useFakeTimers();
38
40
  vi.setSystemTime(new Date("2026-08-12T00:00:00.000Z"));
41
+ const receivedAtMs = Date.now();
39
42
  const database = createInMemoryDatabasePlugin();
40
- const append = vi.spyOn(database.models.insights, "append");
43
+ const append = vi.spyOn(database.models.insights, "record");
41
44
  const hotUpdater = createHotUpdater({
42
45
  database,
43
46
  clientAccess: { type: "public" },
@@ -45,28 +48,173 @@ describe("createHotUpdater Insights", () => {
45
48
 
46
49
  const ingestion = await hotUpdater.handlers.client(eventRequest());
47
50
  vi.advanceTimersByTime(1);
48
- const overview = await hotUpdater.handlers.admin(
49
- new Request("https://example.com/installations/overview"),
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
+ ),
50
64
  );
51
65
 
52
66
  expect(ingestion.status).toBe(204);
53
67
  expect(append).toHaveBeenCalledWith(
54
68
  expect.objectContaining({
55
- install_id: "install-1",
56
- sdk_version: "2.0.0",
57
- to_bundle_id: "bundle-1",
58
- type: "UNCHANGED",
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
+ }),
59
75
  }),
60
76
  );
61
- expect(hotUpdater.insights).toMatchObject({
62
- mode: "bounded",
63
- maxMatchingRows: 50_000,
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",
64
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
+ );
65
159
  expect(overview.status).toBe(200);
66
- await expect(overview.json()).resolves.toEqual({
67
- bundles: [{ bundleId: "bundle-1", installations: 1 }],
68
- trackedInstallations: 1,
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" },
69
210
  });
211
+ expect(
212
+ (
213
+ await hotUpdater.handlers.admin(
214
+ new Request(`https://example.com${path}`),
215
+ )
216
+ ).status,
217
+ ).toBe(400);
70
218
  });
71
219
 
72
220
  it("checks the official schema before Insights reads and writes", async () => {
@@ -81,7 +229,7 @@ describe("createHotUpdater Insights", () => {
81
229
  });
82
230
 
83
231
  expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
84
- await hotUpdater.insights?.getBundleEventOverview();
232
+ await hotUpdater.insights.listEvents({});
85
233
 
86
234
  expect(createMigrator).toHaveBeenCalledOnce();
87
235
  });
@@ -94,11 +242,11 @@ describe("createHotUpdater Insights", () => {
94
242
 
95
243
  expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
96
244
  const clientQuery = await hotUpdater.handlers.client(
97
- new Request("https://example.com/installations/overview"),
245
+ new Request("https://example.com/events"),
98
246
  );
99
247
  const adminIngestion = await hotUpdater.handlers.admin(eventRequest());
100
248
  const adminQuery = await hotUpdater.handlers.admin(
101
- new Request("https://example.com/installations/overview"),
249
+ new Request("https://example.com/events"),
102
250
  );
103
251
 
104
252
  expect(clientQuery.status).toBe(404);
@@ -123,6 +271,95 @@ describe("createHotUpdater Insights", () => {
123
271
  });
124
272
  });
125
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
+
126
363
  it.each(["fromReleaseId", "toReleaseId"] as const)(
127
364
  "rejects an omitted %s instead of treating it as null",
128
365
  async (field) => {