@hot-updater/server 1.0.0-rc.1 → 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 (191) 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 +10 -3
  5. package/dist/adapters/drizzle.d.cts +0 -1
  6. package/dist/adapters/drizzle.d.mts +0 -1
  7. package/dist/adapters/drizzle.mjs +11 -3
  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 +24 -9
  48. package/dist/createHotUpdaterCore.d.cts +1 -1
  49. package/dist/createHotUpdaterCore.d.mts +1 -1
  50. package/dist/createHotUpdaterCore.mjs +23 -9
  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.d.cts +1 -2
  82. package/dist/handler.d.mts +1 -2
  83. package/dist/handlerReleaseCatalogRoutes.cjs +0 -1
  84. package/dist/handlerReleaseManagementRoutes.cjs +0 -1
  85. package/dist/handlerTypes.d.cts +0 -1
  86. package/dist/handlerTypes.d.mts +0 -1
  87. package/dist/handlerVersionRoutes.d.cts +1 -0
  88. package/dist/handlerVersionRoutes.d.mts +1 -0
  89. package/dist/index.cjs +1 -1
  90. package/dist/index.d.cts +4 -4
  91. package/dist/index.d.mts +4 -4
  92. package/dist/index.mjs +1 -1
  93. package/dist/insights/domain.d.cts +37 -69
  94. package/dist/insights/domain.d.mts +37 -69
  95. package/dist/insights/errors.cjs +1 -8
  96. package/dist/insights/errors.mjs +2 -8
  97. package/dist/insights/eventInput.cjs +49 -5
  98. package/dist/insights/eventInput.mjs +48 -6
  99. package/dist/insights/provider.cjs +314 -0
  100. package/dist/insights/provider.d.cts +6 -0
  101. package/dist/insights/provider.d.mts +6 -0
  102. package/dist/insights/provider.mjs +314 -0
  103. package/dist/insights/queryInput.cjs +78 -43
  104. package/dist/insights/queryInput.mjs +76 -40
  105. package/dist/insights/routes.cjs +20 -25
  106. package/dist/insights/routes.mjs +22 -27
  107. package/dist/insights/types.d.cts +27 -12
  108. package/dist/insights/types.d.mts +27 -12
  109. package/dist/node.d.cts +0 -1
  110. package/dist/node.d.mts +0 -1
  111. package/dist/package.cjs +1 -1
  112. package/dist/package.mjs +1 -1
  113. package/dist/schema/index.cjs +17 -3
  114. package/dist/schema/index.mjs +4 -4
  115. package/dist/schema/v1_0_0.cjs +72 -30
  116. package/dist/schema/v1_0_0.mjs +72 -31
  117. package/dist/storageAccess.cjs +0 -1
  118. package/dist/types/index.d.cts +0 -1
  119. package/dist/types/index.d.mts +0 -1
  120. package/package.json +7 -7
  121. package/src/adapters/databasePluginTestDatabase.ts +14 -0
  122. package/src/adapters/drizzle.spec.ts +99 -0
  123. package/src/adapters/drizzle.ts +21 -3
  124. package/src/adapters/drizzleCrud.ts +154 -2
  125. package/src/adapters/drizzleLazyDB.ts +17 -3
  126. package/src/adapters/insightsTransaction.ts +32 -0
  127. package/src/adapters/kysely.spec.ts +29 -0
  128. package/src/adapters/kysely.ts +10 -1
  129. package/src/adapters/kyselyCrud.ts +121 -0
  130. package/src/adapters/mongodb.integration.spec.ts +210 -0
  131. package/src/adapters/mongodb.spec.ts +69 -1
  132. package/src/adapters/mongodb.ts +66 -2
  133. package/src/adapters/mongodbCollections.ts +5 -0
  134. package/src/adapters/mongodbQuery.ts +32 -3
  135. package/src/adapters/mongodbReads.ts +69 -0
  136. package/src/adapters/mongodbTestClient.ts +41 -6
  137. package/src/adapters/mongodbTestFilter.ts +4 -1
  138. package/src/adapters/mongodbWrites.ts +27 -0
  139. package/src/adapters/prisma.spec.ts +97 -0
  140. package/src/adapters/prisma.ts +103 -3
  141. package/src/adapters/prismaRows.ts +37 -1
  142. package/src/adapters/prismaTestClient.ts +22 -5
  143. package/src/apiKeys.integration.spec.ts +1 -1
  144. package/src/createHotUpdaterCore.ts +21 -7
  145. package/src/db/databasePluginUpdateCheck.bench.ts +3 -0
  146. package/src/db/fixedMigrator.spec.ts +65 -0
  147. package/src/db/fixedMigratorMongo.spec.ts +56 -9
  148. package/src/db/fixedMigratorMongo.ts +10 -10
  149. package/src/db/index.spec.ts +8 -1
  150. package/src/db/schema/insightsCollation.ts +21 -0
  151. package/src/db/schema/sql.ts +8 -5
  152. package/src/db/schemaGenerators.ts +41 -13
  153. package/src/db/types.spec.ts +8 -0
  154. package/src/db/types.ts +10 -4
  155. package/src/handler-standalone.integration.spec.ts +5 -7
  156. package/src/index.ts +2 -2
  157. package/src/insights/domain.ts +39 -75
  158. package/src/insights/errors.ts +0 -7
  159. package/src/insights/eventInput.ts +60 -3
  160. package/src/insights/insights.integration.spec.ts +254 -17
  161. package/src/insights/provider.spec.ts +385 -0
  162. package/src/insights/provider.ts +591 -0
  163. package/src/insights/queryInput.ts +112 -73
  164. package/src/insights/routes.ts +37 -56
  165. package/src/insights/types.ts +47 -36
  166. package/src/schema/dsl-all-versions.spec.ts +3 -1
  167. package/src/schema/v1_0_0.spec.ts +77 -3
  168. package/src/schema/v1_0_0.ts +94 -30
  169. package/dist/insights/bounded/activeOverview.cjs +0 -87
  170. package/dist/insights/bounded/activeOverview.mjs +0 -85
  171. package/dist/insights/bounded/installationSearch.cjs +0 -42
  172. package/dist/insights/bounded/installationSearch.mjs +0 -42
  173. package/dist/insights/bounded/persistence.cjs +0 -39
  174. package/dist/insights/bounded/persistence.mjs +0 -38
  175. package/dist/insights/bounded/provider.cjs +0 -170
  176. package/dist/insights/bounded/provider.d.cts +0 -7
  177. package/dist/insights/bounded/provider.d.mts +0 -7
  178. package/dist/insights/bounded/provider.mjs +0 -170
  179. package/dist/insights/bounded/scan.cjs +0 -144
  180. package/dist/insights/bounded/scan.mjs +0 -138
  181. package/dist/insights/persistence.d.cts +0 -6
  182. package/dist/insights/persistence.d.mts +0 -6
  183. package/src/insights/bounded/activeOverview.spec.ts +0 -189
  184. package/src/insights/bounded/activeOverview.ts +0 -148
  185. package/src/insights/bounded/installationSearch.spec.ts +0 -130
  186. package/src/insights/bounded/installationSearch.ts +0 -69
  187. package/src/insights/bounded/persistence.ts +0 -43
  188. package/src/insights/bounded/provider.spec.ts +0 -219
  189. package/src/insights/bounded/provider.ts +0 -229
  190. package/src/insights/bounded/scan.ts +0 -235
  191. package/src/insights/persistence.ts +0 -10
