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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/dist/_virtual/_rolldown/runtime.cjs +1 -1
  2. package/dist/adapters/databaseConstraintErrors.cjs +0 -1
  3. package/dist/adapters/databasePluginUtils.cjs +0 -1
  4. package/dist/adapters/drizzle.cjs +11 -4
  5. package/dist/adapters/drizzle.d.cts +0 -1
  6. package/dist/adapters/drizzle.d.mts +0 -1
  7. package/dist/adapters/drizzle.mjs +12 -4
  8. package/dist/adapters/drizzleCrud.cjs +62 -1
  9. package/dist/adapters/drizzleCrud.mjs +61 -2
  10. package/dist/adapters/drizzleLazyDB.cjs +6 -1
  11. package/dist/adapters/drizzleLazyDB.mjs +6 -1
  12. package/dist/adapters/drizzleQuery.cjs +3 -2
  13. package/dist/adapters/drizzleQuery.mjs +3 -1
  14. package/dist/adapters/insightsTransaction.cjs +22 -0
  15. package/dist/adapters/insightsTransaction.mjs +22 -0
  16. package/dist/adapters/kysely.cjs +2 -1
  17. package/dist/adapters/kysely.d.cts +0 -1
  18. package/dist/adapters/kysely.d.mts +0 -1
  19. package/dist/adapters/kysely.mjs +3 -1
  20. package/dist/adapters/kyselyCrud.cjs +54 -3
  21. package/dist/adapters/kyselyCrud.mjs +54 -3
  22. package/dist/adapters/kyselyQuery.cjs +2 -2
  23. package/dist/adapters/kyselyQuery.mjs +2 -1
  24. package/dist/adapters/mongodb.cjs +27 -1
  25. package/dist/adapters/mongodb.d.cts +1 -1
  26. package/dist/adapters/mongodb.d.mts +1 -1
  27. package/dist/adapters/mongodb.mjs +28 -1
  28. package/dist/adapters/mongodbCollections.cjs +2 -0
  29. package/dist/adapters/mongodbCollections.mjs +2 -0
  30. package/dist/adapters/mongodbQuery.cjs +21 -4
  31. package/dist/adapters/mongodbQuery.mjs +21 -5
  32. package/dist/adapters/mongodbReads.cjs +91 -29
  33. package/dist/adapters/mongodbReads.mjs +92 -29
  34. package/dist/adapters/mongodbWrites.cjs +16 -2
  35. package/dist/adapters/mongodbWrites.mjs +18 -3
  36. package/dist/adapters/prisma.cjs +55 -7
  37. package/dist/adapters/prisma.d.cts +0 -1
  38. package/dist/adapters/prisma.d.mts +0 -1
  39. package/dist/adapters/prisma.mjs +56 -7
  40. package/dist/adapters/prismaQuery.cjs +0 -1
  41. package/dist/adapters/prismaRows.cjs +22 -1
  42. package/dist/adapters/prismaRows.mjs +22 -1
  43. package/dist/apiKeys.cjs +6 -2
  44. package/dist/apiKeys.d.cts +0 -1
  45. package/dist/apiKeys.d.mts +0 -1
  46. package/dist/apiKeys.mjs +3 -3
  47. package/dist/createHotUpdaterCore.cjs +27 -12
  48. package/dist/createHotUpdaterCore.d.cts +8 -8
  49. package/dist/createHotUpdaterCore.d.mts +8 -8
  50. package/dist/createHotUpdaterCore.mjs +26 -12
  51. package/dist/db/createBundleDiff.cjs +2 -2
  52. package/dist/db/createBundleDiff.d.cts +1 -5
  53. package/dist/db/createBundleDiff.d.mts +1 -5
  54. package/dist/db/databasePluginCore.cjs +0 -1
  55. package/dist/db/fixedMigrator.cjs +2 -2
  56. package/dist/db/fixedMigrator.mjs +2 -2
  57. package/dist/db/fixedMigratorMongo.cjs +6 -4
  58. package/dist/db/fixedMigratorMongo.mjs +6 -4
  59. package/dist/db/index.d.cts +0 -1
  60. package/dist/db/index.d.mts +0 -1
  61. package/dist/db/releaseCatalog.cjs +0 -1
  62. package/dist/db/releaseCatalog.d.cts +0 -1
  63. package/dist/db/releaseCatalog.d.mts +0 -1
  64. package/dist/db/schema/insightsCollation.cjs +14 -0
  65. package/dist/db/schema/insightsCollation.mjs +14 -0
  66. package/dist/db/schema/sql.cjs +11 -7
  67. package/dist/db/schema/sql.mjs +8 -8
  68. package/dist/db/schemaGenerators.cjs +28 -9
  69. package/dist/db/schemaGenerators.mjs +28 -9
  70. package/dist/db/schemaReadiness.cjs +2 -0
  71. package/dist/db/schemaReadiness.d.cts +1 -0
  72. package/dist/db/schemaReadiness.d.mts +1 -0
  73. package/dist/db/schemaReadiness.mjs +2 -0
  74. package/dist/db/sqlMigrationExecution.cjs +1 -1
  75. package/dist/db/sqlMigrationExecution.mjs +1 -0
  76. package/dist/db/types.cjs +3 -1
  77. package/dist/db/types.d.cts +3 -6
  78. package/dist/db/types.d.mts +3 -6
  79. package/dist/db/types.mjs +2 -2
  80. package/dist/db/updateArtifacts.cjs +0 -1
  81. package/dist/handler.cjs +5 -5
  82. package/dist/handler.d.cts +1 -2
  83. package/dist/handler.d.mts +1 -2
  84. package/dist/handler.mjs +5 -5
  85. package/dist/handlerReleaseCatalogRoutes.cjs +0 -1
  86. package/dist/handlerReleaseManagementRoutes.cjs +0 -1
  87. package/dist/handlerTypes.d.cts +0 -1
  88. package/dist/handlerTypes.d.mts +0 -1
  89. package/dist/handlerVersionRoutes.d.cts +1 -0
  90. package/dist/handlerVersionRoutes.d.mts +1 -0
  91. package/dist/index.cjs +2 -2
  92. package/dist/index.d.cts +4 -4
  93. package/dist/index.d.mts +4 -4
  94. package/dist/index.mjs +2 -2
  95. package/dist/insights/domain.d.cts +89 -0
  96. package/dist/insights/domain.d.mts +89 -0
  97. package/dist/insights/errors.cjs +15 -0
  98. package/dist/insights/errors.mjs +14 -0
  99. package/dist/{analytics → insights}/eventInput.cjs +58 -14
  100. package/dist/{analytics → insights}/eventInput.mjs +58 -16
  101. package/dist/insights/provider.cjs +314 -0
  102. package/dist/insights/provider.d.cts +6 -0
  103. package/dist/insights/provider.d.mts +6 -0
  104. package/dist/insights/provider.mjs +314 -0
  105. package/dist/insights/queryInput.cjs +93 -0
  106. package/dist/insights/queryInput.mjs +91 -0
  107. package/dist/insights/routes.cjs +58 -0
  108. package/dist/insights/routes.mjs +56 -0
  109. package/dist/insights/types.d.cts +32 -0
  110. package/dist/insights/types.d.mts +32 -0
  111. package/dist/node.d.cts +0 -1
  112. package/dist/node.d.mts +0 -1
  113. package/dist/package.cjs +1 -1
  114. package/dist/package.mjs +1 -1
  115. package/dist/schema/index.cjs +17 -3
  116. package/dist/schema/index.mjs +4 -4
  117. package/dist/schema/v1_0_0.cjs +72 -30
  118. package/dist/schema/v1_0_0.mjs +72 -31
  119. package/dist/storageAccess.cjs +0 -1
  120. package/dist/types/index.d.cts +0 -1
  121. package/dist/types/index.d.mts +0 -1
  122. package/package.json +7 -7
  123. package/src/adapters/databasePluginTestDatabase.ts +14 -0
  124. package/src/adapters/drizzle.spec.ts +99 -0
  125. package/src/adapters/drizzle.ts +22 -4
  126. package/src/adapters/drizzleCrud.ts +154 -2
  127. package/src/adapters/drizzleLazyDB.ts +17 -3
  128. package/src/adapters/insightsTransaction.ts +32 -0
  129. package/src/adapters/kysely.spec.ts +29 -0
  130. package/src/adapters/kysely.ts +10 -1
  131. package/src/adapters/kyselyCrud.ts +121 -0
  132. package/src/adapters/mongodb.integration.spec.ts +210 -0
  133. package/src/adapters/mongodb.spec.ts +69 -1
  134. package/src/adapters/mongodb.ts +66 -2
  135. package/src/adapters/mongodbCollections.ts +5 -0
  136. package/src/adapters/mongodbQuery.ts +32 -3
  137. package/src/adapters/mongodbReads.ts +69 -0
  138. package/src/adapters/mongodbTestClient.ts +41 -6
  139. package/src/adapters/mongodbTestFilter.ts +4 -1
  140. package/src/adapters/mongodbWrites.ts +27 -0
  141. package/src/adapters/prisma.spec.ts +97 -0
  142. package/src/adapters/prisma.ts +103 -3
  143. package/src/adapters/prismaRows.ts +37 -1
  144. package/src/adapters/prismaTestClient.ts +22 -5
  145. package/src/apiKeys.integration.spec.ts +3 -3
  146. package/src/createHotUpdaterCore.ts +30 -16
  147. package/src/db/databasePluginUpdateCheck.bench.ts +3 -0
  148. package/src/db/fixedMigrator.spec.ts +65 -0
  149. package/src/db/fixedMigratorMongo.spec.ts +56 -9
  150. package/src/db/fixedMigratorMongo.ts +10 -10
  151. package/src/db/index.spec.ts +8 -1
  152. package/src/db/schema/insightsCollation.ts +21 -0
  153. package/src/db/schema/sql.ts +8 -5
  154. package/src/db/schemaGenerators.ts +41 -13
  155. package/src/db/types.spec.ts +11 -3
  156. package/src/db/types.ts +13 -7
  157. package/src/handler-standalone.integration.spec.ts +6 -8
  158. package/src/handler.ts +12 -12
  159. package/src/index.ts +3 -3
  160. package/src/insights/domain.ts +103 -0
  161. package/src/insights/errors.ts +11 -0
  162. package/src/{analytics → insights}/eventInput.ts +71 -14
  163. package/src/insights/insights.integration.spec.ts +381 -0
  164. package/src/insights/provider.spec.ts +385 -0
  165. package/src/insights/provider.ts +591 -0
  166. package/src/insights/queryInput.ts +141 -0
  167. package/src/insights/routes.ts +104 -0
  168. package/src/insights/types.ts +55 -0
  169. package/src/schema/dsl-all-versions.spec.ts +3 -1
  170. package/src/schema/v1_0_0.spec.ts +77 -3
  171. package/src/schema/v1_0_0.ts +94 -30
  172. package/dist/analytics/bounded/activeOverview.cjs +0 -87
  173. package/dist/analytics/bounded/activeOverview.mjs +0 -85
  174. package/dist/analytics/bounded/installationSearch.cjs +0 -42
  175. package/dist/analytics/bounded/installationSearch.mjs +0 -42
  176. package/dist/analytics/bounded/persistence.cjs +0 -39
  177. package/dist/analytics/bounded/persistence.mjs +0 -38
  178. package/dist/analytics/bounded/provider.cjs +0 -170
  179. package/dist/analytics/bounded/provider.d.cts +0 -7
  180. package/dist/analytics/bounded/provider.d.mts +0 -7
  181. package/dist/analytics/bounded/provider.mjs +0 -170
  182. package/dist/analytics/bounded/scan.cjs +0 -144
  183. package/dist/analytics/bounded/scan.mjs +0 -138
  184. package/dist/analytics/domain.d.cts +0 -121
  185. package/dist/analytics/domain.d.mts +0 -121
  186. package/dist/analytics/errors.cjs +0 -22
  187. package/dist/analytics/errors.mjs +0 -20
  188. package/dist/analytics/persistence.d.cts +0 -6
  189. package/dist/analytics/persistence.d.mts +0 -6
  190. package/dist/analytics/queryInput.cjs +0 -58
  191. package/dist/analytics/queryInput.mjs +0 -55
  192. package/dist/analytics/routes.cjs +0 -63
  193. package/dist/analytics/routes.mjs +0 -61
  194. package/dist/analytics/types.d.cts +0 -17
  195. package/dist/analytics/types.d.mts +0 -17
  196. package/src/analytics/analytics.integration.spec.ts +0 -144
  197. package/src/analytics/bounded/activeOverview.spec.ts +0 -189
  198. package/src/analytics/bounded/activeOverview.ts +0 -148
  199. package/src/analytics/bounded/installationSearch.spec.ts +0 -130
  200. package/src/analytics/bounded/installationSearch.ts +0 -69
  201. package/src/analytics/bounded/persistence.ts +0 -43
  202. package/src/analytics/bounded/provider.spec.ts +0 -219
  203. package/src/analytics/bounded/provider.ts +0 -229
  204. package/src/analytics/bounded/scan.ts +0 -235
  205. package/src/analytics/domain.ts +0 -139
  206. package/src/analytics/errors.ts +0 -18
  207. package/src/analytics/persistence.ts +0 -10
  208. package/src/analytics/queryInput.ts +0 -102
  209. package/src/analytics/routes.ts +0 -123
  210. package/src/analytics/types.ts +0 -44
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  createDatabasePlugin,
3
3
  DatabasePluginInputError,
