@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
@@ -3,6 +3,7 @@ import type {
3
3
  BundlePatchRow,
4
4
  ChannelRow,
5
5
  ApiKeyRow,
6
+ InsightsInstallationRow,
6
7
  ReleaseCatalogRow,
7
8
  } from "@hot-updater/plugin-core";
8
9
 
@@ -13,10 +14,14 @@ export type DrizzleTable = Record<string, unknown>;
13
14
 
14
15
  type DrizzleMutation = {
15
16
  readonly execute: () => Promise<unknown>;
17
+ readonly all?: () => unknown[];
18
+ readonly run?: () => unknown;
16
19
  };
17
20
 
18
21
  export type DrizzleInsertMutation = DrizzleMutation & {
19
- readonly onConflictDoNothing?: () => DrizzleMutation;
22
+ readonly onDuplicateKeyUpdate?: (config: { set: object }) => DrizzleMutation;
23
+ readonly onConflictDoNothing?: () => DrizzleInsertMutation;
24
+ readonly returning?: (fields: Record<string, unknown>) => DrizzleMutation;
20
25
  };
21
26
 
22
27
  type DrizzleInsertBuilder = {
@@ -30,6 +35,7 @@ type DrizzleQuery<TRow> = {
30
35
  };
31
36
 
32
37
  export type DrizzleDB = {
38
+ readonly resultKind?: "sync" | "async";
33
39
  readonly _: { readonly fullSchema: Record<string, DrizzleTable> };
34
40
  readonly $count: (table: DrizzleTable, where?: unknown) => Promise<number>;
35
41
  readonly delete: (table: DrizzleTable) => {
@@ -41,6 +47,7 @@ export type DrizzleDB = {
41
47
  readonly channels: DrizzleQuery<ChannelRow>;
42
48
  readonly bundle_patches: DrizzleQuery<BundlePatchRow>;
43
49
  readonly bundle_events: DrizzleQuery<BundleEventRow>;
50
+ readonly bundle_installations: DrizzleQuery<InsightsInstallationRow>;
44
51
  readonly api_keys: DrizzleQuery<ApiKeyRow>;
45
52
  readonly releases: DrizzleQuery<StoredReleaseRow>;
46
53
  readonly release_catalogs: DrizzleQuery<ReleaseCatalogRow>;
@@ -51,7 +58,7 @@ export type DrizzleDB = {
51
58
  };
52
59
  };
53
60
  readonly transaction?: <TResult>(
54
- operation: (transaction: DrizzleDB) => Promise<TResult>,
61
+ operation: (transaction: DrizzleDB) => TResult | Promise<TResult>,
55
62
  ) => Promise<TResult>;
56
63
  };
57
64
 
@@ -82,6 +89,7 @@ const isDrizzleDB = (value: unknown): value is DrizzleDB => {
82
89
  if (
83
90
  !isRecord(query) ||
84
91
  !isDrizzleQuery(query["bundle_events"]) ||
92
+ !isDrizzleQuery(query["bundle_installations"]) ||
85
93
  !isDrizzleQuery(query["bundle_patches"]) ||
86
94
  !isDrizzleQuery(query["bundles"]) ||
87
95
  !isDrizzleQuery(query["channels"]) ||
@@ -193,6 +201,12 @@ export const createLazyDB = (config: DrizzleConfig): DrizzleDB => {
193
201
  findMany: async (args) =>
194
202
  (await getDB()).query.bundle_events.findMany(args),
195
203
  },
204
+ bundle_installations: {
205
+ findFirst: async (args) =>
206
+ (await getDB()).query.bundle_installations.findFirst(args),
207
+ findMany: async (args) =>
208
+ (await getDB()).query.bundle_installations.findMany(args),
209
+ },
196
210
  bundle_patches: {
197
211
  findFirst: async (args) =>
198
212
  (await getDB()).query.bundle_patches.findFirst(args),
@@ -241,7 +255,7 @@ export const createLazyDB = (config: DrizzleConfig): DrizzleDB => {
241
255
  ...(config.transaction === true
242
256
  ? {
243
257
  transaction: async <TResult>(
244
- operation: (transaction: DrizzleDB) => Promise<TResult>,
258
+ operation: (transaction: DrizzleDB) => TResult | Promise<TResult>,
245
259
  ): Promise<TResult> => {
246
260
  const db = await getDB();
247
261
  if (db.transaction === undefined) {
@@ -0,0 +1,32 @@
1
+ const isRetryableConflict = (error: unknown): boolean => {
2
+ if (typeof error !== "object" || error === null) return false;
3
+ if (
4
+ "code" in error &&
5
+ [
6
+ "ER_DUP_ENTRY",
7
+ "ER_LOCK_DEADLOCK",
8
+ "ER_LOCK_WAIT_TIMEOUT",
9
+ "40001",
10
+ "40P01",
11
+ ].includes(String(error.code))
12
+ )
13
+ return true;
14
+ return (
15
+ "cause" in error &&
16
+ error.cause !== error &&
17
+ isRetryableConflict(error.cause)
18
+ );
19
+ };
20
+
21
+ export const runInsightsTransaction = async (
22
+ operation: () => Promise<void>,
23
+ ): Promise<void> => {
24
+ for (let attempt = 0; ; attempt += 1) {
25
+ try {
26
+ await operation();
27
+ return;
28
+ } catch (error) {
29
+ if (attempt >= 2 || !isRetryableConflict(error)) throw error;
30
+ }
31
+ }
32
+ };
@@ -1,10 +1,12 @@
1
1
  import { PGlite } from "@electric-sql/pglite";
2
+ import { toInsightsInstallationRow } from "@hot-updater/plugin-core";
2
3
  import { Kysely } from "kysely";
3
4
  import { PGliteDialect } from "kysely-pglite-dialect";
4
5
  import { describe, expect, it } from "vitest";
5
6
 
6
7
  import {
7
8
  createBundlePatchRowFixture,
9
+ createBundleEventRowFixture,
8
10
  createBundleRowFixture,
9
11
  createChannelRowFixture,
10
12
  } from "../../../test-utils/src/databaseTestFixtures";
@@ -54,6 +56,33 @@ setupDatabasePluginTestSuite({
54
56
  });
55
57
 
56
58
  describe("kyselyAdapter SQLite JSON storage", () => {
59
+ it("rolls back the event when the installation write fails and permits retry", async () => {
60
+ const db = new PGlite();
61
+ const kysely = new Kysely<object>({ dialect: new PGliteDialect(db) });
62
+ await db.exec(DATABASE_PLUGIN_TEST_SCHEMA_SQL);
63
+ await db.exec(
64
+ "alter table bundle_installations add constraint reject_snapshot check (install_id <> 'install-703')",
65
+ );
66
+ const plugin = kyselyAdapter({ db: kysely, provider: "postgresql" });
67
+ const event = createBundleEventRowFixture("703", 100);
68
+ const input = { event, installation: toInsightsInstallationRow(event) };
69
+ try {
70
+ await expect(plugin.models.insights.record(input)).rejects.toThrow();
71
+ expect((await db.query("select id from bundle_events")).rows).toEqual([]);
72
+ await db.exec(
73
+ "alter table bundle_installations drop constraint reject_snapshot",
74
+ );
75
+ await plugin.models.insights.record(input);
76
+ await expect(
77
+ plugin.models.insights.findInstallations({
78
+ installId: event.install_id,
79
+ }),
80
+ ).resolves.toEqual([input.installation]);
81
+ } finally {
82
+ await kysely.destroy();
83
+ await db.close();
84
+ }
85
+ });
57
86
  it("round-trips JSON values through text columns", async () => {
58
87
  const sqliteClient = new PGlite();
59
88
  const sqliteDatabase = new Kysely<object>({
@@ -11,7 +11,8 @@ import type {
11
11
  ORMSQLProvider,
12
12
  RelationMode,
13
13
  } from "../db/types";
14
- import { createKyselyCrud } from "./kyselyCrud";
14
+ import { runInsightsTransaction } from "./insightsTransaction";
15
+ import { createKyselyCrud, recordKyselyInsights } from "./kyselyCrud";
15
16
 
16
17
  type KyselySQLProvider = Exclude<ORMSQLProvider, "mssql">;
17
18
 
@@ -31,6 +32,14 @@ const createImplementation = <TDatabase extends object>(
31
32
  const crud = createKyselyCrud(db, config.provider, relationMode);
32
33
  return {
33
34
  ...crud,
35
+ recordInsights: (input) =>
36
+ runInsightsTransaction(() =>
37
+ db
38
+ .transaction()
39
+ .execute((transaction) =>
40
+ recordKyselyInsights(transaction, config.provider, input),
41
+ ),
42
+ ),
34
43
  deleteChannel: (input) =>
35
44
  db
36
45
  .transaction()
@@ -4,6 +4,8 @@ import {
4
4
  type BundlePatchRow,
5
5
  type ChannelRow,
6
6
  type ApiKeyRow,
7
+ type InsightsInstallationRow,
8
+ type InsightsRecordInput,
7
9
  type ReleaseCatalogRow,
8
10
  } from "@hot-updater/plugin-core";
9
11
  import type {
@@ -54,6 +56,7 @@ const whereClause = (
54
56
  const orderClause = (
55
57
  input:
56
58
  | {
59
+ readonly model?: string;
57
60
  readonly orderBy?: readonly {
58
61
  readonly direction: "asc" | "desc";
59
62
  readonly field: string;
@@ -67,6 +70,14 @@ const orderClause = (
67
70
  return sql` order by ${sql.join(
68
71
  clauses.map((clause) => {
69
72
  const field = sql.ref(clause.field);
73
+ if (
74
+ input?.model === "bundle_events" ||
75
+ input?.model === "bundle_installations"
76
+ ) {
77
+ return clause.direction === "asc"
78
+ ? sql`${field} asc`
79
+ : sql`${field} desc`;
80
+ }
70
81
  const nulls =
71
82
  clause.nulls ?? (clause.direction === "asc" ? "last" : "first");
72
83
  const nullOrder =
@@ -110,6 +121,46 @@ const insertRow = async (
110
121
  return Number(result.numAffectedRows ?? 0) > 0;
111
122
  };
112
123
 
124
+ export const recordKyselyInsights = async (
125
+ executor: QueryExecutorProvider,
126
+ provider: Exclude<ORMSQLProvider, "mssql">,
127
+ { event, installation }: InsightsRecordInput,
128
+ ): Promise<void> => {
129
+ const insert = (table: string, row: object) => {
130
+ const entries = Object.entries(row);
131
+ return sql`insert into ${sql.table(table)} (${sql.join(entries.map(([field]) => sql.ref(field)))}) values (${sql.join(entries.map(([, value]) => value))})`;
132
+ };
133
+ if (provider === "mysql") {
134
+ const existing = await sql<{
135
+ id: string;
136
+ }>`select id from bundle_events where id = ${event.id} for update`.execute(
137
+ executor,
138
+ );
139
+ if (existing.rows.length > 0) return;
140
+ await insert("bundle_events", event).execute(executor);
141
+ await sql`${insert("bundle_installations", installation)} on duplicate key update install_id = ${installation.install_id}`.execute(
142
+ executor,
143
+ );
144
+ } else {
145
+ const result =
146
+ await sql`${insert("bundle_events", event)} on conflict (id) do nothing returning id`.execute(
147
+ executor,
148
+ );
149
+ if (result.rows.length === 0) return;
150
+ await sql`${insert("bundle_installations", installation)} on conflict (install_id) do nothing`.execute(
151
+ executor,
152
+ );
153
+ }
154
+ const assignments = Object.entries(installation).map(
155
+ ([field, value]) => sql`${sql.ref(field)} = ${value}`,
156
+ );
157
+ await sql`update ${sql.table("bundle_installations")} set ${sql.join(assignments)}
158
+ where ${sql.ref("install_id")} = ${installation.install_id}
159
+ and (${sql.ref("received_at_ms")} < ${installation.received_at_ms}
160
+ or (${sql.ref("received_at_ms")} = ${installation.received_at_ms}
161
+ and ${sql.ref("id")} < ${installation.id}))`.execute(executor);
162
+ };
163
+
113
164
  const updateBundle = async (
114
165
  executor: QueryExecutorProvider,
115
166
  id: string,
@@ -298,6 +349,15 @@ export const createKyselyCrud = (
298
349
  case "bundle_events":
299
350
  await insertRow(executor, "bundle_events", input.data, provider);
300
351
  return input.data;
352
+ case "bundle_installations":
353
+ await insertRow(
354
+ executor,
355
+ "bundle_installations",
356
+ input.data,
357
+ provider,
358
+ input.onConflict,
359
+ );
360
+ return input.data;
301
361
  case "releases":
302
362
  await insertRow(
303
363
  executor,
@@ -330,6 +390,38 @@ export const createKyselyCrud = (
330
390
  }
331
391
  },
332
392
  async update(input) {
393
+ if (input.model === "bundle_installations") {
394
+ const installId = input.where.find(
395
+ (item) =>
396
+ item.field === "install_id" &&
397
+ (item.operator === undefined || item.operator === "eq") &&
398
+ typeof item.value === "string",
399
+ )?.value;
400
+ if (typeof installId !== "string") {
401
+ throw new KyselyAdapterInvariantError(
402
+ "bundle_installations.update.selector",
403
+ );
404
+ }
405
+ const assignments = Object.entries(input.update)
406
+ .filter(([, value]) => value !== undefined)
407
+ .map(([field, value]) => sql`${sql.ref(field)} = ${value}`);
408
+ const where = buildKyselyWhere(provider, input.where);
409
+ let updated = false;
410
+ if (assignments.length > 0 && where !== undefined) {
411
+ const result = await sql`update ${sql.table(
412
+ "bundle_installations",
413
+ )} set ${sql.join(assignments)} where ${where}`.execute(executor);
414
+ updated = Number(result.numAffectedRows ?? 0) > 0;
415
+ }
416
+ if (!updated) return null;
417
+ const result =
418
+ await sql<InsightsInstallationRow>`select * from ${sql.table(
419
+ "bundle_installations",
420
+ )} where ${sql.ref("install_id")} = ${installId} limit 1`.execute(
421
+ executor,
422
+ );
423
+ return result.rows[0] ?? null;
424
+ }
333
425
  const selector = input.where[0];
334
426
  if (selector === undefined || typeof selector.value !== "string") {
335
427
  throw new KyselyAdapterInvariantError(`${input.model}.update.selector`);
@@ -452,6 +544,12 @@ export const createKyselyCrud = (
452
544
  throw new DatabasePluginInputError("invalid-operation");
453
545
  }
454
546
  switch (input.model) {
547
+ case "bundle_events":
548
+ return countRows(
549
+ executor,
550
+ "bundle_events",
551
+ buildKyselyWhere(provider, input.where),
552
+ );
455
553
  case "bundles":
456
554
  return countRows(
457
555
  executor,
@@ -470,6 +568,12 @@ export const createKyselyCrud = (
470
568
  "releases",
471
569
  buildKyselyWhere(provider, input.where),
472
570
  );
571
+ case "bundle_installations":
572
+ return countRows(
573
+ executor,
574
+ "bundle_installations",
575
+ buildKyselyWhere(provider, input.where),
576
+ );
473
577
  }
474
578
  },
475
579
  async findOne(input) {
@@ -519,6 +623,14 @@ export const createKyselyCrud = (
519
623
  const row = result.rows[0];
520
624
  return row === undefined ? null : fromStoredReleaseCatalogRow(row);
521
625
  }
626
+ case "bundle_installations": {
627
+ const where = whereClause(buildKyselyWhere(provider, input.where));
628
+ const result =
629
+ await sql<InsightsInstallationRow>`select * from ${sql.table(
630
+ "bundle_installations",
631
+ )}${where} limit 1`.execute(executor);
632
+ return result.rows[0] ?? null;
633
+ }
522
634
  }
523
635
  },
524
636
  async findMany(input) {
@@ -551,6 +663,15 @@ export const createKyselyCrud = (
551
663
  )}${where}${order}${pagination}`.execute(executor);
552
664
  return [...result.rows];
553
665
  }
666
+ case "bundle_installations": {
667
+ const where = whereClause(buildKyselyWhere(provider, input.where));
668
+ const order = orderClause(input);
669
+ const result =
670
+ await sql<InsightsInstallationRow>`select * from ${sql.table(
671
+ "bundle_installations",
672
+ )}${where}${order}${pagination}`.execute(executor);
673
+ return [...result.rows];
674
+ }
554
675
  case "api_keys": {
555
676
  const where = whereClause(buildKyselyWhere(provider, input.where));
556
677
  const order = orderClause(input);
@@ -0,0 +1,210 @@
1
+ import net from "node:net";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ toInsightsInstallationRow,
6
+ type BundleEventRow,
7
+ } from "@hot-updater/plugin-core";
8
+ import { execa } from "execa";
9
+ import { MongoClient } from "mongodb";
10
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
11
+
12
+ import { createBundleEventRowFixture } from "../../../test-utils/src/databaseTestFixtures";
13
+ import { mongoAdapter } from "./mongodb";
14
+
15
+ const availablePort = (): Promise<number> =>
16
+ new Promise((resolve, reject) => {
17
+ const server = net.createServer();
18
+ server.once("error", reject);
19
+ server.listen(0, "127.0.0.1", () => {
20
+ const address = server.address();
21
+ if (address === null || typeof address === "string") {
22
+ server.close();
23
+ reject(new Error("Failed to allocate MongoDB test port"));
24
+ return;
25
+ }
26
+ server.close((error) => (error ? reject(error) : resolve(address.port)));
27
+ });
28
+ });
29
+
30
+ describe("MongoDB native Insights transactions", () => {
31
+ let client: MongoClient;
32
+ let composeEnvironment: Record<string, string>;
33
+ const composeFile = path.resolve(
34
+ import.meta.dirname,
35
+ "../../../../examples-server/hono-mongodb/docker-compose.yml",
36
+ );
37
+ const compose = (args: string[]) =>
38
+ execa("docker", ["compose", "-f", composeFile, ...args], {
39
+ env: composeEnvironment,
40
+ });
41
+ const insights = () => mongoAdapter({ client }).models.insights;
42
+ const record = (event: BundleEventRow) =>
43
+ insights().record({
44
+ event,
45
+ installation: toInsightsInstallationRow(event),
46
+ });
47
+
48
+ beforeAll(async () => {
49
+ const port = await availablePort();
50
+ composeEnvironment = {
51
+ COMPOSE_PROJECT_NAME: `hot-updater-insights-${process.pid}`,
52
+ HOT_UPDATER_E2E_MONGODB_PORT: String(port),
53
+ };
54
+ await compose(["up", "-d", "--wait"]);
55
+ client = new MongoClient(
56
+ `mongodb://127.0.0.1:${port}/insights_native?replicaSet=rs0&directConnection=true`,
57
+ );
58
+ await client.connect();
59
+ const adapter = mongoAdapter({ client });
60
+ await (await adapter.createMigrator!().migrateToLatest()).execute();
61
+ }, 120_000);
62
+
63
+ beforeEach(async () => {
64
+ await client.db().collection("bundle_events").deleteMany({});
65
+ await client.db().collection("bundle_installations").deleteMany({});
66
+ });
67
+
68
+ afterAll(async () => {
69
+ await client?.close();
70
+ if (composeEnvironment) await compose(["down", "-v", "--remove-orphans"]);
71
+ }, 60_000);
72
+
73
+ it("rolls back both canonical changes after a native installation validation error", async () => {
74
+ const previous = {
75
+ ...createBundleEventRowFixture("801", 100),
76
+ user_id: "original",
77
+ };
78
+ await record(previous);
79
+ await client.db().command({
80
+ collMod: "bundle_installations",
81
+ validator: { user_id: { $ne: "reject" } },
82
+ });
83
+ const failed = {
84
+ ...createBundleEventRowFixture("802", 200),
85
+ install_id: previous.install_id,
86
+ user_id: "reject",
87
+ };
88
+ try {
89
+ await expect(record(failed)).rejects.toThrow();
90
+ expect(
91
+ await client.db().collection("bundle_events").countDocuments({}),
92
+ ).toBe(1);
93
+ await expect(
94
+ insights().findInstallations({ installId: previous.install_id }),
95
+ ).resolves.toEqual([toInsightsInstallationRow(previous)]);
96
+ } finally {
97
+ await client
98
+ .db()
99
+ .command({ collMod: "bundle_installations", validator: {} });
100
+ }
101
+ await record(failed);
102
+ await record(failed);
103
+ expect(
104
+ await client.db().collection("bundle_events").countDocuments({}),
105
+ ).toBe(2);
106
+ await expect(
107
+ insights().findInstallations({ installId: previous.install_id }),
108
+ ).resolves.toEqual([toInsightsInstallationRow(failed)]);
109
+ });
110
+
111
+ it("retries native conflicts without dropping events or letting duplicate IDs rewrite state", async () => {
112
+ const events = Array.from({ length: 12 }, (_, index) => ({
113
+ ...createBundleEventRowFixture(String(810 + index), 100),
114
+ install_id: "concurrent-installation",
115
+ user_id: index === 11 ? null : "old-user",
116
+ }));
117
+ await Promise.all(events.map(record));
118
+ const winner = events.at(-1)!;
119
+ await Promise.all(Array.from({ length: 4 }, () => record(winner)));
120
+ const conflictingDuplicate = {
121
+ ...events[0]!,
122
+ install_id: "different-installation",
123
+ received_at_ms: 1000,
124
+ };
125
+ await record(conflictingDuplicate);
126
+ expect(
127
+ await client.db().collection("bundle_events").countDocuments({}),
128
+ ).toBe(12);
129
+ await expect(
130
+ insights().findInstallations({ installId: winner.install_id }),
131
+ ).resolves.toEqual([toInsightsInstallationRow(winner)]);
132
+ await expect(
133
+ insights().findInstallations({
134
+ installId: conflictingDuplicate.install_id,
135
+ }),
136
+ ).resolves.toEqual([]);
137
+ await expect(
138
+ insights().findInstallations({ userId: "old-user", limit: 10 }),
139
+ ).resolves.toEqual([]);
140
+ });
141
+
142
+ it("accepts concurrent retries of a previously unseen event ID exactly once", async () => {
143
+ const event = createBundleEventRowFixture("825", 100);
144
+ await Promise.all(Array.from({ length: 12 }, () => record(event)));
145
+ expect(
146
+ await client.db().collection("bundle_events").countDocuments({}),
147
+ ).toBe(1);
148
+ await expect(
149
+ insights().findInstallations({ installId: event.install_id }),
150
+ ).resolves.toEqual([toInsightsInstallationRow(event)]);
151
+ });
152
+
153
+ it("uses raw recovery predicates consistently in native counts and indexed lists", async () => {
154
+ const applied = createBundleEventRowFixture("831", 100);
155
+ const recovered: BundleEventRow = {
156
+ ...createBundleEventRowFixture("832", 200),
157
+ type: "RECOVERED",
158
+ update_strategy: "appVersion",
159
+ install_id: applied.install_id,
160
+ from_bundle_id: applied.to_bundle_id,
161
+ to_bundle_id: applied.from_bundle_id!,
162
+ };
163
+ await record(applied);
164
+ await record(recovered);
165
+ const filter = {
166
+ type: "RECOVERED" as const,
167
+ platform: "ios" as const,
168
+ channel: "production",
169
+ fromBundleId: applied.to_bundle_id,
170
+ };
171
+ await expect(
172
+ insights().countEvents({ filter, sinceMs: 200, beforeReceivedAtMs: 201 }),
173
+ ).resolves.toBe(1);
174
+ await expect(
175
+ insights().listEvents({
176
+ filter: { kind: "bundle", ...filter },
177
+ sinceMs: 200,
178
+ beforeReceivedAtMs: 201,
179
+ limit: 10,
180
+ }),
181
+ ).resolves.toEqual([recovered]);
182
+ await expect(
183
+ insights().countEvents({ filter, sinceMs: 100, beforeReceivedAtMs: 200 }),
184
+ ).resolves.toBe(0);
185
+ await expect(
186
+ insights().countInstallations({
187
+ platform: "ios",
188
+ channel: "production",
189
+ bundleId: applied.to_bundle_id,
190
+ sinceMs: 0,
191
+ }),
192
+ ).resolves.toBe(0);
193
+ const explanation = await client
194
+ .db()
195
+ .collection("bundle_events")
196
+ .find({
197
+ type: "RECOVERED",
198
+ platform: "ios",
199
+ channel: "production",
200
+ from_bundle_id: applied.to_bundle_id,
201
+ received_at_ms: { $gte: 200, $lt: 201 },
202
+ })
203
+ .collation({ locale: "simple" })
204
+ .sort({ received_at_ms: -1, id: -1 })
205
+ .hint("bundle_events_from_bundle_idx")
206
+ .explain("executionStats");
207
+ expect(explanation.executionStats.nReturned).toBe(1);
208
+ expect(explanation.executionStats.totalKeysExamined).toBeLessThanOrEqual(2);
209
+ });
210
+ });
@@ -1,9 +1,13 @@
1
- import { createDatabaseClient } from "@hot-updater/plugin-core";
1
+ import {
2
+ createDatabaseClient,
3
+ toInsightsInstallationRow,
4
+ } from "@hot-updater/plugin-core";
2
5
  import { describe, expect, it } from "vitest";
3
6
 
4
7
  import {
5
8
  createBundlePatchRowFixture,
6
9
  createBundleRowFixture,
10
+ createBundleEventRowFixture,
7
11
  } from "../../../test-utils/src/databaseTestFixtures";
8
12
  import { setupDatabasePluginTestSuite } from "../../../test-utils/src/setupDatabasePluginTestSuite";
9
13
  import { mongoAdapter } from "./mongodb";
@@ -22,6 +26,70 @@ setupDatabasePluginTestSuite({
22
26
  });
23
27
 
24
28
  describe("mongoAdapter capabilities", () => {
29
+ it("records Insights atomically even without legacy catalog transactions enabled", async () => {
30
+ harness.reset();
31
+ const insights = mongoAdapter({ client: harness.client }).models.insights;
32
+ const event = createBundleEventRowFixture("981", 100);
33
+ const input = { event, installation: toInsightsInstallationRow(event) };
34
+ harness.failNextInstallationWrite();
35
+ await expect(insights.record(input)).rejects.toThrow(
36
+ "injected installation write failure",
37
+ );
38
+ await expect(
39
+ insights.listEvents({
40
+ filter: { kind: "all" },
41
+ beforeReceivedAtMs: 200,
42
+ limit: 10,
43
+ }),
44
+ ).resolves.toEqual([]);
45
+ await expect(
46
+ insights.findInstallations({ installId: event.install_id }),
47
+ ).resolves.toEqual([]);
48
+ await insights.record(input);
49
+ await insights.record(input);
50
+ await expect(
51
+ insights.listEvents({
52
+ filter: { kind: "all" },
53
+ beforeReceivedAtMs: 200,
54
+ limit: 10,
55
+ }),
56
+ ).resolves.toEqual([event]);
57
+ await expect(
58
+ insights.findInstallations({ installId: event.install_id }),
59
+ ).resolves.toEqual([input.installation]);
60
+ });
61
+
62
+ it("preserves all concurrent events while advancing one installation and clearing its user", async () => {
63
+ harness.reset();
64
+ const insights = mongoAdapter({ client: harness.client }).models.insights;
65
+ const events = ["990", "993", "991", "992"].map((suffix) => ({
66
+ ...createBundleEventRowFixture(suffix, 100),
67
+ install_id: "concurrent-installation",
68
+ user_id: suffix === "993" ? null : "previous-user",
69
+ }));
70
+ await Promise.all(
71
+ events.map((event) =>
72
+ insights.record({
73
+ event,
74
+ installation: toInsightsInstallationRow(event),
75
+ }),
76
+ ),
77
+ );
78
+ await expect(
79
+ insights.findInstallations({ installId: "concurrent-installation" }),
80
+ ).resolves.toEqual([toInsightsInstallationRow(events[1]!)]);
81
+ await expect(
82
+ insights.findInstallations({ userId: "previous-user", limit: 10 }),
83
+ ).resolves.toEqual([]);
84
+ await expect(
85
+ insights.listEvents({
86
+ filter: { kind: "all" },
87
+ beforeReceivedAtMs: 200,
88
+ limit: 10,
89
+ }),
90
+ ).resolves.toHaveLength(4);
91
+ });
92
+
25
93
  it("returns an adapter without an unsafe atomic-batch fallback", () => {
26
94
  const plugin = mongoAdapter({ client: harness.client });
27
95
  expect(plugin.name).toBe("mongodb");