@hot-updater/server 0.36.7 → 1.0.0-rc.1

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 (291) hide show
  1. package/dist/adapters/databaseConstraintErrors.cjs +23 -0
  2. package/dist/adapters/databaseConstraintErrors.mjs +21 -0
  3. package/dist/adapters/databasePluginUtils.cjs +117 -0
  4. package/dist/adapters/databasePluginUtils.mjs +106 -0
  5. package/dist/adapters/drizzle.cjs +53 -176
  6. package/dist/adapters/drizzle.d.cts +6 -4
  7. package/dist/adapters/drizzle.d.mts +6 -4
  8. package/dist/adapters/drizzle.mjs +54 -177
  9. package/dist/adapters/drizzleCrud.cjs +250 -0
  10. package/dist/adapters/drizzleCrud.mjs +249 -0
  11. package/dist/adapters/drizzleLazyDB.cjs +84 -36
  12. package/dist/adapters/drizzleLazyDB.mjs +84 -36
  13. package/dist/adapters/drizzleQuery.cjs +68 -0
  14. package/dist/adapters/drizzleQuery.mjs +67 -0
  15. package/dist/adapters/kysely.cjs +18 -138
  16. package/dist/adapters/kysely.d.cts +3 -12
  17. package/dist/adapters/kysely.d.mts +3 -12
  18. package/dist/adapters/kysely.mjs +19 -139
  19. package/dist/adapters/kyselyCrud.cjs +254 -0
  20. package/dist/adapters/kyselyCrud.mjs +253 -0
  21. package/dist/adapters/kyselyQuery.cjs +56 -0
  22. package/dist/adapters/kyselyQuery.mjs +55 -0
  23. package/dist/adapters/mongodb.cjs +21 -174
  24. package/dist/adapters/mongodb.d.cts +3 -2
  25. package/dist/adapters/mongodb.d.mts +3 -2
  26. package/dist/adapters/mongodb.mjs +21 -174
  27. package/dist/adapters/mongodbCollections.cjs +36 -0
  28. package/dist/adapters/mongodbCollections.mjs +30 -0
  29. package/dist/adapters/mongodbQuery.cjs +77 -0
  30. package/dist/adapters/mongodbQuery.mjs +70 -0
  31. package/dist/adapters/mongodbReads.cjs +148 -0
  32. package/dist/adapters/mongodbReads.mjs +147 -0
  33. package/dist/adapters/mongodbWrites.cjs +134 -0
  34. package/dist/adapters/mongodbWrites.mjs +133 -0
  35. package/dist/adapters/prisma.cjs +205 -180
  36. package/dist/adapters/prisma.d.cts +3 -3
  37. package/dist/adapters/prisma.d.mts +3 -3
  38. package/dist/adapters/prisma.mjs +206 -181
  39. package/dist/adapters/prismaQuery.cjs +58 -0
  40. package/dist/adapters/prismaQuery.mjs +56 -0
  41. package/dist/adapters/prismaRows.cjs +198 -0
  42. package/dist/adapters/prismaRows.mjs +188 -0
  43. package/dist/apiKeys.cjs +139 -0
  44. package/dist/apiKeys.d.cts +50 -0
  45. package/dist/apiKeys.d.mts +50 -0
  46. package/dist/apiKeys.mjs +133 -0
  47. package/dist/createHotUpdaterCore.cjs +48 -26
  48. package/dist/createHotUpdaterCore.d.cts +49 -17
  49. package/dist/createHotUpdaterCore.d.mts +49 -17
  50. package/dist/createHotUpdaterCore.mjs +51 -29
  51. package/dist/db/createBundleDiff.cjs +45 -68
  52. package/dist/db/createBundleDiff.d.cts +3 -3
  53. package/dist/db/createBundleDiff.d.mts +3 -3
  54. package/dist/db/createBundleDiff.mjs +47 -68
  55. package/dist/db/databasePluginCore.cjs +138 -0
  56. package/dist/db/databasePluginCore.mjs +137 -0
  57. package/dist/db/fixedMigrator.cjs +36 -93
  58. package/dist/db/fixedMigrator.mjs +36 -93
  59. package/dist/db/fixedMigratorMongo.cjs +319 -0
  60. package/dist/db/fixedMigratorMongo.mjs +319 -0
  61. package/dist/db/fixedMigratorShared.cjs +23 -0
  62. package/dist/db/fixedMigratorShared.mjs +18 -0
  63. package/dist/db/mongoMigrationExecution.cjs +11 -0
  64. package/dist/db/mongoMigrationExecution.mjs +11 -0
  65. package/dist/db/releaseCatalog.cjs +66 -0
  66. package/dist/db/releaseCatalog.d.cts +17 -0
  67. package/dist/db/releaseCatalog.d.mts +17 -0
  68. package/dist/db/releaseCatalog.mjs +64 -0
  69. package/dist/db/schema/mongodb.cjs +14 -6
  70. package/dist/db/schema/mongodb.mjs +14 -6
  71. package/dist/db/schema/registry.cjs +0 -2
  72. package/dist/db/schema/registry.mjs +1 -2
  73. package/dist/db/schema/sql.cjs +31 -14
  74. package/dist/db/schema/sql.mjs +32 -13
  75. package/dist/db/schema/sqlOperations.cjs +4 -4
  76. package/dist/db/schema/sqlOperations.mjs +5 -5
  77. package/dist/db/schemaGenerators.cjs +52 -18
  78. package/dist/db/schemaGenerators.mjs +52 -18
  79. package/dist/db/schemaReadiness.cjs +4 -3
  80. package/dist/db/schemaReadiness.mjs +4 -3
  81. package/dist/db/sqlMigrationExecution.cjs +67 -0
  82. package/dist/db/sqlMigrationExecution.mjs +66 -0
  83. package/dist/db/types.cjs +2 -6
  84. package/dist/db/types.d.cts +60 -16
  85. package/dist/db/types.d.mts +60 -16
  86. package/dist/db/types.mjs +3 -6
  87. package/dist/db/updateArtifacts.cjs +114 -60
  88. package/dist/db/updateArtifacts.mjs +117 -62
  89. package/dist/handler.cjs +108 -282
  90. package/dist/handler.d.cts +3 -46
  91. package/dist/handler.d.mts +3 -46
  92. package/dist/handler.mjs +107 -281
  93. package/dist/handlerBundleRoutes.cjs +155 -0
  94. package/dist/handlerBundleRoutes.mjs +155 -0
  95. package/dist/handlerErrors.cjs +9 -0
  96. package/dist/handlerErrors.mjs +9 -0
  97. package/dist/handlerParameters.cjs +39 -0
  98. package/dist/handlerParameters.mjs +34 -0
  99. package/dist/handlerReleaseCatalogRoutes.cjs +111 -0
  100. package/dist/handlerReleaseCatalogRoutes.mjs +110 -0
  101. package/dist/handlerReleaseManagementRoutes.cjs +125 -0
  102. package/dist/handlerReleaseManagementRoutes.mjs +124 -0
  103. package/dist/handlerTypes.d.cts +63 -0
  104. package/dist/handlerTypes.d.mts +63 -0
  105. package/dist/handlerVersionRoutes.cjs +10 -0
  106. package/dist/handlerVersionRoutes.d.cts +4 -0
  107. package/dist/handlerVersionRoutes.d.mts +4 -0
  108. package/dist/handlerVersionRoutes.mjs +9 -0
  109. package/dist/index.cjs +10 -1
  110. package/dist/index.d.cts +9 -3
  111. package/dist/index.d.mts +9 -3
  112. package/dist/index.mjs +5 -2
  113. package/dist/insights/bounded/activeOverview.cjs +87 -0
  114. package/dist/insights/bounded/activeOverview.mjs +85 -0
  115. package/dist/insights/bounded/installationSearch.cjs +42 -0
  116. package/dist/insights/bounded/installationSearch.mjs +42 -0
  117. package/dist/insights/bounded/persistence.cjs +39 -0
  118. package/dist/insights/bounded/persistence.mjs +38 -0
  119. package/dist/insights/bounded/provider.cjs +170 -0
  120. package/dist/insights/bounded/provider.d.cts +7 -0
  121. package/dist/insights/bounded/provider.d.mts +7 -0
  122. package/dist/insights/bounded/provider.mjs +170 -0
  123. package/dist/insights/bounded/scan.cjs +144 -0
  124. package/dist/insights/bounded/scan.mjs +138 -0
  125. package/dist/insights/domain.d.cts +121 -0
  126. package/dist/insights/domain.d.mts +121 -0
  127. package/dist/insights/errors.cjs +22 -0
  128. package/dist/insights/errors.mjs +20 -0
  129. package/dist/insights/eventInput.cjs +111 -0
  130. package/dist/insights/eventInput.mjs +111 -0
  131. package/dist/insights/persistence.d.cts +6 -0
  132. package/dist/insights/persistence.d.mts +6 -0
  133. package/dist/insights/queryInput.cjs +58 -0
  134. package/dist/insights/queryInput.mjs +55 -0
  135. package/dist/insights/routes.cjs +63 -0
  136. package/dist/insights/routes.mjs +61 -0
  137. package/dist/insights/types.d.cts +17 -0
  138. package/dist/insights/types.d.mts +17 -0
  139. package/dist/node.cjs +19 -12
  140. package/dist/node.d.cts +15 -10
  141. package/dist/node.d.mts +15 -10
  142. package/dist/node.mjs +19 -13
  143. package/dist/package.cjs +1 -1
  144. package/dist/package.mjs +1 -1
  145. package/dist/schema/dsl.cjs +19 -2
  146. package/dist/schema/dsl.mjs +17 -3
  147. package/dist/schema/index.cjs +1 -8
  148. package/dist/schema/index.mjs +2 -8
  149. package/dist/schema/types.cjs +3 -1
  150. package/dist/schema/types.mjs +3 -2
  151. package/dist/schema/v1_0_0.cjs +364 -0
  152. package/dist/schema/v1_0_0.mjs +357 -0
  153. package/dist/storageAccess.cjs +69 -25
  154. package/dist/storageAccess.mjs +68 -25
  155. package/dist/types/index.d.cts +4 -3
  156. package/dist/types/index.d.mts +5 -4
  157. package/package.json +8 -7
  158. package/src/adapters/databaseConstraintErrors.ts +25 -0
  159. package/src/adapters/databasePluginTestDatabase.ts +106 -0
  160. package/src/adapters/databasePluginUtils.spec.ts +29 -0
  161. package/src/adapters/databasePluginUtils.ts +214 -0
  162. package/src/adapters/drizzle.spec.ts +208 -47
  163. package/src/adapters/drizzle.ts +106 -293
  164. package/src/adapters/drizzleCrud.ts +419 -0
  165. package/src/adapters/drizzleLazyDB.ts +206 -100
  166. package/src/adapters/drizzleQuery.ts +164 -0
  167. package/src/adapters/kysely.spec.ts +126 -88
  168. package/src/adapters/kysely.ts +76 -334
  169. package/src/adapters/kyselyCrud.ts +588 -0
  170. package/src/adapters/kyselyQuery.ts +128 -0
  171. package/src/adapters/mongodb.spec.ts +141 -0
  172. package/src/adapters/mongodb.ts +60 -288
  173. package/src/adapters/mongodbCollections.ts +62 -0
  174. package/src/adapters/mongodbQuery.ts +226 -0
  175. package/src/adapters/mongodbReads.ts +311 -0
  176. package/src/adapters/mongodbRows.spec.ts +64 -0
  177. package/src/adapters/mongodbRows.ts +118 -0
  178. package/src/adapters/mongodbTestClient.ts +342 -0
  179. package/src/adapters/mongodbTestFilter.ts +166 -0
  180. package/src/adapters/mongodbWrites.ts +260 -0
  181. package/src/adapters/prisma.spec.ts +196 -0
  182. package/src/adapters/prisma.ts +417 -292
  183. package/src/adapters/prismaQuery.ts +129 -0
  184. package/src/adapters/prismaRows.spec.ts +98 -0
  185. package/src/adapters/prismaRows.ts +301 -0
  186. package/src/adapters/prismaTestClient.ts +396 -0
  187. package/src/apiKeys.integration.spec.ts +192 -0
  188. package/src/apiKeys.spec.ts +162 -0
  189. package/src/apiKeys.ts +220 -0
  190. package/src/createHotUpdaterCore.ts +164 -100
  191. package/src/db/bundleRows.spec.ts +164 -0
  192. package/src/db/bundleRows.ts +10 -140
  193. package/src/db/createBundleDiff.spec.ts +172 -127
  194. package/src/db/createBundleDiff.ts +91 -110
  195. package/src/db/databasePluginCore.spec.ts +62 -0
  196. package/src/db/databasePluginCore.testFixtures.ts +76 -0
  197. package/src/db/databasePluginCore.ts +185 -0
  198. package/src/db/databasePluginUpdateCheck.bench.ts +107 -0
  199. package/src/db/fixedMigrator.spec.ts +184 -42
  200. package/src/db/fixedMigrator.ts +64 -205
  201. package/src/db/fixedMigratorMongo.spec.ts +192 -0
  202. package/src/db/fixedMigratorMongo.ts +425 -0
  203. package/src/db/fixedMigratorShared.ts +36 -0
  204. package/src/db/hotUpdaterSchema.ts +0 -1
  205. package/src/db/index.spec.ts +333 -1025
  206. package/src/db/mongoMigrationExecution.ts +23 -0
  207. package/src/db/node-sqlite.d.ts +25 -0
  208. package/src/db/releaseCatalog.ts +148 -0
  209. package/src/db/schema/mongodb.ts +20 -6
  210. package/src/db/schema/sql.ts +52 -11
  211. package/src/db/schema/sqlOperations.ts +5 -4
  212. package/src/db/schemaGenerators.ts +107 -23
  213. package/src/db/schemaReadiness.ts +2 -1
  214. package/src/db/sqlMigrationExecution.spec.ts +65 -0
  215. package/src/db/sqlMigrationExecution.ts +108 -0
  216. package/src/db/types.spec.ts +55 -0
  217. package/src/db/types.ts +129 -65
  218. package/src/db/updateArtifacts.spec.ts +359 -0
  219. package/src/db/updateArtifacts.ts +290 -126
  220. package/src/handler-standalone.integration.spec.ts +401 -364
  221. package/src/handler.spec.ts +137 -582
  222. package/src/handler.testFixtures.ts +30 -0
  223. package/src/handler.ts +218 -616
  224. package/src/handlerAuth.integration.spec.ts +33 -0
  225. package/src/handlerBundleRoutes.ts +259 -0
  226. package/src/handlerErrors.ts +7 -0
  227. package/src/handlerManagement.spec.ts +262 -0
  228. package/src/handlerParameters.ts +105 -0
  229. package/src/handlerReleaseCatalogRoutes.ts +154 -0
  230. package/src/handlerReleaseManagementRoutes.ts +213 -0
  231. package/src/handlerTypes.ts +96 -0
  232. package/src/handlerVersionRoutes.ts +12 -0
  233. package/src/index.ts +22 -2
  234. package/src/insights/bounded/activeOverview.spec.ts +189 -0
  235. package/src/insights/bounded/activeOverview.ts +148 -0
  236. package/src/insights/bounded/installationSearch.spec.ts +130 -0
  237. package/src/insights/bounded/installationSearch.ts +69 -0
  238. package/src/insights/bounded/persistence.ts +43 -0
  239. package/src/insights/bounded/provider.spec.ts +219 -0
  240. package/src/insights/bounded/provider.ts +229 -0
  241. package/src/insights/bounded/scan.ts +235 -0
  242. package/src/insights/domain.ts +139 -0
  243. package/src/insights/errors.ts +18 -0
  244. package/src/insights/eventInput.ts +168 -0
  245. package/src/insights/insights.integration.spec.ts +144 -0
  246. package/src/insights/persistence.ts +10 -0
  247. package/src/insights/queryInput.ts +102 -0
  248. package/src/insights/routes.ts +123 -0
  249. package/src/insights/types.ts +44 -0
  250. package/src/node.spec.ts +79 -10
  251. package/src/node.ts +20 -17
  252. package/src/releaseCatalogRoutes.integration.spec.ts +238 -0
  253. package/src/runtime.spec.ts +126 -959
  254. package/src/runtime.testFixtures.ts +71 -0
  255. package/src/schema/dsl-all-versions.spec.ts +26 -17
  256. package/src/schema/dsl.ts +41 -2
  257. package/src/schema/index.ts +3 -9
  258. package/src/schema/types.ts +9 -3
  259. package/src/schema/v1_0_0.spec.ts +146 -0
  260. package/src/schema/v1_0_0.ts +465 -0
  261. package/src/storageAccess.spec.ts +127 -32
  262. package/src/storageAccess.ts +106 -55
  263. package/src/types/index.ts +2 -1
  264. package/dist/db/bundleRows.cjs +0 -82
  265. package/dist/db/bundleRows.d.cts +0 -33
  266. package/dist/db/bundleRows.d.mts +0 -33
  267. package/dist/db/bundleRows.mjs +0 -79
  268. package/dist/db/pluginCore.cjs +0 -242
  269. package/dist/db/pluginCore.mjs +0 -241
  270. package/dist/db/schema/sqlMigrations.cjs +0 -74
  271. package/dist/db/schema/sqlMigrations.mjs +0 -73
  272. package/dist/db/schemaEnhancements.cjs +0 -20
  273. package/dist/db/schemaEnhancements.mjs +0 -19
  274. package/dist/route.cjs +0 -7
  275. package/dist/route.mjs +0 -7
  276. package/dist/schema/v0_21_0.cjs +0 -38
  277. package/dist/schema/v0_21_0.mjs +0 -37
  278. package/dist/schema/v0_29_0.cjs +0 -45
  279. package/dist/schema/v0_29_0.mjs +0 -44
  280. package/dist/schema/v0_31_0.cjs +0 -82
  281. package/dist/schema/v0_31_0.mjs +0 -80
  282. package/src/db/pluginCore.spec.ts +0 -1095
  283. package/src/db/pluginCore.ts +0 -475
  284. package/src/db/pluginUpdateCheck.bench.ts +0 -254
  285. package/src/db/schema/sqlMigrations.ts +0 -219
  286. package/src/db/schemaEnhancements.ts +0 -55
  287. package/src/route.ts +0 -7
  288. package/src/schema/v0_21_0.ts +0 -52
  289. package/src/schema/v0_29_0.ts +0 -62
  290. package/src/schema/v0_31_0.spec.ts +0 -73
  291. package/src/schema/v0_31_0.ts +0 -120
