@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,148 @@
1
+ import type {
2
+ ActiveInstallationOverview,
3
+ ActiveInstallationWindow,
4
+ } from "../domain";
5
+ import type { BundleEventPersistenceRow } from "../persistence";
6
+
7
+ const HOUR_MS = 60 * 60 * 1_000;
8
+ const DAY_MS = 24 * HOUR_MS;
9
+
10
+ export const ACTIVE_BUNDLE_EVENT_TYPES = [
11
+ "UPDATE_APPLIED",
12
+ "RECOVERED",
13
+ "RELEASE_ADOPTED",
14
+ "UNCHANGED",
15
+ ] as const;
16
+
17
+ const activeWindowDefinitions = {
18
+ "24h": { bucketCount: 24, bucketSizeMs: HOUR_MS },
19
+ "7d": { bucketCount: 7, bucketSizeMs: DAY_MS },
20
+ "30d": { bucketCount: 30, bucketSizeMs: DAY_MS },
21
+ } as const satisfies Record<
22
+ ActiveInstallationWindow,
23
+ { readonly bucketCount: number; readonly bucketSizeMs: number }
24
+ >;
25
+
26
+ type ActiveWindowDefinition =
27
+ (typeof activeWindowDefinitions)[ActiveInstallationWindow];
28
+
29
+ export function getActiveWindowDefinition(
30
+ window: ActiveInstallationWindow,
31
+ ): ActiveWindowDefinition {
32
+ return activeWindowDefinitions[window];
33
+ }
34
+
35
+ type ActiveOverviewRequest = {
36
+ readonly rows: readonly BundleEventPersistenceRow[];
37
+ readonly asOfMs: number;
38
+ readonly window: ActiveInstallationWindow;
39
+ readonly userId?: string;
40
+ };
41
+
42
+ function compareCodePoints(left: string, right: string): number {
43
+ if (left < right) return -1;
44
+ if (left > right) return 1;
45
+ return 0;
46
+ }
47
+
48
+ function isNewer(
49
+ candidate: BundleEventPersistenceRow,
50
+ current: BundleEventPersistenceRow,
51
+ ): boolean {
52
+ return (
53
+ candidate.received_at_ms > current.received_at_ms ||
54
+ (candidate.received_at_ms === current.received_at_ms &&
55
+ compareCodePoints(candidate.id, current.id) > 0)
56
+ );
57
+ }
58
+
59
+ export function collectActiveInstallationOverview(
60
+ request: ActiveOverviewRequest,
61
+ ): ActiveInstallationOverview {
62
+ const definition = getActiveWindowDefinition(request.window);
63
+ const windowStartMs =
64
+ request.asOfMs - definition.bucketCount * definition.bucketSizeMs;
65
+ const rows = request.rows.filter(
66
+ (row) =>
67
+ row.received_at_ms >= windowStartMs &&
68
+ row.received_at_ms < request.asOfMs,
69
+ );
70
+ const latestByInstall = new Map<string, BundleEventPersistenceRow>();
71
+ for (const row of rows) {
72
+ const current = latestByInstall.get(row.install_id);
73
+ if (current === undefined || isNewer(row, current)) {
74
+ latestByInstall.set(row.install_id, row);
75
+ }
76
+ }
77
+ const selectedRows = [...latestByInstall.values()].filter(
78
+ (row) => request.userId === undefined || row.user_id === request.userId,
79
+ );
80
+ const selectedInstallIds = new Set(selectedRows.map((row) => row.install_id));
81
+ const bundleCounts = new Map<string, number>();
82
+ for (const row of selectedRows) {
83
+ bundleCounts.set(
84
+ row.to_bundle_id,
85
+ (bundleCounts.get(row.to_bundle_id) ?? 0) + 1,
86
+ );
87
+ }
88
+ const latestByBucket = Array.from(
89
+ { length: definition.bucketCount },
90
+ () => new Map<string, BundleEventPersistenceRow>(),
91
+ );
92
+ for (const row of rows) {
93
+ if (!selectedInstallIds.has(row.install_id)) continue;
94
+ const bucketIndex = Math.floor(
95
+ (row.received_at_ms - windowStartMs) / definition.bucketSizeMs,
96
+ );
97
+ const bucket = latestByBucket[bucketIndex];
98
+ if (bucket === undefined) continue;
99
+ const current = bucket.get(row.install_id);
100
+ if (current === undefined || isNewer(row, current)) {
101
+ bucket.set(row.install_id, row);
102
+ }
103
+ }
104
+ const bundleCountsByBucket = latestByBucket.map((bucket) => {
105
+ const counts = new Map<string, number>();
106
+ for (const row of bucket.values()) {
107
+ counts.set(row.to_bundle_id, (counts.get(row.to_bundle_id) ?? 0) + 1);
108
+ }
109
+ return counts;
110
+ });
111
+ const bundleObservationTotals = new Map<string, number>();
112
+ for (const counts of bundleCountsByBucket) {
113
+ for (const [bundleId, count] of counts) {
114
+ bundleObservationTotals.set(
115
+ bundleId,
116
+ (bundleObservationTotals.get(bundleId) ?? 0) + count,
117
+ );
118
+ }
119
+ }
120
+ return {
121
+ asOfMs: request.asOfMs,
122
+ window: request.window,
123
+ activeInstallations: selectedInstallIds.size,
124
+ series: latestByBucket.map((bucket, index) => ({
125
+ bucketStartMs: windowStartMs + index * definition.bucketSizeMs,
126
+ value: bucket.size,
127
+ })),
128
+ bundleSeries: [...bundleObservationTotals]
129
+ .sort(
130
+ ([leftId, leftTotal], [rightId, rightTotal]) =>
131
+ rightTotal - leftTotal || compareCodePoints(leftId, rightId),
132
+ )
133
+ .map(([bundleId]) => ({
134
+ bundleId,
135
+ series: bundleCountsByBucket.map((counts, index) => ({
136
+ bucketStartMs: windowStartMs + index * definition.bucketSizeMs,
137
+ value: counts.get(bundleId) ?? 0,
138
+ })),
139
+ })),
140
+ bundles: [...bundleCounts]
141
+ .map(([bundleId, installations]) => ({ bundleId, installations }))
142
+ .sort(
143
+ (left, right) =>
144
+ right.installations - left.installations ||
145
+ compareCodePoints(left.bundleId, right.bundleId),
146
+ ),
147
+ };
148
+ }
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { BundleEventPersistenceRow } from "../persistence";
4
+ import { searchEventInstallations } from "./installationSearch";
5
+
6
+ type UpdateAppliedRow = BundleEventPersistenceRow & {
7
+ readonly type: "UPDATE_APPLIED";
8
+ };
9
+
10
+ function eventRow(overrides: Partial<UpdateAppliedRow> = {}): UpdateAppliedRow {
11
+ return {
12
+ id: "event-1",
13
+ type: "UPDATE_APPLIED",
14
+ install_id: "install-1",
15
+ user_id: "user-1",
16
+ username: "alice",
17
+ from_release_id: null,
18
+ from_bundle_id: "bundle-old",
19
+ to_release_id: null,
20
+ to_bundle_id: "bundle-new",
21
+ platform: "ios",
22
+ app_version: "1.0.0",
23
+ channel: "production",
24
+ cohort: "default",
25
+ update_strategy: "fingerprint",
26
+ fingerprint_hash: null,
27
+ sdk_version: null,
28
+ received_at_ms: 1,
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ describe("searchEventInstallations", () => {
34
+ it.each([
35
+ ["installation id", "INSTALL-1"],
36
+ ["user id", "USER-1"],
37
+ ["username", "ALICE"],
38
+ ])("matches %s without case sensitivity", (_identity, query) => {
39
+ const row = eventRow();
40
+
41
+ const result = searchEventInstallations({
42
+ rows: [row],
43
+ query,
44
+ limit: 10,
45
+ offset: 0,
46
+ });
47
+
48
+ expect(result.data.map(({ installId }) => installId)).toEqual([
49
+ "install-1",
50
+ ]);
51
+ });
52
+
53
+ it("returns the newest state when an older identity matches", () => {
54
+ const rows = [
55
+ eventRow({
56
+ id: "event-old",
57
+ username: "legacy-name",
58
+ to_bundle_id: "bundle-old",
59
+ received_at_ms: 1,
60
+ }),
61
+ eventRow({
62
+ id: "event-new",
63
+ username: "current-name",
64
+ to_bundle_id: "bundle-current",
65
+ received_at_ms: 2,
66
+ }),
67
+ ];
68
+
69
+ const result = searchEventInstallations({
70
+ rows,
71
+ query: "LEGACY-NAME",
72
+ limit: 10,
73
+ offset: 0,
74
+ });
75
+
76
+ expect(result.data).toEqual([
77
+ {
78
+ installId: "install-1",
79
+ username: "current-name",
80
+ userId: "user-1",
81
+ lastKnownBundleId: "bundle-current",
82
+ latestStatus: "UPDATE_APPLIED",
83
+ platform: "ios",
84
+ appVersion: "1.0.0",
85
+ channel: "production",
86
+ cohort: "default",
87
+ receivedAtMs: 2,
88
+ },
89
+ ]);
90
+ });
91
+
92
+ it("sorts installation ids by code point", () => {
93
+ const rows = [
94
+ eventRow({ install_id: "install-z" }),
95
+ eventRow({ install_id: "install-a" }),
96
+ eventRow({ install_id: "install-A" }),
97
+ ];
98
+
99
+ const result = searchEventInstallations({
100
+ rows,
101
+ query: "",
102
+ limit: 10,
103
+ offset: 0,
104
+ });
105
+
106
+ expect(result.data.map(({ installId }) => installId)).toEqual([
107
+ "install-A",
108
+ "install-a",
109
+ "install-z",
110
+ ]);
111
+ });
112
+
113
+ it("applies the offset and caps the page size at 100", () => {
114
+ const rows = Array.from({ length: 105 }, (_, index) =>
115
+ eventRow({ install_id: `install-${index.toString().padStart(3, "0")}` }),
116
+ );
117
+
118
+ const result = searchEventInstallations({
119
+ rows,
120
+ query: "",
121
+ limit: 200,
122
+ offset: 2,
123
+ });
124
+
125
+ expect(result.data).toHaveLength(100);
126
+ expect(result.data.at(0)?.installId).toBe("install-002");
127
+ expect(result.data.at(-1)?.installId).toBe("install-101");
128
+ expect(result.pagination).toEqual({ total: 105, limit: 200, offset: 2 });
129
+ });
130
+ });
@@ -0,0 +1,69 @@
1
+ import type { InstallationSearchRow, OffsetPaginationResult } from "../domain";
2
+ import type { BundleEventPersistenceRow } from "../persistence";
3
+ import { compareCodePoints, compareEventNewest } from "./scan";
4
+
5
+ type InstallationSearchRequest = {
6
+ readonly rows: readonly BundleEventPersistenceRow[];
7
+ readonly query: string;
8
+ readonly limit: number;
9
+ readonly offset: number;
10
+ };
11
+
12
+ function toSearchRow(row: BundleEventPersistenceRow): InstallationSearchRow {
13
+ return {
14
+ installId: row.install_id,
15
+ username: row.username,
16
+ userId: row.user_id,
17
+ lastKnownBundleId: row.to_bundle_id,
18
+ latestStatus: row.type,
19
+ platform: row.platform,
20
+ appVersion: row.app_version,
21
+ channel: row.channel,
22
+ cohort: row.cohort,
23
+ receivedAtMs: row.received_at_ms,
24
+ };
25
+ }
26
+
27
+ function matchesIdentity(
28
+ row: BundleEventPersistenceRow,
29
+ query: string,
30
+ ): boolean {
31
+ return (
32
+ row.install_id.toLowerCase().includes(query) ||
33
+ row.user_id?.toLowerCase().includes(query) === true ||
34
+ row.username?.toLowerCase().includes(query) === true
35
+ );
36
+ }
37
+
38
+ export function searchEventInstallations(
39
+ request: InstallationSearchRequest,
40
+ ): OffsetPaginationResult<InstallationSearchRow> {
41
+ const query = request.query.toLowerCase();
42
+ const matchingInstallIds = new Set<string>();
43
+ for (const row of request.rows) {
44
+ if (query.length === 0 || matchesIdentity(row, query)) {
45
+ matchingInstallIds.add(row.install_id);
46
+ }
47
+ }
48
+ const latestByInstall = new Map<string, BundleEventPersistenceRow>();
49
+ for (const row of request.rows) {
50
+ if (!matchingInstallIds.has(row.install_id)) continue;
51
+ const current = latestByInstall.get(row.install_id);
52
+ if (current === undefined || compareEventNewest(row, current) < 0) {
53
+ latestByInstall.set(row.install_id, row);
54
+ }
55
+ }
56
+ const matchingRows = [...latestByInstall.values()].sort((left, right) =>
57
+ compareCodePoints(left.install_id, right.install_id),
58
+ );
59
+ const pageSize = Math.min(Math.max(request.limit, 0), 100);
60
+ const page = matchingRows.slice(request.offset, request.offset + pageSize);
61
+ return {
62
+ data: page.map(toSearchRow),
63
+ pagination: {
64
+ total: matchingRows.length,
65
+ limit: request.limit,
66
+ offset: request.offset,
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,43 @@
1
+ import { createUUIDv7 } from "@hot-updater/plugin-core";
2
+
3
+ import type { CreateBundleEventRequest } from "../domain.js";
4
+ import type { BundleEventPersistenceRow } from "../persistence.js";
5
+
6
+ export function createBundleEventRow(
7
+ input: CreateBundleEventRequest,
8
+ ): BundleEventPersistenceRow {
9
+ const base = {
10
+ id: createUUIDv7(),
11
+ install_id: input.installId,
12
+ user_id: input.userId ?? null,
13
+ username: input.username ?? null,
14
+ from_release_id: input.fromReleaseId,
15
+ to_release_id: input.toReleaseId,
16
+ to_bundle_id: input.toBundleId,
17
+ platform: input.platform,
18
+ app_version: input.appVersion,
19
+ channel: input.channel,
20
+ cohort: input.cohort,
21
+ fingerprint_hash: input.fingerprintHash,
22
+ sdk_version: input.sdkVersion ?? null,
23
+ received_at_ms: Date.now(),
24
+ };
25
+ switch (input.type) {
26
+ case "UPDATE_APPLIED":
27
+ case "RECOVERED":
28
+ case "RELEASE_ADOPTED":
29
+ return {
30
+ ...base,
31
+ type: input.type,
32
+ from_bundle_id: input.fromBundleId,
33
+ update_strategy: input.updateStrategy,
34
+ };
35
+ case "UNCHANGED":
36
+ return {
37
+ ...base,
38
+ type: input.type,
39
+ from_bundle_id: null,
40
+ update_strategy: null,
41
+ };
42
+ }
43
+ }
@@ -0,0 +1,219 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { InsightsScanLimitExceededError } from "../errors";
4
+ import type {
5
+ InsightsPersistence,
6
+ InsightsScanInput,
7
+ BundleEventPersistenceRow,
8
+ } from "../persistence";
9
+ import { createInsightsProvider } from "./provider";
10
+ import { getWindowRange } from "./scan";
11
+
12
+ const eventRow = (
13
+ id: string,
14
+ receivedAtMs: number,
15
+ installId = `install-${id}`,
16
+ ): BundleEventPersistenceRow => ({
17
+ id,
18
+ type: "UPDATE_APPLIED",
19
+ install_id: installId,
20
+ user_id: null,
21
+ username: null,
22
+ from_release_id: null,
23
+ from_bundle_id: "old",
24
+ to_release_id: null,
25
+ to_bundle_id: "new",
26
+ platform: "ios",
27
+ app_version: "1.0.0",
28
+ channel: "production",
29
+ cohort: "default",
30
+ update_strategy: "fingerprint",
31
+ fingerprint_hash: null,
32
+ sdk_version: null,
33
+ received_at_ms: receivedAtMs,
34
+ });
35
+
36
+ const compareRows = (
37
+ left: BundleEventPersistenceRow,
38
+ right: BundleEventPersistenceRow,
39
+ ): number =>
40
+ left.received_at_ms - right.received_at_ms || left.id.localeCompare(right.id);
41
+
42
+ const inMemoryPersistence = (
43
+ initialRows: readonly BundleEventPersistenceRow[],
44
+ maximumPageSize = Number.MAX_SAFE_INTEGER,
45
+ ): InsightsPersistence => {
46
+ const rows = [...initialRows];
47
+ return {
48
+ async append(row) {
49
+ rows.push(row);
50
+ },
51
+ async scan(input: InsightsScanInput) {
52
+ return rows
53
+ .filter((row) => row.received_at_ms < input.beforeReceivedAtMs)
54
+ .filter(
55
+ (row) =>
56
+ input.after === undefined ||
57
+ row.received_at_ms > input.after.receivedAtMs ||
58
+ (row.received_at_ms === input.after.receivedAtMs &&
59
+ row.id > input.after.id),
60
+ )
61
+ .toSorted(compareRows)
62
+ .slice(0, Math.min(input.limit, maximumPageSize));
63
+ },
64
+ };
65
+ };
66
+
67
+ describe("createInsightsProvider", () => {
68
+ it("starts windowed insights scans at the storage lower boundary", async () => {
69
+ // Given
70
+ const now = Date.UTC(2026, 1, 2, 12);
71
+ vi.spyOn(Date, "now").mockReturnValue(now);
72
+ const scan = vi.fn<InsightsPersistence["scan"]>().mockResolvedValue([]);
73
+ const provider = createInsightsProvider({
74
+ async append() {},
75
+ scan,
76
+ });
77
+
78
+ // When
79
+ await provider.getBundleEventInsights("new", "24h", 20, 0);
80
+
81
+ // Then
82
+ expect(scan).toHaveBeenCalledWith(
83
+ expect.objectContaining({
84
+ after: {
85
+ id: "00000000-0000-0000-0000-000000000000",
86
+ receivedAtMs: getWindowRange("24h", now).rangeStart,
87
+ },
88
+ }),
89
+ );
90
+ vi.restoreAllMocks();
91
+ });
92
+ it("counts distinct installations across cursor pages", async () => {
93
+ const rows = Array.from({ length: 1_001 }, (_, index) =>
94
+ eventRow(index.toString().padStart(4, "0"), 1_000, `install-${index}`),
95
+ );
96
+ const provider = createInsightsProvider(inMemoryPersistence(rows));
97
+
98
+ await expect(provider.getBundleEventSummary("new")).resolves.toEqual({
99
+ installed: 1_001,
100
+ recovered: 0,
101
+ });
102
+ });
103
+
104
+ it("continues scanning when persistence caps pages below the requested limit", async () => {
105
+ const rows = Array.from({ length: 101 }, (_, index) =>
106
+ eventRow(index.toString().padStart(4, "0"), 1_000, `install-${index}`),
107
+ );
108
+ const provider = createInsightsProvider(inMemoryPersistence(rows, 17));
109
+
110
+ await expect(provider.getBundleEventSummary("new")).resolves.toEqual({
111
+ installed: 101,
112
+ recovered: 0,
113
+ });
114
+ });
115
+
116
+ it("collects distinct 30-day movement for multiple Bundles in one scan", async () => {
117
+ const now = Date.UTC(2026, 7, 18, 12);
118
+ vi.spyOn(Date, "now").mockReturnValue(now);
119
+ const applied = eventRow("applied", now - 1_000, "install-applied");
120
+ const duplicateApplied = eventRow(
121
+ "applied-again",
122
+ now - 900,
123
+ "install-applied",
124
+ );
125
+ const recovered = {
126
+ ...eventRow("recovered", now - 800, "install-recovered"),
127
+ type: "RECOVERED" as const,
128
+ from_bundle_id: "new",
129
+ to_bundle_id: "fallback",
130
+ update_strategy: "fingerprint" as const,
131
+ } satisfies BundleEventPersistenceRow;
132
+ const other = {
133
+ ...eventRow("other", now - 700, "install-other"),
134
+ to_bundle_id: "other",
135
+ };
136
+ const outsideWindow = eventRow(
137
+ "outside-window",
138
+ now - 31 * 24 * 60 * 60 * 1_000,
139
+ "install-old",
140
+ );
141
+ const persistence = inMemoryPersistence([
142
+ outsideWindow,
143
+ applied,
144
+ duplicateApplied,
145
+ recovered,
146
+ other,
147
+ ]);
148
+ const scan = vi.spyOn(persistence, "scan");
149
+ const provider = createInsightsProvider(persistence);
150
+
151
+ await expect(
152
+ provider.getBundleEventSummaries(["new", "other", "missing"], "30d"),
153
+ ).resolves.toEqual([
154
+ { bundleId: "new", installed: 1, recovered: 1 },
155
+ { bundleId: "other", installed: 1, recovered: 0 },
156
+ { bundleId: "missing", installed: 0, recovered: 0 },
157
+ ]);
158
+ expect(scan).toHaveBeenCalledTimes(2);
159
+ vi.restoreAllMocks();
160
+ });
161
+
162
+ it("counts an installation whose current bundle is reported by UNCHANGED", async () => {
163
+ const unchanged: BundleEventPersistenceRow = {
164
+ ...eventRow("unchanged", 1, "install-unchanged"),
165
+ type: "UNCHANGED",
166
+ from_bundle_id: null,
167
+ update_strategy: null,
168
+ };
169
+ const provider = createInsightsProvider(inMemoryPersistence([unchanged]));
170
+
171
+ await expect(provider.getBundleEventOverview()).resolves.toEqual({
172
+ trackedInstallations: 1,
173
+ bundles: [{ bundleId: "new", installations: 1 }],
174
+ });
175
+ });
176
+
177
+ it.each([
178
+ [eventRow("same", 1), eventRow("same", 2)],
179
+ [eventRow("later", 2), eventRow("earlier", 1)],
180
+ ])(
181
+ "rejects duplicate ids and rows outside strict cursor order",
182
+ async (...rows) => {
183
+ let delivered = false;
184
+ const provider = createInsightsProvider({
185
+ async append() {},
186
+ async scan() {
187
+ if (delivered) return [];
188
+ delivered = true;
189
+ return rows;
190
+ },
191
+ });
192
+
193
+ await expect(provider.getBundleEventSummary("new")).rejects.toMatchObject(
194
+ {
195
+ name: "InsightsPersistenceOrderError",
196
+ },
197
+ );
198
+ },
199
+ );
200
+
201
+ it("fails instead of returning partial insights beyond 50,000 rows", async () => {
202
+ const persistence: InsightsPersistence = {
203
+ async append() {},
204
+ async scan(input) {
205
+ const start =
206
+ input.after === undefined ? 0 : Number(input.after.id) + 1;
207
+ return Array.from({ length: input.limit }, (_, index) => {
208
+ const id = start + index;
209
+ return eventRow(String(id), id);
210
+ });
211
+ },
212
+ };
213
+ const provider = createInsightsProvider(persistence);
214
+
215
+ await expect(provider.getBundleEventSummary("new")).rejects.toBeInstanceOf(
216
+ InsightsScanLimitExceededError,
217
+ );
218
+ });
219
+ });