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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/dist/_virtual/_rolldown/runtime.cjs +1 -1
  2. package/dist/adapters/databaseConstraintErrors.cjs +0 -1
  3. package/dist/adapters/databasePluginUtils.cjs +0 -1
  4. package/dist/adapters/drizzle.cjs +11 -4
  5. package/dist/adapters/drizzle.d.cts +0 -1
  6. package/dist/adapters/drizzle.d.mts +0 -1
  7. package/dist/adapters/drizzle.mjs +12 -4
  8. package/dist/adapters/drizzleCrud.cjs +62 -1
  9. package/dist/adapters/drizzleCrud.mjs +61 -2
  10. package/dist/adapters/drizzleLazyDB.cjs +6 -1
  11. package/dist/adapters/drizzleLazyDB.mjs +6 -1
  12. package/dist/adapters/drizzleQuery.cjs +3 -2
  13. package/dist/adapters/drizzleQuery.mjs +3 -1
  14. package/dist/adapters/insightsTransaction.cjs +22 -0
  15. package/dist/adapters/insightsTransaction.mjs +22 -0
  16. package/dist/adapters/kysely.cjs +2 -1
  17. package/dist/adapters/kysely.d.cts +0 -1
  18. package/dist/adapters/kysely.d.mts +0 -1
  19. package/dist/adapters/kysely.mjs +3 -1
  20. package/dist/adapters/kyselyCrud.cjs +54 -3
  21. package/dist/adapters/kyselyCrud.mjs +54 -3
  22. package/dist/adapters/kyselyQuery.cjs +2 -2
  23. package/dist/adapters/kyselyQuery.mjs +2 -1
  24. package/dist/adapters/mongodb.cjs +27 -1
  25. package/dist/adapters/mongodb.d.cts +1 -1
  26. package/dist/adapters/mongodb.d.mts +1 -1
  27. package/dist/adapters/mongodb.mjs +28 -1
  28. package/dist/adapters/mongodbCollections.cjs +2 -0
  29. package/dist/adapters/mongodbCollections.mjs +2 -0
  30. package/dist/adapters/mongodbQuery.cjs +21 -4
  31. package/dist/adapters/mongodbQuery.mjs +21 -5
  32. package/dist/adapters/mongodbReads.cjs +91 -29
  33. package/dist/adapters/mongodbReads.mjs +92 -29
  34. package/dist/adapters/mongodbWrites.cjs +16 -2
  35. package/dist/adapters/mongodbWrites.mjs +18 -3
  36. package/dist/adapters/prisma.cjs +55 -7
  37. package/dist/adapters/prisma.d.cts +0 -1
  38. package/dist/adapters/prisma.d.mts +0 -1
  39. package/dist/adapters/prisma.mjs +56 -7
  40. package/dist/adapters/prismaQuery.cjs +0 -1
  41. package/dist/adapters/prismaRows.cjs +22 -1
  42. package/dist/adapters/prismaRows.mjs +22 -1
  43. package/dist/apiKeys.cjs +6 -2
  44. package/dist/apiKeys.d.cts +0 -1
  45. package/dist/apiKeys.d.mts +0 -1
  46. package/dist/apiKeys.mjs +3 -3
  47. package/dist/createHotUpdaterCore.cjs +27 -12
  48. package/dist/createHotUpdaterCore.d.cts +8 -8
  49. package/dist/createHotUpdaterCore.d.mts +8 -8
  50. package/dist/createHotUpdaterCore.mjs +26 -12
  51. package/dist/db/createBundleDiff.cjs +2 -2
  52. package/dist/db/createBundleDiff.d.cts +1 -5
  53. package/dist/db/createBundleDiff.d.mts +1 -5
  54. package/dist/db/databasePluginCore.cjs +0 -1
  55. package/dist/db/fixedMigrator.cjs +2 -2
  56. package/dist/db/fixedMigrator.mjs +2 -2
  57. package/dist/db/fixedMigratorMongo.cjs +6 -4
  58. package/dist/db/fixedMigratorMongo.mjs +6 -4
  59. package/dist/db/index.d.cts +0 -1
  60. package/dist/db/index.d.mts +0 -1
  61. package/dist/db/releaseCatalog.cjs +0 -1
  62. package/dist/db/releaseCatalog.d.cts +0 -1
  63. package/dist/db/releaseCatalog.d.mts +0 -1
  64. package/dist/db/schema/insightsCollation.cjs +14 -0
  65. package/dist/db/schema/insightsCollation.mjs +14 -0
  66. package/dist/db/schema/sql.cjs +11 -7
  67. package/dist/db/schema/sql.mjs +8 -8
  68. package/dist/db/schemaGenerators.cjs +28 -9
  69. package/dist/db/schemaGenerators.mjs +28 -9
  70. package/dist/db/schemaReadiness.cjs +2 -0
  71. package/dist/db/schemaReadiness.d.cts +1 -0
  72. package/dist/db/schemaReadiness.d.mts +1 -0
  73. package/dist/db/schemaReadiness.mjs +2 -0
  74. package/dist/db/sqlMigrationExecution.cjs +1 -1
  75. package/dist/db/sqlMigrationExecution.mjs +1 -0
  76. package/dist/db/types.cjs +3 -1
  77. package/dist/db/types.d.cts +3 -6
  78. package/dist/db/types.d.mts +3 -6
  79. package/dist/db/types.mjs +2 -2
  80. package/dist/db/updateArtifacts.cjs +0 -1
  81. package/dist/handler.cjs +5 -5
  82. package/dist/handler.d.cts +1 -2
  83. package/dist/handler.d.mts +1 -2
  84. package/dist/handler.mjs +5 -5
  85. package/dist/handlerReleaseCatalogRoutes.cjs +0 -1
  86. package/dist/handlerReleaseManagementRoutes.cjs +0 -1
  87. package/dist/handlerTypes.d.cts +0 -1
  88. package/dist/handlerTypes.d.mts +0 -1
  89. package/dist/handlerVersionRoutes.d.cts +1 -0
  90. package/dist/handlerVersionRoutes.d.mts +1 -0
  91. package/dist/index.cjs +2 -2
  92. package/dist/index.d.cts +4 -4
  93. package/dist/index.d.mts +4 -4
  94. package/dist/index.mjs +2 -2
  95. package/dist/insights/domain.d.cts +89 -0
  96. package/dist/insights/domain.d.mts +89 -0
  97. package/dist/insights/errors.cjs +15 -0
  98. package/dist/insights/errors.mjs +14 -0
  99. package/dist/{analytics → insights}/eventInput.cjs +58 -14
  100. package/dist/{analytics → insights}/eventInput.mjs +58 -16
  101. package/dist/insights/provider.cjs +314 -0
  102. package/dist/insights/provider.d.cts +6 -0
  103. package/dist/insights/provider.d.mts +6 -0
  104. package/dist/insights/provider.mjs +314 -0
  105. package/dist/insights/queryInput.cjs +93 -0
  106. package/dist/insights/queryInput.mjs +91 -0
  107. package/dist/insights/routes.cjs +58 -0
  108. package/dist/insights/routes.mjs +56 -0
  109. package/dist/insights/types.d.cts +32 -0
  110. package/dist/insights/types.d.mts +32 -0
  111. package/dist/node.d.cts +0 -1
  112. package/dist/node.d.mts +0 -1
  113. package/dist/package.cjs +1 -1
  114. package/dist/package.mjs +1 -1
  115. package/dist/schema/index.cjs +17 -3
  116. package/dist/schema/index.mjs +4 -4
  117. package/dist/schema/v1_0_0.cjs +72 -30
  118. package/dist/schema/v1_0_0.mjs +72 -31
  119. package/dist/storageAccess.cjs +0 -1
  120. package/dist/types/index.d.cts +0 -1
  121. package/dist/types/index.d.mts +0 -1
  122. package/package.json +7 -7
  123. package/src/adapters/databasePluginTestDatabase.ts +14 -0
  124. package/src/adapters/drizzle.spec.ts +99 -0
  125. package/src/adapters/drizzle.ts +22 -4
  126. package/src/adapters/drizzleCrud.ts +154 -2
  127. package/src/adapters/drizzleLazyDB.ts +17 -3
  128. package/src/adapters/insightsTransaction.ts +32 -0
  129. package/src/adapters/kysely.spec.ts +29 -0
  130. package/src/adapters/kysely.ts +10 -1
  131. package/src/adapters/kyselyCrud.ts +121 -0
  132. package/src/adapters/mongodb.integration.spec.ts +210 -0
  133. package/src/adapters/mongodb.spec.ts +69 -1
  134. package/src/adapters/mongodb.ts +66 -2
  135. package/src/adapters/mongodbCollections.ts +5 -0
  136. package/src/adapters/mongodbQuery.ts +32 -3
  137. package/src/adapters/mongodbReads.ts +69 -0
  138. package/src/adapters/mongodbTestClient.ts +41 -6
  139. package/src/adapters/mongodbTestFilter.ts +4 -1
  140. package/src/adapters/mongodbWrites.ts +27 -0
  141. package/src/adapters/prisma.spec.ts +97 -0
  142. package/src/adapters/prisma.ts +103 -3
  143. package/src/adapters/prismaRows.ts +37 -1
  144. package/src/adapters/prismaTestClient.ts +22 -5
  145. package/src/apiKeys.integration.spec.ts +3 -3
  146. package/src/createHotUpdaterCore.ts +30 -16
  147. package/src/db/databasePluginUpdateCheck.bench.ts +3 -0
  148. package/src/db/fixedMigrator.spec.ts +65 -0
  149. package/src/db/fixedMigratorMongo.spec.ts +56 -9
  150. package/src/db/fixedMigratorMongo.ts +10 -10
  151. package/src/db/index.spec.ts +8 -1
  152. package/src/db/schema/insightsCollation.ts +21 -0
  153. package/src/db/schema/sql.ts +8 -5
  154. package/src/db/schemaGenerators.ts +41 -13
  155. package/src/db/types.spec.ts +11 -3
  156. package/src/db/types.ts +13 -7
  157. package/src/handler-standalone.integration.spec.ts +6 -8
  158. package/src/handler.ts +12 -12
  159. package/src/index.ts +3 -3
  160. package/src/insights/domain.ts +103 -0
  161. package/src/insights/errors.ts +11 -0
  162. package/src/{analytics → insights}/eventInput.ts +71 -14
  163. package/src/insights/insights.integration.spec.ts +381 -0
  164. package/src/insights/provider.spec.ts +385 -0
  165. package/src/insights/provider.ts +591 -0
  166. package/src/insights/queryInput.ts +141 -0
  167. package/src/insights/routes.ts +104 -0
  168. package/src/insights/types.ts +55 -0
  169. package/src/schema/dsl-all-versions.spec.ts +3 -1
  170. package/src/schema/v1_0_0.spec.ts +77 -3
  171. package/src/schema/v1_0_0.ts +94 -30
  172. package/dist/analytics/bounded/activeOverview.cjs +0 -87
  173. package/dist/analytics/bounded/activeOverview.mjs +0 -85
  174. package/dist/analytics/bounded/installationSearch.cjs +0 -42
  175. package/dist/analytics/bounded/installationSearch.mjs +0 -42
  176. package/dist/analytics/bounded/persistence.cjs +0 -39
  177. package/dist/analytics/bounded/persistence.mjs +0 -38
  178. package/dist/analytics/bounded/provider.cjs +0 -170
  179. package/dist/analytics/bounded/provider.d.cts +0 -7
  180. package/dist/analytics/bounded/provider.d.mts +0 -7
  181. package/dist/analytics/bounded/provider.mjs +0 -170
  182. package/dist/analytics/bounded/scan.cjs +0 -144
  183. package/dist/analytics/bounded/scan.mjs +0 -138
  184. package/dist/analytics/domain.d.cts +0 -121
  185. package/dist/analytics/domain.d.mts +0 -121
  186. package/dist/analytics/errors.cjs +0 -22
  187. package/dist/analytics/errors.mjs +0 -20
  188. package/dist/analytics/persistence.d.cts +0 -6
  189. package/dist/analytics/persistence.d.mts +0 -6
  190. package/dist/analytics/queryInput.cjs +0 -58
  191. package/dist/analytics/queryInput.mjs +0 -55
  192. package/dist/analytics/routes.cjs +0 -63
  193. package/dist/analytics/routes.mjs +0 -61
  194. package/dist/analytics/types.d.cts +0 -17
  195. package/dist/analytics/types.d.mts +0 -17
  196. package/src/analytics/analytics.integration.spec.ts +0 -144
  197. package/src/analytics/bounded/activeOverview.spec.ts +0 -189
  198. package/src/analytics/bounded/activeOverview.ts +0 -148
  199. package/src/analytics/bounded/installationSearch.spec.ts +0 -130
  200. package/src/analytics/bounded/installationSearch.ts +0 -69
  201. package/src/analytics/bounded/persistence.ts +0 -43
  202. package/src/analytics/bounded/provider.spec.ts +0 -219
  203. package/src/analytics/bounded/provider.ts +0 -229
  204. package/src/analytics/bounded/scan.ts +0 -235
  205. package/src/analytics/domain.ts +0 -139
  206. package/src/analytics/errors.ts +0 -18
  207. package/src/analytics/persistence.ts +0 -10
  208. package/src/analytics/queryInput.ts +0 -102
  209. package/src/analytics/routes.ts +0 -123
  210. package/src/analytics/types.ts +0 -44
