@hot-updater/server 0.36.6 → 1.0.0-rc.0

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/analytics/bounded/activeOverview.cjs +87 -0
  44. package/dist/analytics/bounded/activeOverview.mjs +85 -0
  45. package/dist/analytics/bounded/installationSearch.cjs +42 -0
  46. package/dist/analytics/bounded/installationSearch.mjs +42 -0
  47. package/dist/analytics/bounded/persistence.cjs +39 -0
  48. package/dist/analytics/bounded/persistence.mjs +38 -0
  49. package/dist/analytics/bounded/provider.cjs +170 -0
  50. package/dist/analytics/bounded/provider.d.cts +7 -0
  51. package/dist/analytics/bounded/provider.d.mts +7 -0
  52. package/dist/analytics/bounded/provider.mjs +170 -0
  53. package/dist/analytics/bounded/scan.cjs +144 -0
  54. package/dist/analytics/bounded/scan.mjs +138 -0
  55. package/dist/analytics/domain.d.cts +121 -0
  56. package/dist/analytics/domain.d.mts +121 -0
  57. package/dist/analytics/errors.cjs +22 -0
  58. package/dist/analytics/errors.mjs +20 -0
  59. package/dist/analytics/eventInput.cjs +111 -0
  60. package/dist/analytics/eventInput.mjs +111 -0
  61. package/dist/analytics/persistence.d.cts +6 -0
  62. package/dist/analytics/persistence.d.mts +6 -0
  63. package/dist/analytics/queryInput.cjs +58 -0
  64. package/dist/analytics/queryInput.mjs +55 -0
  65. package/dist/analytics/routes.cjs +63 -0
  66. package/dist/analytics/routes.mjs +61 -0
  67. package/dist/analytics/types.d.cts +17 -0
  68. package/dist/analytics/types.d.mts +17 -0
  69. package/dist/apiKeys.cjs +139 -0
  70. package/dist/apiKeys.d.cts +50 -0
  71. package/dist/apiKeys.d.mts +50 -0
  72. package/dist/apiKeys.mjs +133 -0
  73. package/dist/createHotUpdaterCore.cjs +48 -26
  74. package/dist/createHotUpdaterCore.d.cts +49 -17
  75. package/dist/createHotUpdaterCore.d.mts +49 -17
  76. package/dist/createHotUpdaterCore.mjs +51 -29
  77. package/dist/db/createBundleDiff.cjs +45 -68
  78. package/dist/db/createBundleDiff.d.cts +3 -3
  79. package/dist/db/createBundleDiff.d.mts +3 -3
  80. package/dist/db/createBundleDiff.mjs +47 -68
  81. package/dist/db/databasePluginCore.cjs +138 -0
  82. package/dist/db/databasePluginCore.mjs +137 -0
  83. package/dist/db/fixedMigrator.cjs +36 -93
  84. package/dist/db/fixedMigrator.mjs +36 -93
  85. package/dist/db/fixedMigratorMongo.cjs +319 -0
  86. package/dist/db/fixedMigratorMongo.mjs +319 -0
  87. package/dist/db/fixedMigratorShared.cjs +23 -0
  88. package/dist/db/fixedMigratorShared.mjs +18 -0
  89. package/dist/db/mongoMigrationExecution.cjs +11 -0
  90. package/dist/db/mongoMigrationExecution.mjs +11 -0
  91. package/dist/db/releaseCatalog.cjs +66 -0
  92. package/dist/db/releaseCatalog.d.cts +17 -0
  93. package/dist/db/releaseCatalog.d.mts +17 -0
  94. package/dist/db/releaseCatalog.mjs +64 -0
  95. package/dist/db/schema/mongodb.cjs +14 -6
  96. package/dist/db/schema/mongodb.mjs +14 -6
  97. package/dist/db/schema/registry.cjs +0 -2
  98. package/dist/db/schema/registry.mjs +1 -2
  99. package/dist/db/schema/sql.cjs +31 -14
  100. package/dist/db/schema/sql.mjs +32 -13
  101. package/dist/db/schema/sqlOperations.cjs +4 -4
  102. package/dist/db/schema/sqlOperations.mjs +5 -5
  103. package/dist/db/schemaGenerators.cjs +52 -18
  104. package/dist/db/schemaGenerators.mjs +52 -18
  105. package/dist/db/schemaReadiness.cjs +4 -3
  106. package/dist/db/schemaReadiness.mjs +4 -3
  107. package/dist/db/sqlMigrationExecution.cjs +67 -0
  108. package/dist/db/sqlMigrationExecution.mjs +66 -0
  109. package/dist/db/types.cjs +2 -6
  110. package/dist/db/types.d.cts +60 -16
  111. package/dist/db/types.d.mts +60 -16
  112. package/dist/db/types.mjs +3 -6
  113. package/dist/db/updateArtifacts.cjs +114 -60
  114. package/dist/db/updateArtifacts.mjs +117 -62
  115. package/dist/handler.cjs +108 -282
  116. package/dist/handler.d.cts +3 -46
  117. package/dist/handler.d.mts +3 -46
  118. package/dist/handler.mjs +107 -281
  119. package/dist/handlerBundleRoutes.cjs +155 -0
  120. package/dist/handlerBundleRoutes.mjs +155 -0
  121. package/dist/handlerErrors.cjs +9 -0
  122. package/dist/handlerErrors.mjs +9 -0
  123. package/dist/handlerParameters.cjs +39 -0
  124. package/dist/handlerParameters.mjs +34 -0
  125. package/dist/handlerReleaseCatalogRoutes.cjs +111 -0
  126. package/dist/handlerReleaseCatalogRoutes.mjs +110 -0
  127. package/dist/handlerReleaseManagementRoutes.cjs +125 -0
  128. package/dist/handlerReleaseManagementRoutes.mjs +124 -0
  129. package/dist/handlerTypes.d.cts +63 -0
  130. package/dist/handlerTypes.d.mts +63 -0
  131. package/dist/handlerVersionRoutes.cjs +10 -0
  132. package/dist/handlerVersionRoutes.d.cts +4 -0
  133. package/dist/handlerVersionRoutes.d.mts +4 -0
  134. package/dist/handlerVersionRoutes.mjs +9 -0
  135. package/dist/index.cjs +10 -1
  136. package/dist/index.d.cts +9 -3
  137. package/dist/index.d.mts +9 -3
  138. package/dist/index.mjs +5 -2
  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/analytics/analytics.integration.spec.ts +144 -0
  188. package/src/analytics/bounded/activeOverview.spec.ts +189 -0
  189. package/src/analytics/bounded/activeOverview.ts +148 -0
  190. package/src/analytics/bounded/installationSearch.spec.ts +130 -0
  191. package/src/analytics/bounded/installationSearch.ts +69 -0
  192. package/src/analytics/bounded/persistence.ts +43 -0
  193. package/src/analytics/bounded/provider.spec.ts +219 -0
  194. package/src/analytics/bounded/provider.ts +229 -0
  195. package/src/analytics/bounded/scan.ts +235 -0
  196. package/src/analytics/domain.ts +139 -0
  197. package/src/analytics/errors.ts +18 -0
  198. package/src/analytics/eventInput.ts +168 -0
  199. package/src/analytics/persistence.ts +10 -0
  200. package/src/analytics/queryInput.ts +102 -0
  201. package/src/analytics/routes.ts +123 -0
  202. package/src/analytics/types.ts +44 -0
  203. package/src/apiKeys.integration.spec.ts +192 -0
  204. package/src/apiKeys.spec.ts +162 -0
  205. package/src/apiKeys.ts +220 -0
  206. package/src/createHotUpdaterCore.ts +164 -100
  207. package/src/db/bundleRows.spec.ts +164 -0
  208. package/src/db/bundleRows.ts +10 -140
  209. package/src/db/createBundleDiff.spec.ts +172 -127
  210. package/src/db/createBundleDiff.ts +91 -110
  211. package/src/db/databasePluginCore.spec.ts +62 -0
  212. package/src/db/databasePluginCore.testFixtures.ts +76 -0
  213. package/src/db/databasePluginCore.ts +185 -0
  214. package/src/db/databasePluginUpdateCheck.bench.ts +107 -0
  215. package/src/db/fixedMigrator.spec.ts +184 -42
  216. package/src/db/fixedMigrator.ts +64 -205
  217. package/src/db/fixedMigratorMongo.spec.ts +192 -0
  218. package/src/db/fixedMigratorMongo.ts +425 -0
  219. package/src/db/fixedMigratorShared.ts +36 -0
  220. package/src/db/hotUpdaterSchema.ts +0 -1
  221. package/src/db/index.spec.ts +333 -1025
  222. package/src/db/mongoMigrationExecution.ts +23 -0
  223. package/src/db/node-sqlite.d.ts +25 -0
  224. package/src/db/releaseCatalog.ts +148 -0
  225. package/src/db/schema/mongodb.ts +20 -6
  226. package/src/db/schema/sql.ts +52 -11
  227. package/src/db/schema/sqlOperations.ts +5 -4
  228. package/src/db/schemaGenerators.ts +107 -23
  229. package/src/db/schemaReadiness.ts +2 -1
  230. package/src/db/sqlMigrationExecution.spec.ts +65 -0
  231. package/src/db/sqlMigrationExecution.ts +108 -0
  232. package/src/db/types.spec.ts +55 -0
  233. package/src/db/types.ts +129 -65
  234. package/src/db/updateArtifacts.spec.ts +359 -0
  235. package/src/db/updateArtifacts.ts +290 -126
  236. package/src/handler-standalone.integration.spec.ts +401 -364
  237. package/src/handler.spec.ts +137 -582
  238. package/src/handler.testFixtures.ts +30 -0
  239. package/src/handler.ts +218 -616
  240. package/src/handlerAuth.integration.spec.ts +33 -0
  241. package/src/handlerBundleRoutes.ts +259 -0
  242. package/src/handlerErrors.ts +7 -0
  243. package/src/handlerManagement.spec.ts +262 -0
  244. package/src/handlerParameters.ts +105 -0
  245. package/src/handlerReleaseCatalogRoutes.ts +154 -0
  246. package/src/handlerReleaseManagementRoutes.ts +213 -0
  247. package/src/handlerTypes.ts +96 -0
  248. package/src/handlerVersionRoutes.ts +12 -0
  249. package/src/index.ts +22 -2
  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,189 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { BundleEventPersistenceRow } from "../persistence";
