@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
@@ -255,19 +255,30 @@ const releaseCatalogsV100 = table("release_catalogs", {
255
255
  referencedColumns: ["id"]
256
256
  })]
257
257
  });
258
+ const insightsTextCollations = {
259
+ postgresql: "\"C\"",
260
+ mysql: "utf8mb4_0900_bin"
261
+ };
262
+ const insightsProviders = [
263
+ "postgresql",
264
+ "mysql",
265
+ "sqlite",
266
+ "cockroachdb",
267
+ "mongodb"
268
+ ];
258
269
  const bundleEventsV100 = table("bundle_events", {
259
270
  id: idColumn("id", "uuid"),
260
- type: stringColumn("type"),
261
- install_id: stringColumn("install_id"),
262
- user_id: stringColumn("user_id").nullable(),
271
+ type: column("type", varchar(32)),
272
+ install_id: column("install_id", varchar(255)).collate(insightsTextCollations),
273
+ user_id: column("user_id", varchar(255)).collate(insightsTextCollations).nullable(),
263
274
  username: stringColumn("username").nullable(),
264
275
  from_release_id: uuid("from_release_id").nullable(),
265
276
  from_bundle_id: uuid("from_bundle_id").nullable(),
266
277
  to_release_id: uuid("to_release_id").nullable(),
267
278
  to_bundle_id: uuid("to_bundle_id"),
268
- platform: stringColumn("platform"),
279
+ platform: stringColumn("platform").collate(insightsTextCollations),
269
280
  app_version: stringColumn("app_version"),
270
- channel: stringColumn("channel"),
281
+ channel: stringColumn("channel").collate(insightsTextCollations),
271
282
  cohort: stringColumn("cohort"),
272
283
  update_strategy: stringColumn("update_strategy").nullable(),
273
284
  fingerprint_hash: stringColumn("fingerprint_hash").nullable(),
@@ -278,43 +289,26 @@ const bundleEventsV100 = table("bundle_events", {
278
289
  index("bundle_events_received_at_idx", ["received_at_ms", "id"]),
279
290
  index("bundle_events_install_idx", [
280
291
  "install_id",
281
- "received_at_ms",
282
- "id"
283
- ]),
284
- index("bundle_events_user_id_idx", [
285
- "user_id",
286
- "received_at_ms",
287
- "id"
288
- ]),
289
- index("bundle_events_username_idx", [
290
- "username",
291
- "received_at_ms",
292
- "id"
293
- ]),
294
- index("bundle_events_to_bundle_idx", [
295
292
  "type",
296
- "to_bundle_id",
297
293
  "received_at_ms",
298
294
  "id"
299
295
  ]),
300
296
  index("bundle_events_from_bundle_idx", [
301
297
  "type",
298
+ "platform",
299
+ "channel",
302
300
  "from_bundle_id",
303
301
  "received_at_ms",
304
302
  "id"
305
- ]),
306
- index("bundle_events_to_release_idx", [
307
- "type",
308
- "to_release_id",
309
- "received_at_ms",
310
- "id"
311
- ]),
312
- index("bundle_events_from_release_idx", [
303
+ ], insightsProviders),
304
+ index("bundle_events_to_bundle_idx", [
313
305
  "type",
314
- "from_release_id",
306
+ "platform",
307
+ "channel",
308
+ "to_bundle_id",
315
309
  "received_at_ms",
316
310
  "id"
317
- ])
311
+ ], insightsProviders)
318
312
  ],
319
313
  checks: [
320
314
  check({
@@ -339,6 +333,52 @@ const bundleEventsV100 = table("bundle_events", {
339
333
  })
340
334
  ]
341
335
  });
336
+ const bundleInstallationsV100 = table("bundle_installations", {
337
+ install_id: idColumn("install_id", varchar(255)).collate(insightsTextCollations),
338
+ id: uuid("id"),
339
+ user_id: column("user_id", varchar(255)).collate(insightsTextCollations).nullable(),
340
+ username: stringColumn("username").nullable(),
341
+ to_bundle_id: uuid("to_bundle_id"),
342
+ type: column("type", varchar(32)),
343
+ platform: stringColumn("platform").collate(insightsTextCollations),
344
+ app_version: stringColumn("app_version"),
345
+ channel: stringColumn("channel").collate(insightsTextCollations),
346
+ cohort: stringColumn("cohort"),
347
+ received_at_ms: float("received_at_ms")
348
+ }, {
349
+ indexes: [
350
+ index("bundle_installations_user_id_idx", ["user_id", "install_id"]),
351
+ index("bundle_installations_received_at_idx", ["received_at_ms"]),
352
+ index("bundle_installations_scope_idx", [
353
+ "platform",
354
+ "channel",
355
+ "received_at_ms"
356
+ ], insightsProviders),
357
+ index("bundle_installations_bundle_idx", [
358
+ "platform",
359
+ "channel",
360
+ "to_bundle_id",
361
+ "received_at_ms"
362
+ ], insightsProviders)
363
+ ],
364
+ checks: [
365
+ check({
366
+ name: "bundle_installations_type_check",
367
+ expression: "type in ('UPDATE_APPLIED', 'RECOVERED', 'RELEASE_ADOPTED', 'UNCHANGED')",
368
+ sqliteInline: true
369
+ }),
370
+ check({
371
+ name: "bundle_installations_platform_check",
372
+ expression: "platform in ('ios', 'android')",
373
+ sqliteInline: true
374
+ }),
375
+ check({
376
+ name: "bundle_installations_received_at_check",
377
+ expression: "received_at_ms >= 0",
378
+ sqliteInline: true
379
+ })
380
+ ]
381
+ });
342
382
  const v1_0_0 = schema({
343
383
  version: "1.0.0",
344
384
  settingsTable: HOT_UPDATER_SETTINGS_TABLE,
@@ -349,9 +389,10 @@ const v1_0_0 = schema({
349
389
  releasesV100,
350
390
  releaseCatalogsV100,
351
391
  bundleEventsV100,
392
+ bundleInstallationsV100,
352
393
  apiKeysV100,
353
394
  createSettingsTable("1.0.0")
354
395
  ]
355
396
  });
356
397
  //#endregion
357
- export { apiKeysV100, bundleEventsV100, bundlePatchesV100, bundlesV100, channelsV100, releaseCatalogsV100, releasesV100, v1_0_0 };
398
+ export { apiKeysV100, bundleEventsV100, bundleInstallationsV100, bundlePatchesV100, bundlesV100, channelsV100, releaseCatalogsV100, releasesV100, v1_0_0 };
@@ -1,4 +1,3 @@
1
- require("./_virtual/_rolldown/runtime.cjs");
2
1
  let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
3
2
  //#region src/storageAccess.ts
4
3
  const assertRemoteUrl = (value) => {
@@ -1,7 +1,6 @@
1
1
  import { HotUpdaterAPI } from "../createHotUpdaterCore.cjs";
2
2
  import { Bundle, Bundle as Bundle$2 } from "@hot-updater/core";
3
3
  import { ChannelRow } from "@hot-updater/plugin-core";
4
-
5
4
  //#region src/types/index.d.ts
6
5
  interface PaginationInfo {
7
6
  total: number;
@@ -1,7 +1,6 @@
1
1
  import { HotUpdaterAPI } from "../createHotUpdaterCore.mjs";
2
2
  import { ChannelRow } from "@hot-updater/plugin-core";
3
3
  import { Bundle as Bundle$1, Bundle as Bundle$2 } from "@hot-updater/core";
4
-
5
4
  //#region src/types/index.d.ts
6
5
  interface PaginationInfo {
7
6
  total: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/server",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.2",
4
4
  "type": "module",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "sideEffects": false,
@@ -54,10 +54,10 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "rou3": "0.7.9",
57
- "verkit": "0.3.2",
58
- "@hot-updater/plugin-core": "1.0.0-rc.0",
59
- "@hot-updater/bsdiff": "1.0.0-rc.0",
60
- "@hot-updater/core": "1.0.0-rc.0"
57
+ "verkit": "0.4.0",
58
+ "@hot-updater/core": "1.0.0-rc.0",
59
+ "@hot-updater/plugin-core": "1.0.0-rc.2",
60
+ "@hot-updater/bsdiff": "1.0.0-rc.0"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@prisma/client": "*",
@@ -91,8 +91,8 @@
91
91
  "mongodb": "6.20.0",
92
92
  "msw": "^2.7.0",
93
93
  "uuidv7": "^1.0.2",
94
- "@hot-updater/standalone": "1.0.0-rc.0",
95
- "@hot-updater/test-utils": "1.0.0-rc.0"
94
+ "@hot-updater/standalone": "1.0.0-rc.2",
95
+ "@hot-updater/test-utils": "1.0.0-rc.2"
96
96
  },
97
97
  "scripts": {
98
98
  "build": "tsdown",
@@ -84,6 +84,19 @@ export const DATABASE_PLUGIN_TEST_SCHEMA_SQL = `
84
84
  sdk_version text,
85
85
  received_at_ms integer not null
86
86
  );
87
+ create table bundle_installations (
88
+ install_id varchar(255) primary key,
89
+ id text not null,
90
+ user_id text,
91
+ username text,
92
+ to_bundle_id text not null,
93
+ type text not null,
94
+ platform text not null,
95
+ app_version text not null,
96
+ channel text not null,
97
+ cohort text not null,
98
+ received_at_ms integer not null
99
+ );
87
100
  create table api_keys (
88
101
  id text primary key,
89
102
  hash text not null unique,
@@ -97,6 +110,7 @@ export const DATABASE_PLUGIN_TEST_SCHEMA_SQL = `
97
110
 
98
111
  export const DATABASE_PLUGIN_TEST_RESET_SQL = `
99
112
  delete from bundle_events;
113
+ delete from bundle_installations;
100
114
  delete from api_keys;
101
115
  delete from bundle_patches;
102
116
  delete from release_catalogs;
@@ -1,4 +1,5 @@
1
1
  import { PGlite } from "@electric-sql/pglite";
2
+ import { toInsightsInstallationRow } from "@hot-updater/plugin-core";
2
3
  import {
3
4
  boolean,
4
5
  doublePrecision,
@@ -11,6 +12,7 @@ import {
11
12
  import { drizzle } from "drizzle-orm/pglite";
12
13
  import { describe, expect, it, vi } from "vitest";
13
14
 
15
+ import { createBundleEventRowFixture } from "../../../test-utils/src/databaseTestFixtures";
14
16
  import { setupDatabasePluginTestSuite } from "../../../test-utils/src/setupDatabasePluginTestSuite";
15
17
  import type { DatabaseAdapterWithCapabilities } from "../db/types";
16
18
  import {
@@ -100,6 +102,19 @@ const bundleEvents = pgTable("bundle_events", {
100
102
  sdk_version: text("sdk_version"),
101
103
  received_at_ms: integer("received_at_ms").notNull(),
102
104
  });
105
+ const bundleInstallations = pgTable("bundle_installations", {
106
+ install_id: varchar("install_id", { length: 255 }).primaryKey(),
107
+ id: text("id").notNull(),
108
+ user_id: text("user_id"),
109
+ username: text("username"),
110
+ to_bundle_id: text("to_bundle_id").notNull(),
111
+ type: text("type").notNull(),
112
+ platform: text("platform").notNull(),
113
+ app_version: text("app_version").notNull(),
114
+ channel: text("channel").notNull(),
115
+ cohort: text("cohort").notNull(),
116
+ received_at_ms: integer("received_at_ms").notNull(),
117
+ });
103
118
  const apiKeys = pgTable("api_keys", {
104
119
  id: text("id").primaryKey(),
105
120
  hash: text("hash").notNull().unique(),
@@ -111,6 +126,7 @@ const apiKeys = pgTable("api_keys", {
111
126
  });
112
127
  const schema = {
113
128
  bundle_events: bundleEvents,
129
+ bundle_installations: bundleInstallations,
114
130
  bundle_patches: bundlePatches,
115
131
  bundles,
116
132
  channels,
@@ -156,6 +172,87 @@ setupDatabasePluginTestSuite({
156
172
  });
157
173
 
158
174
  describe("drizzleAdapter schema requirements", () => {
175
+ it("keeps Bun SQLite event and snapshot writes inside its synchronous transaction", async () => {
176
+ const { stdout } = await promisify(execFile)(
177
+ new URL("../../../../node_modules/.bin/bun", import.meta.url).pathname,
178
+ [
179
+ "-e",
180
+ `
181
+ import { Database } from "bun:sqlite";
182
+ import { drizzle } from "drizzle-orm/bun-sqlite";
183
+ import { toInsightsInstallationRow } from "@hot-updater/plugin-core";
184
+ import { drizzleAdapter } from "./src/adapters/drizzle.ts";
185
+ import { createTableSql } from "./src/db/schema/sql.ts";
186
+ import { createBundleEventRowFixture } from "../test-utils/src/databaseTestFixtures.ts";
187
+ import * as schema from "../../examples-server/elysia-drizzle-libsql/hot-updater-schema.ts";
188
+ const db = new Database(":memory:");
189
+ db.exec(createTableSql("sqlite").join(";"));
190
+ db.exec("create trigger reject_snapshot before insert on bundle_installations begin select raise(abort, 'injected snapshot failure'); end;");
191
+ const plugin = drizzleAdapter({ db: drizzle(db, { schema }), provider: "sqlite" });
192
+ const event = createBundleEventRowFixture("708", 100);
193
+ const input = { event, installation: toInsightsInstallationRow(event) };
194
+ let rejected = false;
195
+ try { await plugin.models.insights.record(input); } catch { rejected = true; }
196
+ if (!rejected || db.query("select count(*) as total from bundle_events").get().total !== 0) throw new Error("transaction failed to roll back");
197
+ db.exec("drop trigger reject_snapshot");
198
+ await plugin.models.insights.record(input);
199
+ await plugin.models.insights.record(input);
200
+ if (db.query("select count(*) as total from bundle_events").get().total !== 1 || db.query("select count(*) as total from bundle_installations").get().total !== 1) throw new Error("retry did not commit exactly one report");
201
+ db.close();
202
+ console.log("atomic retry verified");
203
+ `,
204
+ ],
205
+ { cwd: new URL("../..", import.meta.url).pathname },
206
+ );
207
+ expect(stdout).toContain("atomic retry verified");
208
+ });
209
+ it("rolls back the event when the installation write fails and permits retry", async () => {
210
+ const db = new PGlite();
211
+ await db.exec(DATABASE_PLUGIN_TEST_SCHEMA_SQL);
212
+ await db.exec(
213
+ "alter table bundle_installations add constraint reject_snapshot check (install_id <> 'install-701')",
214
+ );
215
+ const plugin = drizzleAdapter({
216
+ db: drizzle(db, { schema }),
217
+ provider: "postgresql",
218
+ });
219
+ const event = createBundleEventRowFixture("701", 100);
220
+ const input = { event, installation: toInsightsInstallationRow(event) };
221
+ try {
222
+ await expect(plugin.models.insights.record(input)).rejects.toThrow();
223
+ expect((await db.query("select id from bundle_events")).rows).toEqual([]);
224
+ await db.exec(
225
+ "alter table bundle_installations drop constraint reject_snapshot",
226
+ );
227
+ await plugin.models.insights.record(input);
228
+ await expect(
229
+ plugin.models.insights.findInstallations({
230
+ installId: event.install_id,
231
+ }),
232
+ ).resolves.toEqual([input.installation]);
233
+ } finally {
234
+ await db.close();
235
+ }
236
+ });
237
+
238
+ it("requires transaction support before recording on a lazy database", async () => {
239
+ const getDB = vi.fn(() => {
240
+ throw new DrizzleTestStateError();
241
+ });
242
+ const plugin = drizzleAdapter({
243
+ db: getDB,
244
+ provider: "postgresql",
245
+ schema,
246
+ });
247
+ const event = createBundleEventRowFixture("702", 100);
248
+ await expect(
249
+ plugin.models.insights.record({
250
+ event,
251
+ installation: toInsightsInstallationRow(event),
252
+ }),
253
+ ).rejects.toThrow("transaction support");
254
+ expect(getDB).not.toHaveBeenCalled();
255
+ });
159
256
  it("does not resolve a lazy database while generating a schema", () => {
160
257
  const getDB = vi.fn(() => {
161
258
  throw new DrizzleTestStateError();
@@ -235,3 +332,5 @@ describe("drizzleAdapter schema requirements", () => {
235
332
  );
236
333
  });
237
334
  });
335
+ import { execFile } from "node:child_process";
336
+ import { promisify } from "node:util";
@@ -15,8 +15,9 @@ import type {
15
15
  ORMProvider,
16
16
  SchemaGenerator,
17
17
  } from "../db/types";
18
- import { createDrizzleCrud } from "./drizzleCrud";
18
+ import { createDrizzleCrud, recordDrizzleInsights } from "./drizzleCrud";
19
19
  import { createLazyDB } from "./drizzleLazyDB";
20
+ import { runInsightsTransaction } from "./insightsTransaction";
20
21
 
21
22
  export type DrizzleProvider = Exclude<
22
23
  ORMProvider,
@@ -38,6 +39,18 @@ const createImplementation = (
38
39
  const transaction = db.transaction?.bind(db);
39
40
  return {
40
41
  ...crud,
42
+ recordInsights: (input) => {
43
+ if (transaction === undefined) {
44
+ throw new Error(
45
+ "Drizzle Insights recording requires transaction support.",
46
+ );
47
+ }
48
+ return runInsightsTransaction(() =>
49
+ transaction((transactionDatabase) =>
50
+ recordDrizzleInsights(transactionDatabase, config.provider, input),
51
+ ),
52
+ );
53
+ },
41
54
  deleteChannel: (input) => {
42
55
  if (transaction === undefined) {
43
56
  throw new Error(
@@ -115,9 +128,14 @@ export const drizzleAdapter = (
115
128
  list: (input) => getAdapter().models.channels.list(input),
116
129
  delete: (input) => getAdapter().models.channels.delete(input),
117
130
  },
118
- analytics: {
119
- append: (row) => getAdapter().models.analytics.append(row),
120
- scan: (input) => getAdapter().models.analytics.scan(input),
131
+ insights: {
132
+ record: (input) => getAdapter().models.insights.record(input),
133
+ listEvents: (input) => getAdapter().models.insights.listEvents(input),
134
+ findInstallations: (input) =>
135
+ getAdapter().models.insights.findInstallations(input),
136
+ countInstallations: (input) =>
137
+ getAdapter().models.insights.countInstallations(input),
138
+ countEvents: (input) => getAdapter().models.insights.countEvents(input),
121
139
  },
122
140
  apiKeys: {
123
141
  create: (row) => getAdapter().models.apiKeys.create(row),
@@ -1,9 +1,12 @@
1
- import { DatabasePluginInputError } from "@hot-updater/plugin-core";
1
+ import {
2
+ DatabasePluginInputError,
3
+ type InsightsRecordInput,
4
+ } from "@hot-updater/plugin-core";
2
5
  import type {
3
6
  DatabasePluginImplementation,
4
7
  TransactionDatabasePluginImplementation,
5
8
  } from "@hot-updater/plugin-core/internal";
6
- import { asc, desc, eq, sql, type SQLWrapper } from "drizzle-orm";
9
+ import { and, asc, desc, eq, lt, or, sql, type SQLWrapper } from "drizzle-orm";
7
10
 
8
11
  import {
9
12
  isChannelDeleteReferencedError,
@@ -78,9 +81,98 @@ const executeInsert = async (
78
81
  await ignored.execute();
79
82
  };
80
83
 
84
+ export const recordDrizzleInsights = (
85
+ db: DrizzleDB,
86
+ provider: DrizzleProvider,
87
+ input: InsightsRecordInput,
88
+ ): void | Promise<void> => {
89
+ if (db.resultKind !== "sync")
90
+ return recordAsyncDrizzleInsights(db, provider, input);
91
+ const events = getDrizzleTable(db, "bundle_events");
92
+ const installations = getDrizzleTable(db, "bundle_installations");
93
+ const eventInsert = db
94
+ .insert(events)
95
+ .values(input.event)
96
+ .onConflictDoNothing?.()
97
+ .returning?.({ id: getDrizzleColumn(events, "id") });
98
+ const installationInsert = db
99
+ .insert(installations)
100
+ .values(input.installation)
101
+ .onConflictDoNothing?.();
102
+ const installationUpdate = db
103
+ .update(installations)
104
+ .set(input.installation)
105
+ .where(newerInstallationWhere(installations, input.installation));
106
+ if (
107
+ eventInsert?.all === undefined ||
108
+ installationInsert?.run === undefined ||
109
+ installationUpdate.run === undefined
110
+ )
111
+ throw new DrizzleAdapterInvariantError();
112
+ if (eventInsert.all().length === 0) return;
113
+ installationInsert.run();
114
+ installationUpdate.run();
115
+ };
116
+
117
+ const newerInstallationWhere = (
118
+ installations: DrizzleTable,
119
+ installation: InsightsRecordInput["installation"],
120
+ ) => {
121
+ const receivedAt = getDrizzleColumn(installations, "received_at_ms");
122
+ return and(
123
+ eq(getDrizzleColumn(installations, "install_id"), installation.install_id),
124
+ or(
125
+ lt(receivedAt, installation.received_at_ms),
126
+ and(
127
+ eq(receivedAt, installation.received_at_ms),
128
+ lt(getDrizzleColumn(installations, "id"), installation.id),
129
+ ),
130
+ ),
131
+ );
132
+ };
133
+
134
+ const recordAsyncDrizzleInsights = async (
135
+ db: DrizzleDB,
136
+ provider: DrizzleProvider,
137
+ { event, installation }: InsightsRecordInput,
138
+ ): Promise<void> => {
139
+ const events = getDrizzleTable(db, "bundle_events");
140
+ const installations = getDrizzleTable(db, "bundle_installations");
141
+ if (provider === "mysql") {
142
+ const existing = await db.query.bundle_events.findFirst({
143
+ where: eq(getDrizzleColumn(events, "id"), event.id),
144
+ });
145
+ if (existing !== undefined) return;
146
+ await db.insert(events).values(event).execute();
147
+ const insertInstallation = db
148
+ .insert(installations)
149
+ .values(installation)
150
+ .onDuplicateKeyUpdate?.({ set: { install_id: installation.install_id } });
151
+ if (insertInstallation === undefined)
152
+ throw new DrizzleAdapterInvariantError();
153
+ await insertInstallation.execute();
154
+ } else {
155
+ const insert = db.insert(events).values(event).onConflictDoNothing?.();
156
+ const returning = insert?.returning?.({
157
+ id: getDrizzleColumn(events, "id"),
158
+ });
159
+ if (returning === undefined) throw new DrizzleAdapterInvariantError();
160
+ const rows = await returning.execute();
161
+ if (!Array.isArray(rows)) throw new DrizzleAdapterInvariantError();
162
+ if (rows.length === 0) return;
163
+ await executeInsert(db, provider, installations, installation, "ignore");
164
+ }
165
+ await db
166
+ .update(installations)
167
+ .set(installation)
168
+ .where(newerInstallationWhere(installations, installation))
169
+ .execute();
170
+ };
171
+
81
172
  const toOrderBy = (
82
173
  table: DrizzleTable,
83
174
  input: {
175
+ model?: string;
84
176
  orderBy?: readonly {
85
177
  field: string;
86
178
  direction: "asc" | "desc";
@@ -91,6 +183,12 @@ const toOrderBy = (
91
183
  const clauses = input.orderBy;
92
184
  return clauses?.flatMap((clause) => {
93
185
  const column = getDrizzleColumn(table, clause.field);
186
+ if (
187
+ input.model === "bundle_events" ||
188
+ input.model === "bundle_installations"
189
+ ) {
190
+ return [clause.direction === "asc" ? asc(column) : desc(column)];
191
+ }
94
192
  const nulls =
95
193
  clause.nulls ?? (clause.direction === "asc" ? "last" : "first");
96
194
  return [
@@ -110,6 +208,7 @@ export const createDrizzleCrud = (
110
208
  const bundles = getDrizzleTable(db, "bundles");
111
209
  const patches = getDrizzleTable(db, "bundle_patches");
112
210
  const events = getDrizzleTable(db, "bundle_events");
211
+ const installations = getDrizzleTable(db, "bundle_installations");
113
212
  const releases = getDrizzleTable(db, "releases");
114
213
  const releaseCatalogs = getDrizzleTable(db, "release_catalogs");
115
214
  const channels = getDrizzleTable(db, "channels");
@@ -169,6 +268,15 @@ export const createDrizzleCrud = (
169
268
  case "bundle_events":
170
269
  await executeInsert(db, provider, events, input.data, undefined);
171
270
  return input.data;
271
+ case "bundle_installations":
272
+ await executeInsert(
273
+ db,
274
+ provider,
275
+ installations,
276
+ input.data,
277
+ input.onConflict,
278
+ );
279
+ return input.data;
172
280
  case "releases":
173
281
  await executeInsert(
174
282
  db,
@@ -208,6 +316,27 @@ export const createDrizzleCrud = (
208
316
  }
209
317
  },
210
318
  async update(input) {
319
+ if (input.model === "bundle_installations") {
320
+ const installId = input.where.find(
321
+ (item) =>
322
+ item.field === "install_id" &&
323
+ (item.operator === undefined || item.operator === "eq") &&
324
+ typeof item.value === "string",
325
+ )?.value;
326
+ if (typeof installId !== "string") {
327
+ throw new DrizzleAdapterInvariantError();
328
+ }
329
+ const where = buildDrizzleWhere(provider, installations, input.where);
330
+ if (where === undefined) throw new DrizzleAdapterInvariantError();
331
+ await db.update(installations).set(input.update).where(where).execute();
332
+ const row = await db.query.bundle_installations.findFirst({
333
+ where: eq(getDrizzleColumn(installations, "install_id"), installId),
334
+ });
335
+ return row?.id === input.update.id &&
336
+ row.received_at_ms === input.update.received_at_ms
337
+ ? row
338
+ : null;
339
+ }
211
340
  const selector = input.where[0];
212
341
  if (selector === undefined || typeof selector.value !== "string") {
213
342
  throw new DrizzleAdapterInvariantError();
@@ -296,6 +425,11 @@ export const createDrizzleCrud = (
296
425
  throw new DatabasePluginInputError("invalid-operation");
297
426
  }
298
427
  switch (input.model) {
428
+ case "bundle_events":
429
+ return db.$count(
430
+ events,
431
+ buildDrizzleWhere(provider, events, input.where),
432
+ );
299
433
  case "bundles":
300
434
  return db.$count(
301
435
  bundles,
@@ -311,6 +445,11 @@ export const createDrizzleCrud = (
311
445
  releases,
312
446
  buildDrizzleWhere(provider, releases, input.where),
313
447
  );
448
+ case "bundle_installations":
449
+ return db.$count(
450
+ installations,
451
+ buildDrizzleWhere(provider, installations, input.where),
452
+ );
314
453
  }
315
454
  },
316
455
  async findOne(input) {
@@ -351,6 +490,12 @@ export const createDrizzleCrud = (
351
490
  });
352
491
  return row === undefined ? null : fromStoredReleaseCatalogRow(row);
353
492
  }
493
+ case "bundle_installations":
494
+ return (
495
+ (await db.query.bundle_installations.findFirst({
496
+ where: buildDrizzleWhere(provider, installations, input.where),
497
+ })) ?? null
498
+ );
354
499
  }
355
500
  },
356
501
  async findMany(input) {
@@ -374,6 +519,13 @@ export const createDrizzleCrud = (
374
519
  limit: input.limit,
375
520
  offset: input.offset,
376
521
  });
522
+ case "bundle_installations":
523
+ return db.query.bundle_installations.findMany({
524
+ where: buildDrizzleWhere(provider, installations, input.where),
525
+ orderBy: toOrderBy(installations, input),
526
+ limit: input.limit,
527
+ offset: input.offset,
528
+ });
377
529
  case "api_keys":
378
530
  return db.query.api_keys.findMany({
379
531
  where: buildDrizzleWhere(provider, apiKeys, input.where),