@@ -1,10 +1,17 @@
1
- import { createDatabasePlugin } from "@hot-updater/plugin-core";
1
+ import {
2
+ compareInsightsText,
3
+ createDatabasePlugin,
4
+ } from "@hot-updater/plugin-core";
2
5
  import {
3
6
  createDatabasePluginAdapter,
4
7
  type DatabasePluginImplementation,
5
8
  type TransactionDatabasePluginImplementation,
6
9
  } from "@hot-updater/plugin-core/internal";
7
- import type { ClientSession, MongoClient } from "mongodb";
10
+ import {
11
+ MongoServerError,
12
+ type ClientSession,
13
+ type MongoClient,
14
+ } from "mongodb";
8
15
 
9
16
  import { createMongoMigrator } from "../db/fixedMigrator";
10
17
  import type { DatabaseAdapterWithCapabilities } from "../db/types";
@@ -14,6 +21,7 @@ import { createMongoWrites } from "./mongodbWrites";
14
21
 
15
22
  export interface MongoDBConfig {
16
23
  readonly client: MongoClient;
24
+ /** Enables atomic catalog commits. Insights always requires MongoDB 5+ on a replica set or sharded cluster. */
17
25
  readonly transactions?: boolean;
18
26
  }
19
27
 
@@ -23,6 +31,62 @@ const createMongoImplementation = (
23
31
  ): DatabasePluginImplementation => {
24
32
  const collections = createMongoCollections(client);
25
33
  return {
34
+ recordInsights: async (input) => {
35
+ const record = () =>
36
+ client.withSession((recordSession) =>
37
+ recordSession.withTransaction(
38
+ async () => {
39
+ const records = createMongoCollections(client);
40
+ const options = {
41
+ session: recordSession,
42
+ collation: { locale: "simple" },
43
+ };
44
+ const inserted = await records.bundleEvents.updateOne(
45
+ { id: input.event.id },
46
+ { $setOnInsert: input.event },
47
+ { ...options, upsert: true },
48
+ );
49
+ if (inserted.upsertedCount === 0) return;
50
+ const current = await records.bundleInstallations.findOne(
51
+ { install_id: input.installation.install_id },
52
+ options,
53
+ );
54
+ if (current === null) {
55
+ await records.bundleInstallations.insertOne(
56
+ { ...input.installation },
57
+ {
58
+ session: recordSession,
59
+ },
60
+ );
61
+ } else if (
62
+ input.installation.received_at_ms > current.received_at_ms ||
63
+ (input.installation.received_at_ms === current.received_at_ms &&
64
+ compareInsightsText(input.installation.id, current.id) > 0)
65
+ ) {
66
+ await records.bundleInstallations.updateOne(
67
+ { install_id: input.installation.install_id },
68
+ { $set: input.installation },
69
+ options,
70
+ );
71
+ }
72
+ },
73
+ {
74
+ readConcern: { level: "snapshot" },
75
+ readPreference: "primary",
76
+ writeConcern: { w: "majority" },
77
+ },
78
+ ),
79
+ );
80
+ try {
81
+ await record();
82
+ } catch (error) {
83
+ if (!(error instanceof MongoServerError) || error.code !== 11000)
84
+ throw error;
85
+ // A concurrent insert won a canonical key. Read that committed winner
86
+ // in a new transaction; some MongoDB versions do not retry E11000.
87
+ await record();
88
+ }
89
+ },
26
90
  ...createMongoWrites(collections, session),
27
91
  ...createMongoReads(collections, session),
28
92
  };
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  BundleEventRow,
3
+ InsightsInstallationRow,
3
4
  BundlePatchRow,
4
5
  BundleRow,
5
6
  ChannelRow,
@@ -37,6 +38,7 @@ export type MongoCollections = {
37
38
  readonly bundles: Collection<MongoBundleDocument>;
38
39
  readonly bundlePatches: Collection<BundlePatchRow>;
39
40
  readonly bundleEvents: Collection<BundleEventRow>;
41
+ readonly bundleInstallations: Collection<InsightsInstallationRow>;
40
42
  readonly channels: Collection<ChannelRow>;
41
43
  readonly apiKeys: Collection<ApiKeyRow>;
42
44
  readonly releases: Collection<ReleaseRow>;
@@ -51,6 +53,9 @@ export const createMongoCollections = (
51
53
  bundles: database.collection<MongoBundleDocument>("bundles"),
52
54
  bundlePatches: database.collection<BundlePatchRow>("bundle_patches"),
53
55
  bundleEvents: database.collection<BundleEventRow>("bundle_events"),
56
+ bundleInstallations: database.collection<InsightsInstallationRow>(
57
+ "bundle_installations",
58
+ ),
54
59
  channels: database.collection<ChannelRow>("channels"),
55
60
  apiKeys: database.collection<ApiKeyRow>("api_keys"),
56
61
  releases: database.collection<ReleaseRow>("releases"),
@@ -4,6 +4,7 @@ import type {
4
4
  BundlePatchRow,
5
5
  BundleRow,
6
6
  ChannelRow,
7
+ InsightsInstallationRow,
7
8
  ReleaseCatalogRow,
8
9
  ReleaseRow,
9
10
  } from "@hot-updater/plugin-core";
@@ -121,20 +122,39 @@ const predicate = (where: AnyDatabaseWhere): Document => {
121
122
 
122
123
  const createMongoWhereDocument = (
123
124
  where: readonly AnyDatabaseWhere[] | undefined,
125
+ toPredicate: (condition: AnyDatabaseWhere) => Document = predicate,
124
126
  ): Document => {
125
127
  const items = Array.isArray(where) ? where : [];
126
128
  const first = items[0];
127
129
  if (first === undefined) return {};
128
130
 
129
- let result = predicate(first);
131
+ let result = toPredicate(first);
130
132
  for (const item of items.slice(1)) {
131
133
  result = {
132
- [item.connector === "OR" ? "$or" : "$and"]: [result, predicate(item)],
134
+ [item.connector === "OR" ? "$or" : "$and"]: [result, toPredicate(item)],
133
135
  };
134
136
  }
135
137
  return result;
136
138
  };
137
139
 
140
+ // Fixed Insights predicates use native fields so equality/range indexes apply.
141
+ const insightsPredicate = (where: AnyDatabaseWhere): Document => {
142
+ if ("mode" in where && where.mode === "insensitive") return predicate(where);
143
+ switch (where.operator) {
144
+ case undefined:
145
+ case "eq":
146
+ return { [where.field]: { $eq: where.value } };
147
+ case "gt":
148
+ case "gte":
149
+ case "lt":
150
+ case "lte":
151
+ case "in":
152
+ return { [where.field]: { [`$${where.operator}`]: where.value } };
153
+ default:
154
+ return predicate(where);
155
+ }
156
+ };
157
+
138
158
  export function createMongoBundleWhere(
139
159
  where: readonly DatabaseWhere<"bundles">[] | undefined,
140
160
  ): Filter<BundleRow>;
@@ -159,7 +179,16 @@ export function createMongoEventWhere(
159
179
  export function createMongoEventWhere(
160
180
  where: readonly DatabaseWhere<"bundle_events">[] | undefined,
161
181
  ): Document {
162
- return createMongoWhereDocument(where);
182
+ return createMongoWhereDocument(where, insightsPredicate);
183
+ }
184
+
185
+ export function createMongoInstallationWhere(
186
+ where: readonly DatabaseWhere<"bundle_installations">[] | undefined,
187
+ ): Filter<InsightsInstallationRow>;
188
+ export function createMongoInstallationWhere(
189
+ where: readonly DatabaseWhere<"bundle_installations">[] | undefined,
190
+ ): Document {
191
+ return createMongoWhereDocument(where, insightsPredicate);
163
192
  }
164
193
 
165
194
  export function createMongoApiKeyWhere(
@@ -19,6 +19,7 @@ import {
19
19
  createMongoChannelWhere,
20
20
  createMongoApiKeyWhere,
21
21
  createMongoEventWhere,
22
+ createMongoInstallationWhere,
22
23
  createMongoPatchWhere,
23
24
  createMongoReleaseCatalogWhere,
24
25
  createMongoReleaseWhere,
@@ -91,6 +92,8 @@ const findMongoRows = async (
91
92
  .find(createMongoEventWhere(input.where), {
92
93
  projection: WITHOUT_MONGO_ID,
93
94
  ...mongoSessionOptions(session),
95
+ collation: { locale: "simple" },
96
+ readPreference: "primary",
94
97
  })
95
98
  .skip(input.offset)
96
99
  .limit(input.limit);
@@ -100,6 +103,38 @@ const findMongoRows = async (
100
103
  .find(createMongoEventWhere(input.where), {
101
104
  projection: WITHOUT_MONGO_ID,
102
105
  ...mongoSessionOptions(session),
106
+ collation: { locale: "simple" },
107
+ readPreference: "primary",
108
+ })
109
+ .toArray();
110
+ return sortRowsByOrder(rows, rawOrderBy).slice(
111
+ input.offset,
112
+ input.offset + input.limit,
113
+ );
114
+ }
115
+ const sort = createMongoSort(input);
116
+ return sort === undefined
117
+ ? cursor.toArray()
118
+ : cursor.sort(sort).toArray();
119
+ }
120
+ case "bundle_installations": {
121
+ const cursor = collections.bundleInstallations
122
+ .find(createMongoInstallationWhere(input.where), {
123
+ projection: WITHOUT_MONGO_ID,
124
+ ...mongoSessionOptions(session),
125
+ collation: { locale: "simple" },
126
+ readPreference: "primary",
127
+ })
128
+ .skip(input.offset)
129
+ .limit(input.limit);
130
+ if (rawOrderBy === undefined) return cursor.toArray();
131
+ if (needsInMemoryOrder) {
132
+ const rows = await collections.bundleInstallations
133
+ .find(createMongoInstallationWhere(input.where), {
134
+ projection: WITHOUT_MONGO_ID,
135
+ ...mongoSessionOptions(session),
136
+ collation: { locale: "simple" },
137
+ readPreference: "primary",
103
138
  })
104
139
  .toArray();
105
140
  return sortRowsByOrder(rows, rawOrderBy).slice(
@@ -248,6 +283,30 @@ export const createMongoReads = (
248
283
  createMongoReleaseWhere(input.where),
249
284
  mongoSessionOptions(session),
250
285
  );
286
+ case "bundle_installations":
287
+ return collections.bundleInstallations.countDocuments(
288
+ createMongoInstallationWhere(input.where),
289
+ {
290
+ ...mongoSessionOptions(session),
291
+ collation: { locale: "simple" },
292
+ readPreference: "primary",
293
+ ...(session === undefined
294
+ ? { readConcern: { level: "snapshot" } }
295
+ : {}),
296
+ },
297
+ );
298
+ case "bundle_events":
299
+ return collections.bundleEvents.countDocuments(
300
+ createMongoEventWhere(input.where),
301
+ {
302
+ ...mongoSessionOptions(session),
303
+ collation: { locale: "simple" },
304
+ readPreference: "primary",
305
+ ...(session === undefined
306
+ ? { readConcern: { level: "snapshot" } }
307
+ : {}),
308
+ },
309
+ );
251
310
  }
252
311
  },
253
312
  findOne: async (input) => {
@@ -300,6 +359,16 @@ export const createMongoReads = (
300
359
  ...mongoSessionOptions(session),
301
360
  },
302
361
  );
362
+ case "bundle_installations":
363
+ return collections.bundleInstallations.findOne(
364
+ createMongoInstallationWhere(input.where),
365
+ {
366
+ projection: WITHOUT_MONGO_ID,
367
+ ...mongoSessionOptions(session),
368
+ collation: { locale: "simple" },
369
+ readPreference: "primary",
370
+ },
371
+ );
303
372
  }
304
373
  },
305
374
  findMany: (input) => {
@@ -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
  });