@@ -1,18 +1,11 @@
1
1
  import { PGlite } from "@electric-sql/pglite";
2
- import type { Bundle, GetBundlesArgs, UpdateInfo } from "@hot-updater/core";
2
+ import type { Bundle } from "@hot-updater/core";
3
3
  import { NIL_UUID } from "@hot-updater/core";
4
- import type {
5
- RuntimeStoragePlugin,
6
- StorageResolveContext,
7
- } from "@hot-updater/plugin-core";
8
- import { createDatabasePlugin } from "@hot-updater/plugin-core";
9
- import {
10
- setupBundleMethodsTestSuite,
11
- setupGetUpdateInfoTestSuite,
12
- } from "@hot-updater/test-utils";
4
+ import { createStoragePlugin } from "@hot-updater/plugin-core";
5
+ import { sql } from "drizzle-orm";
13
6
  import { Kysely } from "kysely";
14
7
  import { PGliteDialect } from "kysely-pglite-dialect";
15
- import type { MongoClient } from "mongodb";
8
+ import { type ClientSession, MongoClient } from "mongodb";
16
9
  import {
17
10
  afterAll,
18
11
  afterEach,
@@ -24,20 +17,28 @@ import {
24
17
  vi,
25
18
  } from "vitest";
26
19
 
20
+ import { createInMemoryDatabasePlugin } from "../../../test-utils/test/inMemoryDatabasePlugin";
27
21
  import { drizzleAdapter } from "../adapters/drizzle";
28
22
  import { kyselyAdapter } from "../adapters/kysely";
29
23
  import { mongoAdapter } from "../adapters/mongodb";
30
24
  import { prismaAdapter } from "../adapters/prisma";
31
- import { createHotUpdater } from "../index";
32
- import { bundleToRow } from "./bundleRows";
33
25
  import {
34
- createSchemaMigrationSql,
35
- createTableSql,
36
- hotUpdaterSchemaVersions,
37
- } from "./hotUpdaterSchema";
26
+ createHotUpdater as createRuntimeHotUpdater,
27
+ type CreateHotUpdaterOptions,
28
+ } from "../index";
29
+ import { bundleToRow } from "./bundleRows";
30
+ import { createTableSql, hotUpdaterSchemaVersions } from "./hotUpdaterSchema";
38
31
  import { createMigrator, generateSchema } from "./index";
39
32
  import { generateDrizzleSchema } from "./schemaGenerators";
40
- import type { DatabasePluginFactory, ORMProvider } from "./types";
33
+ import type { DatabasePlugin, ORMProvider } from "./types";
34
+
35
+ const createHotUpdater = (
36
+ options: Omit<CreateHotUpdaterOptions, "clientAccess">,
37
+ ) =>
38
+ createRuntimeHotUpdater({
39
+ ...options,
40
+ clientAccess: { type: "public" },
41
+ });
41
42
 
42
43
  const RAW_PRISMA_SCHEMA = `model bundles {
43
44
  id String @id
@@ -71,7 +72,7 @@ model bundle_patches {
71
72
  }
72
73
  model private_hot_updater_settings {
73
74
  key String @id
74
- value String @default("0.31.0")
75
+ value String @default("0.36.0")
75
76
  }`;
76
77
 
77
78
  const RAW_DRIZZLE_SCHEMA = `import { relations } from "drizzle-orm";
@@ -135,6 +136,11 @@ export const bundle_patches = pgTable(
135
136
  .onDelete("cascade"),
136
137
  ])
137
138
 
139
+ export const private_hot_updater_settings = pgTable("private_hot_updater_settings", {
140
+ key: varchar("key", { length: 255 }).primaryKey().notNull(),
141
+ version: varchar("version", { length: 255 }).notNull().default("0.36.0"),
142
+ })
143
+
138
144
  export const bundle_patchesRelations = relations(bundle_patches, ({ one, many }) => ({
139
145
  bundle: one(bundles, {
140
146
  relationName: "bundle_patches_bundles_patches",
@@ -150,24 +156,30 @@ export const bundle_patchesRelations = relations(bundle_patches, ({ one, many })
150
156
 
151
157
  function createTestStoragePlugin(
152
158
  protocol: string,
153
- resolveFileUrl: (
154
- storageUri: string,
155
- context?: StorageResolveContext,
156
- ) => string,
157
159
  readText: (storageUri: string) => Promise<string | null> = async () => null,
158
- ): RuntimeStoragePlugin {
159
- return {
160
+ ) {
161
+ return createStoragePlugin({
160
162
  name: `${protocol}TestStorage`,
161
- supportedProtocol: protocol,
162
- profiles: {
163
- runtime: {
164
- readText,
165
- async getDownloadUrl(storageUri, context) {
166
- return { fileUrl: resolveFileUrl(storageUri, context) };
167
- },
168
- },
163
+ protocol,
164
+ async get({ storageUri }) {
165
+ const text = await readText(storageUri);
166
+ return { response: text === null ? null : new Response(text) };
169
167
  },
170
- };
168
+ async getDownloadUrl({ storageUri }) {
169
+ const prefixes: Record<string, string> = {
170
+ gs: "https://firebase.example.com/",
171
+ r2: "https://r2.example.com/",
172
+ s3: "https://s3.example.com/",
173
+ "supabase-storage":
174
+ "https://supabase.example.com/storage/v1/object/sign/",
175
+ };
176
+ return {
177
+ url: storageUri
178
+ .replace(`${protocol}://`, prefixes[protocol] ?? "")
179
+ .replace(/([^:]\/)\/+/g, "$1"),
180
+ };
181
+ },
182
+ });
171
183
  }