@@ -12,6 +12,7 @@ type Tables = {
12
12
  bundle_patches: MongoTestRow[];
13
13
  bundles: MongoTestRow[];
14
14
  bundle_events: MongoTestRow[];
15
+ bundle_installations: MongoTestRow[];
15
16
  channels: MongoTestRow[];
16
17
  api_keys: MongoTestRow[];
17
18
  releases: MongoTestRow[];
@@ -25,6 +26,7 @@ type UpdateInput =
25
26
  type MongoTestHooks = {
26
27
  beforeBundlePatchInsert?: () => Promise<void>;
27
28
  failNextBundleTombstone: boolean;
29
+ failNextInstallationWrite: boolean;
28
30
  operationCount: number;
29
31
  };
30
32
 
@@ -149,11 +151,24 @@ const createCollection = (
149
151
  },
150
152
  insertOne: async (row: MongoTestRow): Promise<void> => {
151
153
  hooks.operationCount += 1;
154
+ if (model === "bundle_installations" && hooks.failNextInstallationWrite) {
155
+ hooks.failNextInstallationWrite = false;
156
+ throw new MongoTestConstraintError("injected installation write failure");
157
+ }
152
158
  if (model === "bundle_patches") await hooks.beforeBundlePatchInsert?.();
153
- const key = "id" in row ? row.id : row.scope_key;
159
+ const key =
160
+ model === "bundle_installations" && "install_id" in row
161
+ ? row.install_id
162
+ : "id" in row
163
+ ? row.id
164
+ : row.scope_key;
154
165
  if (
155
166
  tables[model].some((candidate) =>
156
- "id" in candidate ? candidate.id === key : candidate.scope_key === key,
167
+ model === "bundle_installations" && "install_id" in candidate
168
+ ? candidate.install_id === key
169
+ : "id" in candidate
170
+ ? candidate.id === key
171
+ : candidate.scope_key === key,
157
172
  )
158
173
  ) {
159
174
  throw new MongoTestConstraintError("duplicate id");
@@ -197,6 +212,10 @@ const createCollection = (
197
212
  options?: { readonly upsert?: boolean },
198
213
  ): Promise<{ matchedCount: number; upsertedCount: number }> => {
199
214
  hooks.operationCount += 1;
215
+ if (model === "bundle_installations" && hooks.failNextInstallationWrite) {
216
+ hooks.failNextInstallationWrite = false;
217
+ throw new MongoTestConstraintError("injected installation write failure");
218
+ }
200
219
  const index = tables[model].findIndex((row) =>
201
220
  matchesMongoTestFilter(row, filter),
202
221
  );
@@ -211,12 +230,18 @@ const createCollection = (
211
230
  }
212
231
  if (options?.upsert === true && "$setOnInsert" in update) {
213
232
  const key =
214
- "id" in update.$setOnInsert
215
- ? update.$setOnInsert.id
216
- : update.$setOnInsert.scope_key;
233
+ model === "bundle_installations" && "install_id" in update.$setOnInsert
234
+ ? update.$setOnInsert.install_id
235
+ : "id" in update.$setOnInsert
236
+ ? update.$setOnInsert.id
237
+ : update.$setOnInsert.scope_key;
217
238
  if (
218
239
  tables[model].some((row) =>
219
- "id" in row ? row.id === key : row.scope_key === key,
240
+ model === "bundle_installations" && "install_id" in row
241
+ ? row.install_id === key
242
+ : "id" in row
243
+ ? row.id === key
244
+ : row.scope_key === key,
220
245
  )
221
246
  ) {
222
247
  throw new MongoTestConstraintError("duplicate id");
@@ -237,6 +262,8 @@ const createDatabase = (tables: Tables, hooks: MongoTestHooks) => ({
237
262
  return createCollection(tables, "bundle_patches", hooks);
238
263
  case "bundle_events":
239
264
  return createCollection(tables, "bundle_events", hooks);
265
+ case "bundle_installations":
266
+ return createCollection(tables, "bundle_installations", hooks);
240
267
  case "channels":
241
268
  return createCollection(tables, "channels", hooks);
242
269
  case "api_keys":
@@ -256,6 +283,7 @@ export const createMongoTestHarness = () => {
256
283
  bundle_patches: [],
257
284
  bundles: [],
258
285
  bundle_events: [],
286
+ bundle_installations: [],
259
287
  channels: [],
260
288
  api_keys: [],
261
289
  releases: [],
@@ -263,6 +291,7 @@ export const createMongoTestHarness = () => {
263
291
  };
264
292
  const hooks: MongoTestHooks = {
265
293
  failNextBundleTombstone: false,
294
+ failNextInstallationWrite: false,
266
295
  operationCount: 0,
267
296
  };
268
297
  let activeTables = tables;
@@ -290,6 +319,7 @@ export const createMongoTestHarness = () => {
290
319
  tables.bundle_patches = staged.bundle_patches;
291
320
  tables.bundles = staged.bundles;
292
321
  tables.bundle_events = staged.bundle_events;
322
+ tables.bundle_installations = staged.bundle_installations;
293
323
  tables.channels = staged.channels;
294
324
  tables.api_keys = staged.api_keys;
295
325
  tables.releases = staged.releases;
@@ -309,11 +339,13 @@ export const createMongoTestHarness = () => {
309
339
  close: () => client.close(),
310
340
  reset: (): void => {
311
341
  hooks.failNextBundleTombstone = false;
342
+ hooks.failNextInstallationWrite = false;
312
343
  hooks.operationCount = 0;
313
344
  transactionQueue = Promise.resolve();
314
345
  tables.bundle_patches = [];
315
346
  tables.bundles = [];
316
347
  tables.bundle_events = [];
348
+ tables.bundle_installations = [];
317
349
  tables.channels = [];
318
350
  tables.api_keys = [];
319
351
  tables.releases = [];
@@ -328,6 +360,9 @@ export const createMongoTestHarness = () => {
328
360
  failNextBundleTombstone: (): void => {
329
361
  hooks.failNextBundleTombstone = true;
330
362
  },
363
+ failNextInstallationWrite: (): void => {
364
+ hooks.failNextInstallationWrite = true;
365
+ },
331
366
  setBundleField: (id: string, field: string, value: unknown): void => {
332
367
  const row = tables.bundles.find(
333
368
  (candidate) => "id" in candidate && candidate.id === id,
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  BundleEventRow,
3
+ InsightsInstallationRow,
3
4
  BundlePatchRow,
4
5
  BundleRow,
5
6
  ChannelRow,
@@ -10,6 +11,7 @@ import type {
10
11
 
11
12
  export type MongoTestRow =
12
13
  | BundleEventRow
14
+ | InsightsInstallationRow
13
15
  | BundlePatchRow
14
16
  | BundleRow
15
17
  | ChannelRow
@@ -28,7 +30,7 @@ const compare = (left: unknown, right: unknown): number => {
28
30
  return left - right;
29
31
  }
30
32
  if (typeof left === "string" && typeof right === "string") {
31
- return left.localeCompare(right);
33
+ return Buffer.compare(Buffer.from(left), Buffer.from(right));
32
34
  }
33
35
  return 0;
34
36
  };
@@ -100,6 +102,7 @@ const resolveExpression = (row: MongoTestRow, expression: unknown): unknown => {
100
102
 
101
103
  const matchesField = (current: unknown, condition: unknown): boolean => {
102
104
  if (!isRecord(condition)) return Object.is(current, condition);
105
+ if ("$eq" in condition) return Object.is(current, condition["$eq"]);
103
106
  if (typeof condition["$exists"] === "boolean") {
104
107
  return condition["$exists"] ? current !== undefined : current === undefined;
105
108
  }
@@ -11,10 +11,12 @@ import {
11
11
  MongoAdapterConstraintError,
12
12
  mongoSessionOptions,
13
13
  WITHOUT_INTERNAL_FIELDS,
14
+ WITHOUT_MONGO_ID,
14
15
  } from "./mongodbCollections";
15
16
  import {
16
17
  createMongoBundleWhere,
17
18
  createMongoChannelWhere,
19
+ createMongoInstallationWhere,
18
20
  createMongoApiKeyWhere,
19
21
  createMongoPatchWhere,
20
22
  createMongoReleaseCatalogWhere,
@@ -116,6 +118,20 @@ export const createMongoWrites = (
116
118
  mongoSessionOptions(session),
117
119
  );
118
120
  return input.data;
121
+ case "bundle_installations":
122
+ if (input.onConflict === "ignore") {
123
+ await collections.bundleInstallations.updateOne(
124
+ { install_id: input.data.install_id },
125
+ { $setOnInsert: input.data },
126
+ { upsert: true, ...mongoSessionOptions(session) },
127
+ );
128
+ return input.data;
129
+ }
130
+ await collections.bundleInstallations.insertOne(
131
+ input.data,
132
+ mongoSessionOptions(session),
133
+ );
134
+ return input.data;
119
135
  case "releases":
120
136
  await collections.releases.insertOne(
121
137
  input.data,
@@ -159,6 +175,17 @@ export const createMongoWrites = (
159
175
  }
160
176
  },
161
177
  update: async (input) => {
178
+ if (input.model === "bundle_installations") {
179
+ return collections.bundleInstallations.findOneAndUpdate(
180
+ createMongoInstallationWhere(input.where),
181
+ { $set: input.update },
182
+ {
183
+ projection: WITHOUT_MONGO_ID,
184
+ returnDocument: "after",
185
+ ...mongoSessionOptions(session),
186
+ },
187
+ );
188
+ }
162
189
  if (input.model === "api_keys") {
163
190
  return collections.apiKeys.findOneAndUpdate(
164
191
  createMongoApiKeyWhere(input.where),
@@ -1,5 +1,7 @@
1
+ import { toInsightsInstallationRow } from "@hot-updater/plugin-core";
1
2
  import { describe, expect, expectTypeOf, it } from "vitest";
2
3
 
4
+ import { createBundleEventRowFixture } from "../../../test-utils/src/databaseTestFixtures";
3
5
  import { setupDatabasePluginTestSuite } from "../../../test-utils/src/setupDatabasePluginTestSuite";
4
6
  import { prismaAdapter, type PrismaConfig } from "./prisma";
5
7
  import { createPrismaTestHarness } from "./prismaTestClient";
@@ -34,6 +36,101 @@ const productionChannel = {
34
36
  } as const;
35
37
 
36
38
  describe("prismaAdapter capabilities", () => {
39
+ it("rejects every SQL Server Insights operation before touching the database", async () => {
40
+ const plugin = prismaAdapter({ prisma: {}, provider: "mssql" });
41
+ expect(plugin.generateSchema?.("latest").code).not.toContain(
42
+ "bundle_events_to_bundle_idx",
43
+ );
44
+ expect(plugin.generateSchema?.("latest").code).toContain(
45
+ "releases_scope_order_idx",
46
+ );
47
+ const event = createBundleEventRowFixture("707", 100);
48
+ const filter = {
49
+ platform: "ios" as const,
50
+ channel: "production",
51
+ type: "UPDATE_APPLIED" as const,
52
+ toBundleId: event.to_bundle_id,
53
+ };
54
+ const calls = [
55
+ plugin.models.insights.record({
56
+ event,
57
+ installation: toInsightsInstallationRow(event),
58
+ }),
59
+ plugin.models.insights.listEvents({
60
+ filter: { kind: "all" },
61
+ beforeReceivedAtMs: 101,
62
+ limit: 10,
63
+ }),
64
+ plugin.models.insights.findInstallations({ installId: event.install_id }),
65
+ plugin.models.insights.countInstallations({
66
+ platform: "ios",
67
+ channel: "production",
68
+ sinceMs: 0,
69
+ }),
70
+ plugin.models.insights.countEvents({
71
+ filter,
72
+ sinceMs: 0,
73
+ beforeReceivedAtMs: 101,
74
+ }),
75
+ ];
76
+ for (const call of calls)
77
+ await expect(call).rejects.toThrow("SQL Server Insights is unsupported");
78
+ });
79
+ it("requires callback transactions before recording Insights", async () => {
80
+ const { $transaction: _transaction, ...client } = harness.client;
81
+ const plugin = prismaAdapter({ prisma: client, provider: "postgresql" });
82
+ const event = createBundleEventRowFixture("704", 100);
83
+ await expect(
84
+ plugin.models.insights.record({
85
+ event,
86
+ installation: toInsightsInstallationRow(event),
87
+ }),
88
+ ).rejects.toThrow("Insights recording requires callback transactions");
89
+ });
90
+
91
+ it("rolls back event insertion on an installation write failure", async () => {
92
+ const isolated = createPrismaTestHarness();
93
+ const event = createBundleEventRowFixture("705", 100);
94
+ const input = { event, installation: toInsightsInstallationRow(event) };
95
+ const client = {
96
+ ...isolated.client,
97
+ $transaction: <TResult>(
98
+ callback: (transaction: object) => Promise<TResult>,
99
+ ) =>
100
+ isolated.client.$transaction((transaction) =>
101
+ callback({
102
+ ...transaction,
103
+ bundle_installations: {
104
+ ...Reflect.get(transaction, "bundle_installations"),
105
+ create: async () => {
106
+ throw new Error("injected snapshot failure");
107
+ },
108
+ },
109
+ }),
110
+ ),
111
+ };
112
+ const plugin = prismaAdapter({ prisma: client, provider: "postgresql" });
113
+ await expect(plugin.models.insights.record(input)).rejects.toThrow(
114
+ "injected snapshot failure",
115
+ );
116
+ const working = prismaAdapter({
117
+ prisma: isolated.client,
118
+ provider: "postgresql",
119
+ });
120
+ await expect(
121
+ working.models.insights.listEvents({
122
+ filter: { kind: "all" },
123
+ beforeReceivedAtMs: 101,
124
+ limit: 10,
125
+ }),
126
+ ).resolves.toEqual([]);
127
+ await working.models.insights.record(input);
128
+ await expect(
129
+ working.models.insights.findInstallations({
130
+ installId: event.install_id,
131
+ }),
132
+ ).resolves.toEqual([input.installation]);
133
+ });
37
134
  it("excludes MongoDB from the public configuration", () => {
38
135
  expectTypeOf<"mongodb">().not.toMatchTypeOf<PrismaConfig["provider"]>();
39
136
  });
@@ -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 = [];
@@ -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);