@hot-updater/server 0.36.7 → 1.0.0-rc.1

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 (291) hide show
  1. package/dist/adapters/databaseConstraintErrors.cjs +23 -0
  2. package/dist/adapters/databaseConstraintErrors.mjs +21 -0
  3. package/dist/adapters/databasePluginUtils.cjs +117 -0
  4. package/dist/adapters/databasePluginUtils.mjs +106 -0
  5. package/dist/adapters/drizzle.cjs +53 -176
  6. package/dist/adapters/drizzle.d.cts +6 -4
  7. package/dist/adapters/drizzle.d.mts +6 -4
  8. package/dist/adapters/drizzle.mjs +54 -177
  9. package/dist/adapters/drizzleCrud.cjs +250 -0
  10. package/dist/adapters/drizzleCrud.mjs +249 -0
  11. package/dist/adapters/drizzleLazyDB.cjs +84 -36
  12. package/dist/adapters/drizzleLazyDB.mjs +84 -36
  13. package/dist/adapters/drizzleQuery.cjs +68 -0
  14. package/dist/adapters/drizzleQuery.mjs +67 -0
  15. package/dist/adapters/kysely.cjs +18 -138
  16. package/dist/adapters/kysely.d.cts +3 -12
  17. package/dist/adapters/kysely.d.mts +3 -12
  18. package/dist/adapters/kysely.mjs +19 -139
  19. package/dist/adapters/kyselyCrud.cjs +254 -0
  20. package/dist/adapters/kyselyCrud.mjs +253 -0
  21. package/dist/adapters/kyselyQuery.cjs +56 -0
  22. package/dist/adapters/kyselyQuery.mjs +55 -0
  23. package/dist/adapters/mongodb.cjs +21 -174
  24. package/dist/adapters/mongodb.d.cts +3 -2
  25. package/dist/adapters/mongodb.d.mts +3 -2
  26. package/dist/adapters/mongodb.mjs +21 -174
  27. package/dist/adapters/mongodbCollections.cjs +36 -0
  28. package/dist/adapters/mongodbCollections.mjs +30 -0
  29. package/dist/adapters/mongodbQuery.cjs +77 -0
  30. package/dist/adapters/mongodbQuery.mjs +70 -0
  31. package/dist/adapters/mongodbReads.cjs +148 -0
  32. package/dist/adapters/mongodbReads.mjs +147 -0
  33. package/dist/adapters/mongodbWrites.cjs +134 -0
  34. package/dist/adapters/mongodbWrites.mjs +133 -0
  35. package/dist/adapters/prisma.cjs +205 -180
  36. package/dist/adapters/prisma.d.cts +3 -3
  37. package/dist/adapters/prisma.d.mts +3 -3
  38. package/dist/adapters/prisma.mjs +206 -181
  39. package/dist/adapters/prismaQuery.cjs +58 -0
  40. package/dist/adapters/prismaQuery.mjs +56 -0
  41. package/dist/adapters/prismaRows.cjs +198 -0
  42. package/dist/adapters/prismaRows.mjs +188 -0
  43. package/dist/apiKeys.cjs +139 -0
  44. package/dist/apiKeys.d.cts +50 -0
  45. package/dist/apiKeys.d.mts +50 -0
  46. package/dist/apiKeys.mjs +133 -0
  47. package/dist/createHotUpdaterCore.cjs +48 -26
  48. package/dist/createHotUpdaterCore.d.cts +49 -17
  49. package/dist/createHotUpdaterCore.d.mts +49 -17
  50. package/dist/createHotUpdaterCore.mjs +51 -29
  51. package/dist/db/createBundleDiff.cjs +45 -68
  52. package/dist/db/createBundleDiff.d.cts +3 -3
  53. package/dist/db/createBundleDiff.d.mts +3 -3
  54. package/dist/db/createBundleDiff.mjs +47 -68
  55. package/dist/db/databasePluginCore.cjs +138 -0
  56. package/dist/db/databasePluginCore.mjs +137 -0
  57. package/dist/db/fixedMigrator.cjs +36 -93
  58. package/dist/db/fixedMigrator.mjs +36 -93
  59. package/dist/db/fixedMigratorMongo.cjs +319 -0
  60. package/dist/db/fixedMigratorMongo.mjs +319 -0
  61. package/dist/db/fixedMigratorShared.cjs +23 -0
  62. package/dist/db/fixedMigratorShared.mjs +18 -0
  63. package/dist/db/mongoMigrationExecution.cjs +11 -0
  64. package/dist/db/mongoMigrationExecution.mjs +11 -0
  65. package/dist/db/releaseCatalog.cjs +66 -0
  66. package/dist/db/releaseCatalog.d.cts +17 -0
  67. package/dist/db/releaseCatalog.d.mts +17 -0
  68. package/dist/db/releaseCatalog.mjs +64 -0
  69. package/dist/db/schema/mongodb.cjs +14 -6
  70. package/dist/db/schema/mongodb.mjs +14 -6
  71. package/dist/db/schema/registry.cjs +0 -2
  72. package/dist/db/schema/registry.mjs +1 -2
  73. package/dist/db/schema/sql.cjs +31 -14
  74. package/dist/db/schema/sql.mjs +32 -13
  75. package/dist/db/schema/sqlOperations.cjs +4 -4
  76. package/dist/db/schema/sqlOperations.mjs +5 -5
  77. package/dist/db/schemaGenerators.cjs +52 -18
  78. package/dist/db/schemaGenerators.mjs +52 -18
  79. package/dist/db/schemaReadiness.cjs +4 -3
  80. package/dist/db/schemaReadiness.mjs +4 -3
  81. package/dist/db/sqlMigrationExecution.cjs +67 -0
  82. package/dist/db/sqlMigrationExecution.mjs +66 -0
  83. package/dist/db/types.cjs +2 -6
  84. package/dist/db/types.d.cts +60 -16
  85. package/dist/db/types.d.mts +60 -16
  86. package/dist/db/types.mjs +3 -6
  87. package/dist/db/updateArtifacts.cjs +114 -60
  88. package/dist/db/updateArtifacts.mjs +117 -62
  89. package/dist/handler.cjs +108 -282
  90. package/dist/handler.d.cts +3 -46
  91. package/dist/handler.d.mts +3 -46
  92. package/dist/handler.mjs +107 -281
  93. package/dist/handlerBundleRoutes.cjs +155 -0
  94. package/dist/handlerBundleRoutes.mjs +155 -0
  95. package/dist/handlerErrors.cjs +9 -0
  96. package/dist/handlerErrors.mjs +9 -0
  97. package/dist/handlerParameters.cjs +39 -0
  98. package/dist/handlerParameters.mjs +34 -0
  99. package/dist/handlerReleaseCatalogRoutes.cjs +111 -0
  100. package/dist/handlerReleaseCatalogRoutes.mjs +110 -0
  101. package/dist/handlerReleaseManagementRoutes.cjs +125 -0
  102. package/dist/handlerReleaseManagementRoutes.mjs +124 -0
  103. package/dist/handlerTypes.d.cts +63 -0
  104. package/dist/handlerTypes.d.mts +63 -0
  105. package/dist/handlerVersionRoutes.cjs +10 -0
  106. package/dist/handlerVersionRoutes.d.cts +4 -0
  107. package/dist/handlerVersionRoutes.d.mts +4 -0
  108. package/dist/handlerVersionRoutes.mjs +9 -0
  109. package/dist/index.cjs +10 -1
  110. package/dist/index.d.cts +9 -3
  111. package/dist/index.d.mts +9 -3
  112. package/dist/index.mjs +5 -2
  113. package/dist/insights/bounded/activeOverview.cjs +87 -0
  114. package/dist/insights/bounded/activeOverview.mjs +85 -0
  115. package/dist/insights/bounded/installationSearch.cjs +42 -0
  116. package/dist/insights/bounded/installationSearch.mjs +42 -0
  117. package/dist/insights/bounded/persistence.cjs +39 -0
  118. package/dist/insights/bounded/persistence.mjs +38 -0
  119. package/dist/insights/bounded/provider.cjs +170 -0
  120. package/dist/insights/bounded/provider.d.cts +7 -0
  121. package/dist/insights/bounded/provider.d.mts +7 -0
  122. package/dist/insights/bounded/provider.mjs +170 -0
  123. package/dist/insights/bounded/scan.cjs +144 -0
  124. package/dist/insights/bounded/scan.mjs +138 -0
  125. package/dist/insights/domain.d.cts +121 -0
  126. package/dist/insights/domain.d.mts +121 -0
  127. package/dist/insights/errors.cjs +22 -0
  128. package/dist/insights/errors.mjs +20 -0
  129. package/dist/insights/eventInput.cjs +111 -0
  130. package/dist/insights/eventInput.mjs +111 -0
  131. package/dist/insights/persistence.d.cts +6 -0
  132. package/dist/insights/persistence.d.mts +6 -0
  133. package/dist/insights/queryInput.cjs +58 -0
  134. package/dist/insights/queryInput.mjs +55 -0
  135. package/dist/insights/routes.cjs +63 -0
  136. package/dist/insights/routes.mjs +61 -0
  137. package/dist/insights/types.d.cts +17 -0
  138. package/dist/insights/types.d.mts +17 -0
  139. package/dist/node.cjs +19 -12
  140. package/dist/node.d.cts +15 -10
  141. package/dist/node.d.mts +15 -10
  142. package/dist/node.mjs +19 -13
  143. package/dist/package.cjs +1 -1
  144. package/dist/package.mjs +1 -1
  145. package/dist/schema/dsl.cjs +19 -2
  146. package/dist/schema/dsl.mjs +17 -3
  147. package/dist/schema/index.cjs +1 -8
  148. package/dist/schema/index.mjs +2 -8
  149. package/dist/schema/types.cjs +3 -1
  150. package/dist/schema/types.mjs +3 -2
  151. package/dist/schema/v1_0_0.cjs +364 -0
  152. package/dist/schema/v1_0_0.mjs +357 -0
  153. package/dist/storageAccess.cjs +69 -25
  154. package/dist/storageAccess.mjs +68 -25
  155. package/dist/types/index.d.cts +4 -3
  156. package/dist/types/index.d.mts +5 -4
  157. package/package.json +8 -7
  158. package/src/adapters/databaseConstraintErrors.ts +25 -0
  159. package/src/adapters/databasePluginTestDatabase.ts +106 -0
  160. package/src/adapters/databasePluginUtils.spec.ts +29 -0
  161. package/src/adapters/databasePluginUtils.ts +214 -0
  162. package/src/adapters/drizzle.spec.ts +208 -47
  163. package/src/adapters/drizzle.ts +106 -293
  164. package/src/adapters/drizzleCrud.ts +419 -0
  165. package/src/adapters/drizzleLazyDB.ts +206 -100
  166. package/src/adapters/drizzleQuery.ts +164 -0
  167. package/src/adapters/kysely.spec.ts +126 -88
  168. package/src/adapters/kysely.ts +76 -334
  169. package/src/adapters/kyselyCrud.ts +588 -0
  170. package/src/adapters/kyselyQuery.ts +128 -0
  171. package/src/adapters/mongodb.spec.ts +141 -0
  172. package/src/adapters/mongodb.ts +60 -288
  173. package/src/adapters/mongodbCollections.ts +62 -0
  174. package/src/adapters/mongodbQuery.ts +226 -0
  175. package/src/adapters/mongodbReads.ts +311 -0
  176. package/src/adapters/mongodbRows.spec.ts +64 -0
  177. package/src/adapters/mongodbRows.ts +118 -0
  178. package/src/adapters/mongodbTestClient.ts +342 -0
  179. package/src/adapters/mongodbTestFilter.ts +166 -0
  180. package/src/adapters/mongodbWrites.ts +260 -0
  181. package/src/adapters/prisma.spec.ts +196 -0
  182. package/src/adapters/prisma.ts +417 -292
  183. package/src/adapters/prismaQuery.ts +129 -0
  184. package/src/adapters/prismaRows.spec.ts +98 -0
  185. package/src/adapters/prismaRows.ts +301 -0
  186. package/src/adapters/prismaTestClient.ts +396 -0
  187. package/src/apiKeys.integration.spec.ts +192 -0
  188. package/src/apiKeys.spec.ts +162 -0
  189. package/src/apiKeys.ts +220 -0
  190. package/src/createHotUpdaterCore.ts +164 -100
  191. package/src/db/bundleRows.spec.ts +164 -0
  192. package/src/db/bundleRows.ts +10 -140
  193. package/src/db/createBundleDiff.spec.ts +172 -127
  194. package/src/db/createBundleDiff.ts +91 -110
  195. package/src/db/databasePluginCore.spec.ts +62 -0
  196. package/src/db/databasePluginCore.testFixtures.ts +76 -0
  197. package/src/db/databasePluginCore.ts +185 -0
  198. package/src/db/databasePluginUpdateCheck.bench.ts +107 -0
  199. package/src/db/fixedMigrator.spec.ts +184 -42
  200. package/src/db/fixedMigrator.ts +64 -205
  201. package/src/db/fixedMigratorMongo.spec.ts +192 -0
  202. package/src/db/fixedMigratorMongo.ts +425 -0
  203. package/src/db/fixedMigratorShared.ts +36 -0
  204. package/src/db/hotUpdaterSchema.ts +0 -1
  205. package/src/db/index.spec.ts +333 -1025
  206. package/src/db/mongoMigrationExecution.ts +23 -0
  207. package/src/db/node-sqlite.d.ts +25 -0
  208. package/src/db/releaseCatalog.ts +148 -0
  209. package/src/db/schema/mongodb.ts +20 -6
  210. package/src/db/schema/sql.ts +52 -11
  211. package/src/db/schema/sqlOperations.ts +5 -4
  212. package/src/db/schemaGenerators.ts +107 -23
  213. package/src/db/schemaReadiness.ts +2 -1
  214. package/src/db/sqlMigrationExecution.spec.ts +65 -0
  215. package/src/db/sqlMigrationExecution.ts +108 -0
  216. package/src/db/types.spec.ts +55 -0
  217. package/src/db/types.ts +129 -65
  218. package/src/db/updateArtifacts.spec.ts +359 -0
  219. package/src/db/updateArtifacts.ts +290 -126
  220. package/src/handler-standalone.integration.spec.ts +401 -364
  221. package/src/handler.spec.ts +137 -582
  222. package/src/handler.testFixtures.ts +30 -0
  223. package/src/handler.ts +218 -616
  224. package/src/handlerAuth.integration.spec.ts +33 -0
  225. package/src/handlerBundleRoutes.ts +259 -0
  226. package/src/handlerErrors.ts +7 -0
  227. package/src/handlerManagement.spec.ts +262 -0
  228. package/src/handlerParameters.ts +105 -0
  229. package/src/handlerReleaseCatalogRoutes.ts +154 -0
  230. package/src/handlerReleaseManagementRoutes.ts +213 -0
  231. package/src/handlerTypes.ts +96 -0
  232. package/src/handlerVersionRoutes.ts +12 -0
  233. package/src/index.ts +22 -2
  234. package/src/insights/bounded/activeOverview.spec.ts +189 -0
  235. package/src/insights/bounded/activeOverview.ts +148 -0
  236. package/src/insights/bounded/installationSearch.spec.ts +130 -0
  237. package/src/insights/bounded/installationSearch.ts +69 -0
  238. package/src/insights/bounded/persistence.ts +43 -0
  239. package/src/insights/bounded/provider.spec.ts +219 -0
  240. package/src/insights/bounded/provider.ts +229 -0
  241. package/src/insights/bounded/scan.ts +235 -0
  242. package/src/insights/domain.ts +139 -0
  243. package/src/insights/errors.ts +18 -0
  244. package/src/insights/eventInput.ts +168 -0
  245. package/src/insights/insights.integration.spec.ts +144 -0
  246. package/src/insights/persistence.ts +10 -0
  247. package/src/insights/queryInput.ts +102 -0
  248. package/src/insights/routes.ts +123 -0
  249. package/src/insights/types.ts +44 -0
  250. package/src/node.spec.ts +79 -10
  251. package/src/node.ts +20 -17
  252. package/src/releaseCatalogRoutes.integration.spec.ts +238 -0
  253. package/src/runtime.spec.ts +126 -959
  254. package/src/runtime.testFixtures.ts +71 -0
  255. package/src/schema/dsl-all-versions.spec.ts +26 -17
  256. package/src/schema/dsl.ts +41 -2
  257. package/src/schema/index.ts +3 -9
  258. package/src/schema/types.ts +9 -3
  259. package/src/schema/v1_0_0.spec.ts +146 -0
  260. package/src/schema/v1_0_0.ts +465 -0
  261. package/src/storageAccess.spec.ts +127 -32
  262. package/src/storageAccess.ts +106 -55
  263. package/src/types/index.ts +2 -1
  264. package/dist/db/bundleRows.cjs +0 -82
  265. package/dist/db/bundleRows.d.cts +0 -33
  266. package/dist/db/bundleRows.d.mts +0 -33
  267. package/dist/db/bundleRows.mjs +0 -79
  268. package/dist/db/pluginCore.cjs +0 -242
  269. package/dist/db/pluginCore.mjs +0 -241
  270. package/dist/db/schema/sqlMigrations.cjs +0 -74
  271. package/dist/db/schema/sqlMigrations.mjs +0 -73
  272. package/dist/db/schemaEnhancements.cjs +0 -20
  273. package/dist/db/schemaEnhancements.mjs +0 -19
  274. package/dist/route.cjs +0 -7
  275. package/dist/route.mjs +0 -7
  276. package/dist/schema/v0_21_0.cjs +0 -38
  277. package/dist/schema/v0_21_0.mjs +0 -37
  278. package/dist/schema/v0_29_0.cjs +0 -45
  279. package/dist/schema/v0_29_0.mjs +0 -44
  280. package/dist/schema/v0_31_0.cjs +0 -82
  281. package/dist/schema/v0_31_0.mjs +0 -80
  282. package/src/db/pluginCore.spec.ts +0 -1095
  283. package/src/db/pluginCore.ts +0 -475
  284. package/src/db/pluginUpdateCheck.bench.ts +0 -254
  285. package/src/db/schema/sqlMigrations.ts +0 -219
  286. package/src/db/schemaEnhancements.ts +0 -55
  287. package/src/route.ts +0 -7
  288. package/src/schema/v0_21_0.ts +0 -52
  289. package/src/schema/v0_29_0.ts +0 -62
  290. package/src/schema/v0_31_0.spec.ts +0 -73
  291. package/src/schema/v0_31_0.ts +0 -120