172
184
 
173
185
  function createSchemaOnlyAdapter({
@@ -180,75 +192,29 @@ function createSchemaOnlyAdapter({
180
192
  name: string;
181
193
  provider: ORMProvider;
182
194
  path: string;
183
- }): DatabasePluginFactory {
184
- const factory: DatabasePluginFactory = () => ({
185
- name,
186
- async getBundleById() {
187
- return null;
188
- },
189
- async getBundles() {
190
- return {
191
- data: [],
192
- pagination: {
193
- currentPage: 1,
194
- hasNextPage: false,
195
- hasPreviousPage: false,
196
- total: 0,
197
- totalPages: 0,
198
- },
199
- };
200
- },
201
- async getChannels() {
202
- return [];
203
- },
204
- async appendBundle() {},
205
- async updateBundle() {},
206
- async deleteBundle() {},
207
- async commitBundle() {},
208
- });
209
- factory.adapterName = name;
210
- factory.provider = provider;
211
- factory.generateSchema = (_version, schemaName = name) => {
212
- return {
195
+ }): DatabasePlugin {
196
+ return {
197
+ ...createInMemoryDatabasePlugin(),
198
+ adapterName: name,
199
+ provider,
200
+ generateSchema: (_version, schemaName = name) => ({
213
201
  code,
214
202
  path: path || schemaName,
215
- };
203
+ }),
216
204
  };
217
- return factory;
218
205
  }
219
206
 
220
207
  const transactionBundle: Bundle = {
208
+ archiveByteSize: 1_024,
221
209
  id: "00000000-0000-0000-0000-000000000777",
222
210
  platform: "ios",
223
- shouldForceUpdate: false,
224
- enabled: true,
225
211
  fileHash: "transaction-hash",
226
212
  gitCommitHash: null,
227
- message: "transaction bundle",
228
- channel: "production",
229
213
  storageUri: "s3://test-bucket/transaction.zip",
230
- targetAppVersion: "1.0.0",
231
- fingerprintHash: null,
232
- };
233
-
234
- const appVersionFastPathBundle: Bundle = {
235
- ...transactionBundle,
236
- id: "00000000-0000-0000-0000-000000000778",
237
- fileHash: "app-version-fast-path-hash",
238
- message: "app version fast path bundle",
239
- targetAppVersion: "1.0.0",
240
- };
241
-
242
- const fingerprintFastPathBundle: Bundle = {
243
- ...transactionBundle,
244
- id: "00000000-0000-0000-0000-000000000779",
245
- fileHash: "fingerprint-fast-path-hash",
246
- fingerprintHash: "fingerprint-fast-path",
247
- message: "fingerprint fast path bundle",
248
- targetAppVersion: null,
249
214
  };
215
+ const transactionChannelId = "00000000-0000-0000-0000-000000000700";
250
216
 
251
- describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
217
+ describe("server/db hotUpdater (PGlite + Kysely)", async () => {
252
218
  const db = new PGlite();
253
219
 
254
220
  const kysely = new Kysely<object>({ dialect: new PGliteDialect(db) });
@@ -266,42 +232,11 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
266
232
  db: kysely,
267
233
  provider: "postgresql",
268
234
  }),
269
- storages: [
270
- createTestStoragePlugin(
271
- "s3",
272
- (storageUri) =>
273
- storageUri
274
- .replace("s3://", "https://s3.example.com/")
275
- .replace(/([^:]\/)\/+/g, "$1"),
276
- readStoredText,
277
- ),
278
- createTestStoragePlugin(
279
- "r2",
280
- (storageUri) =>
281
- storageUri
282
- .replace("r2://", "https://r2.example.com/")
283
- .replace(/([^:]\/)\/+/g, "$1"),
284
- readStoredText,
285
- ),
286
- createTestStoragePlugin(
287
- "supabase-storage",
288
- (storageUri) =>
289
- storageUri
290
- .replace(
291
- "supabase-storage://",
292
- "https://supabase.example.com/storage/v1/object/sign/",
293
- )
294
- .replace(/([^:]\/)\/+/g, "$1"),
295
- readStoredText,
296
- ),
297
- createTestStoragePlugin(
298
- "gs",
299
- (storageUri) =>
300
- storageUri
301
- .replace("gs://", "https://firebase.example.com/")
302
- .replace(/([^:]\/)\/+/g, "$1"),
303
- readStoredText,
304
- ),
235
+ storage: [
236
+ createTestStoragePlugin("s3", readStoredText),
237
+ createTestStoragePlugin("r2", readStoredText),
238
+ createTestStoragePlugin("supabase-storage", readStoredText),
239
+ createTestStoragePlugin("gs", readStoredText),
305
240
  ],
306
241
  });
307
242
  const prismaSchemaHotUpdater = createHotUpdater({
@@ -331,7 +266,22 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
331
266
 
332
267
  it("uses the default generated schema artifact path for Drizzle", () => {
333
268
  const adapter = drizzleAdapter({
334
- db: { _: { fullSchema: {} } },
269
+ db: {
270
+ _: {
271
+ fullSchema: {
272
+ bundle_patches: {},
273
+ bundles: {},
274
+ },
275
+ },
276
+ $count: vi.fn(),
277
+ delete: vi.fn(),
278
+ insert: vi.fn(),
279
+ query: {
280
+ bundle_patches: { findFirst: vi.fn(), findMany: vi.fn() },
281
+ bundles: { findFirst: vi.fn(), findMany: vi.fn() },
282
+ },
283
+ update: vi.fn(),
284
+ },
335
285
  provider: "sqlite",
336
286
  });
337
287
 
@@ -351,8 +301,11 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
351
301
 
352
302
  beforeEach(async () => {
353
303
  storageTexts.clear();
304
+ await db.exec("DELETE FROM release_catalogs");
305
+ await db.exec("DELETE FROM releases");
354
306
  await db.exec("DELETE FROM bundle_patches");
355
307
  await db.exec("DELETE FROM bundles");
308
+ await db.exec("DELETE FROM channels");
356
309
  });
357
310
 
358
311
  afterAll(async () => {
@@ -360,34 +313,20 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
360
313
  await db.close();
361
314
  });
362
315
 
363
- const getUpdateInfo = async (
364
- bundles: Bundle[],
365
- options: GetBundlesArgs,
366
- ): Promise<UpdateInfo | null> => {
367
- // Insert fixtures via the server API to exercise its types + mapping
368
- for (const b of bundles) {
369
- await hotUpdater.insertBundle(b);
370
- }
371
- return hotUpdater.getUpdateInfo(options);
372
- };
373
-
374
- setupGetUpdateInfoTestSuite({ getUpdateInfo });
375
- setupBundleMethodsTestSuite({
376
- getBundleById: hotUpdater.getBundleById.bind(hotUpdater),
377
- getChannels: hotUpdater.getChannels.bind(hotUpdater),
378
- insertBundle: hotUpdater.insertBundle.bind(hotUpdater),
379
- getBundles: hotUpdater.getBundles.bind(hotUpdater),
380
- updateBundleById: hotUpdater.updateBundleById.bind(hotUpdater),
381
- deleteBundleById: hotUpdater.deleteBundleById.bind(hotUpdater),
382
- });
383
-
384
316
  describe("schema generation", () => {
385
317
  it("includes relations, defaults, and indexes in Prisma output", () => {
386
318
  const code = generateSchema(prismaSchemaHotUpdater, "latest").code;
387
319
 
388
- expect(code).toContain('channel String @default("production")');
389
320
  expect(code).toContain('metadata Json @default("{}")');
390
- expect(code).toContain('value String @default("0.31.0")');
321
+ expect(code).toContain('value String @default("1.0.0")');
322
+ expect(code).toContain("model channels {");
323
+ expect(code).toContain("id String @db.VarChar(255) @id");
324
+ expect(code).toContain("name String @db.VarChar(255)");
325
+ expect(code).toContain('@@unique([name], map: "channels_name_key")');
326
+ expect(code).toContain("channel_id String @db.VarChar(255)");
327
+ expect(code).toContain(
328
+ 'channelRecord channels @relation("releases_channels", fields: [channel_id], references: [id], onUpdate: Restrict, onDelete: Restrict)',
329
+ );
391
330
  expect(code).toContain(
392
331
  'patches bundle_patches[] @relation("bundle_patches_bundles_patches")',
393
332
  );
@@ -400,7 +339,13 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
400
339
  expect(code).toContain(
401
340
  'baseBundle bundles @relation("bundle_patches_bundles_baseForPatches"',
402
341
  );
403
- expect(code).toContain('@@index([channel], map: "bundles_channel_idx")');
342
+ expect(code).not.toContain(
343
+ '@@index([channel], map: "bundles_channel_idx")',
344
+ );
345
+ expect(code).toContain(
346
+ '@@index([scope_key, id], map: "releases_scope_order_idx")',
347
+ );
348
+ expect(code).toContain("scope_key String @db.VarChar(2048) @id");
404
349
  expect(code).not.toContain("bundles_platform_idx");
405
350
  expect(code).toContain(
406
351
  '@@index([bundle_id], map: "bundle_patches_bundle_id_idx")',
@@ -414,12 +359,10 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
414
359
  expect(code).not.toContain('metadata Json @default("{}")');
415
360
  });
416
361
 
417
- it("generates ORM schema from the requested version snapshot", () => {
418
- const code = generateSchema(prismaSchemaHotUpdater, "0.21.0").code;
419
-
420
- expect(code).toContain('value String @default("0.21.0")');
421
- expect(code).not.toContain("rollout_cohort_count");
422
- expect(code).not.toContain("bundle_patches");
362
+ it("rejects generating a retired schema snapshot", () => {
363
+ expect(() => generateSchema(prismaSchemaHotUpdater, "0.21.0")).toThrow(
364
+ "Unsupported Hot Updater schema version: 0.21.0",
365
+ );
423
366
  });
424
367
 
425
368
  it("includes foreign keys and indexes in Drizzle output", () => {
@@ -431,36 +374,40 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
431
374
  /export const bundle_patches = [\s\S]*?(?=\n\nexport const bundle_patchesRelations = )/,
432
375
  )?.[0];
433
376
 
434
- expect(code).toContain(
435
- 'channel: text("channel").notNull().default("production")',
436
- );
437
377
  expect(code).toContain(
438
378
  'metadata: json("metadata").notNull().default({})',
439
379
  );
440
380
  expect(code).toContain('name: "bundle_patches_bundle_id_fk"');
441
381
  expect(code).toContain('name: "bundle_patches_base_bundle_id_fk"');
442
- expect(bundlesBlock).toContain(
443
- 'index("bundles_channel_idx").on(table.channel)',
444
- );
382
+ expect(code).toContain('.onDelete("cascade")');
383
+ expect(bundlesBlock).not.toContain("bundles_channel_idx");
445
384
  expect(bundlesBlock).not.toContain("bundles_platform_idx");
446
- expect(bundlesBlock).toContain(
447
- 'index("bundles_target_app_version_idx").on(table.target_app_version)',
448
- );
385
+ expect(bundlesBlock).not.toContain("target_app_version");
449
386
  expect(bundlesBlock).not.toContain(
450
387
  'index("bundle_patches_bundle_id_idx").on(table.bundle_id)',
451
388
  );
452
389
  expect(bundlePatchesBlock).toContain(
453
390
  'index("bundle_patches_bundle_id_idx").on(table.bundle_id)',
454
391
  );
455
- expect(bundlePatchesBlock).not.toContain(
456
- 'index("bundles_target_app_version_idx").on(table.target_app_version)',
392
+ expect(code).toContain(
393
+ 'index("releases_scope_order_idx").on(table.scope_key, table.id)',
394
+ );
395
+ expect(code).toContain(
396
+ 'scope_key: varchar("scope_key", { length: 2048 }).primaryKey().notNull()',
457
397
  );
458
398
  const generatedCode = generateDrizzleSchema("postgresql");
459
399
  expect(generatedCode).toContain(
460
400
  'id: varchar("id", { length: 255 }).primaryKey().notNull()',
461
401
  );
462
402
  expect(generatedCode).toContain(
463
- 'version: varchar("version", { length: 255 }).notNull().default("0.31.0")',
403
+ 'version: varchar("version", { length: 255 }).notNull().default("1.0.0")',
404
+ );
405
+ expect(generatedCode).toContain(
406
+ 'uniqueIndex("channels_name_key").on(table.name)',
407
+ );
408
+ expect(generatedCode).toContain('name: "releases_channel_id_fk"');
409
+ expect(generatedCode).toContain(
410
+ 'index("releases_channel_platform_order_idx").on(table.channel_id, table.platform, table.id)',
464
411
  );
465
412
  expect(generatedCode).not.toContain('key: varchar("key"');
466
413
  expect(generatedCode).not.toContain('value: text("value"');
@@ -468,46 +415,24 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
468
415
  });
469
416
 
470
417
  describe("migrator enhancements", () => {
471
- it("derives incremental migrations from versioned schemas", () => {
418
+ it("registers only schema 1.0.0", () => {
472
419
  expect(hotUpdaterSchemaVersions.map((schema) => schema.version)).toEqual([
473
- "0.21.0",
474
- "0.29.0",
475
- "0.31.0",
420
+ "1.0.0",
476
421
  ]);
477
-
478
- const v029Sql = createSchemaMigrationSql(
479
- "0.21.0",
480
- "0.29.0",
481
- "postgresql",
482
- ).join("\n");
483
- const v031Sql = createSchemaMigrationSql(
484
- "0.29.0",
485
- "0.31.0",
486
- "postgresql",
487
- ).join("\n");
488
-
489
- expect(v029Sql).toContain(
490
- "alter table bundles add column rollout_cohort_count",
491
- );
492
- expect(v029Sql).not.toContain("bundle_patches");
493
- expect(v031Sql).toContain(
494
- "alter table bundles add column manifest_storage_uri",
495
- );
496
- expect(v031Sql).toContain("create table if not exists bundle_patches");
497
- expect(v031Sql).toContain(
498
- "add constraint bundle_patches_bundle_id_fk foreign key",
499
- );
500
422
  });
501
423
 
502
424
  it("omits MySQL defaults for text and JSON columns", () => {
503
425
  const sql = createTableSql("mysql").join("\n");
504
426
 
505
- expect(sql).toContain("channel text not null");
506
- expect(sql).not.toContain("channel text not null default");
427
+ expect(sql).toContain("create table channels");
507
428
  expect(sql).toContain("metadata json not null");
508
429
  expect(sql).not.toContain("metadata json not null default");
509
430
  expect(sql).toContain("`key` varchar(255) primary key");
510
431
  expect(sql).not.toContain("\nkey varchar(255) primary key");
432
+ expect(sql).toContain(
433
+ "create table api_keys (\nid varchar(255) primary key not null",
434
+ );
435
+ expect(sql).not.toContain("create table api_keys (\nid text primary key");
511
436
  expect(sql).toContain(
512
437
  "create index bundle_patches_bundle_id_idx on bundle_patches(bundle_id)",
513
438
  );
@@ -534,9 +459,11 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
534
459
  });
535
460
  const sql = result.getSQL?.() ?? "";
536
461
 
537
- expect(sql).toContain("create index bundles_channel_idx on bundles");
538
462
  expect(sql).toContain(
539
- "add constraint check_version_or_fingerprint check",
463
+ "create index releases_scope_order_idx on releases",
464
+ );
465
+ expect(sql).toContain(
466
+ "add constraint releases_strategy_target_check check",
540
467
  );
541
468
  expect(sql).toContain(
542
469
  "add constraint bundle_patches_bundle_id_fk foreign key",
@@ -558,7 +485,7 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
558
485
  }
559
486
  });
560
487
 
561
- it("migrates existing 0.21.0 Kysely schemas incrementally", async () => {
488
+ it("rejects an existing v0 Kysely schema instead of upgrading it", async () => {
562
489
  const migrationDb = new PGlite();
563
490
  const migrationKysely = new Kysely<object>({
564
491
  dialect: new PGliteDialect(migrationDb),
@@ -572,20 +499,6 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
572
499
 
573
500
  try {
574
501
  await migrationDb.exec(`
575
- create table bundles (
576
- id uuid primary key,
577
- platform text not null,
578
- should_force_update boolean not null,
579
- enabled boolean not null,
580
- file_hash text not null,
581
- git_commit_hash text,
582
- message text,
583
- channel text not null default 'production',
584
- storage_uri text not null,
585
- target_app_version text,
586
- fingerprint_hash text,
587
- metadata json not null default '{}'::json
588
- );
589
502
  create table private_hot_updater_settings (
590
503
  key varchar(255) primary key,
591
504
  value text not null
@@ -595,31 +508,12 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
595
508
  `);
596
509
 
597
510
  const migrator = createMigrator(migrationHotUpdater);
598
- const result = await migrator.migrateToLatest({
599
- mode: "from-schema",
600
- updateSettings: true,
601
- });
602
- const sql = result.getSQL?.() ?? "";
603
-
604
- expect(sql).toContain(
605
- "alter table bundles add column rollout_cohort_count",
606
- );
607
- expect(sql).toContain(
608
- "alter table bundles add column manifest_storage_uri",
609
- );
610
- expect(sql).toContain("create table if not exists bundle_patches");
611
- expect(sql).not.toContain("create table if not exists bundles");
612
-
613
- await result.execute();
614
-
615
- const version = await migrationDb.query<{ value: string }>(
616
- "select value from private_hot_updater_settings where key = 'version'",
617
- );
618
- expect(version.rows[0]?.value).toBe("0.31.0");
619
- await migrationDb.query(
620
- "select rollout_cohort_count, target_cohorts, manifest_storage_uri from bundles limit 0",
621
- );
622
- await migrationDb.query("select * from bundle_patches limit 0");
511
+ await expect(
512
+ migrator.migrateToLatest({
513
+ mode: "from-schema",
514
+ updateSettings: true,
515
+ }),
516
+ ).rejects.toThrow("Hot Updater v1 cannot migrate schema 0.21.0");
623
517
  } finally {
624
518
  await migrationKysely.destroy();
625
519
  await migrationDb.close();
@@ -694,7 +588,10 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
694
588
 
695
589
  it("creates MongoDB indexes for runtime query fields", async () => {
696
590
  const collection = {
697
- findOne: vi.fn(async () => null),
591
+ find: vi.fn(() => ({
592
+ limit: () => ({ toArray: async () => [] }),
593
+ })),
594
+ listIndexes: () => ({ toArray: async () => [] }),
698
595
  };
699
596
  const client = {
700
597
  db: () => ({
@@ -711,19 +608,28 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
711
608
  expect(result.operations).toEqual(
712
609
  expect.arrayContaining([
713
610
  expect.objectContaining({
714
- sql: "create index bundles_id_idx on bundles(id)",
611
+ description:
612
+ "Create unique MongoDB index: bundles_id_idx on bundles(id)",
715
613
  }),
716
614
  expect.objectContaining({
717
- sql: "create index bundles_target_app_version_idx on bundles(target_app_version)",
615
+ description:
616
+ "Create unique MongoDB index: bundle_patches_id_idx on bundle_patches(id)",
718
617
  }),
719
618
  expect.objectContaining({
720
- sql: "create index bundles_fingerprint_hash_idx on bundles(fingerprint_hash)",
619
+ description:
620
+ "Create unique MongoDB index: release_catalogs_scope_key_idx on release_catalogs(scope_key)",
721
621
  }),
722
622
  expect.objectContaining({
723
- sql: "create index bundles_platform_idx on bundles(platform)",
623
+ description:
624
+ "Create MongoDB index: releases_fingerprint_hash_idx on releases(fingerprint_hash)",
724
625
  }),
725
626
  expect.objectContaining({
726
- sql: "create index bundle_patches_base_bundle_id_idx on bundle_patches(base_bundle_id)",
627
+ description:
628
+ "Create MongoDB index: bundles_platform_idx on bundles(platform)",
629
+ }),
630
+ expect.objectContaining({
631
+ description:
632
+ "Create MongoDB index: bundle_patches_base_bundle_id_idx on bundle_patches(base_bundle_id)",
727
633
  }),
728
634
  ]),
729
635
  );
@@ -802,7 +708,7 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
802
708
  `);
803
709
 
804
710
  await expect(migrationHotUpdater.getChannels()).rejects.toThrow(
805
- "Hot Updater database schema version 0.21.0 is not supported by kysely.",
711
+ "Hot Updater v1 cannot migrate schema 0.21.0 in place.",
806
712
  );
807
713
  } finally {
808
714
  await migrationKysely.destroy();
@@ -812,7 +718,12 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
812
718
 
813
719
  it("rejects runtime access when a MongoDB schema is stale", async () => {
814
720
  const settings = {
815
- findOne: vi.fn(async () => ({ key: "version", value: "0.21.0" })),
721
+ find: vi.fn(({ key }: { readonly key: string }) => ({
722
+ limit: () => ({
723
+ toArray: async () =>
724
+ key === "version" ? [{ key, value: "0.21.0" }] : [],
725
+ }),
726
+ })),
816
727
  };
817
728
  const bundles = {
818
729
  countDocuments: vi.fn(async () => 0),
@@ -836,7 +747,7 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
836
747
  });
837
748
 
838
749
  await expect(mongoHotUpdater.getBundles({ limit: 10 })).rejects.toThrow(
839
- "Hot Updater database schema version 0.21.0 is not supported by mongodb.",
750
+ "Hot Updater v1 cannot migrate schema 0.21.0 in place.",
840
751
  );
841
752
  expect(bundles.countDocuments).not.toHaveBeenCalled();
842
753
  });
@@ -848,426 +759,84 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
848
759
  limit: 10,
849
760
  where: { id: { in: [] } },
850
761
  });
851
- const byTargetAppVersion = await hotUpdater.getBundles({
852
- limit: 10,
853
- where: { targetAppVersionIn: [] },
854
- });
855
-
856
762
  expect(byId.data).toEqual([]);
857
763
  expect(byId.pagination.total).toBe(0);
858
- expect(byTargetAppVersion.data).toEqual([]);
859
- expect(byTargetAppVersion.pagination.total).toBe(0);
860
- });
861
-
862
- it("combines Prisma targetAppVersion filters without overwriting", async () => {
863
- const bundles = {
864
- count: vi.fn(async () => 0),
865
- createMany: vi.fn(),
866
- deleteMany: vi.fn(),
867
- findFirst: vi.fn(),
868
- findMany: vi.fn(async () => []),
869
- upsert: vi.fn(),
870
- };
871
- const patches = {
872
- count: vi.fn(),
873
- createMany: vi.fn(),
874
- deleteMany: vi.fn(),
875
- findFirst: vi.fn(),
876
- findMany: vi.fn(async () => []),
877
- upsert: vi.fn(),
878
- };
879
- const plugin = prismaAdapter({
880
- prisma: { bundles, bundle_patches: patches },
881
- provider: "postgresql",
882
- })();
883
-
884
- await plugin.getBundles({
885
- limit: 10,
886
- where: {
887
- targetAppVersion: "1.0.x",
888
- targetAppVersionNotNull: true,
889
- },
890
- });
891
-
892
- expect(bundles.findMany).toHaveBeenCalledWith(
893
- expect.objectContaining({
894
- where: expect.objectContaining({
895
- AND: [
896
- { target_app_version: "1.0.x" },
897
- { target_app_version: { not: null } },
898
- ],
899
- }),
900
- }),
901
- );
902
- });
903
-
904
- it("combines MongoDB targetAppVersion filters without overwriting", async () => {
905
- const toArray = vi.fn(async () => []);
906
- const limit = vi.fn(() => ({ toArray }));
907
- const skip = vi.fn(() => ({ limit }));
908
- const sort = vi.fn(() => ({ skip }));
909
- const bundles = {
910
- countDocuments: vi.fn(async () => 0),
911
- distinct: vi.fn(),
912
- find: vi.fn(() => ({ sort })),
913
- findOne: vi.fn(),
914
- };
915
- const patches = {
916
- deleteMany: vi.fn(),
917
- find: vi.fn(() => ({
918
- sort: vi.fn(() => ({ toArray: vi.fn(async () => []) })),
919
- })),
920
- insertMany: vi.fn(),
921
- };
922
- const client = {
923
- db: () => ({
924
- collection: (name: string) =>
925
- name === "bundle_patches" ? patches : bundles,
926
- }),
927
- } as unknown as MongoClient;
928
- const plugin = mongoAdapter({ client })();
929
-
930
- await plugin.getBundles({
931
- limit: 10,
932
- where: {
933
- targetAppVersion: "1.0.x",
934
- targetAppVersionNotNull: true,
935
- },
936
- });
937
-
938
- expect(bundles.countDocuments).toHaveBeenCalledWith({
939
- $and: [
940
- { target_app_version: "1.0.x" },
941
- { target_app_version: { $exists: true, $nin: [null, ""] } },
942
- ],
943
- });
944
764
  });
945
765
 
946
- it("uses Prisma update-check queries without generic list pagination", async () => {
947
- const appVersionRow = bundleToRow(appVersionFastPathBundle);
948
- const fingerprintRow = bundleToRow(fingerprintFastPathBundle);
949
- const bundles = {
950
- count: vi.fn(async () => {
951
- throw new Error("unexpected generic Prisma count");
952
- }),
953
- createMany: vi.fn(),
954
- deleteMany: vi.fn(),
955
- findFirst: vi.fn(),
956
- findMany: vi
957
- .fn()
958
- .mockResolvedValueOnce([
959
- { target_app_version: appVersionRow.target_app_version },
960
- ])
961
- .mockResolvedValueOnce([appVersionRow])
962
- .mockResolvedValueOnce([fingerprintRow]),
963
- upsert: vi.fn(),
964
- };
965
- const patches = {
766
+ it("commits Prisma bundle changes inside a transaction when available", async () => {
767
+ const rootBundles = {
966
768
  count: vi.fn(),
967
- createMany: vi.fn(),
769
+ create: vi.fn(),
968
770
  deleteMany: vi.fn(),
969
771
  findFirst: vi.fn(),
970
- findMany: vi.fn(async () => []),
971
- upsert: vi.fn(),
972
- };
973
- const plugin = prismaAdapter({
974
- prisma: { bundles, bundle_patches: patches },
975
- provider: "postgresql",
976
- })();
977
-
978
- await expect(
979
- plugin.getUpdateInfo?.({
980
- _updateStrategy: "appVersion",
981
- appVersion: "1.0.0",
982
- bundleId: NIL_UUID,
983
- platform: "ios",
984
- }),
985
- ).resolves.toMatchObject({
986
- id: appVersionFastPathBundle.id,
987
- status: "UPDATE",
988
- });
989
- await expect(
990
- plugin.getUpdateInfo?.({
991
- _updateStrategy: "fingerprint",
992
- bundleId: NIL_UUID,
993
- fingerprintHash: "fingerprint-fast-path",
994
- platform: "ios",
995
- }),
996
- ).resolves.toMatchObject({
997
- id: fingerprintFastPathBundle.id,
998
- status: "UPDATE",
999
- });
1000
-
1001
- expect(bundles.count).not.toHaveBeenCalled();
1002
- expect(bundles.findMany).toHaveBeenNthCalledWith(
1003
- 1,
1004
- expect.objectContaining({
1005
- select: { target_app_version: true },
1006
- where: expect.objectContaining({
1007
- channel: "production",
1008
- id: { gte: NIL_UUID },
1009
- }),
1010
- }),
1011
- );
1012
- expect(bundles.findMany).toHaveBeenNthCalledWith(
1013
- 2,
1014
- expect.objectContaining({
1015
- where: expect.objectContaining({
1016
- target_app_version: { in: ["1.0.0"] },
1017
- }),
1018
- }),
1019
- );
1020
- expect(bundles.findMany).toHaveBeenNthCalledWith(
1021
- 3,
1022
- expect.objectContaining({
1023
- where: expect.objectContaining({
1024
- fingerprint_hash: "fingerprint-fast-path",
1025
- }),
1026
- }),
1027
- );
1028
- expect(patches.findMany).toHaveBeenCalledTimes(2);
1029
- });
1030
-
1031
- it("uses Drizzle update-check queries without generic list pagination", async () => {
1032
- const appVersionRow = bundleToRow(appVersionFastPathBundle);
1033
- const fingerprintRow = bundleToRow(fingerprintFastPathBundle);
1034
- const tables = {
1035
- bundle_patches: {
1036
- bundle_id: "bundle_id",
1037
- id: "patch_id",
1038
- order_index: "order_index",
1039
- },
1040
- bundles: {
1041
- channel: "channel",
1042
- enabled: "enabled",
1043
- fingerprint_hash: "fingerprint_hash",
1044
- id: "id",
1045
- platform: "platform",
1046
- target_app_version: "target_app_version",
1047
- },
1048
- };
1049
- const bundleFindMany = vi
1050
- .fn()
1051
- .mockResolvedValueOnce([
1052
- { target_app_version: appVersionRow.target_app_version },
1053
- ])
1054
- .mockResolvedValueOnce([appVersionRow])
1055
- .mockResolvedValueOnce([fingerprintRow]);
1056
- const patchFindMany = vi.fn(async () => []);
1057
- const db = {
1058
- _: { fullSchema: tables },
1059
- $count: vi.fn(async () => {
1060
- throw new Error("unexpected generic Drizzle count");
1061
- }),
1062
- delete: vi.fn(() => ({
1063
- where: vi.fn(async () => undefined),
1064
- })),
1065
- insert: vi.fn(),
1066
- query: {
1067
- bundle_patches: {
1068
- findMany: patchFindMany,
1069
- },
1070
- bundles: {
1071
- findFirst: vi.fn(),
1072
- findMany: bundleFindMany,
1073
- },
1074
- },
1075
- select: vi.fn(),
772
+ findMany: vi.fn(),
1076
773
  update: vi.fn(),
774
+ upsert: vi.fn(),
1077
775
  };
1078
- const plugin = drizzleAdapter({
1079
- db,
1080
- provider: "postgresql",
1081
- })();
1082
-
1083
- await expect(
1084
- plugin.getUpdateInfo?.({
1085
- _updateStrategy: "appVersion",
1086
- appVersion: "1.0.0",
1087
- bundleId: NIL_UUID,
1088
- platform: "ios",
1089
- }),
1090
- ).resolves.toMatchObject({
1091
- id: appVersionFastPathBundle.id,
1092
- status: "UPDATE",
1093
- });
1094
- await expect(
1095
- plugin.getUpdateInfo?.({
1096
- _updateStrategy: "fingerprint",
1097
- bundleId: NIL_UUID,
1098
- fingerprintHash: "fingerprint-fast-path",
1099
- platform: "ios",
1100
- }),
1101
- ).resolves.toMatchObject({
1102
- id: fingerprintFastPathBundle.id,
1103
- status: "UPDATE",
1104
- });
1105
-
1106
- expect(db.$count).not.toHaveBeenCalled();
1107
- expect(bundleFindMany).toHaveBeenNthCalledWith(
1108
- 1,
1109
- expect.objectContaining({
1110
- columns: { target_app_version: true },
1111
- }),
1112
- );
1113
- const appVersionWhere = JSON.stringify(bundleFindMany.mock.calls[0]?.[0]);
1114
- const fingerprintWhere = JSON.stringify(
1115
- bundleFindMany.mock.calls[2]?.[0],
1116
- );
1117
- expect(appVersionWhere).toContain("production");
1118
- expect(appVersionWhere).toContain(NIL_UUID);
1119
- expect(fingerprintWhere).toContain("production");
1120
- expect(fingerprintWhere).toContain(NIL_UUID);
1121
- expect(fingerprintWhere).toContain("fingerprint-fast-path");
1122
- expect(bundleFindMany).toHaveBeenCalledTimes(3);
1123
- expect(patchFindMany).toHaveBeenCalledTimes(2);
1124
- });
1125
-
1126
- it("uses MongoDB update-check queries without generic list pagination", async () => {
1127
- const appVersionRow = bundleToRow(appVersionFastPathBundle);
1128
- const fingerprintRow = bundleToRow(fingerprintFastPathBundle);
1129
- const projectToArray = vi.fn(async () => [
1130
- { target_app_version: appVersionRow.target_app_version },
1131
- ]);
1132
- const sortToArray = vi
1133
- .fn()
1134
- .mockResolvedValueOnce([appVersionRow])
1135
- .mockResolvedValueOnce([fingerprintRow]);
1136
- const project = vi.fn(() => ({ toArray: projectToArray }));
1137
- const sort = vi.fn(() => ({ toArray: sortToArray }));
1138
- const bundles = {
1139
- countDocuments: vi.fn(async () => {
1140
- throw new Error("unexpected generic MongoDB count");
1141
- }),
1142
- deleteMany: vi.fn(),
1143
- distinct: vi.fn(),
1144
- find: vi.fn((filter: Record<string, unknown>) => {
1145
- const targetAppVersion = filter["target_app_version"];
1146
- if (
1147
- typeof targetAppVersion === "object" &&
1148
- targetAppVersion !== null &&
1149
- "$exists" in targetAppVersion
1150
- ) {
1151
- return { project };
1152
- }
1153
- return { sort };
1154
- }),
1155
- findOne: vi.fn(),
1156
- updateOne: vi.fn(),
1157
- };
1158
- const patches = {
1159
- deleteMany: vi.fn(),
1160
- find: vi.fn(() => ({
1161
- sort: vi.fn(() => ({ toArray: vi.fn(async () => []) })),
1162
- })),
1163
- insertMany: vi.fn(),
1164
- };
1165
- const client = {
1166
- db: () => ({
1167
- collection: (name: string) =>
1168
- name === "bundle_patches" ? patches : bundles,
1169
- }),
1170
- } as unknown as MongoClient;
1171
- const plugin = mongoAdapter({ client })();
1172
-
1173
- await expect(
1174
- plugin.getUpdateInfo?.({
1175
- _updateStrategy: "appVersion",
1176
- appVersion: "1.0.0",
1177
- bundleId: NIL_UUID,
1178
- platform: "ios",
1179
- }),
1180
- ).resolves.toMatchObject({
1181
- id: appVersionFastPathBundle.id,
1182
- status: "UPDATE",
1183
- });
1184
- await expect(
1185
- plugin.getUpdateInfo?.({
1186
- _updateStrategy: "fingerprint",
1187
- bundleId: NIL_UUID,
1188
- fingerprintHash: "fingerprint-fast-path",
1189
- platform: "ios",
1190
- }),
1191
- ).resolves.toMatchObject({
1192
- id: fingerprintFastPathBundle.id,
1193
- status: "UPDATE",
1194
- });
1195
-
1196
- expect(bundles.countDocuments).not.toHaveBeenCalled();
1197
- expect(bundles.find).toHaveBeenNthCalledWith(
1198
- 1,
1199
- expect.objectContaining({
1200
- channel: "production",
1201
- id: { $gte: NIL_UUID },
1202
- target_app_version: { $exists: true, $nin: [null, ""] },
1203
- }),
1204
- );
1205
- expect(bundles.find).toHaveBeenNthCalledWith(
1206
- 2,
1207
- expect.objectContaining({
1208
- target_app_version: { $in: ["1.0.0"] },
1209
- }),
1210
- );
1211
- expect(bundles.find).toHaveBeenNthCalledWith(
1212
- 3,
1213
- expect.objectContaining({
1214
- fingerprint_hash: "fingerprint-fast-path",
1215
- }),
1216
- );
1217
- expect(patches.find).toHaveBeenCalledTimes(2);
1218
- });
1219
-
1220
- it("commits Prisma bundle changes inside a transaction when available", async () => {
1221
- const rootBundles = {
776
+ const rootPatches = {
1222
777
  count: vi.fn(),
1223
- createMany: vi.fn(),
778
+ create: vi.fn(),
1224
779
  deleteMany: vi.fn(),
1225
780
  findFirst: vi.fn(),
1226
781
  findMany: vi.fn(),
782
+ update: vi.fn(),
1227
783
  upsert: vi.fn(),
1228
784
  };
1229
- const rootPatches = {
785
+ const channels = {
1230
786
  count: vi.fn(),
1231
- createMany: vi.fn(),
787
+ create: vi.fn(),
1232
788
  deleteMany: vi.fn(),
1233
- findFirst: vi.fn(),
789
+ findFirst: vi.fn(async () => ({
790
+ id: transactionChannelId,
791
+ name: "production",
792
+ })),
1234
793
  findMany: vi.fn(),
794
+ update: vi.fn(),
1235
795
  upsert: vi.fn(),
1236
796
  };
1237
797
  const txBundles = {
1238
798
  ...rootBundles,
1239
- upsert: vi.fn(async () => undefined),
1240
- };
1241
- const txPatches = {
1242
- ...rootPatches,
1243
- deleteMany: vi.fn(async () => undefined),
799
+ create: vi.fn(async ({ data }) => data),
1244
800
  };
801
+ const txPatches = { ...rootPatches };
1245
802
  const $transaction = vi.fn(
1246
803
  async (operation: (tx: Record<string, unknown>) => Promise<unknown>) =>
1247
804
  operation({
1248
805
  bundle_patches: txPatches,
1249
806
  bundles: txBundles,
807
+ channels,
1250
808
  }),
1251
809
  );
1252
- const plugin = prismaAdapter({
810
+ const adapter = prismaAdapter({
1253
811
  prisma: {
1254
812
  $transaction,
1255
813
  bundle_patches: rootPatches,
1256
814
  bundles: rootBundles,
815
+ channels,
1257
816
  },
1258
817
  provider: "postgresql",
1259
- })();
818
+ });
1260
819
 
1261
- await plugin.appendBundle(transactionBundle);
1262
- await plugin.commitBundle();
820
+ await adapter.commit({
821
+ changes: [
822
+ {
823
+ model: "bundles",
824
+ operation: "insert",
825
+ row: bundleToRow(transactionBundle),
826
+ },
827
+ ],
828
+ });
1263
829
 
1264
830
  expect($transaction).toHaveBeenCalledTimes(1);
1265
- expect(txBundles.upsert).toHaveBeenCalledTimes(1);
1266
- expect(rootBundles.upsert).not.toHaveBeenCalled();
831
+ expect(txBundles.create).toHaveBeenCalledTimes(1);
832
+ expect(rootBundles.create).not.toHaveBeenCalled();
1267
833
  });
1268
834
 
1269
835
  it("commits Drizzle bundle changes inside a transaction when available", async () => {
1270
836
  const tables = {
837
+ bundle_events: {
838
+ id: "event_id",
839
+ },
1271
840
  bundle_patches: {
1272
841
  bundle_id: "bundle_id",
1273
842
  id: "patch_id",
@@ -1276,6 +845,20 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
1276
845
  bundles: {
1277
846
  id: "id",
1278
847
  },
848
+ channels: {
849
+ id: sql.raw("channel_id"),
850
+ name: sql.raw("channel_name"),
851
+ },
852
+ api_keys: {
853
+ id: "access_key_id",
854
+ },
855
+ release_catalogs: {
856
+ scope_key: "scope_key",
857
+ },
858
+ releases: {
859
+ id: "release_id",
860
+ scope_key: "scope_key",
861
+ },
1279
862
  };
1280
863
  const rootInsert = vi.fn(() => ({
1281
864
  values: vi.fn(() => ({ execute: vi.fn(async () => undefined) })),
@@ -1291,13 +874,37 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
1291
874
  })),
1292
875
  insert,
1293
876
  query: {
877
+ bundle_events: {
878
+ findFirst: vi.fn(),
879
+ findMany: vi.fn(),
880
+ },
1294
881
  bundle_patches: {
882
+ findFirst: vi.fn(),
1295
883
  findMany: vi.fn(),
1296
884
  },
1297
885
  bundles: {
1298
886
  findFirst: vi.fn(async () => undefined),
1299
887
  findMany: vi.fn(),
1300
888
  },
889
+ channels: {
890
+ findFirst: vi.fn(async () => ({
891
+ id: transactionChannelId,
892
+ name: "production",
893
+ })),
894
+ findMany: vi.fn(),
895
+ },
896
+ api_keys: {
897
+ findFirst: vi.fn(),
898
+ findMany: vi.fn(),
899
+ },
900
+ release_catalogs: {
901
+ findFirst: vi.fn(),
902
+ findMany: vi.fn(),
903
+ },
904
+ releases: {
905
+ findFirst: vi.fn(),
906
+ findMany: vi.fn(),
907
+ },
1301
908
  },
1302
909
  select: vi.fn(),
1303
910
  update: vi.fn(() => ({
@@ -1315,26 +922,27 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
1315
922
  ...createDb(rootInsert),
1316
923
  transaction,
1317
924
  };
1318
- const plugin = drizzleAdapter({
925
+ const adapter = drizzleAdapter({
1319
926
  db,
1320
927
  provider: "postgresql",
1321
- })();
928
+ });
1322
929
 
1323
- await plugin.appendBundle(transactionBundle);
1324
- await plugin.commitBundle();
930
+ await adapter.commit({
931
+ changes: [
932
+ {
933
+ model: "bundles",
934
+ operation: "insert",
935
+ row: bundleToRow(transactionBundle),
936
+ },
937
+ ],
938
+ });
1325
939
 
1326
940
  expect(transaction).toHaveBeenCalledTimes(1);
1327
941
  expect(txInsert).toHaveBeenCalledTimes(1);
1328
942
  expect(rootInsert).not.toHaveBeenCalled();
1329
943
  });
1330
944
 
1331
- it("commits MongoDB bundle changes inside a session transaction when available", async () => {
1332
- const session = {
1333
- endSession: vi.fn(async () => undefined),
1334
- withTransaction: vi.fn(async (operation: () => Promise<void>) =>
1335
- operation(),
1336
- ),
1337
- };
945
+ it("does not expose an implicit MongoDB transaction", () => {
1338
946
  const bundles = {
1339
947
  countDocuments: vi.fn(),
1340
948
  deleteMany: vi.fn(),
@@ -1353,64 +961,69 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
1353
961
  collection: (name: string) =>
1354
962
  name === "bundle_patches" ? patches : bundles,
1355
963
  }),
1356
- startSession: vi.fn(() => session),
1357
964
  } as unknown as MongoClient;
1358
- const plugin = mongoAdapter({ client })();
965
+ const adapter = mongoAdapter({ client });
1359
966
 
1360
- await plugin.appendBundle(transactionBundle);
1361
- await plugin.commitBundle();
1362
-
1363
- expect(client.startSession).toHaveBeenCalledTimes(1);
1364
- expect(session.withTransaction).toHaveBeenCalledTimes(1);
1365
- expect(bundles.updateOne).toHaveBeenCalledWith(
1366
- { id: transactionBundle.id },
1367
- expect.any(Object),
1368
- expect.objectContaining({ session, upsert: true }),
1369
- );
1370
- expect(patches.deleteMany).toHaveBeenCalledWith(
1371
- { bundle_id: transactionBundle.id },
1372
- { session },
1373
- );
1374
- expect(session.endSession).toHaveBeenCalledTimes(1);
967
+ expect(Reflect.has(adapter, "transaction")).toBe(false);
1375
968
  });
1376
- });
1377
969
 
1378
- describe("bundle validation", () => {
1379
- it("rejects bundles without targeting information", async () => {
1380
- await expect(
1381
- hotUpdater.insertBundle({
1382
- id: "00000000-0000-0000-0000-000000000999",
1383
- platform: "ios",
1384
- shouldForceUpdate: false,
1385
- enabled: true,
1386
- fileHash: "missing-target",
1387
- gitCommitHash: null,
1388
- message: null,
1389
- channel: "production",
1390
- storageUri: "s3://test-bucket/missing-target.zip",
1391
- targetAppVersion: null,
1392
- fingerprintHash: null,
970
+ it("uses a MongoDB session when transactions are enabled", async () => {
971
+ const client = new MongoClient("mongodb://localhost");
972
+ const session = client.startSession();
973
+ const withTransaction = vi.fn(
974
+ async (operation: (session: ClientSession) => Promise<unknown>) =>
975
+ operation(session),
976
+ );
977
+ Object.defineProperty(session, "withTransaction", {
978
+ value: withTransaction,
979
+ });
980
+ const insertOne = vi.fn(async () => undefined);
981
+ Object.defineProperty(client, "db", {
982
+ value: () => ({
983
+ collection: () => ({
984
+ findOne: vi.fn(async () => ({
985
+ id: transactionChannelId,
986
+ name: "production",
987
+ })),
988
+ insertOne,
989
+ updateOne: vi.fn(async () => ({ matchedCount: 1 })),
990
+ }),
1393
991
  }),
1394
- ).rejects.toThrow(
1395
- "Bundle must define either targetAppVersion or fingerprintHash.",
992
+ });
993
+ const withSession = vi.fn(
994
+ async (operation: (session: ClientSession) => Promise<unknown>) =>
995
+ operation(session),
1396
996
  );
997
+ Object.defineProperty(client, "withSession", { value: withSession });
998
+ const adapter = mongoAdapter({ client, transactions: true });
999
+
1000
+ await adapter.commit({
1001
+ changes: [
1002
+ {
1003
+ model: "bundles",
1004
+ operation: "insert",
1005
+ row: bundleToRow(transactionBundle),
1006
+ },
1007
+ ],
1008
+ });
1009
+
1010
+ expect(withSession).toHaveBeenCalledTimes(1);
1011
+ expect(withTransaction).toHaveBeenCalledTimes(1);
1012
+ expect(insertOne).toHaveBeenCalledWith(bundleToRow(transactionBundle), {
1013
+ session,
1014
+ });
1397
1015
  });
1398
1016
  });
1399
1017
 
1400
1018
  describe("getBundleById", () => {
1401
1019
  it("should retrieve bundle by id without Prisma validation errors", async () => {
1402
1020
  const bundle: Bundle = {
1021
+ archiveByteSize: 1_024,
1403
1022
  id: "00000000-0000-0000-0000-000000000010",
1404
1023
  platform: "ios",
1405
- shouldForceUpdate: false,
1406
- enabled: true,
1407
1024
  fileHash: "test-hash",
1408
1025
  gitCommitHash: null,
1409
- message: "Test bundle for getBundleById",
1410
- channel: "production",
1411
1026
  storageUri: "s3://test-bucket/test.zip",
1412
- targetAppVersion: "1.0.0",
1413
- fingerprintHash: null,
1414
1027
  };
1415
1028
 
1416
1029
  await hotUpdater.insertBundle(bundle);
@@ -1434,59 +1047,23 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
1434
1047
  });
1435
1048
 
1436
1049
  describe("getChannels", () => {
1437
- it("should retrieve all unique channels without Prisma validation errors", async () => {
1438
- const bundles: Bundle[] = [
1439
- {
1440
- id: "00000000-0000-0000-0000-000000000020",
1441
- platform: "ios",
1442
- shouldForceUpdate: false,
1443
- enabled: true,
1444
- fileHash: "hash1",
1445
- gitCommitHash: null,
1446
- message: "Bundle 1",
1447
- channel: "production",
1448
- storageUri: "s3://test/1.zip",
1449
- targetAppVersion: "1.0.0",
1450
- fingerprintHash: null,
1451
- },
1452
- {
1453
- id: "00000000-0000-0000-0000-000000000021",
1454
- platform: "android",
1455
- shouldForceUpdate: false,
1456
- enabled: true,
1457
- fileHash: "hash2",
1458
- gitCommitHash: null,
1459
- message: "Bundle 2",
1460
- channel: "staging",
1461
- storageUri: "s3://test/2.zip",
1462
- targetAppVersion: "1.0.0",
1463
- fingerprintHash: null,
1464
- },
1465
- {
1466
- id: "00000000-0000-0000-0000-000000000022",
1467
- platform: "ios",
1468
- shouldForceUpdate: false,
1469
- enabled: true,
1470
- fileHash: "hash3",
1471
- gitCommitHash: null,
1472
- message: "Bundle 3",
1473
- channel: "production",
1474
- storageUri: "s3://test/3.zip",
1475
- targetAppVersion: "1.0.0",
1476
- fingerprintHash: null,
1477
- },
1478
- ];
1479
-
1480
- for (const bundle of bundles) {
1481
- await hotUpdater.insertBundle(bundle);
1482
- }
1050
+ it("retrieves canonical Channel rows without Prisma validation errors", async () => {
1051
+ await hotUpdater.insertChannel({
1052
+ onConflict: "returnExisting",
1053
+ row: { id: "channel-production", name: "production" },
1054
+ });
1055
+ await hotUpdater.insertChannel({
1056
+ onConflict: "returnExisting",
1057
+ row: { id: "channel-staging", name: "staging" },
1058
+ });
1483
1059
 
1484
- // This should not throw a Prisma validation error
1485
1060
  const channels = await hotUpdater.getChannels();
1486
1061
 
1487
1062
  expect(channels).toHaveLength(2);
1488
- expect(channels).toContain("production");
1489
- expect(channels).toContain("staging");
1063
+ expect(channels.map(({ name }) => name)).toEqual([
1064
+ "production",
1065
+ "staging",
1066
+ ]);
1490
1067
  });
1491
1068
 
1492
1069
  it("should return empty array when no bundles exist", async () => {
@@ -1495,7 +1072,7 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
1495
1072
  });
1496
1073
  });
1497
1074
 
1498
- describe("getAppUpdateInfo with storage plugins", () => {
1075
+ describe("getArtifactInfo with storage plugins", () => {
1499
1076
  beforeEach(() => {
1500
1077
  // Fix time for deterministic signed URLs
1501
1078
  vi.useFakeTimers();
@@ -1508,27 +1085,17 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
1508
1085
 
1509
1086
  it("resolves s3:// storage URI to signed URL via s3StoragePlugin", async () => {
1510
1087
  const bundle: Bundle = {
1088
+ archiveByteSize: 1_024,
1511
1089
  id: "00000000-0000-0000-0000-000000000001",
1512
1090
  platform: "ios",
1513
- shouldForceUpdate: false,
1514
- enabled: true,
1515
1091
  fileHash: "hash123",
1516
1092
  gitCommitHash: null,
1517
- message: "Test bundle",
1518
- channel: "production",
1519
1093
  storageUri: "s3://test-bucket/bundles/bundle.zip",
1520
- targetAppVersion: "1.0.0",
1521
- fingerprintHash: null,
1522
1094
  };
1523
1095
 
1524
1096
  await hotUpdater.insertBundle(bundle);
1525
1097
 
1526
- const updateInfo = await hotUpdater.getAppUpdateInfo({
1527
- appVersion: "1.0.0",
1528
- bundleId: NIL_UUID,
1529
- platform: "ios",
1530
- _updateStrategy: "appVersion",
1531
- });
1098
+ const updateInfo = await hotUpdater.getArtifactInfo(bundle.id, NIL_UUID);
1532
1099
 
1533
1100
  expect(updateInfo).not.toBeNull();
1534
1101
  expect(updateInfo?.fileUrl).toBe(
@@ -1536,173 +1103,59 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
1536
1103
  );
1537
1104
  });
1538
1105
 
1539
- it("passes through http:// URLs without plugin resolution", async () => {
1540
- const bundle: Bundle = {
1541
- id: "00000000-0000-0000-0000-000000000004",
1542
- platform: "ios",
1543
- shouldForceUpdate: false,
1544
- enabled: true,
1545
- fileHash: "hashhttp",
1546
- gitCommitHash: null,
1547
- message: "HTTP bundle",
1548
- channel: "production",
1549
- storageUri: "s3://bundle/bundle.zip",
1550
- targetAppVersion: "1.0.0",
1551
- fingerprintHash: null,
1552
- };
1553
-
1554
- await hotUpdater.insertBundle(bundle);
1555
-
1556
- const updateInfo = await hotUpdater.getAppUpdateInfo({
1557
- appVersion: "1.0.0",
1558
- bundleId: NIL_UUID,
1559
- platform: "ios",
1560
- _updateStrategy: "appVersion",
1561
- });
1562
-
1563
- expect(updateInfo).not.toBeNull();
1564
- expect(updateInfo?.fileUrl).toBe(
1565
- "https://s3.example.com/bundle/bundle.zip",
1566
- );
1567
- });
1568
-
1569
- it("passes through https:// URLs without plugin resolution", async () => {
1570
- const bundle: Bundle = {
1571
- id: "00000000-0000-0000-0000-000000000005",
1572
- platform: "ios",
1573
- shouldForceUpdate: false,
1574
- enabled: true,
1575
- fileHash: "hashhttps",
1576
- gitCommitHash: null,
1577
- message: "HTTPS bundle",
1578
- channel: "production",
1579
- storageUri: "https://cdn.example.com/bundle.zip",
1580
- targetAppVersion: "1.0.0",
1581
- fingerprintHash: null,
1582
- };
1583
-
1584
- await hotUpdater.insertBundle(bundle);
1585
-
1586
- const updateInfo = await hotUpdater.getAppUpdateInfo({
1587
- appVersion: "1.0.0",
1588
- bundleId: NIL_UUID,
1589
- platform: "ios",
1590
- _updateStrategy: "appVersion",
1591
- });
1592
-
1593
- expect(updateInfo).not.toBeNull();
1594
- expect(updateInfo?.fileUrl).toBe("https://cdn.example.com/bundle.zip");
1595
- });
1596
-
1597
- it("returns null when no update is available", async () => {
1598
- const updateInfo = await hotUpdater.getAppUpdateInfo({
1599
- appVersion: "99.0.0",
1600
- bundleId: NIL_UUID,
1601
- platform: "ios",
1602
- _updateStrategy: "appVersion",
1603
- });
1604
-
1605
- expect(updateInfo).toBeNull();
1606
- });
1607
-
1608
- it("works with fingerprint strategy", async () => {
1609
- const bundle: Bundle = {
1610
- id: "00000000-0000-0000-0000-000000000008",
1611
- platform: "ios",
1612
- shouldForceUpdate: false,
1613
- enabled: true,
1614
- fileHash: "hashfp",
1615
- gitCommitHash: null,
1616
- message: "Fingerprint bundle",
1617
- channel: "production",
1618
- storageUri: "s3://test-bucket/fp-bundle.zip",
1619
- targetAppVersion: null,
1620
- fingerprintHash: "fingerprint123",
1621
- };
1622
-
1623
- await hotUpdater.insertBundle(bundle);
1624
-
1625
- const updateInfo = await hotUpdater.getAppUpdateInfo({
1626
- fingerprintHash: "fingerprint123",
1627
- bundleId: NIL_UUID,
1628
- platform: "ios",
1629
- _updateStrategy: "fingerprint",
1630
- });
1631
-
1632
- expect(updateInfo).not.toBeNull();
1633
- expect(updateInfo?.fileUrl).toBe(
1634
- "https://s3.example.com/test-bucket/fp-bundle.zip",
1635
- );
1636
- });
1637
-
1638
- it("returns manifest metadata and hbc patch descriptors for createHotUpdater", async () => {
1106
+ it("returns manifest metadata and hbc patch descriptors", async () => {
1639
1107
  const currentManifestStorageUri =
1640
- "s3://test-bucket/releases/00000000-0000-0000-0000-000000000101/manifest.json";
1108
+ "s3://test-bucket/releases/bundles/00000000-0000-0000-0000-000000000101/manifest.json";
1641
1109
  const nextManifestStorageUri =
1642
- "s3://test-bucket/releases/00000000-0000-0000-0000-000000000102/manifest.json";
1110
+ "s3://test-bucket/releases/bundles/00000000-0000-0000-0000-000000000102/manifest.json";
1643
1111
  const olderBundle: Bundle = {
1112
+ archiveByteSize: 1_024,
1644
1113
  id: "00000000-0000-0000-0000-000000000100",
1645
1114
  platform: "ios",
1646
- shouldForceUpdate: false,
1647
- enabled: true,
1648
1115
  fileHash: "hash-older-zip",
1649
1116
  gitCommitHash: null,
1650
- message: "Older bundle",
1651
- channel: "production",
1652
1117
  storageUri:
1653
- "s3://test-bucket/releases/00000000-0000-0000-0000-000000000100/bundle.zip",
1654
- targetAppVersion: "1.0.0",
1655
- fingerprintHash: null,
1118
+ "s3://test-bucket/releases/bundles/00000000-0000-0000-0000-000000000100/bundle.zip",
1656
1119
  };
1657
1120
  const currentBundle: Bundle = {
1121
+ archiveByteSize: 1_024,
1658
1122
  id: "00000000-0000-0000-0000-000000000101",
1659
1123
  platform: "ios",
1660
- shouldForceUpdate: false,
1661
- enabled: true,
1662
1124
  fileHash: "hash-current-zip",
1663
1125
  gitCommitHash: null,
1664
- message: "Current bundle",
1665
- channel: "production",
1666
1126
  storageUri:
1667
- "s3://test-bucket/releases/00000000-0000-0000-0000-000000000101/bundle.zip",
1668
- targetAppVersion: "1.0.0",
1669
- fingerprintHash: null,
1670
- assetBaseStorageUri:
1671
- "s3://test-bucket/releases/00000000-0000-0000-0000-000000000101/files",
1127
+ "s3://test-bucket/releases/bundles/00000000-0000-0000-0000-000000000101/bundle.zip",
1128
+ assetBaseStorageUri: "s3://test-bucket/releases/assets",
1672
1129
  manifestFileHash: "sig:manifest-current",
1673
1130
  manifestStorageUri: currentManifestStorageUri,
1674
1131
  };
1675
1132
  const nextBundle: Bundle = {
1133
+ archiveByteSize: 1_024,
1676
1134
  id: "00000000-0000-0000-0000-000000000102",
1677
1135
  platform: "ios",
1678
- shouldForceUpdate: false,
1679
- enabled: true,
1680
1136
  fileHash: "hash-next-zip",
1681
1137
  gitCommitHash: null,
1682
- message: "Next bundle",
1683
- channel: "production",
1684
1138
  storageUri:
1685
- "s3://test-bucket/releases/00000000-0000-0000-0000-000000000102/bundle.zip",
1686
- targetAppVersion: "1.0.0",
1687
- fingerprintHash: null,
1688
- assetBaseStorageUri:
1689
- "s3://test-bucket/releases/00000000-0000-0000-0000-000000000102/files",
1139
+ "s3://test-bucket/releases/bundles/00000000-0000-0000-0000-000000000102/bundle.zip",
1140
+ assetBaseStorageUri: "s3://test-bucket/releases/assets",
1690
1141
  manifestFileHash: "sig:manifest-next",
1691
1142
  manifestStorageUri: nextManifestStorageUri,
1692
1143
  patches: [
1693
1144
  {
1694
1145
  baseBundleId: "00000000-0000-0000-0000-000000000100",
1695
1146
  baseFileHash: "hash-older-bundle",
1147
+ byteSize: 48,
1696
1148
  patchFileHash: "hash-older-bsdiff",
1697
1149
  patchStorageUri:
1698
- "s3://test-bucket/releases/00000000-0000-0000-0000-000000000102/patches/00000000-0000-0000-0000-000000000100/index.ios.bundle.bsdiff",
1150
+ "s3://test-bucket/releases/bundles/00000000-0000-0000-0000-000000000102/patches/00000000-0000-0000-0000-000000000100/index.ios.bundle.bsdiff",
1699
1151
  },
1700
1152
  {
1701
1153
  baseBundleId: currentBundle.id,
1702
1154
  baseFileHash: "hash-old-bundle",
1155
+ byteSize: 48,
1703
1156
  patchFileHash: "hash-bsdiff",
1704
1157
  patchStorageUri:
1705
- "s3://test-bucket/releases/00000000-0000-0000-0000-000000000102/patches/00000000-0000-0000-0000-000000000101/index.ios.bundle.bsdiff",
1158
+ "s3://test-bucket/releases/bundles/00000000-0000-0000-0000-000000000102/patches/00000000-0000-0000-0000-000000000101/index.ios.bundle.bsdiff",
1706
1159
  },
1707
1160
  ],
1708
1161
  };
@@ -1747,19 +1200,13 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
1747
1200
 
1748
1201
  try {
1749
1202
  await expect(
1750
- hotUpdater.getAppUpdateInfo({
1751
- appVersion: "1.0.0",
1752
- bundleId: currentBundle.id,
1753
- channel: "production",
1754
- platform: "ios",
1755
- _updateStrategy: "appVersion",
1756
- }),
1203
+ hotUpdater.getArtifactInfo(nextBundle.id, currentBundle.id),
1757
1204
  ).resolves.toEqual({
1758
1205
  changedAssets: {
1759
1206
  "index.ios.bundle": {
1760
1207
  file: {
1761
1208
  compression: "br",
1762
- url: "https://s3.example.com/test-bucket/releases/00000000-0000-0000-0000-000000000102/files/index.ios.bundle.br",
1209
+ url: "https://s3.example.com/test-bucket/releases/assets/sha256/ha/hash-new-bundle.br",
1763
1210
  },
1764
1211
  fileHash: "hash-new-bundle",
1765
1212
  patch: {
@@ -1768,20 +1215,16 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
1768
1215
  baseFileHash: "hash-old-bundle",
1769
1216
  patchFileHash: "hash-bsdiff",
1770
1217
  patchUrl:
1771
- "https://s3.example.com/test-bucket/releases/00000000-0000-0000-0000-000000000102/patches/00000000-0000-0000-0000-000000000101/index.ios.bundle.bsdiff",
1218
+ "https://s3.example.com/test-bucket/releases/bundles/00000000-0000-0000-0000-000000000102/patches/00000000-0000-0000-0000-000000000101/index.ios.bundle.bsdiff",
1772
1219
  },
1773
1220
  },
1774
1221
  },
1775
1222
  fileHash: "hash-next-zip",
1776
1223
  fileUrl:
1777
- "https://s3.example.com/test-bucket/releases/00000000-0000-0000-0000-000000000102/bundle.zip",
1778
- id: nextBundle.id,
1224
+ "https://s3.example.com/test-bucket/releases/bundles/00000000-0000-0000-0000-000000000102/bundle.zip",
1779
1225
  manifestFileHash: "sig:manifest-next",
1780
1226
  manifestUrl:
1781
- "https://s3.example.com/test-bucket/releases/00000000-0000-0000-0000-000000000102/manifest.json",
1782
- message: "Next bundle",
1783
- shouldForceUpdate: false,
1784
- status: "UPDATE",
1227
+ "https://s3.example.com/test-bucket/releases/bundles/00000000-0000-0000-0000-000000000102/manifest.json",
1785
1228
  });
1786
1229
  expect(fetchMock).not.toHaveBeenCalled();
1787
1230
  } finally {
@@ -1789,24 +1232,18 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
1789
1232
  }
1790
1233
  });
1791
1234
 
1792
- it("propagates manifest storage read failures for createHotUpdater", async () => {
1235
+ it("propagates manifest storage read failures", async () => {
1793
1236
  const nextManifestStorageUri =
1794
- "s3://test-bucket/releases/00000000-0000-0000-0000-000000000109/manifest.json";
1237
+ "s3://test-bucket/releases/bundles/00000000-0000-0000-0000-000000000109/manifest.json";
1795
1238
  const nextBundle: Bundle = {
1239
+ archiveByteSize: 1_024,
1796
1240
  id: "00000000-0000-0000-0000-000000000109",
1797
1241
  platform: "ios",
1798
- shouldForceUpdate: false,
1799
- enabled: true,
1800
1242
  fileHash: "hash-next-zip",
1801
1243
  gitCommitHash: null,
1802
- message: "Next bundle",
1803
- channel: "production",
1804
1244
  storageUri:
1805
- "s3://test-bucket/releases/00000000-0000-0000-0000-000000000109/bundle.zip",
1806
- targetAppVersion: "1.0.0",
1807
- fingerprintHash: null,
1808
- assetBaseStorageUri:
1809
- "s3://test-bucket/releases/00000000-0000-0000-0000-000000000109/files",
1245
+ "s3://test-bucket/releases/bundles/00000000-0000-0000-0000-000000000109/bundle.zip",
1246
+ assetBaseStorageUri: "s3://test-bucket/releases/assets",
1810
1247
  manifestFileHash: "sig:manifest-next",
1811
1248
  manifestStorageUri: nextManifestStorageUri,
1812
1249
  };
@@ -1818,137 +1255,8 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
1818
1255
  );
1819
1256
 
1820
1257
  await expect(
1821
- hotUpdater.getAppUpdateInfo({
1822
- appVersion: "1.0.0",
1823
- bundleId: NIL_UUID,
1824
- channel: "production",
1825
- platform: "ios",
1826
- _updateStrategy: "appVersion",
1827
- }),
1258
+ hotUpdater.getArtifactInfo(nextBundle.id, NIL_UUID),
1828
1259
  ).rejects.toThrow("storage read failed");
1829
1260
  });
1830
1261
  });
1831
-
1832
- describe("database plugin factories", () => {
1833
- it("keeps optional maintenance capabilities lazy", () => {
1834
- const factory = vi.fn(() => ({
1835
- async getBundleById() {
1836
- return null;
1837
- },
1838
- async getBundles() {
1839
- return {
1840
- data: [],
1841
- pagination: {
1842
- hasNextPage: false,
1843
- hasPreviousPage: false,
1844
- currentPage: 1,
1845
- totalPages: 1,
1846
- total: 0,
1847
- },
1848
- };
1849
- },
1850
- async getChannels() {
1851
- return [];
1852
- },
1853
- async commitBundle() {},
1854
- }));
1855
- createHotUpdater({
1856
- database: createDatabasePlugin({
1857
- name: "lazyPlugin",
1858
- factory,
1859
- })({}),
1860
- });
1861
-
1862
- expect(factory).not.toHaveBeenCalled();
1863
- });
1864
-
1865
- it("isolates pending mutation state between overlapping writes", async () => {
1866
- const committedBundleIds: string[][] = [];
1867
- const onUnmount = vi.fn(async () => undefined);
1868
- let releaseFirstCommit!: () => void;
1869
- let notifyFirstCommitStarted!: () => void;
1870
- const firstCommitStarted = new Promise<void>((resolve) => {
1871
- notifyFirstCommitStarted = resolve;
1872
- });
1873
- const firstCommitGate = new Promise<void>((resolve) => {
1874
- releaseFirstCommit = resolve;
1875
- });
1876
- let commitCount = 0;
1877
-
1878
- const isolatedHotUpdater = createHotUpdater({
1879
- database: createDatabasePlugin({
1880
- name: "isolatedPlugin",
1881
- factory: () => ({
1882
- async getBundleById() {
1883
- return null;
1884
- },
1885
- async getBundles() {
1886
- return {
1887
- data: [],
1888
- pagination: {
1889
- hasNextPage: false,
1890
- hasPreviousPage: false,
1891
- currentPage: 1,
1892
- totalPages: 1,
1893
- total: 0,
1894
- },
1895
- };
1896
- },
1897
- async getChannels() {
1898
- return [];
1899
- },
1900
- onUnmount,
1901
- async commitBundle({ changedSets }) {
1902
- commitCount += 1;
1903
- committedBundleIds.push(
1904
- changedSets.map((change) => change.data.id),
1905
- );
1906
-
1907
- if (commitCount === 1) {
1908
- notifyFirstCommitStarted();
1909
- await firstCommitGate;
1910
- }
1911
- },
1912
- }),
1913
- })({}),
1914
- });
1915
-
1916
- const firstBundleId = "00000000-0000-0000-0000-000000000030";
1917
- const secondBundleId = "00000000-0000-0000-0000-000000000031";
1918
-
1919
- const firstInsert = isolatedHotUpdater.insertBundle({
1920
- id: firstBundleId,
1921
- platform: "ios",
1922
- shouldForceUpdate: false,
1923
- enabled: true,
1924
- fileHash: "hash-1",
1925
- gitCommitHash: null,
1926
- message: "first bundle",
1927
- channel: "production",
1928
- storageUri: "s3://test-bucket/first.zip",
1929
- targetAppVersion: "1.0.0",
1930
- fingerprintHash: null,
1931
- });
1932
- await firstCommitStarted;
1933
-
1934
- const secondInsert = isolatedHotUpdater.insertBundle({
1935
- id: secondBundleId,
1936
- platform: "ios",
1937
- shouldForceUpdate: false,
1938
- enabled: true,
1939
- fileHash: "hash-2",
1940
- gitCommitHash: null,
1941
- message: "second bundle",
1942
- channel: "production",
1943
- storageUri: "s3://test-bucket/second.zip",
1944
- targetAppVersion: "1.0.0",
1945
- fingerprintHash: null,
1946
- });
1947
-
1948
- releaseFirstCommit();
1949
- await Promise.all([firstInsert, secondInsert]);
1950
-
1951
- expect(committedBundleIds).toEqual([[firstBundleId], [secondBundleId]]);
1952
- });
1953
- });
1954
1262
  });