4
+ type InsightsRecordInput,
5
+ type InsightsModel,
4
6
  } from "@hot-updater/plugin-core";
5
7
  import {
6
8
  createDatabasePluginAdapter,
@@ -30,6 +32,7 @@ import { createPrismaOrderBy, createPrismaWhere } from "./prismaQuery";
30
32
  import {
31
33
  getPrismaDelegate,
32
34
  parsePrismaBundleEventRow,
35
+ parsePrismaInsightsInstallationRow,
33
36
  parsePrismaBundleRow,
34
37
  parsePrismaChannelRow,
35
38
  parsePrismaApiKeyRow,
@@ -69,6 +72,7 @@ const runPrismaTransaction = <TResult>(
69
72
  client: PrismaTransactionClient,
70
73
  isolationLevel: "default" | "serializable",
71
74
  callback: (client: object) => Promise<TResult>,
75
+ retryUniqueConflict = false,
72
76
  ): Promise<TResult> => {
73
77
  const execute = () =>
74
78
  isolationLevel === "serializable"
@@ -86,7 +90,8 @@ const runPrismaTransaction = <TResult>(
86
90
  typeof error !== "object" ||
87
91
  error === null ||
88
92
  !("code" in error) ||
89
- error.code !== "P2034"
93
+ (error.code !== "P2034" &&
94
+ !(retryUniqueConflict && error.code === "P2002"))
90
95
  ) {
91
96
  throw error;
92
97
  }
@@ -135,6 +140,8 @@ const findMany = async (
135
140
  return parsePrismaRows(rows, parsePrismaPatchRow);
136
141
  case "bundle_events":
137
142
  return parsePrismaRows(rows, parsePrismaBundleEventRow);
143
+ case "bundle_installations":
144
+ return parsePrismaRows(rows, parsePrismaInsightsInstallationRow);
138
145
  case "channels":
139
146
  return parsePrismaRows(rows, parsePrismaChannelRow);
140
147
  case "api_keys":
@@ -218,7 +225,9 @@ const createCrudImplementation = (
218
225
  const where =
219
226
  input.model === "channels"
220
227
  ? { name: input.data.name }
221
- : { hash: input.data.hash };
228
+ : input.model === "api_keys"
229
+ ? { hash: input.data.hash }
230
+ : { install_id: input.data.install_id };
222
231
  row = await delegate.upsert({
223
232
  where,
224
233
  create: input.data,
@@ -234,6 +243,8 @@ const createCrudImplementation = (
234
243
  return parsePrismaPatchRow(row);
235
244
  case "bundle_events":
236
245
  return parsePrismaBundleEventRow(row);
246
+ case "bundle_installations":
247
+ return parsePrismaInsightsInstallationRow(row);
237
248
  case "channels":
238
249
  return parsePrismaChannelRow(row);
239
250
  case "api_keys":
@@ -245,6 +256,36 @@ const createCrudImplementation = (
245
256
  }
246
257
  },
247
258
  update: async (input) => {
259
+ if (input.model === "bundle_installations") {
260
+ const installId = input.where.find(
261
+ (item) =>
262
+ item.field === "install_id" &&
263
+ (item.operator === undefined || item.operator === "eq") &&
264
+ typeof item.value === "string",
265
+ )?.value;
266
+ if (typeof installId !== "string") {
267
+ throw new PrismaAdapterError(
268
+ "bundle_installations update requires install_id",
269
+ );
270
+ }
271
+ const delegate = getPrismaDelegate(client, "bundle_installations");
272
+ if (delegate.updateMany === undefined) {
273
+ throw new PrismaAdapterError(
274
+ 'model delegate "bundle_installations" requires updateMany',
275
+ );
276
+ }
277
+ const result = await delegate.updateMany({
278
+ where: createPrismaWhere(input.where, provider),
279
+ data: input.update,
280
+ });
281
+ if (result.count === 0) return null;
282
+ const stored = await delegate.findFirst({
283
+ where: { install_id: installId },
284
+ });
285
+ return stored === null
286
+ ? null
287
+ : parsePrismaInsightsInstallationRow(stored);
288
+ }
248
289
  const id = input.where[0]?.value;
249
290
  if (typeof id !== "string") {
250
291
  throw new PrismaAdapterError(
@@ -343,6 +384,8 @@ const createCrudImplementation = (
343
384
  return parsePrismaReleaseRow(row);
344
385
  case "release_catalogs":
345
386
  return parsePrismaReleaseCatalogRow(row);
387
+ case "bundle_installations":
388
+ return parsePrismaInsightsInstallationRow(row);
346
389
  }
347
390
  },
348
391
  findMany: (input) => findMany(client, input, provider),
@@ -356,6 +399,48 @@ const createPrismaImplementation = (
356
399
  const crud = createCrudImplementation(client, provider, relationMode);
357
400
  const implementation: DatabasePluginImplementation = {
358
401
  ...crud,
402
+ recordInsights: (input: InsightsRecordInput) => {
403
+ if (!hasCallbackTransaction(client)) {
404
+ throw new PrismaAdapterError(
405
+ "Insights recording requires callback transactions",
406
+ );
407
+ }
408
+ return runPrismaTransaction(
409
+ client,
410
+ "serializable",
411
+ async (transactionClient) => {
412
+ const events = getPrismaDelegate(transactionClient, "bundle_events");
413
+ if (
414
+ (await events.findFirst({ where: { id: input.event.id } })) !== null
415
+ )
416
+ return;
417
+ await events.create({ data: input.event });
418
+ const installations = getPrismaDelegate(
419
+ transactionClient,
420
+ "bundle_installations",
421
+ );
422
+ const existing = await installations.findFirst({
423
+ where: { install_id: input.installation.install_id },
424
+ });
425
+ if (existing === null) {
426
+ await installations.create({ data: input.installation });
427
+ return;
428
+ }
429
+ const current = parsePrismaInsightsInstallationRow(existing);
430
+ if (
431
+ current.received_at_ms > input.installation.received_at_ms ||
432
+ (current.received_at_ms === input.installation.received_at_ms &&
433
+ current.id >= input.installation.id)
434
+ )
435
+ return;
436
+ await installations.update({
437
+ where: { install_id: input.installation.install_id },
438
+ data: input.installation,
439
+ });
440
+ },
441
+ true,
442
+ );
443
+ },
359
444
  deleteChannel: (input) => {
360
445
  if (!hasCallbackTransaction(client)) {
361
446
  throw new PrismaAdapterError(
@@ -442,10 +527,25 @@ export const prismaAdapter = (
442
527
  config.provider,
443
528
  ),
444
529
  );
530
+ const unsupportedMssqlInsights = async (): Promise<never> => {
531
+ throw new PrismaAdapterError(
532
+ "SQL Server Insights is unsupported: string keys pad trailing spaces and do not provide the required exact UTF-8 identity ordering",
533
+ );
534
+ };
535
+ const insights: InsightsModel =
536
+ config.provider === "mssql"
537
+ ? {
538
+ record: unsupportedMssqlInsights,
539
+ listEvents: unsupportedMssqlInsights,
540
+ findInstallations: unsupportedMssqlInsights,
541
+ countInstallations: unsupportedMssqlInsights,
542
+ countEvents: unsupportedMssqlInsights,
543
+ }
544
+ : adapter.models.insights;
445
545
  return Object.assign(
446
546
  createDatabasePlugin({
447
547
  name: "prisma",
448
- models: adapter.models,
548
+ models: { ...adapter.models, insights },
449
549
  commit: adapter.commit,
450
550
  }),
451
551
  {
@@ -4,6 +4,7 @@ import type {
4
4
  BundleRow,
5
5
  ChannelRow,
6
6
  ApiKeyRow,
7
+ InsightsInstallationRow,
7
8
  ReleaseCatalogRow,
8
9
  ReleaseRow,
9
10
  } from "@hot-updater/plugin-core";
@@ -19,7 +20,9 @@ export type PrismaDelegate = {
19
20
  readonly findFirst: (args?: PrismaQuery) => Promise<unknown>;
20
21
  readonly findMany: (args?: PrismaQuery) => Promise<readonly unknown[]>;
21
22
  readonly update: (args: PrismaQuery) => Promise<unknown>;
22
- readonly updateMany?: (args: PrismaQuery) => Promise<unknown>;
23
+ readonly updateMany?: (
24
+ args: PrismaQuery,
25
+ ) => Promise<{ readonly count: number }>;
23
26
  readonly upsert: (args: PrismaQuery) => Promise<unknown>;
24
27
  };
25
28
 
@@ -48,6 +51,7 @@ const modelDelegates = {
48
51
  bundles: "bundles",
49
52
  bundle_patches: "bundle_patches",
50
53
  bundle_events: "bundle_events",
54
+ bundle_installations: "bundle_installations",
51
55
  channels: "channels",
52
56
  api_keys: "api_keys",
53
57
  releases: "releases",
@@ -196,6 +200,38 @@ export const parsePrismaBundleEventRow = (value: unknown): BundleEventRow => {
196
200
  } as BundleEventRow;
197
201
  };
198
202
 
203
+ export const parsePrismaInsightsInstallationRow = (
204
+ value: unknown,
205
+ ): InsightsInstallationRow => {
206
+ if (!isRecord(value)) {
207
+ throw new PrismaAdapterError("invalid installation row");
208
+ }
209
+ const platform = readString(value, "platform");
210
+ const type = readString(value, "type");
211
+ if (
212
+ (platform !== "ios" && platform !== "android") ||
213
+ (type !== "UPDATE_APPLIED" &&
214
+ type !== "RECOVERED" &&
215
+ type !== "RELEASE_ADOPTED" &&
216
+ type !== "UNCHANGED")
217
+ ) {
218
+ throw new PrismaAdapterError("invalid installation fields");
219
+ }
220
+ return {
221
+ install_id: readString(value, "install_id"),
222
+ id: readString(value, "id"),
223
+ user_id: readNullableString(value, "user_id"),
224
+ username: readNullableString(value, "username"),
225
+ to_bundle_id: readString(value, "to_bundle_id"),
226
+ type,
227
+ platform,
228
+ app_version: readString(value, "app_version"),
229
+ channel: readString(value, "channel"),
230
+ cohort: readString(value, "cohort"),
231
+ received_at_ms: readNumber(value, "received_at_ms"),
232
+ };
233
+ };
234
+
199
235
  export const parsePrismaApiKeyRow = (value: unknown): ApiKeyRow => {
200
236
  if (!isRecord(value)) {
201
237
  throw new PrismaAdapterError("invalid API key row");
@@ -4,9 +4,11 @@ import type {
4
4
  BundleRow,
5
5
  ChannelRow,
6
6
  ApiKeyRow,
7
+ InsightsInstallationRow,
7
8
  ReleaseCatalogRow,
8
9
  ReleaseRow,
9
10
  } from "@hot-updater/plugin-core";
11
+ import { compareInsightsText } from "@hot-updater/plugin-core";
10
12
 
11
13
  type Row =
12
14
  | BundleEventRow
@@ -14,6 +16,7 @@ type Row =
14
16
  | BundleRow
15
17
  | ChannelRow
16
18
  | ApiKeyRow
19
+ | InsightsInstallationRow
17
20
  | ReleaseRow
18
21
  | ReleaseCatalogRow;
19
22
  type Table = Row[];
@@ -21,6 +24,7 @@ type Tables = {
21
24
  bundle_patches: Table;
22
25
  bundles: Table;
23
26
  bundle_events: Table;
27
+ bundle_installations: Table;
24
28
  channels: Table;
25
29
  api_keys: Table;
26
30
  releases: Table;
@@ -60,7 +64,7 @@ const compare = (left: unknown, right: unknown): number => {
60
64
  return left - right;
61
65
  }
62
66
  if (typeof left === "string" && typeof right === "string") {
63
- return left.localeCompare(right);
67
+ return compareInsightsText(left, right);
64
68
  }
65
69
  return 0;
66
70
  };
@@ -71,7 +75,12 @@ const normalize = (value: unknown, insensitive: boolean): unknown =>
71
75
  const readField = (row: Row, field: string): unknown =>
72
76
  Object.entries(row).find(([key]) => key === field)?.[1];
73
77
 
74
- const rowKey = (row: Row): string => ("id" in row ? row.id : row.scope_key);
78
+ const rowKey = (model: keyof Tables, row: Row): string =>
79
+ model === "bundle_installations" && "install_id" in row
80
+ ? row.install_id
81
+ : "id" in row
82
+ ? row.id
83
+ : row.scope_key;
75
84
 
76
85
  const matchesCondition = (current: unknown, condition: unknown): boolean => {
77
86
  if (!isRecord(condition)) return Object.is(current, condition);
@@ -190,7 +199,9 @@ const createDelegate = (tables: Tables, model: keyof Tables, hooks: Hooks) => ({
190
199
  count: async (args?: QueryArgs): Promise<number> =>
191
200
  tables[model].filter((row) => matchesWhere(row, args?.where)).length,
192
201
  create: async ({ data }: CreateArgs): Promise<Row> => {
193
- if (tables[model].some((row) => rowKey(row) === rowKey(data))) {
202
+ if (
203
+ tables[model].some((row) => rowKey(model, row) === rowKey(model, data))
204
+ ) {
194
205
  throw new PrismaTestConstraintError("duplicate id");
195
206
  }
196
207
  if (
@@ -220,7 +231,7 @@ const createDelegate = (tables: Tables, model: keyof Tables, hooks: Hooks) => ({
220
231
  matchesWhere(row, args?.where),
221
232
  );
222
233
  if (model === "bundles") {
223
- const ids = new Set(selected.map(rowKey));
234
+ const ids = new Set(selected.map((row) => rowKey(model, row)));
224
235
  if (
225
236
  tables.releases.some(
226
237
  (row) =>
@@ -293,7 +304,9 @@ const createDelegate = (tables: Tables, model: keyof Tables, hooks: Hooks) => ({
293
304
  tables[model][index] = updated;
294
305
  return structuredClone(updated);
295
306
  }
296
- if (tables[model].some((row) => rowKey(row) === rowKey(create))) {
307
+ if (
308
+ tables[model].some((row) => rowKey(model, row) === rowKey(model, create))
309
+ ) {
297
310
  throw new PrismaTestConstraintError("duplicate id");
298
311
  }
299
312
  assertReferences(tables, model, create);
@@ -304,6 +317,7 @@ const createDelegate = (tables: Tables, model: keyof Tables, hooks: Hooks) => ({
304
317
 
305
318
  const createClient = (tables: Tables, hooks: Hooks) => ({
306
319
  bundle_events: createDelegate(tables, "bundle_events", hooks),
320
+ bundle_installations: createDelegate(tables, "bundle_installations", hooks),
307
321
  bundle_patches: createDelegate(tables, "bundle_patches", hooks),
308
322
  bundles: createDelegate(tables, "bundles", hooks),
309
323
  channels: createDelegate(tables, "channels", hooks),
@@ -317,6 +331,7 @@ export const createPrismaTestHarness = () => {
317
331
  bundle_patches: [],
318
332
  bundles: [],
319
333
  bundle_events: [],
334
+ bundle_installations: [],
320
335
  channels: [],
321
336
  api_keys: [],
322
337
  releases: [],
@@ -347,6 +362,7 @@ export const createPrismaTestHarness = () => {
347
362
  tables.bundle_patches = transactionTables.bundle_patches;
348
363
  tables.bundles = transactionTables.bundles;
349
364
  tables.bundle_events = transactionTables.bundle_events;
365
+ tables.bundle_installations = transactionTables.bundle_installations;
350
366
  tables.channels = transactionTables.channels;
351
367
  tables.api_keys = transactionTables.api_keys;
352
368
  tables.releases = transactionTables.releases;
@@ -387,6 +403,7 @@ export const createPrismaTestHarness = () => {
387
403
  tables.bundle_patches = [];
388
404
  tables.bundles = [];
389
405
  tables.bundle_events = [];
406
+ tables.bundle_installations = [];
390
407
  tables.channels = [];
391
408
  tables.api_keys = [];
392
409
  tables.releases = [];
@@ -31,7 +31,7 @@ describe("createHotUpdater API keys", () => {
31
31
  ).toThrow("clientAccess.headerName must be a valid header name.");
32
32
  });
33
33
 
34
- it("protects only client OTA and Analytics write routes", async () => {
34
+ it("protects only client OTA and Insights write routes", async () => {
35
35
  const database = createInMemoryDatabasePlugin();
36
36
  await registerApiKey({
37
37
  apiKeys: database.models.apiKeys,
@@ -59,7 +59,7 @@ describe("createHotUpdater API keys", () => {
59
59
  expect(
60
60
  (
61
61
  await hotUpdater.handlers.admin(
62
- withApiKey("https://example.com/installations/overview"),
62
+ withApiKey("https://example.com/events"),
63
63
  )
64
64
  ).status,
65
65
  ).toBe(200);
@@ -86,7 +86,7 @@ describe("createHotUpdater API keys", () => {
86
86
  expect(revoked).not.toHaveProperty("hash");
87
87
  });
88
88
 
89
- it("authenticates before parsing Analytics event bodies", async () => {
89
+ it("authenticates before parsing Insights event bodies", async () => {
90
90
  const database = createInMemoryDatabasePlugin();
91
91
  await registerApiKey({
92
92
  apiKeys: database.models.apiKeys,
@@ -1,10 +1,9 @@
1
1
  import {
2
2
  assertStorageOperations,
3
+ type InsightsModel,
3
4
  type StoragePlugin,
4
5
  } from "@hot-updater/plugin-core";
5
6
 
6
- import { createAnalyticsProvider } from "./analytics/bounded/provider";
7
- import type { AnalyticsProvider } from "./analytics/types";
8
7
  import {
9
8
  authenticateApiKey,
10
9
  createApiKeyManagement,
@@ -20,17 +19,19 @@ import {
20
19
  isDatabasePlugin,
21
20
  } from "./db/types";
22
21
  import { createHotUpdaterHandlers, type HotUpdaterHandlers } from "./handler";
22
+ import { createInsightsProvider } from "./insights/provider";
23
+ import type { InsightsProvider } from "./insights/types";
23
24
  import { createStorageAccess } from "./storageAccess";
24
25
 
25
26
  export type RuntimeHotUpdaterAPI = DatabaseAPI & {
26
27
  readonly handlers: HotUpdaterHandlers;
27
28
  readonly adapterName: string;
28
29
  /**
29
- * Built-in Analytics provider. Client ingestion and admin query routes are
30
- * always mounted; React Native clients independently opt into lifecycle
31
- * reporting with `HotUpdater.init({ analytics: true })`.
30
+ * Built-in Insights provider. Client ingestion and admin query routes are
31
+ * always mounted; React Native clients report lifecycle events by default
32
+ * and can opt out with `HotUpdater.init({ insights: false })`.
32
33
  */
33
- readonly analytics: AnalyticsProvider;
34
+ readonly insights: InsightsProvider;
34
35
  /**
35
36
  * In-process API key lifecycle operations for trusted server tooling.
36
37
  * Creation returns the plaintext once; list and revoke expose only metadata.
@@ -44,7 +45,7 @@ export type HotUpdaterAPI = RuntimeHotUpdaterAPI;
44
45
  export type ClientAccessPolicy =
45
46
  | {
46
47
  /**
47
- * Leaves Release Catalog, artifact, and Analytics ingestion routes
48
+ * Leaves Release Catalog, artifact, and Insights ingestion routes
48
49
  * publicly accessible without a client credential.
49
50
  */
50
51
  readonly type: "public";
@@ -52,7 +53,7 @@ export type ClientAccessPolicy =
52
53
  | {
53
54
  /**
54
55
  * Requires a key registered in `database.models.apiKeys` for
55
- * Release Catalog, artifact, and Analytics ingestion requests.
56
+ * Release Catalog, artifact, and Insights ingestion requests.
56
57
  */
57
58
  readonly type: "api-key";
58
59
  /**
@@ -168,16 +169,29 @@ export function createHotUpdaterCore(
168
169
  readStorageText,
169
170
  });
170
171
  const clientAccess = normalizeClientAccess(options.clientAccess);
171
- const analytics = createAnalyticsProvider({
172
- async append(row) {
172
+ const insightsModel: InsightsModel = {
173
+ async record(input) {
173
174
  await assertSchemaReady();
174
- return plugin.models.analytics.append(row);
175
+ return plugin.models.insights.record(input);
175
176
  },
176
- async scan(input) {
177
+ async listEvents(input) {
177
178
  await assertSchemaReady();
178
- return plugin.models.analytics.scan(input);
179
+ return plugin.models.insights.listEvents(input);
179
180
  },
180
- });
181
+ async findInstallations(input) {
182
+ await assertSchemaReady();
183
+ return plugin.models.insights.findInstallations(input);
184
+ },
185
+ async countInstallations(input) {
186
+ await assertSchemaReady();
187
+ return plugin.models.insights.countInstallations(input);
188
+ },
189
+ async countEvents(input) {
190
+ await assertSchemaReady();
191
+ return plugin.models.insights.countEvents(input);
192
+ },
193
+ };
194
+ const insights = createInsightsProvider(insightsModel);
181
195
  const apiKeys = createApiKeyManagement({
182
196
  apiKeys: plugin.models.apiKeys,
183
197
  beforeOperation: assertSchemaReady,
@@ -185,7 +199,7 @@ export function createHotUpdaterCore(
185
199
 
186
200
  const handlers = createHotUpdaterHandlers(
187
201
  core.api,
188
- analytics,
202
+ insights,
189
203
  clientAccess.type === "api-key"
190
204
  ? {
191
205
  authenticate: (request) =>
@@ -204,7 +218,7 @@ export function createHotUpdaterCore(
204
218
  const api: RuntimeHotUpdaterAPI = Object.assign(
205
219
  {
206
220
  adapterName: adapterCapabilities.adapterName ?? core.adapterName,
207
- analytics,
221
+ insights,
208
222
  apiKeys,
209
223
  handlers,
210
224
  },
@@ -54,6 +54,9 @@ class BenchmarkMutationError extends Error {
54
54
 
55
55
  const name = "release-catalog-benchmark";
56
56
  const adapter = createDatabasePluginAdapter(name, {
57
+ async recordInsights() {
58
+ throw new BenchmarkMutationError();
59
+ },
57
60
  async count() {
58
61
  return 0;
59
62
  },
@@ -1,11 +1,14 @@
1
1
  import { DatabaseSync, type SqliteValue } from "node:sqlite";
2
2
 
3
3
  import { PGlite } from "@electric-sql/pglite";
4
+ import { toInsightsInstallationRow } from "@hot-updater/plugin-core";
4
5
  import { Kysely, SqliteDialect } from "kysely";
5
6
  import { PGliteDialect } from "kysely-pglite-dialect";
6
7
  import { afterEach, describe, expect, it, vi } from "vitest";
7
8
 
9
+ import { createBundleEventRowFixture } from "../../../test-utils/src/databaseTestFixtures";
8
10
  import { createInMemoryDatabasePlugin } from "../../../test-utils/test/inMemoryDatabasePlugin";
11
+ import { kyselyAdapter } from "../adapters/kysely";
9
12
  import { createDatabasePluginCore } from "./databasePluginCore";
10
13
  import { createKyselyMigrator } from "./fixedMigrator";
11
14
  import { createSchemaReadinessChecker } from "./schemaReadiness";
@@ -54,6 +57,68 @@ describe("Kysely migrator", () => {
54
57
  await Promise.all(databases.splice(0).map((db) => db.close()));
55
58
  });
56
59
 
60
+ it("creates the final Insights access paths in the initial schema", async () => {
61
+ const database = new PGlite();
62
+ databases.push(database);
63
+ const kysely = new Kysely<SettingsDatabase>({
64
+ dialect: new PGliteDialect(database),
65
+ });
66
+ kyselyInstances.push(kysely);
67
+ const migrator = createKyselyMigrator({
68
+ db: kysely,
69
+ provider: "postgresql",
70
+ });
71
+ await expect(migrator.next()).resolves.toEqual({ version: "1.0.0" });
72
+ await expect(migrator.previous()).resolves.toBeUndefined();
73
+ const migration = await migrator.migrateToLatest();
74
+ await migration.execute();
75
+ await expect(migrator.getVersion()).resolves.toBe("1.0.0");
76
+ const plugin = kyselyAdapter({ db: kysely, provider: "postgresql" });
77
+ const event = createBundleEventRowFixture("706", 100);
78
+ const input = { event, installation: toInsightsInstallationRow(event) };
79
+ await plugin.models.insights.record(input);
80
+
81
+ const repeated = await migrator.migrateToLatest();
82
+ expect(repeated.operations).toEqual([]);
83
+ expect(repeated.getSQL?.()).toBe("");
84
+ await repeated.execute();
85
+ await expect(migrator.next()).resolves.toBeUndefined();
86
+ await expect(
87
+ plugin.models.insights.findInstallations({ installId: event.install_id }),
88
+ ).resolves.toEqual([input.installation]);
89
+ await expect(
90
+ plugin.models.insights.countEvents({
91
+ filter: {
92
+ platform: "ios",
93
+ channel: "production",
94
+ type: "UPDATE_APPLIED",
95
+ toBundleId: event.to_bundle_id,
96
+ },
97
+ sinceMs: 0,
98
+ beforeReceivedAtMs: 101,
99
+ }),
100
+ ).resolves.toBe(1);
101
+ const indexes = await database.query<{ indexname: string }>(
102
+ "select indexname from pg_indexes where tablename in ('bundle_events', 'bundle_installations')",
103
+ );
104
+ expect(indexes.rows.map(({ indexname }) => indexname)).toEqual(
105
+ expect.arrayContaining([
106
+ "bundle_events_from_bundle_idx",
107
+ "bundle_events_to_bundle_idx",
108
+ "bundle_installations_scope_idx",
109
+ "bundle_installations_bundle_idx",
110
+ ]),
111
+ );
112
+ await database.exec("set enable_seqscan = off");
113
+ const plan = await database.query<{ "QUERY PLAN": string }>(
114
+ "explain select * from bundle_events where type = 'UPDATE_APPLIED' and platform = 'ios' and channel = 'production' and to_bundle_id = $1 and received_at_ms < 101 order by received_at_ms desc, id desc limit 10",
115
+ [event.to_bundle_id],
116
+ );
117
+ expect(plan.rows.map((row) => row["QUERY PLAN"]).join("\n")).toContain(
118
+ "bundle_events_to_bundle_idx",
119
+ );
120
+ });
121
+
57
122
  it("creates schema 1.0.0 from an empty database", async () => {
58
123
  const database = new PGlite();
59
124
  databases.push(database);