@@ -0,0 +1,168 @@
1
+ import type {
2
+ CreateBundleEventRequest,
3
+ CreateBundleEventRequestBase,
4
+ } from "./domain";
5
+ import {
6
+ InsightsBadRequestError,
7
+ InsightsPayloadTooLargeError,
8
+ } from "./errors";
9
+
10
+ const MAX_EVENT_STRING_LENGTH = 1_024;
11
+ export const EVENT_BODY_MAX_BYTES = 16 * 1_024;
12
+
13
+ const eventKeys = new Set([
14
+ "type",
15
+ "installId",
16
+ "toBundleId",
17
+ "userId",
18
+ "username",
19
+ "platform",
20
+ "appVersion",
21
+ "channel",
22
+ "cohort",
23
+ "fingerprintHash",
24
+ "fromBundleId",
25
+ "fromReleaseId",
26
+ "toReleaseId",
27
+ "updateStrategy",
28
+ "sdkVersion",
29
+ ]);
30
+
31
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ function requireStringField(
36
+ payload: Readonly<Record<string, unknown>>,
37
+ key: string,
38
+ ): string {
39
+ const value = payload[key];
40
+ if (
41
+ typeof value !== "string" ||
42
+ value.length === 0 ||
43
+ value.length > MAX_EVENT_STRING_LENGTH
44
+ ) {
45
+ throw new InsightsBadRequestError(`Invalid event field: ${key}`);
46
+ }
47
+ return value;
48
+ }
49
+
50
+ function requireNullableStringField(
51
+ payload: Readonly<Record<string, unknown>>,
52
+ key: string,
53
+ ): string | null {
54
+ if (payload[key] === null) return null;
55
+ return requireStringField(payload, key);
56
+ }
57
+
58
+ async function readBoundedText(request: Request): Promise<string> {
59
+ const contentLength = request.headers.get("content-length");
60
+ const declaredByteLength = Number(contentLength);
61
+ if (
62
+ contentLength !== null &&
63
+ Number.isSafeInteger(declaredByteLength) &&
64
+ declaredByteLength > EVENT_BODY_MAX_BYTES
65
+ ) {
66
+ throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
67
+ }
68
+ if (request.body === null) return "";
69
+ const reader = request.body.getReader();
70
+ const decoder = new TextDecoder();
71
+ let byteLength = 0;
72
+ let text = "";
73
+ while (true) {
74
+ const result = await reader.read();
75
+ if (result.done) break;
76
+ byteLength += result.value.byteLength;
77
+ if (byteLength > EVENT_BODY_MAX_BYTES) {
78
+ await reader.cancel();
79
+ throw new InsightsPayloadTooLargeError(EVENT_BODY_MAX_BYTES);
80
+ }
81
+ text += decoder.decode(result.value, { stream: true });
82
+ }
83
+ return text + decoder.decode();
84
+ }
85
+
86
+ async function parseJson(request: Request): Promise<unknown> {
87
+ const text = await readBoundedText(request);
88
+ try {
89
+ return JSON.parse(text);
90
+ } catch (error) {
91
+ if (error instanceof SyntaxError) {
92
+ throw new InsightsBadRequestError("Invalid event payload");
93
+ }
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ function requireEvent(payload: unknown): CreateBundleEventRequest {
99
+ if (
100
+ !isRecord(payload) ||
101
+ Object.keys(payload).some((key) => !eventKeys.has(key))
102
+ ) {
103
+ throw new InsightsBadRequestError("Invalid event payload");
104
+ }
105
+ const platform = requireStringField(payload, "platform");
106
+ if (platform !== "ios" && platform !== "android") {
107
+ throw new InsightsBadRequestError("Invalid event field: platform");
108
+ }
109
+ const base: CreateBundleEventRequestBase = {
110
+ installId: requireStringField(payload, "installId"),
111
+ toBundleId: requireStringField(payload, "toBundleId"),
112
+ ...(payload.userId === undefined
113
+ ? {}
114
+ : { userId: requireStringField(payload, "userId") }),
115
+ ...(payload.username === undefined
116
+ ? {}
117
+ : { username: requireStringField(payload, "username") }),
118
+ platform,
119
+ appVersion: requireStringField(payload, "appVersion"),
120
+ channel: requireStringField(payload, "channel"),
121
+ cohort: requireStringField(payload, "cohort"),
122
+ fingerprintHash: requireNullableStringField(payload, "fingerprintHash"),
123
+ sdkVersion:
124
+ payload.sdkVersion === undefined
125
+ ? null
126
+ : requireNullableStringField(payload, "sdkVersion"),
127
+ fromReleaseId: requireNullableStringField(payload, "fromReleaseId"),
128
+ toReleaseId: requireNullableStringField(payload, "toReleaseId"),
129
+ };
130
+ const type = requireStringField(payload, "type");
131
+ switch (type) {
132
+ case "UPDATE_APPLIED":
133
+ case "RECOVERED":
134
+ case "RELEASE_ADOPTED": {
135
+ const updateStrategy = requireStringField(payload, "updateStrategy");
136
+ if (updateStrategy !== "fingerprint" && updateStrategy !== "appVersion") {
137
+ throw new InsightsBadRequestError(
138
+ "Invalid event field: updateStrategy",
139
+ );
140
+ }
141
+ return {
142
+ ...base,
143
+ type,
144
+ fromBundleId: requireStringField(payload, "fromBundleId"),
145
+ updateStrategy,
146
+ };
147
+ }
148
+ case "UNCHANGED":
149
+ if (payload.fromBundleId !== null || payload.updateStrategy !== null) {
150
+ throw new InsightsBadRequestError("Invalid unchanged event shape");
151
+ }
152
+ return {
153
+ ...base,
154
+ type,
155
+ fromBundleId: null,
156
+ updateStrategy: null,
157
+ };
158
+ default:
159
+ throw new InsightsBadRequestError("Invalid event field: type");
160
+ }
161
+ }
162
+
163
+ export async function parseBundleEventRequest(
164
+ request: Request,
165
+ ): Promise<CreateBundleEventRequest> {
166
+ const payload = await parseJson(request);
167
+ return requireEvent(payload);
168
+ }
@@ -0,0 +1,144 @@
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
+ sdkVersion: "2.0.0",
22
+ } as const;
23
+
24
+ const eventRequest = (body: unknown = event) =>
25
+ new Request("https://example.com/events", {
26
+ method: "POST",
27
+ headers: { "content-type": "application/json" },
28
+ body: JSON.stringify(body),
29
+ });
30
+
31
+ afterEach(() => {
32
+ vi.useRealTimers();
33
+ });
34
+
35
+ describe("createHotUpdater Insights", () => {
36
+ it("always persists events and serves queries without a server Insights flag", async () => {
37
+ vi.useFakeTimers();
38
+ vi.setSystemTime(new Date("2026-08-12T00:00:00.000Z"));
39
+ const database = createInMemoryDatabasePlugin();
40
+ const append = vi.spyOn(database.models.insights, "append");
41
+ const hotUpdater = createHotUpdater({
42
+ database,
43
+ clientAccess: { type: "public" },
44
+ });
45
+
46
+ const ingestion = await hotUpdater.handlers.client(eventRequest());
47
+ vi.advanceTimersByTime(1);
48
+ const overview = await hotUpdater.handlers.admin(
49
+ new Request("https://example.com/installations/overview"),
50
+ );
51
+
52
+ expect(ingestion.status).toBe(204);
53
+ expect(append).toHaveBeenCalledWith(
54
+ expect.objectContaining({
55
+ install_id: "install-1",
56
+ sdk_version: "2.0.0",
57
+ to_bundle_id: "bundle-1",
58
+ type: "UNCHANGED",
59
+ }),
60
+ );
61
+ expect(hotUpdater.insights).toMatchObject({
62
+ mode: "bounded",
63
+ maxMatchingRows: 50_000,
64
+ });
65
+ expect(overview.status).toBe(200);
66
+ await expect(overview.json()).resolves.toEqual({
67
+ bundles: [{ bundleId: "bundle-1", installations: 1 }],
68
+ trackedInstallations: 1,
69
+ });
70
+ });
71
+
72
+ it("checks the official schema before Insights reads and writes", async () => {
73
+ const database = createSchemaManagedDatabase(
74
+ "kysely",
75
+ HOT_UPDATER_SCHEMA_VERSION,
76
+ );
77
+ const createMigrator = vi.spyOn(database, "createMigrator");
78
+ const hotUpdater = createHotUpdater({
79
+ database,
80
+ clientAccess: { type: "public" },
81
+ });
82
+
83
+ expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
84
+ await hotUpdater.insights?.getBundleEventOverview();
85
+
86
+ expect(createMigrator).toHaveBeenCalledOnce();
87
+ });
88
+
89
+ it("keeps ingestion and queries on separate handler surfaces", async () => {
90
+ const hotUpdater = createHotUpdater({
91
+ database: createInMemoryDatabasePlugin(),
92
+ clientAccess: { type: "public" },
93
+ });
94
+
95
+ expect((await hotUpdater.handlers.client(eventRequest())).status).toBe(204);
96
+ const clientQuery = await hotUpdater.handlers.client(
97
+ new Request("https://example.com/installations/overview"),
98
+ );
99
+ const adminIngestion = await hotUpdater.handlers.admin(eventRequest());
100
+ const adminQuery = await hotUpdater.handlers.admin(
101
+ new Request("https://example.com/installations/overview"),
102
+ );
103
+
104
+ expect(clientQuery.status).toBe(404);
105
+ expect(adminIngestion.status).toBe(404);
106
+ expect(adminQuery.status).toBe(200);
107
+ expect(adminQuery.headers.get("cache-control")).toBe("private, no-store");
108
+ });
109
+
110
+ it("returns a stable client error for malformed event payloads", async () => {
111
+ const hotUpdater = createHotUpdater({
112
+ database: createInMemoryDatabasePlugin(),
113
+ clientAccess: { type: "public" },
114
+ });
115
+
116
+ const response = await hotUpdater.handlers.client(
117
+ eventRequest({ ...event, platform: "web" }),
118
+ );
119
+
120
+ expect(response.status).toBe(400);
121
+ await expect(response.json()).resolves.toEqual({
122
+ error: "Invalid event field: platform",
123
+ });
124
+ });
125
+
126
+ it.each(["fromReleaseId", "toReleaseId"] as const)(
127
+ "rejects an omitted %s instead of treating it as null",
128
+ async (field) => {
129
+ const hotUpdater = createHotUpdater({
130
+ database: createInMemoryDatabasePlugin(),
131
+ clientAccess: { type: "public" },
132
+ });
133
+ const payload: Record<string, unknown> = { ...event };
134
+ delete payload[field];
135
+
136
+ const response = await hotUpdater.handlers.client(eventRequest(payload));
137
+
138
+ expect(response.status).toBe(400);
139
+ await expect(response.json()).resolves.toEqual({
140
+ error: `Invalid event field: ${field}`,
141
+ });
142
+ },
143
+ );
144
+ });
@@ -0,0 +1,10 @@
1
+ import type {
2
+ InsightsScanCursor,
3
+ InsightsScanInput,
4
+ InsightsModel,
5
+ BundleEventRow,
6
+ } from "@hot-updater/plugin-core";
7
+
8
+ export type BundleEventPersistenceRow = BundleEventRow;
9
+ export type InsightsPersistence = InsightsModel;
10
+ export type { InsightsScanCursor, InsightsScanInput };
@@ -0,0 +1,102 @@
1
+ import type {
2
+ ActiveInstallationInput,
3
+ BundleEventInsightsWindow,
4
+ } from "./domain";
5
+ import { InsightsBadRequestError } from "./errors";
6
+
7
+ type IntegerBounds = {
8
+ readonly defaultValue: number;
9
+ readonly minimum: number;
10
+ readonly maximum?: number;
11
+ };
12
+
13
+ const EVENT_LIST_BOUNDS = {
14
+ defaultValue: 50,
15
+ maximum: 100,
16
+ minimum: 1,
17
+ } as const satisfies IntegerBounds;
18
+ const EVENT_LIST_OFFSET_BOUNDS = {
19
+ defaultValue: 0,
20
+ minimum: 0,
21
+ } as const satisfies IntegerBounds;
22
+ const MAX_USER_ID_LENGTH = 1_024;
23
+
24
+ export type PaginationInput = {
25
+ readonly limit: number;
26
+ readonly offset: number;
27
+ };
28
+
29
+ export type InsightsQueryInput = PaginationInput & {
30
+ readonly window: BundleEventInsightsWindow;
31
+ };
32
+
33
+ function parseInteger(url: URL, key: string, bounds: IntegerBounds): number {
34
+ const value = url.searchParams.get(key);
35
+ if (value === null) return bounds.defaultValue;
36
+ const parsed = Number(value);
37
+ if (
38
+ !Number.isSafeInteger(parsed) ||
39
+ parsed < bounds.minimum ||
40
+ (bounds.maximum !== undefined && parsed > bounds.maximum)
41
+ ) {
42
+ throw new InsightsBadRequestError(`Invalid '${key}' query parameter.`);
43
+ }
44
+ return parsed;
45
+ }
46
+
47
+ export const parsePagination = (request: Request): PaginationInput => {
48
+ const url = new URL(request.url);
49
+ return {
50
+ limit: parseInteger(url, "limit", EVENT_LIST_BOUNDS),
51
+ offset: parseInteger(url, "offset", EVENT_LIST_OFFSET_BOUNDS),
52
+ };
53
+ };
54
+
55
+ export const parseInsightsQuery = (request: Request): InsightsQueryInput => {
56
+ const url = new URL(request.url);
57
+ const window = url.searchParams.get("window") ?? "24h";
58
+ if (
59
+ window !== "24h" &&
60
+ window !== "7d" &&
61
+ window !== "30d" &&
62
+ window !== "all"
63
+ ) {
64
+ throw new InsightsBadRequestError("Invalid 'window' query parameter.");
65
+ }
66
+ return { ...parsePagination(request), window };
67
+ };
68
+
69
+ export const parseActiveInstallationInput = (
70
+ request: Request,
71
+ ): ActiveInstallationInput => {
72
+ const url = new URL(request.url);
73
+ const windows = url.searchParams.getAll("window");
74
+ if (windows.length > 1) {
75
+ throw new InsightsBadRequestError("Duplicate 'window' query parameter.");
76
+ }
77
+ const window = windows[0] ?? "30d";
78
+ if (window !== "24h" && window !== "7d" && window !== "30d") {
79
+ throw new InsightsBadRequestError("Invalid 'window' query parameter.");
80
+ }
81
+ const userIds = url.searchParams.getAll("userId");
82
+ if (userIds.length > 1) {
83
+ throw new InsightsBadRequestError("Duplicate 'userId' query parameter.");
84
+ }
85
+ const userId = userIds[0];
86
+ if (
87
+ userId !== undefined &&
88
+ (userId.length === 0 || userId.length > MAX_USER_ID_LENGTH)
89
+ ) {
90
+ throw new InsightsBadRequestError("Invalid 'userId' query parameter.");
91
+ }
92
+ return userId === undefined ? { window } : { window, userId };
93
+ };
94
+
95
+ export const parseSearchInput = (
96
+ request: Request,
97
+ ): PaginationInput & { readonly query: string } => ({
98
+ ...parsePagination(request),
99
+ query: new URL(request.url).searchParams.get("query")?.trim() ?? "",
100
+ });
101
+
102
+ export const parseEmptyInput = (): undefined => undefined;
@@ -0,0 +1,123 @@
1
+ import type { RouteHandler } from "../handlerTypes";
2
+ import {
3
+ InsightsBadRequestError,
4
+ InsightsPayloadTooLargeError,
5
+ InsightsScanLimitExceededError,
6
+ } from "./errors";
7
+ import { parseBundleEventRequest } from "./eventInput";
8
+ import {
9
+ parseActiveInstallationInput,
10
+ parseInsightsQuery,
11
+ parsePagination,
12
+ parseSearchInput,
13
+ } from "./queryInput";
14
+ import type { InsightsProvider } from "./types";
15
+
16
+ const json = (body: unknown, status: number): Response =>
17
+ Response.json(body, {
18
+ headers: { "cache-control": "private, no-store" },
19
+ status,
20
+ });
21
+
22
+ const requireParam = (
23
+ params: Readonly<Record<string, string>>,
24
+ key: string,
25
+ ): string => {
26
+ const value = params[key];
27
+ if (value === undefined || value.length === 0) {
28
+ throw new InsightsBadRequestError(`Missing route parameter: ${key}`);
29
+ }
30
+ return value;
31
+ };
32
+
33
+ const run = async (operation: () => Promise<Response>): Promise<Response> => {
34
+ try {
35
+ return await operation();
36
+ } catch (error) {
37
+ if (error instanceof InsightsBadRequestError) {
38
+ return json({ error: error.message }, 400);
39
+ }
40
+ if (error instanceof InsightsPayloadTooLargeError) {
41
+ return json({ error: error.message }, 413);
42
+ }
43
+ if (error instanceof InsightsScanLimitExceededError) {
44
+ return json(
45
+ {
46
+ error: {
47
+ code: "INSIGHTS_SCAN_LIMIT_EXCEEDED",
48
+ limit: error.limit,
49
+ },
50
+ },
51
+ 503,
52
+ );
53
+ }
54
+ throw error;
55
+ }
56
+ };
57
+
58
+ const query = (operation: () => Promise<unknown>): Promise<Response> =>
59
+ run(async () => json(await operation(), 200));
60
+
61
+ export const createInsightsRouteHandlers = (
62
+ provider: InsightsProvider,
63
+ ): Record<string, RouteHandler> => ({
64
+ appendBundleEvent: async (_params, request) =>
65
+ run(async () => {
66
+ await provider.appendBundleEvent(await parseBundleEventRequest(request));
67
+ return new Response(null, { status: 204 });
68
+ }),
69
+ getBundleEventSummary: (params) =>
70
+ query(() => provider.getBundleEventSummary(requireParam(params, "id"))),
71
+ getBundleEventInsights: (params, request) =>
72
+ query(() => {
73
+ const input = parseInsightsQuery(request);
74
+ return provider.getBundleEventInsights(
75
+ requireParam(params, "id"),
76
+ input.window,
77
+ input.limit,
78
+ input.offset,
79
+ );
80
+ }),
81
+ getBundleEventOverview: () => query(() => provider.getBundleEventOverview()),
82
+ getActiveInstallationOverview: (_params, request) =>
83
+ query(() =>
84
+ provider.getActiveInstallationOverview(
85
+ parseActiveInstallationInput(request),
86
+ ),
87
+ ),
88
+ searchInstallations: (_params, request) =>
89
+ query(() => {
90
+ const input = parseSearchInput(request);
91
+ return provider.searchInstallations(
92
+ input.query,
93
+ input.limit,
94
+ input.offset,
95
+ );
96
+ }),
97
+ getInstallationHistory: (params, request) =>
98
+ query(() => {
99
+ const input = parsePagination(request);
100
+ return provider.getInstallationHistory(
101
+ requireParam(params, "installId"),
102
+ input.limit,
103
+ input.offset,
104
+ );
105
+ }),
106
+ });
107
+
108
+ export const registerInsightsClientRoutes = (
109
+ add: (method: string, path: string, handler: string) => void,
110
+ ): void => {
111
+ add("POST", "/events", "appendBundleEvent");
112
+ };
113
+
114
+ export const registerInsightsAdminRoutes = (
115
+ add: (method: string, path: string, handler: string) => void,
116
+ ): void => {
117
+ add("GET", "/bundles/:id/events/summary", "getBundleEventSummary");
118
+ add("GET", "/bundles/:id/events/insights", "getBundleEventInsights");
119
+ add("GET", "/installations/overview", "getBundleEventOverview");
120
+ add("GET", "/installations/active", "getActiveInstallationOverview");
121
+ add("GET", "/installations", "searchInstallations");
122
+ add("GET", "/installations/:installId/events", "getInstallationHistory");
123
+ };
@@ -0,0 +1,44 @@
1
+ import type {
2
+ ActiveInstallationInput,
3
+ ActiveInstallationOverview,
4
+ BundleEventInsightsResult,
5
+ BundleEventInsightsWindow,
6
+ BundleEventOverview,
7
+ BundleEventSummary,
8
+ BundleEventSummaryByBundle,
9
+ CreateBundleEventRequest,
10
+ InstallationHistoryRow,
11
+ InstallationSearchRow,
12
+ OffsetPaginationResult,
13
+ } from "./domain";
14
+
15
+ export type InsightsProvider = {
16
+ readonly mode: "bounded";
17
+ readonly maxMatchingRows: number;
18
+ appendBundleEvent(input: CreateBundleEventRequest): Promise<void>;
19
+ getBundleEventSummary(bundleId: string): Promise<BundleEventSummary>;
20
+ getBundleEventSummaries(
21
+ bundleIds: readonly string[],
22
+ window: BundleEventInsightsWindow,
23
+ ): Promise<readonly BundleEventSummaryByBundle[]>;
24
+ getBundleEventInsights(
25
+ bundleId: string,
26
+ window: BundleEventInsightsWindow,
27
+ limit: number,
28
+ offset: number,
29
+ ): Promise<BundleEventInsightsResult>;
30
+ getBundleEventOverview(): Promise<BundleEventOverview>;
31
+ getActiveInstallationOverview(
32
+ input: ActiveInstallationInput,
33
+ ): Promise<ActiveInstallationOverview>;
34
+ searchInstallations(
35
+ query: string,
36
+ limit: number,
37
+ offset: number,
38
+ ): Promise<OffsetPaginationResult<InstallationSearchRow>>;
39
+ getInstallationHistory(
40
+ installId: string,
41
+ limit: number,
42
+ offset: number,
43
+ ): Promise<OffsetPaginationResult<InstallationHistoryRow>>;
44
+ };
package/src/node.spec.ts CHANGED
@@ -4,14 +4,12 @@ import { toNodeHandler } from "./node";
4
4
 
5
5
  describe("server node entry", () => {
6
6
  it("converts a Web Request handler to Node middleware", async () => {
7
- const hotUpdater = {
8
- handler: async (request: Request) =>
9
- Response.json({
10
- method: request.method,
11
- pathname: new URL(request.url).pathname,
12
- }),
13
- };
14
- const middleware = toNodeHandler(hotUpdater);
7
+ const middleware = toNodeHandler(async (request: Request) =>
8
+ Response.json({
9
+ method: request.method,
10
+ pathname: new URL(request.url).pathname,
11
+ }),
12
+ );
15
13
  const headers = new Map<string, string | string[]>();
16
14
  const response = {
17
15
  body: "",
@@ -23,8 +21,8 @@ describe("server node entry", () => {
23
21
  setHeader(name: string, value: string | string[]) {
24
22
  headers.set(name, value);
25
23
  },
26
- send(body: string) {
27
- this.body = body;
24
+ send(body: Uint8Array) {
25
+ this.body = new TextDecoder().decode(body);
28
26
  },
29
27
  end() {},
30
28
  };
@@ -47,4 +45,75 @@ describe("server node entry", () => {
47
45
  pathname: "/api/check",
48
46
  });
49
47
  });
48
+
49
+ it("uses the mount-relative URL supplied by Express", async () => {
50
+ const middleware = toNodeHandler(async (request: Request) =>
51
+ Response.json({ pathname: new URL(request.url).pathname }),
52
+ );
53
+ const response = {
54
+ body: "",
55
+ statusCode: 0,
56
+ status(code: number) {
57
+ this.statusCode = code;
58
+ return this;
59
+ },
60
+ setHeader() {},
61
+ send(body: Uint8Array) {
62
+ this.body = new TextDecoder().decode(body);
63
+ },
64
+ end() {},
65
+ };
66
+
67
+ await middleware(
68
+ {
69
+ method: "GET",
70
+ url: "/channels",
71
+ headers: { host: "example.com" },
72
+ protocol: "https",
73
+ get: (name: string) => (name === "host" ? "example.com" : undefined),
74
+ },
75
+ response,
76
+ );
77
+
78
+ expect(JSON.parse(response.body)).toEqual({
79
+ pathname: "/channels",
80
+ });
81
+ });
82
+
83
+ it("preserves binary response bytes", async () => {
84
+ const bytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0xff, 0xfe, 0x00]);
85
+ const middleware = toNodeHandler(
86
+ async () =>
87
+ new Response(bytes, {
88
+ headers: { "content-type": "application/zip" },
89
+ }),
90
+ );
91
+ const response = {
92
+ body: new Uint8Array(),
93
+ statusCode: 0,
94
+ status(code: number) {
95
+ this.statusCode = code;
96
+ return this;
97
+ },
98
+ setHeader() {},
99
+ send(body: Uint8Array) {
100
+ this.body = new Uint8Array(body);
101
+ },
102
+ end() {},
103
+ };
104
+
105
+ await middleware(
106
+ {
107
+ method: "GET",
108
+ url: "/hot-updater/storage/token/signature",
109
+ headers: { host: "example.com" },
110
+ protocol: "https",
111
+ get: (name: string) => (name === "host" ? "example.com" : undefined),
112
+ },
113
+ response,
114
+ );
115
+
116
+ expect(response.statusCode).toBe(200);
117
+ expect([...response.body]).toEqual([...bytes]);
118
+ });
50
119
  });