@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
@@ -1,9 +1,12 @@
1
- import { DatabasePluginInputError } from "@hot-updater/plugin-core";
1
+ import {
2
+ DatabasePluginInputError,
3
+ type InsightsRecordInput,
4
+ } from "@hot-updater/plugin-core";
2
5
  import type {
3
6
  DatabasePluginImplementation,
4
7
  TransactionDatabasePluginImplementation,
5
8
  } from "@hot-updater/plugin-core/internal";
6
- import { asc, desc, eq, sql, type SQLWrapper } from "drizzle-orm";
9
+ import { and, asc, desc, eq, lt, or, sql, type SQLWrapper } from "drizzle-orm";
7
10
 
8
11
  import {
9
12
  isChannelDeleteReferencedError,
@@ -78,9 +81,98 @@ const executeInsert = async (
78
81
  await ignored.execute();
79
82
  };
80
83
 
84
+ export const recordDrizzleInsights = (
85
+ db: DrizzleDB,
86
+ provider: DrizzleProvider,
87
+ input: InsightsRecordInput,
88
+ ): void | Promise<void> => {
89
+ if (db.resultKind !== "sync")
90
+ return recordAsyncDrizzleInsights(db, provider, input);
91
+ const events = getDrizzleTable(db, "bundle_events");
92
+ const installations = getDrizzleTable(db, "bundle_installations");
93
+ const eventInsert = db
94
+ .insert(events)
95
+ .values(input.event)
96
+ .onConflictDoNothing?.()
97
+ .returning?.({ id: getDrizzleColumn(events, "id") });
98
+ const installationInsert = db
99
+ .insert(installations)
100
+ .values(input.installation)
101
+ .onConflictDoNothing?.();
102
+ const installationUpdate = db
103
+ .update(installations)
104
+ .set(input.installation)
105
+ .where(newerInstallationWhere(installations, input.installation));
106
+ if (
107
+ eventInsert?.all === undefined ||
108
+ installationInsert?.run === undefined ||
109
+ installationUpdate.run === undefined
110
+ )
111
+ throw new DrizzleAdapterInvariantError();
112
+ if (eventInsert.all().length === 0) return;
113
+ installationInsert.run();
114
+ installationUpdate.run();
115
+ };
116
+
117
+ const newerInstallationWhere = (
118
+ installations: DrizzleTable,
119
+ installation: InsightsRecordInput["installation"],
120
+ ) => {
121
+ const receivedAt = getDrizzleColumn(installations, "received_at_ms");
122
+ return and(
123
+ eq(getDrizzleColumn(installations, "install_id"), installation.install_id),
124
+ or(
125
+ lt(receivedAt, installation.received_at_ms),
126
+ and(
127
+ eq(receivedAt, installation.received_at_ms),
128
+ lt(getDrizzleColumn(installations, "id"), installation.id),
129
+ ),
130
+ ),
131
+ );
132
+ };
133
+
134
+ const recordAsyncDrizzleInsights = async (
135
+ db: DrizzleDB,
136
+ provider: DrizzleProvider,
137
+ { event, installation }: InsightsRecordInput,
138
+ ): Promise<void> => {
139
+ const events = getDrizzleTable(db, "bundle_events");
140
+ const installations = getDrizzleTable(db, "bundle_installations");
141
+ if (provider === "mysql") {
142
+ const existing = await db.query.bundle_events.findFirst({
143
+ where: eq(getDrizzleColumn(events, "id"), event.id),
144
+ });
145
+ if (existing !== undefined) return;
146
+ await db.insert(events).values(event).execute();
147
+ const insertInstallation = db
148
+ .insert(installations)
149
+ .values(installation)
150
+ .onDuplicateKeyUpdate?.({ set: { install_id: installation.install_id } });
151
+ if (insertInstallation === undefined)
152
+ throw new DrizzleAdapterInvariantError();
153
+ await insertInstallation.execute();
154
+ } else {
155
+ const insert = db.insert(events).values(event).onConflictDoNothing?.();
156
+ const returning = insert?.returning?.({
157
+ id: getDrizzleColumn(events, "id"),
158
+ });
159
+ if (returning === undefined) throw new DrizzleAdapterInvariantError();
160
+ const rows = await returning.execute();
161
+ if (!Array.isArray(rows)) throw new DrizzleAdapterInvariantError();
162
+ if (rows.length === 0) return;
163
+ await executeInsert(db, provider, installations, installation, "ignore");
164
+ }
165
+ await db
166
+ .update(installations)
167
+ .set(installation)
168
+ .where(newerInstallationWhere(installations, installation))
169
+ .execute();
170
+ };
171
+
81
172
  const toOrderBy = (
82
173
  table: DrizzleTable,
83
174
  input: {
175
+ model?: string;
84
176
  orderBy?: readonly {
85
177
  field: string;
86
178
  direction: "asc" | "desc";
@@ -91,6 +183,12 @@ const toOrderBy = (
91
183
  const clauses = input.orderBy;
92
184
  return clauses?.flatMap((clause) => {
93
185
  const column = getDrizzleColumn(table, clause.field);
186
+ if (
187
+ input.model === "bundle_events" ||
188
+ input.model === "bundle_installations"
189
+ ) {
190
+ return [clause.direction === "asc" ? asc(column) : desc(column)];
191
+ }
94
192
  const nulls =
95
193
  clause.nulls ?? (clause.direction === "asc" ? "last" : "first");
96
194
  return [
@@ -110,6 +208,7 @@ export const createDrizzleCrud = (
110
208
  const bundles = getDrizzleTable(db, "bundles");
111
209
  const patches = getDrizzleTable(db, "bundle_patches");
112
210
  const events = getDrizzleTable(db, "bundle_events");
211
+ const installations = getDrizzleTable(db, "bundle_installations");
113
212
  const releases = getDrizzleTable(db, "releases");
114
213
  const releaseCatalogs = getDrizzleTable(db, "release_catalogs");
115
214
  const channels = getDrizzleTable(db, "channels");
@@ -169,6 +268,15 @@ export const createDrizzleCrud = (
169
268
  case "bundle_events":
170
269
  await executeInsert(db, provider, events, input.data, undefined);
171
270
  return input.data;
271
+ case "bundle_installations":
272
+ await executeInsert(
273
+ db,
274
+ provider,
275
+ installations,
276
+ input.data,
277
+ input.onConflict,
278
+ );
279
+ return input.data;
172
280
  case "releases":
173
281
  await executeInsert(
174
282
  db,
@@ -208,6 +316,27 @@ export const createDrizzleCrud = (
208
316
  }
209
317
  },
210
318
  async update(input) {
319
+ if (input.model === "bundle_installations") {
320
+ const installId = input.where.find(
321
+ (item) =>
322
+ item.field === "install_id" &&
323
+ (item.operator === undefined || item.operator === "eq") &&
324
+ typeof item.value === "string",
325
+ )?.value;
326
+ if (typeof installId !== "string") {
327
+ throw new DrizzleAdapterInvariantError();
328
+ }
329
+ const where = buildDrizzleWhere(provider, installations, input.where);
330
+ if (where === undefined) throw new DrizzleAdapterInvariantError();
331
+ await db.update(installations).set(input.update).where(where).execute();
332
+ const row = await db.query.bundle_installations.findFirst({
333
+ where: eq(getDrizzleColumn(installations, "install_id"), installId),
334
+ });
335
+ return row?.id === input.update.id &&
336
+ row.received_at_ms === input.update.received_at_ms
337
+ ? row
338
+ : null;
339
+ }
211
340
  const selector = input.where[0];
212
341
  if (selector === undefined || typeof selector.value !== "string") {
213
342
  throw new DrizzleAdapterInvariantError();
@@ -296,6 +425,11 @@ export const createDrizzleCrud = (
296
425
  throw new DatabasePluginInputError("invalid-operation");
297
426
  }
298
427
  switch (input.model) {
428
+ case "bundle_events":
429
+ return db.$count(
430
+ events,
431
+ buildDrizzleWhere(provider, events, input.where),
432
+ );
299
433
  case "bundles":
300
434
  return db.$count(
301
435
  bundles,
@@ -311,6 +445,11 @@ export const createDrizzleCrud = (
311
445
  releases,
312
446
  buildDrizzleWhere(provider, releases, input.where),
313
447
  );
448
+ case "bundle_installations":
449
+ return db.$count(
450
+ installations,
451
+ buildDrizzleWhere(provider, installations, input.where),
452
+ );
314
453
  }
315
454
  },
316
455
  async findOne(input) {
@@ -351,6 +490,12 @@ export const createDrizzleCrud = (
351
490
  });
352
491
  return row === undefined ? null : fromStoredReleaseCatalogRow(row);
353
492
  }
493
+ case "bundle_installations":
494
+ return (
495
+ (await db.query.bundle_installations.findFirst({
496
+ where: buildDrizzleWhere(provider, installations, input.where),
497
+ })) ?? null
498
+ );
354
499
  }
355
500
  },
356
501
  async findMany(input) {
@@ -374,6 +519,13 @@ export const createDrizzleCrud = (
374
519
  limit: input.limit,
375
520
  offset: input.offset,
376
521
  });
522
+ case "bundle_installations":
523
+ return db.query.bundle_installations.findMany({
524
+ where: buildDrizzleWhere(provider, installations, input.where),
525
+ orderBy: toOrderBy(installations, input),
526
+ limit: input.limit,
527
+ offset: input.offset,
528
+ });
377
529
  case "api_keys":
378
530
  return db.query.api_keys.findMany({
379
531
  where: buildDrizzleWhere(provider, apiKeys, input.where),
@@ -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);