4
+ import { collectActiveInstallationOverview } from "./activeOverview";
5
+
6
+ const HOUR_MS = 60 * 60 * 1_000;
7
+ const DAY_MS = 24 * HOUR_MS;
8
+
9
+ type EventRowOptions = {
10
+ readonly id: string;
11
+ readonly receivedAtMs: number;
12
+ readonly installId?: string;
13
+ readonly userId?: string | null;
14
+ readonly bundleId?: string;
15
+ };
16
+
17
+ type SeriesWindow = {
18
+ readonly startMs: number;
19
+ readonly bucketCount: number;
20
+ readonly bucketSizeMs: number;
21
+ };
22
+
23
+ function eventRow(options: EventRowOptions): BundleEventPersistenceRow {
24
+ return {
25
+ id: options.id,
26
+ type: "UPDATE_APPLIED",
27
+ install_id: options.installId ?? `install-${options.id}`,
28
+ user_id: options.userId ?? null,
29
+ username: null,
30
+ from_release_id: null,
31
+ from_bundle_id: "old",
32
+ to_release_id: null,
33
+ to_bundle_id: options.bundleId ?? "new",
34
+ platform: "ios",
35
+ app_version: "1.0.0",
36
+ channel: "production",
37
+ cohort: "default",
38
+ update_strategy: "fingerprint",
39
+ fingerprint_hash: null,
40
+ sdk_version: null,
41
+ received_at_ms: options.receivedAtMs,
42
+ };
43
+ }
44
+
45
+ function expectedSeries(
46
+ window: SeriesWindow,
47
+ values: Readonly<Record<number, number>>,
48
+ ): { readonly bucketStartMs: number; readonly value: number }[] {
49
+ return Array.from({ length: window.bucketCount }, (_, index) => ({
50
+ bucketStartMs: window.startMs + index * window.bucketSizeMs,
51
+ value: values[index] ?? 0,
52
+ }));
53
+ }
54
+
55
+ describe("collectActiveInstallationOverview", () => {
56
+ it("uses the strict window and latest installation rows for a user", () => {
57
+ const asOfMs = 100 * HOUR_MS;
58
+ const windowStartMs = asOfMs - 24 * HOUR_MS;
59
+ const window = {
60
+ startMs: windowStartMs,
61
+ bucketCount: 24,
62
+ bucketSizeMs: HOUR_MS,
63
+ } as const;
64
+ const rows = [
65
+ eventRow({
66
+ id: "a-start",
67
+ receivedAtMs: windowStartMs,
68
+ installId: "install-a",
69
+ userId: "user-a",
70
+ bundleId: "bundle-b",
71
+ }),
72
+ eventRow({
73
+ id: "a-latest",
74
+ receivedAtMs: asOfMs - 1,
75
+ installId: "install-a",
76
+ userId: "user-a",
77
+ bundleId: "bundle-a",
78
+ }),
79
+ eventRow({
80
+ id: "before-window",
81
+ receivedAtMs: windowStartMs - 1,
82
+ userId: "user-a",
83
+ }),
84
+ eventRow({
85
+ id: "at-cutoff",
86
+ receivedAtMs: asOfMs,
87
+ userId: "user-a",
88
+ }),
89
+ eventRow({
90
+ id: "tie-a",
91
+ receivedAtMs: asOfMs - HOUR_MS,
92
+ installId: "install-tie",
93
+ userId: "user-a",
94
+ bundleId: "bundle-b",
95
+ }),
96
+ eventRow({
97
+ id: "tie-z",
98
+ receivedAtMs: asOfMs - HOUR_MS,
99
+ installId: "install-tie",
100
+ userId: "user-a",
101
+ bundleId: "bundle-c",
102
+ }),
103
+ eventRow({
104
+ id: "changed-user-old",
105
+ receivedAtMs: windowStartMs,
106
+ installId: "install-changed-user",
107
+ userId: "user-a",
108
+ }),
109
+ eventRow({
110
+ id: "changed-user-new",
111
+ receivedAtMs: asOfMs - 1,
112
+ installId: "install-changed-user",
113
+ userId: "user-b",
114
+ }),
115
+ ];
116
+
117
+ expect(
118
+ collectActiveInstallationOverview({
119
+ rows,
120
+ asOfMs,
121
+ window: "24h",
122
+ userId: "user-a",
123
+ }),
124
+ ).toEqual({
125
+ asOfMs,
126
+ window: "24h",
127
+ activeInstallations: 2,
128
+ series: expectedSeries(window, { 0: 1, 23: 2 }),
129
+ bundleSeries: [
130
+ {
131
+ bundleId: "bundle-a",
132
+ series: expectedSeries(window, { 23: 1 }),
133
+ },
134
+ {
135
+ bundleId: "bundle-b",
136
+ series: expectedSeries(window, { 0: 1 }),
137
+ },
138
+ {
139
+ bundleId: "bundle-c",
140
+ series: expectedSeries(window, { 23: 1 }),
141
+ },
142
+ ],
143
+ bundles: [
144
+ { bundleId: "bundle-a", installations: 1 },
145
+ { bundleId: "bundle-c", installations: 1 },
146
+ ],
147
+ });
148
+ });
149
+
150
+ it("includes installations with any user when the filter is omitted", () => {
151
+ const asOfMs = 10 * DAY_MS;
152
+ const windowStartMs = asOfMs - 7 * DAY_MS;
153
+ const window = {
154
+ startMs: windowStartMs,
155
+ bucketCount: 7,
156
+ bucketSizeMs: DAY_MS,
157
+ } as const;
158
+ const rows = [
159
+ eventRow({
160
+ id: "user-a",
161
+ receivedAtMs: windowStartMs,
162
+ userId: "user-a",
163
+ bundleId: "bundle-b",
164
+ }),
165
+ eventRow({
166
+ id: "anonymous",
167
+ receivedAtMs: asOfMs - 1,
168
+ bundleId: "bundle-a",
169
+ }),
170
+ ];
171
+
172
+ const result = collectActiveInstallationOverview({
173
+ rows,
174
+ asOfMs,
175
+ window: "7d",
176
+ });
177
+
178
+ expect(result.activeInstallations).toBe(2);
179
+ expect(result.series).toEqual(expectedSeries(window, { 0: 1, 6: 1 }));
180
+ expect(result.bundles).toEqual([
181
+ { bundleId: "bundle-a", installations: 1 },
182
+ { bundleId: "bundle-b", installations: 1 },
183
+ ]);
184
+ expect(result.bundleSeries.map(({ bundleId }) => bundleId)).toEqual([
185
+ "bundle-a",
186
+ "bundle-b",
187
+ ]);
188
+ });
189
+ });
@@ -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
+ }