@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
@@ -2,6 +2,7 @@ import { MongoClient } from "mongodb";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
 
4
4
  import { createInMemoryDatabasePlugin } from "../../../test-utils/test/inMemoryDatabasePlugin";
5
+ import { HOT_UPDATER_SCHEMA_VERSION } from "../schema/types";
5
6
  import { createDatabasePluginCore } from "./databasePluginCore";
6
7
  import { createMongoMigrator } from "./fixedMigrator";
7
8
  import { createSchemaReadinessChecker } from "./schemaReadiness";
@@ -29,7 +30,7 @@ function createSettingsMongoClient(
29
30
  }
30
31
 
31
32
  describe("MongoDB migration", () => {
32
- it("creates schema 1.0.0 from an empty database", async () => {
33
+ it("creates the current schema from an empty database", async () => {
33
34
  const settings = new Map<string, unknown>();
34
35
  const settingsCollection = {
35
36
  find: ({ key }: { readonly key: string }) => ({
@@ -50,7 +51,7 @@ describe("MongoDB migration", () => {
50
51
  const modelCollection = {
51
52
  find: () => ({ toArray: async () => [] }),
52
53
  listIndexes: () => ({ toArray: async () => [] }),
53
- createIndex: async () => "created",
54
+ createIndex: vi.fn(async () => "created"),
54
55
  };
55
56
  const command = vi.fn(
56
57
  async (_input: Record<string, unknown>): Promise<void> => undefined,
@@ -76,7 +77,47 @@ describe("MongoDB migration", () => {
76
77
  { key: 1 },
77
78
  { unique: true },
78
79
  );
79
- expect(settings.get("schema.core")).toBe("1.0.0");
80
+ expect(settings.get("schema.core")).toBe(HOT_UPDATER_SCHEMA_VERSION);
81
+ expect(modelCollection.createIndex).toHaveBeenCalledWith(
82
+ { install_id: 1 },
83
+ {
84
+ name: "bundle_installations_install_id_idx",
85
+ unique: true,
86
+ collation: { locale: "simple" },
87
+ },
88
+ );
89
+ expect(modelCollection.createIndex).toHaveBeenCalledWith(
90
+ {
91
+ type: 1,
92
+ platform: 1,
93
+ channel: 1,
94
+ from_bundle_id: 1,
95
+ received_at_ms: 1,
96
+ id: 1,
97
+ },
98
+ {
99
+ name: "bundle_events_from_bundle_idx",
100
+ collation: { locale: "simple" },
101
+ },
102
+ );
103
+ expect(modelCollection.createIndex).toHaveBeenCalledWith(
104
+ {
105
+ type: 1,
106
+ platform: 1,
107
+ channel: 1,
108
+ to_bundle_id: 1,
109
+ received_at_ms: 1,
110
+ id: 1,
111
+ },
112
+ { name: "bundle_events_to_bundle_idx", collation: { locale: "simple" } },
113
+ );
114
+ expect(modelCollection.createIndex).toHaveBeenCalledWith(
115
+ { platform: 1, channel: 1, to_bundle_id: 1, received_at_ms: 1 },
116
+ {
117
+ name: "bundle_installations_bundle_idx",
118
+ collation: { locale: "simple" },
119
+ },
120
+ );
80
121
  const commands = command.mock.calls.map(([input]) => input);
81
122
  expect(commands.find(({ collMod }) => collMod === "bundles")).toMatchObject(
82
123
  {
@@ -138,24 +179,30 @@ describe("MongoDB migration", () => {
138
179
  });
139
180
  });
140
181
 
141
- it("is a no-op when schema.core is already 1.0.0", async () => {
142
- const client = createSettingsMongoClient({ "schema.core": "1.0.0" });
182
+ it("is a no-op when schema.core is current", async () => {
183
+ const client = createSettingsMongoClient({
184
+ "schema.core": HOT_UPDATER_SCHEMA_VERSION,
185
+ });
143
186
  const migrator = createMongoMigrator(client);
144
187
 
145
- await expect(migrator.getVersion()).resolves.toBe("1.0.0");
188
+ await expect(migrator.getVersion()).resolves.toBe(
189
+ HOT_UPDATER_SCHEMA_VERSION,
190
+ );
146
191
  await expect(
147
192
  migrator.migrateToLatest({ mode: "from-schema" }),
148
193
  ).resolves.toMatchObject({ operations: [] });
149
194
  });
150
195
 
151
- it("ignores a leftover version marker when schema.core is 1.0.0", async () => {
196
+ it("ignores a leftover version marker when schema.core is current", async () => {
152
197
  const client = createSettingsMongoClient({
153
- "schema.core": "1.0.0",
198
+ "schema.core": HOT_UPDATER_SCHEMA_VERSION,
154
199
  version: "0.36.0",
155
200
  });
156
201
  const migrator = createMongoMigrator(client);
157
202
 
158
- await expect(migrator.getVersion()).resolves.toBe("1.0.0");
203
+ await expect(migrator.getVersion()).resolves.toBe(
204
+ HOT_UPDATER_SCHEMA_VERSION,
205
+ );
159
206
  await expect(
160
207
  migrator.migrateToLatest({ mode: "from-schema" }),
161
208
  ).resolves.toMatchObject({ operations: [] });
@@ -334,7 +334,11 @@ export const createMongoMigrator = (client: MongoClient): Migrator => {
334
334
  await ensureSettingsKeyIndex();
335
335
  for (const table of hotUpdaterSchema.tables) {
336
336
  if (table.internal) continue;
337
+ const isInsights =
338
+ table.ormName === "bundle_events" ||
339
+ table.ormName === "bundle_installations";
337
340
  const collection = db.collection(table.ormName);
341
+ const collation = isInsights ? { locale: "simple" } : undefined;
338
342
  const primaryKey = table.columns.find(
339
343
  (column) => column.primaryKey,
340
344
  );
@@ -344,18 +348,13 @@ export const createMongoMigrator = (client: MongoClient): Migrator => {
344
348
  );
345
349
  }
346
350
  const idIndexName = `${table.ormName}_${primaryKey.ormName}_idx`;
347
- const existingIndexes = await collection
348
- .listIndexes()
349
- .toArray();
350
- const existingIdIndex = existingIndexes.find(
351
- ({ name }) => name === idIndexName,
352
- );
353
- if (existingIdIndex && existingIdIndex.unique !== true) {
354
- await collection.dropIndex(idIndexName);
355
- }
356
351
  await collection.createIndex(
357
352
  { [primaryKey.ormName]: 1 },
358
- { name: idIndexName, unique: true },
353
+ {
354
+ name: idIndexName,
355
+ unique: true,
356
+ ...(collation ? { collation } : {}),
357
+ },
359
358
  );
360
359
  for (const index of (table.indexes ?? []).filter((item) =>
361
360
  schemaIndexAppliesToProvider(item, "mongodb"),
@@ -367,6 +366,7 @@ export const createMongoMigrator = (client: MongoClient): Migrator => {
367
366
  {
368
367
  name: index.name,
369
368
  ...(index.unique ? { unique: true } : {}),
369
+ ...(collation ? { collation } : {}),
370
370
  },
371
371
  );
372
372
  }
@@ -415,7 +415,7 @@ describe("server/db hotUpdater (PGlite + Kysely)", async () => {
415
415
  });
416
416
 
417
417
  describe("migrator enhancements", () => {
418
- it("registers only schema 1.0.0", () => {
418
+ it("registers only the initial schema", () => {
419
419
  expect(hotUpdaterSchemaVersions.map((schema) => schema.version)).toEqual([
420
420
  "1.0.0",
421
421
  ]);
@@ -837,6 +837,9 @@ describe("server/db hotUpdater (PGlite + Kysely)", async () => {
837
837
  bundle_events: {
838
838
  id: "event_id",
839
839
  },
840
+ bundle_installations: {
841
+ install_id: "install_id",
842
+ },
840
843
  bundle_patches: {
841
844
  bundle_id: "bundle_id",
842
845
  id: "patch_id",
@@ -878,6 +881,10 @@ describe("server/db hotUpdater (PGlite + Kysely)", async () => {
878
881
  findFirst: vi.fn(),
879
882
  findMany: vi.fn(),
880
883
  },
884
+ bundle_installations: {
885
+ findFirst: vi.fn(),
886
+ findMany: vi.fn(),
887
+ },
881
888
  bundle_patches: {
882
889
  findFirst: vi.fn(),
883
890
  findMany: vi.fn(),
@@ -0,0 +1,21 @@
1
+ import { bundleEventsV100, bundleInstallationsV100 } from "../../schema/v1_0_0";
2
+ import type { ORMSQLProvider } from "../types";
3
+ import { getSqlType, sqlColumnDefinition } from "./sql";
4
+
5
+ export const getInsightsCollationSql = (
6
+ provider: ORMSQLProvider,
7
+ ): readonly string[] =>
8
+ [bundleEventsV100, bundleInstallationsV100].flatMap((table) =>
9
+ table.columns.flatMap((column) => {
10
+ const collation = column.providerCollations?.[provider];
11
+ if (collation === undefined) return [];
12
+ if (provider === "mysql") {
13
+ return [
14
+ `alter table ${table.ormName} modify column ${sqlColumnDefinition(table, { ...column, primaryKey: undefined }, provider)}`,
15
+ ];
16
+ }
17
+ return [
18
+ `alter table ${table.ormName} alter column ${column.ormName} type ${getSqlType(column.type, provider)} collate ${collation}`,
19
+ ];
20
+ }),
21
+ );
@@ -5,6 +5,7 @@ import {
5
5
  type HotUpdaterColumnType,
6
6
  type HotUpdaterForeignKeySchema,
7
7
  type HotUpdaterTableSchema,
8
+ type HotUpdaterVersionedSchema,
8
9
  } from "../../schema/types";
9
10
  import type {
10
11
  MigrationOperation,
@@ -209,9 +210,10 @@ export const createTableStatement = (
209
210
  export const createForeignKeySqlStatements = (
210
211
  provider: ORMSQLProvider,
211
212
  relationMode: RelationMode = "foreign-keys",
213
+ schema: HotUpdaterVersionedSchema = hotUpdaterSchema,
212
214
  ): readonly string[] => {
213
215
  if (relationMode !== "foreign-keys" || provider === "sqlite") return [];
214
- return hotUpdaterSchema.tables.flatMap((table) =>
216
+ return schema.tables.flatMap((table) =>
215
217
  (table.foreignKeys ?? []).map((foreignKey) =>
216
218
  createForeignKeySql(table, foreignKey, provider),
217
219
  ),
@@ -221,21 +223,22 @@ export const createForeignKeySqlStatements = (
221
223
  export const createTableSql = (
222
224
  provider: ORMSQLProvider,
223
225
  relationMode: RelationMode = "foreign-keys",
226
+ schema: HotUpdaterVersionedSchema = hotUpdaterSchema,
224
227
  ): readonly string[] => [
225
- ...hotUpdaterSchema.tables.map((table) =>
228
+ ...schema.tables.map((table) =>
226
229
  createTableStatement(table, provider, relationMode),
227
230
  ),
228
- ...hotUpdaterSchema.tables.flatMap((table) =>
231
+ ...schema.tables.flatMap((table) =>
229
232
  (table.indexes ?? [])
230
233
  .filter((index) => schemaIndexAppliesToProvider(index, provider))
231
234
  .map((index) => createIndexSql(table, index, provider)),
232
235
  ),
233
236
  ...(provider === "sqlite"
234
237
  ? []
235
- : hotUpdaterSchema.tables.flatMap((table) =>
238
+ : schema.tables.flatMap((table) =>
236
239
  (table.checks ?? []).map((check) =>
237
240
  createCheckSql(table, check, provider),
238
241
  ),
239
242
  )),
240
- ...createForeignKeySqlStatements(provider, relationMode),
243
+ ...createForeignKeySqlStatements(provider, relationMode, schema),
241
244
  ];
@@ -7,11 +7,13 @@ import {
7
7
  type HotUpdaterTableSchema,
8
8
  type HotUpdaterVersionedSchema,
9
9
  } from "../schema/types";
10
+ import { getInsightsCollationSql } from "./schema/insightsCollation";
10
11
  import {
11
12
  getHotUpdaterSchemaVersion,
12
13
  hotUpdaterSchema,
13
14
  schemaIndexAppliesToProvider,
14
15
  } from "./schema/registry";
16
+ import { getSqlType } from "./schema/sql";
15
17
  import type { ORMProvider, ORMSQLProvider, SchemaGenerator } from "./types";
16
18
  import { getSQLProvider } from "./types";
17
19
 
@@ -154,19 +156,33 @@ const prismaIndexes = (
154
156
  export const generatePrismaSchema = (
155
157
  provider: ORMProvider,
156
158
  schema: HotUpdaterVersionedSchema = hotUpdaterSchema,
157
- ) =>
158
- schema.tables
159
- .map((table) => {
160
- const lines = [
161
- ...table.columns.map((column) => prismaField(column, provider)),
162
- ...prismaRelationFields(table, schema),
163
- ...prismaIndexes(table, provider),
164
- ];
165
- return `model ${table.ormName} {\n${lines
166
- .map((line) => ` ${line}`)
167
- .join("\n")}\n}`;
168
- })
169
- .join("\n\n");
159
+ ) => {
160
+ const sqlProvider = getSQLProvider(provider);
161
+ const collationSql = sqlProvider ? getInsightsCollationSql(sqlProvider) : [];
162
+ const header =
163
+ collationSql.length === 0
164
+ ? ""
165
+ : [
166
+ "// Apply these statements in the initial Prisma SQL migration to preserve exact Insights identities and cursors:",
167
+ ...collationSql.map((statement) => `// ${statement};`),
168
+ "",
169
+ ].join("\n");
170
+ return (
171
+ header +
172
+ schema.tables
173
+ .map((table) => {
174
+ const lines = [
175
+ ...table.columns.map((column) => prismaField(column, provider)),
176
+ ...prismaRelationFields(table, schema),
177
+ ...prismaIndexes(table, provider),
178
+ ];
179
+ return `model ${table.ormName} {\n${lines
180
+ .map((line) => ` ${line}`)
181
+ .join("\n")}\n}`;
182
+ })
183
+ .join("\n\n")
184
+ );
185
+ };
170
186
 
171
187
  const drizzleImportSource = (provider: ORMSQLProvider) =>
172
188
  provider === "sqlite"
@@ -186,6 +202,18 @@ const drizzleColumnFn = (
186
202
  column: HotUpdaterColumnSchema,
187
203
  provider: ORMSQLProvider,
188
204
  ): { code: string; imports: readonly string[] } => {
205
+ const collation = column.providerCollations?.[provider];
206
+ if (collation !== undefined) {
207
+ const charset =
208
+ provider === "mysql"
209
+ ? ` character set ${collation === "ascii_bin" ? "ascii" : "utf8mb4"}`
210
+ : "";
211
+ const dataType = `${getSqlType(column.type, provider)}${charset} collate ${collation}`;
212
+ return {
213
+ code: `customType<{ data: string }>({ dataType: () => ${literal(dataType)} })(${literal(column.ormName)})`,
214
+ imports: ["customType"],
215
+ };
216
+ }
189
217
  if (provider === "sqlite") {
190
218
  if (column.type === "bool") {
191
219
  return {
@@ -22,9 +22,16 @@ describe("isDatabasePlugin", () => {
22
22
  bundles: { ...plugin.models.bundles, findMany: null },
23
23
  },
24
24
  };
25
- const missingAnalytics = {
25
+ const missingInsights = {
26
26
  ...plugin,
27
- models: { ...plugin.models, analytics: undefined },
27
+ models: { ...plugin.models, insights: undefined },
28
+ };
29
+ const malformedInsights = {
30
+ ...plugin,
31
+ models: {
32
+ ...plugin.models,
33
+ insights: { ...plugin.models.insights, listEvents: null },
34
+ },
28
35
  };
29
36
  const malformedChannels = {
30
37
  ...plugin,
@@ -47,7 +54,8 @@ describe("isDatabasePlugin", () => {
47
54
 
48
55
  expect(isDatabasePlugin(factory)).toBe(false);
49
56
  expect(isDatabasePlugin(malformedBundles)).toBe(false);
50
- expect(isDatabasePlugin(missingAnalytics)).toBe(false);
57
+ expect(isDatabasePlugin(missingInsights)).toBe(false);
58
+ expect(isDatabasePlugin(malformedInsights)).toBe(false);
51
59
  expect(isDatabasePlugin(malformedChannels)).toBe(false);
52
60
  expect(isDatabasePlugin(malformedApiKeys)).toBe(false);
53
61
  expect(isDatabasePlugin(malformedDispose)).toBe(false);
package/src/db/types.ts CHANGED
@@ -123,13 +123,19 @@ export function isDatabasePlugin(plugin: unknown): plugin is DatabasePlugin {
123
123
  typeof plugin.models.channels.list === "function" &&
124
124
  "delete" in plugin.models.channels &&
125
125
  typeof plugin.models.channels.delete === "function" &&
126
- "analytics" in plugin.models &&
127
- typeof plugin.models.analytics === "object" &&
128
- plugin.models.analytics !== null &&
129
- "append" in plugin.models.analytics &&
130
- typeof plugin.models.analytics.append === "function" &&
131
- "scan" in plugin.models.analytics &&
132
- typeof plugin.models.analytics.scan === "function" &&
126
+ "insights" in plugin.models &&
127
+ typeof plugin.models.insights === "object" &&
128
+ plugin.models.insights !== null &&
129
+ "record" in plugin.models.insights &&
130
+ typeof plugin.models.insights.record === "function" &&
131
+ "listEvents" in plugin.models.insights &&
132
+ typeof plugin.models.insights.listEvents === "function" &&
133
+ "findInstallations" in plugin.models.insights &&
134
+ typeof plugin.models.insights.findInstallations === "function" &&
135
+ "countEvents" in plugin.models.insights &&
136
+ typeof plugin.models.insights.countEvents === "function" &&
137
+ "countInstallations" in plugin.models.insights &&
138
+ typeof plugin.models.insights.countInstallations === "function" &&
133
139
  "apiKeys" in plugin.models &&
134
140
  typeof plugin.models.apiKeys === "object" &&
135
141
  plugin.models.apiKeys !== null &&
@@ -156,7 +156,7 @@ const commitTestRelease = async (
156
156
  };
157
157
 
158
158
  describe("Handler <-> Standalone Repository Integration", () => {
159
- it("uses Standalone for bundle management and the server database for Analytics", async () => {
159
+ it("uses Standalone for bundle management and the server database for Insights", async () => {
160
160
  const bundleId = uuidv7();
161
161
  const installId = "standalone-install";
162
162
  await createStandaloneClient().insertBundle(
@@ -186,15 +186,13 @@ describe("Handler <-> Standalone Repository Integration", () => {
186
186
  }),
187
187
  }),
188
188
  );
189
- const overview = await api.handlers.admin(
190
- new Request(`${baseUrl}/installations/overview`),
191
- );
189
+ const events = await api.handlers.admin(new Request(`${baseUrl}/events`));
192
190
 
193
191
  expect(ingestion.status).toBe(204);
194
- expect(overview.status).toBe(200);
195
- await expect(overview.json()).resolves.toEqual({
196
- bundles: [{ bundleId, installations: 1 }],
197
- trackedInstallations: 1,
192
+ expect(events.status).toBe(200);
193
+ await expect(events.json()).resolves.toMatchObject({
194
+ data: [{ installId, toBundleId: bundleId, type: "UPDATE_APPLIED" }],
195
+ nextCursor: null,
198
196
  });
199
197
  });
200
198
 
package/src/handler.ts CHANGED
@@ -1,9 +1,3 @@
1
- import {
2
- createAnalyticsRouteHandlers,
3
- registerAnalyticsAdminRoutes,
4
- registerAnalyticsClientRoutes,
5
- } from "./analytics/routes";
6
- import type { AnalyticsProvider } from "./analytics/types";
7
1
  import { createBundleRouteHandlers } from "./handlerBundleRoutes";
8
2
  import { HandlerBadRequestError } from "./handlerErrors";
9
3
  import { createReleaseCatalogRouteHandlers } from "./handlerReleaseCatalogRoutes";
@@ -15,6 +9,12 @@ import type {
15
9
  RouteHandler,
16
10
  } from "./handlerTypes";
17
11
  import { createVersionRouteHandlers } from "./handlerVersionRoutes";
12
+ import {
13
+ createInsightsRouteHandlers,
14
+ registerInsightsAdminRoutes,
15
+ registerInsightsClientRoutes,
16
+ } from "./insights/routes";
17
+ import type { InsightsProvider } from "./insights/types";
18
18
  import { addRoute, createRouter, findRoute } from "./internalRouter";
19
19
 
20
20
  export type {
@@ -142,7 +142,7 @@ export function createHandlers(api: HandlerAPI): HotUpdaterHandlers {
142
142
 
143
143
  export function createHotUpdaterHandlers(
144
144
  api: HandlerAPI,
145
- analytics?: AnalyticsProvider,
145
+ insights?: InsightsProvider,
146
146
  apiKeyAuth?: {
147
147
  readonly authenticate: (request: Request) => Promise<boolean>;
148
148
  readonly headerName: string;
@@ -157,7 +157,7 @@ export function createHotUpdaterHandlers(
157
157
  ...createReleaseCatalogRouteHandlers(apiKeyAuth?.headerName),
158
158
  ...createReleaseManagementRouteHandlers(),
159
159
  ...createBundleRouteHandlers(),
160
- ...(analytics === undefined ? {} : createAnalyticsRouteHandlers(analytics)),
160
+ ...(insights === undefined ? {} : createInsightsRouteHandlers(insights)),
161
161
  ...(downloadStorageObject === undefined
162
162
  ? {}
163
163
  : {
@@ -196,8 +196,8 @@ export function createHotUpdaterHandlers(
196
196
  "/artifacts/:targetBundleId/from/:currentBundleId",
197
197
  "artifact",
198
198
  );
199
- if (analytics !== undefined) {
200
- registerAnalyticsClientRoutes(addClientRoute);
199
+ if (insights !== undefined) {
200
+ registerInsightsClientRoutes(addClientRoute);
201
201
  }
202
202
 
203
203
  const adminRouter = createRouter<string>();
@@ -224,8 +224,8 @@ export function createHotUpdaterHandlers(
224
224
  addAdminRoute("POST", "/bundles", "createBundles");
225
225
  addAdminRoute("PATCH", "/bundles/:id", "updateBundle");
226
226
  addAdminRoute("DELETE", "/bundles/:id", "deleteBundle");
227
- if (analytics !== undefined) {
228
- registerAnalyticsAdminRoutes(addAdminRoute);
227
+ if (insights !== undefined) {
228
+ registerInsightsAdminRoutes(addAdminRoute);
229
229
  }
230
230
 
231
231
  return Object.freeze({
package/src/index.ts CHANGED
@@ -4,9 +4,9 @@ export type {
4
4
  HotUpdaterHandler,
5
5
  HotUpdaterHandlers,
6
6
  } from "./handler";
7
- export { createAnalyticsProvider } from "./analytics/bounded/provider";
8
- export type * from "./analytics/domain";
9
- export type { AnalyticsProvider } from "./analytics/types";
7
+ export { createInsightsProvider } from "./insights/provider";
8
+ export type * from "./insights/domain";
9
+ export type * from "./insights/types";
10
10
  export {
11
11
  API_KEY_HEADER_NAME,
12
12
  createApiKey,
@@ -0,0 +1,103 @@
1
+ export type CreateBundleEventRequestBase = {
2
+ readonly installId: string;
3
+ readonly toBundleId: string;
4
+ readonly userId?: string;
5
+ readonly username?: string;
6
+ readonly platform: "ios" | "android";
7
+ readonly appVersion: string;
8
+ readonly channel: string;
9
+ readonly cohort: string;
10
+ readonly fingerprintHash: string | null;
11
+ readonly sdkVersion?: string | null;
12
+ readonly fromReleaseId: string | null;
13
+ readonly toReleaseId: string | null;
14
+ };
15
+
16
+ export type CreateBundleEventRequest =
17
+ | (CreateBundleEventRequestBase & {
18
+ readonly type: "UPDATE_APPLIED" | "RECOVERED" | "RELEASE_ADOPTED";
19
+ readonly fromBundleId: string;
20
+ readonly updateStrategy: "fingerprint" | "appVersion";
21
+ })
22
+ | (CreateBundleEventRequestBase & {
23
+ readonly type: "UNCHANGED";
24
+ readonly fromBundleId: null;
25
+ readonly updateStrategy: null;
26
+ });
27
+
28
+ export type ActiveInstallationWindow = "24h" | "7d" | "30d";
29
+
30
+ export type EventHistoryRow = {
31
+ readonly id: string;
32
+ readonly installId: string;
33
+ readonly type:
34
+ | "UPDATE_APPLIED"
35
+ | "RECOVERED"
36
+ | "RELEASE_ADOPTED"
37
+ | "UNCHANGED";
38
+ readonly fromBundleId: string | null;
39
+ readonly toBundleId: string;
40
+ readonly username: string | null;
41
+ readonly userId: string | null;
42
+ readonly platform: "ios" | "android";
43
+ readonly appVersion: string;
44
+ readonly channel: string;
45
+ readonly cohort: string;
46
+ readonly receivedAtMs: number;
47
+ };
48
+
49
+ export type InstallationHistoryRow = EventHistoryRow & {
50
+ readonly type: "UPDATE_APPLIED" | "RECOVERED";
51
+ readonly fromBundleId: string;
52
+ };
53
+
54
+ export type InstallationRow = {
55
+ readonly installId: string;
56
+ readonly username: string | null;
57
+ readonly userId: string | null;
58
+ readonly lastKnownBundleId: string;
59
+ readonly latestStatus: EventHistoryRow["type"];
60
+ readonly platform: "ios" | "android";
61
+ readonly appVersion: string;
62
+ readonly channel: string;
63
+ readonly cohort: string;
64
+ readonly receivedAtMs: number;
65
+ };
66
+
67
+ export type CursorPage<T> = {
68
+ readonly data: readonly T[];
69
+ readonly nextCursor: string | null;
70
+ };
71
+
72
+ export type EventCursorPage<T extends EventHistoryRow> = CursorPage<T> & {
73
+ readonly beforeReceivedAtMs: number;
74
+ };
75
+
76
+ export type InsightsScope = {
77
+ readonly platform: "ios" | "android";
78
+ readonly channel: string;
79
+ };
80
+
81
+ export type InsightsBundleSelection = InsightsScope & {
82
+ readonly bundleId: string;
83
+ readonly outcome: "applied" | "recovered" | "adopted";
84
+ };
85
+
86
+ export type InsightsCountMeasurement = {
87
+ readonly count: number;
88
+ readonly measuredAtMs: number;
89
+ };
90
+
91
+ export type ReportingOverview = InsightsScope & {
92
+ readonly window: ActiveInstallationWindow;
93
+ readonly sinceMs: number;
94
+ readonly beforeReceivedAtMs: number;
95
+ readonly reportingInstallations: InsightsCountMeasurement;
96
+ readonly bundle?: {
97
+ readonly bundleId: string;
98
+ readonly reportingInstallations: InsightsCountMeasurement;
99
+ readonly appliedReports: InsightsCountMeasurement;
100
+ readonly recoveredReports: InsightsCountMeasurement;
101
+ readonly adoptedReports: InsightsCountMeasurement;
102
+ };
103
+ };
@@ -0,0 +1,11 @@
1
+ export class InsightsBadRequestError extends Error {
2
+ readonly name = "InsightsBadRequestError";
3
+ }
4
+
5
+ export class InsightsPayloadTooLargeError extends Error {
6
+ readonly name = "InsightsPayloadTooLargeError";
7
+
8
+ constructor(readonly maximumBytes: number) {
9
+ super(`Event payload exceeds ${maximumBytes} bytes`);
10
+ }
11
+ }