@hot-updater/server 0.28.0 → 0.29.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 (84) hide show
  1. package/dist/adapters/drizzle.cjs +7 -7
  2. package/dist/adapters/drizzle.mjs +2 -0
  3. package/dist/adapters/kysely.cjs +7 -7
  4. package/dist/adapters/kysely.mjs +2 -0
  5. package/dist/adapters/mongodb.cjs +7 -7
  6. package/dist/adapters/mongodb.mjs +2 -0
  7. package/dist/adapters/prisma.cjs +7 -7
  8. package/dist/adapters/prisma.mjs +2 -0
  9. package/dist/calculatePagination.cjs +1 -3
  10. package/dist/{calculatePagination.js → calculatePagination.mjs} +1 -2
  11. package/dist/db/index.cjs +24 -15
  12. package/dist/db/index.d.cts +12 -9
  13. package/dist/db/index.d.mts +30 -0
  14. package/dist/db/index.mjs +45 -0
  15. package/dist/db/ormCore.cjs +247 -138
  16. package/dist/db/ormCore.d.cts +35 -17
  17. package/dist/db/ormCore.d.mts +44 -0
  18. package/dist/db/ormCore.mjs +386 -0
  19. package/dist/db/pluginCore.cjs +145 -40
  20. package/dist/db/pluginCore.mjs +176 -0
  21. package/dist/db/types.cjs +1 -3
  22. package/dist/db/types.d.cts +14 -21
  23. package/dist/db/types.d.mts +24 -0
  24. package/dist/db/{types.js → types.mjs} +1 -2
  25. package/dist/handler.cjs +117 -48
  26. package/dist/handler.d.cts +28 -18
  27. package/dist/handler.d.mts +47 -0
  28. package/dist/handler.mjs +217 -0
  29. package/dist/index.cjs +5 -5
  30. package/dist/index.d.cts +3 -3
  31. package/dist/index.d.mts +5 -0
  32. package/dist/index.mjs +4 -0
  33. package/dist/internalRouter.cjs +54 -0
  34. package/dist/internalRouter.mjs +52 -0
  35. package/dist/node.cjs +2 -3
  36. package/dist/node.d.cts +0 -1
  37. package/dist/{node.d.ts → node.d.mts} +1 -2
  38. package/dist/{node.js → node.mjs} +1 -2
  39. package/dist/route.cjs +7 -0
  40. package/dist/route.mjs +7 -0
  41. package/dist/runtime.cjs +42 -0
  42. package/dist/runtime.d.cts +21 -0
  43. package/dist/runtime.d.mts +21 -0
  44. package/dist/runtime.mjs +40 -0
  45. package/dist/schema/v0_21_0.cjs +1 -5
  46. package/dist/schema/{v0_21_0.js → v0_21_0.mjs} +1 -3
  47. package/dist/schema/v0_29_0.cjs +24 -0
  48. package/dist/schema/v0_29_0.mjs +24 -0
  49. package/dist/types/{index.d.ts → index.d.mts} +1 -1
  50. package/package.json +18 -18
  51. package/src/db/index.spec.ts +64 -29
  52. package/src/db/index.ts +55 -35
  53. package/src/db/ormCore.ts +438 -210
  54. package/src/db/ormUpdateCheck.bench.ts +261 -0
  55. package/src/db/pluginCore.ts +298 -49
  56. package/src/db/pluginUpdateCheck.bench.ts +250 -0
  57. package/src/db/types.ts +52 -27
  58. package/src/{handler-standalone-integration.spec.ts → handler-standalone.integration.spec.ts} +106 -0
  59. package/src/handler.spec.ts +156 -0
  60. package/src/handler.ts +296 -77
  61. package/src/internalRouter.ts +104 -0
  62. package/src/route.ts +7 -0
  63. package/src/runtime.spec.ts +277 -0
  64. package/src/runtime.ts +121 -0
  65. package/src/schema/v0_29_0.ts +26 -0
  66. package/dist/_virtual/rolldown_runtime.cjs +0 -25
  67. package/dist/adapters/drizzle.js +0 -3
  68. package/dist/adapters/kysely.js +0 -3
  69. package/dist/adapters/mongodb.js +0 -3
  70. package/dist/adapters/prisma.js +0 -3
  71. package/dist/db/index.d.ts +0 -27
  72. package/dist/db/index.js +0 -36
  73. package/dist/db/ormCore.d.ts +0 -26
  74. package/dist/db/ormCore.js +0 -273
  75. package/dist/db/pluginCore.js +0 -69
  76. package/dist/db/types.d.ts +0 -31
  77. package/dist/handler.d.ts +0 -37
  78. package/dist/handler.js +0 -146
  79. package/dist/index.d.ts +0 -5
  80. package/dist/index.js +0 -5
  81. /package/dist/adapters/{drizzle.d.ts → drizzle.d.mts} +0 -0
  82. /package/dist/adapters/{kysely.d.ts → kysely.d.mts} +0 -0
  83. /package/dist/adapters/{mongodb.d.ts → mongodb.d.mts} +0 -0
  84. /package/dist/adapters/{prisma.d.ts → prisma.d.mts} +0 -0
package/src/db/ormCore.ts CHANGED
@@ -7,43 +7,176 @@ import type {
7
7
  Platform,
8
8
  UpdateInfo,
9
9
  } from "@hot-updater/core";
10
- import { NIL_UUID } from "@hot-updater/core";
11
- import { filterCompatibleAppVersions } from "@hot-updater/plugin-core";
10
+ import {
11
+ DEFAULT_ROLLOUT_COHORT_COUNT,
12
+ isCohortEligibleForUpdate,
13
+ NIL_UUID,
14
+ } from "@hot-updater/core";
15
+ import type {
16
+ DatabaseBundleQueryOptions,
17
+ DatabaseBundleQueryWhere,
18
+ HotUpdaterContext,
19
+ } from "@hot-updater/plugin-core";
20
+ import { semverSatisfies } from "@hot-updater/plugin-core";
12
21
  import type { InferFumaDB } from "fumadb";
13
22
  import { fumadb } from "fumadb";
14
23
  import type { FumaDBAdapter } from "fumadb/adapters";
15
24
  import { calculatePagination } from "../calculatePagination";
16
25
  import { v0_21_0 } from "../schema/v0_21_0";
26
+ import { v0_29_0 } from "../schema/v0_29_0";
17
27
  import type { PaginationInfo } from "../types";
18
28
  import type { DatabaseAPI } from "./types";
19
29
 
30
+ const parseTargetCohorts = (value: unknown): string[] | null => {
31
+ if (!value) return null;
32
+ if (Array.isArray(value)) {
33
+ return value.filter((v): v is string => typeof v === "string");
34
+ }
35
+ if (typeof value === "string") {
36
+ try {
37
+ const parsed = JSON.parse(value) as unknown;
38
+ if (Array.isArray(parsed)) {
39
+ return parsed.filter((v): v is string => typeof v === "string");
40
+ }
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ return null;
46
+ };
47
+
48
+ const schemas: [typeof v0_21_0, typeof v0_29_0] = [v0_21_0, v0_29_0];
49
+
50
+ const getLastItem = <T extends unknown[]>(
51
+ items: T,
52
+ ): T extends [...infer _, infer Last] ? Last : never =>
53
+ items.at(-1) as T extends [...infer _, infer Last] ? Last : never;
54
+
20
55
  export const HotUpdaterDB = fumadb({
21
56
  namespace: "hot_updater",
22
- schemas: [v0_21_0],
57
+ schemas,
23
58
  });
24
-
25
59
  export type HotUpdaterClient = InferFumaDB<typeof HotUpdaterDB>;
26
60
 
27
61
  export type Migrator = ReturnType<HotUpdaterClient["createMigrator"]>;
28
62
 
29
- export function createOrmDatabaseCore({
63
+ export function createOrmDatabaseCore<TContext = unknown>({
30
64
  database,
31
65
  resolveFileUrl,
32
66
  }: {
33
67
  database: FumaDBAdapter;
34
- resolveFileUrl: (storageUri: string | null) => Promise<string | null>;
68
+ resolveFileUrl: (
69
+ storageUri: string | null,
70
+ context?: HotUpdaterContext<TContext>,
71
+ ) => Promise<string | null>;
35
72
  }): {
36
- api: DatabaseAPI;
73
+ api: DatabaseAPI<TContext>;
37
74
  adapterName: string;
38
75
  createMigrator: () => Migrator;
39
76
  generateSchema: HotUpdaterClient["generateSchema"];
40
77
  } {
41
78
  const client = HotUpdaterDB.client(database);
79
+ const UPDATE_CHECK_PAGE_SIZE = 100;
80
+ const isMongoAdapter = client.adapter.name.toLowerCase().includes("mongodb");
81
+
82
+ const ensureORM = async () => {
83
+ const latestSchema = getLastItem(schemas);
84
+ const lastSchemaVersion = latestSchema.version;
85
+
86
+ try {
87
+ const migrator = client.createMigrator();
88
+ const currentVersion = await migrator.getVersion();
89
+
90
+ if (currentVersion === undefined) {
91
+ throw new Error(
92
+ "Database is not initialized. Please run 'npx hot-updater migrate' to set up the database schema.",
93
+ );
94
+ }
95
+
96
+ if (currentVersion !== lastSchemaVersion) {
97
+ throw new Error(
98
+ `Database schema version mismatch. Expected version ${lastSchemaVersion}, but database is on version ${currentVersion}. ` +
99
+ "Please run 'npx hot-updater migrate' to update your database schema.",
100
+ );
101
+ }
102
+
103
+ return client.orm(lastSchemaVersion);
104
+ } catch (error) {
105
+ if (
106
+ error instanceof Error &&
107
+ error.message.includes("doesn't support migration")
108
+ ) {
109
+ return client.orm(lastSchemaVersion);
110
+ }
111
+ throw error;
112
+ }
113
+ };
114
+
115
+ const buildBundleWhere = (where?: DatabaseBundleQueryWhere) => (b: any) => {
116
+ if (where?.id?.in && where.id.in.length === 0) {
117
+ return false;
118
+ }
119
+
120
+ if (where?.targetAppVersionIn && where.targetAppVersionIn.length === 0) {
121
+ return false;
122
+ }
123
+
124
+ const conditions = [];
125
+
126
+ if (where?.channel !== undefined) {
127
+ conditions.push(b("channel", "=", where.channel));
128
+ }
129
+ if (where?.platform !== undefined) {
130
+ conditions.push(b("platform", "=", where.platform));
131
+ }
132
+ if (where?.enabled !== undefined) {
133
+ conditions.push(b("enabled", "=", where.enabled));
134
+ }
135
+ if (where?.id?.eq !== undefined) {
136
+ conditions.push(b("id", "=", where.id.eq));
137
+ }
138
+ if (where?.id?.gt !== undefined) {
139
+ conditions.push(b("id", ">", where.id.gt));
140
+ }
141
+ if (where?.id?.gte !== undefined) {
142
+ conditions.push(b("id", ">=", where.id.gte));
143
+ }
144
+ if (where?.id?.lt !== undefined) {
145
+ conditions.push(b("id", "<", where.id.lt));
146
+ }
147
+ if (where?.id?.lte !== undefined) {
148
+ conditions.push(b("id", "<=", where.id.lte));
149
+ }
150
+ if (where?.id?.in) {
151
+ conditions.push(b("id", "in", where.id.in));
152
+ }
153
+ if (where?.targetAppVersionNotNull) {
154
+ conditions.push(b.isNotNull("target_app_version"));
155
+ }
156
+ if (where?.targetAppVersion !== undefined) {
157
+ conditions.push(
158
+ where.targetAppVersion === null
159
+ ? b.isNull("target_app_version")
160
+ : b("target_app_version", "=", where.targetAppVersion),
161
+ );
162
+ }
163
+ if (where?.targetAppVersionIn) {
164
+ conditions.push(b("target_app_version", "in", where.targetAppVersionIn));
165
+ }
166
+ if (where?.fingerprintHash !== undefined) {
167
+ conditions.push(
168
+ where.fingerprintHash === null
169
+ ? b.isNull("fingerprint_hash")
170
+ : b("fingerprint_hash", "=", where.fingerprintHash),
171
+ );
172
+ }
173
+
174
+ return conditions.length > 0 ? b.and(...conditions) : true;
175
+ };
42
176
 
43
- const api: DatabaseAPI = {
177
+ const api: DatabaseAPI<TContext> = {
44
178
  async getBundleById(id: string): Promise<Bundle | null> {
45
- const version = await client.version();
46
- const orm = client.orm(version);
179
+ const orm = await ensureORM();
47
180
  const result = await orm.findFirst("bundles", {
48
181
  select: [
49
182
  "id",
@@ -58,6 +191,8 @@ export function createOrmDatabaseCore({
58
191
  "target_app_version",
59
192
  "fingerprint_hash",
60
193
  "metadata",
194
+ "rollout_cohort_count",
195
+ "target_cohorts",
61
196
  ],
62
197
  where: (b) => b("id", "=", id),
63
198
  });
@@ -74,13 +209,15 @@ export function createOrmDatabaseCore({
74
209
  storageUri: result.storage_uri,
75
210
  targetAppVersion: result.target_app_version ?? null,
76
211
  fingerprintHash: result.fingerprint_hash ?? null,
212
+ rolloutCohortCount:
213
+ result.rollout_cohort_count ?? DEFAULT_ROLLOUT_COHORT_COUNT,
214
+ targetCohorts: parseTargetCohorts(result.target_cohorts),
77
215
  };
78
216
  return bundle;
79
217
  },
80
218
 
81
219
  async getUpdateInfo(args: GetBundlesArgs): Promise<UpdateInfo | null> {
82
- const version = await client.version();
83
- const orm = client.orm(version);
220
+ const orm = await ensureORM();
84
221
 
85
222
  type UpdateSelectRow = {
86
223
  id: string;
@@ -88,6 +225,10 @@ export function createOrmDatabaseCore({
88
225
  message: string | null;
89
226
  storage_uri: string | null;
90
227
  file_hash: string;
228
+ rollout_cohort_count?: number | null;
229
+ target_cohorts?: unknown | null;
230
+ target_app_version?: string | null;
231
+ fingerprint_hash?: string | null;
91
232
  };
92
233
 
93
234
  const toUpdateInfo = (
@@ -112,167 +253,225 @@ export function createOrmDatabaseCore({
112
253
  fileHash: null,
113
254
  };
114
255
 
115
- const appVersionStrategy = async ({
116
- platform,
117
- appVersion,
118
- bundleId,
119
- minBundleId = NIL_UUID,
120
- channel = "production",
121
- }: AppVersionGetBundlesArgs): Promise<UpdateInfo | null> => {
122
- const versionRows = await orm.findMany("bundles", {
123
- select: ["target_app_version"],
124
- where: (b) => b.and(b("platform", "=", platform)),
125
- });
126
- const allTargetVersions = Array.from(
127
- new Set(
128
- (versionRows ?? [])
129
- .map((r) => r.target_app_version)
130
- .filter((v): v is string => Boolean(v)),
131
- ),
132
- );
133
- const compatibleVersions = filterCompatibleAppVersions(
134
- allTargetVersions,
135
- appVersion,
136
- );
137
-
138
- const baseRows =
139
- compatibleVersions.length === 0
140
- ? []
141
- : await orm.findMany("bundles", {
142
- select: [
143
- "id",
144
- "should_force_update",
145
- "message",
146
- "storage_uri",
147
- "file_hash",
148
- "channel",
149
- "target_app_version",
150
- "enabled",
151
- ],
152
- where: (b) =>
153
- b.and(
154
- b("enabled", "=", true),
155
- b("platform", "=", platform),
156
- b("id", ">=", minBundleId ?? NIL_UUID),
157
- b("channel", "=", channel),
158
- b.isNotNull("target_app_version"),
159
- ),
160
- });
161
-
162
- const candidates = (baseRows ?? []).filter((r) =>
163
- r.target_app_version
164
- ? compatibleVersions.includes(r.target_app_version)
165
- : false,
256
+ const isEligibleForUpdate = (
257
+ row: UpdateSelectRow,
258
+ cohort: string | undefined,
259
+ ): boolean => {
260
+ return isCohortEligibleForUpdate(
261
+ row.id,
262
+ cohort,
263
+ row.rollout_cohort_count ?? null,
264
+ parseTargetCohorts(row.target_cohorts),
166
265
  );
266
+ };
167
267
 
168
- const byIdDesc = (a: { id: string }, b: { id: string }) =>
169
- b.id.localeCompare(a.id);
170
- const sorted = (candidates ?? []).slice().sort(byIdDesc);
171
-
172
- const latestCandidate = sorted[0] ?? null;
173
- const currentBundle = sorted.find((b) => b.id === bundleId);
174
- const updateCandidate =
175
- sorted.find((b) => b.id.localeCompare(bundleId) > 0) ?? null;
176
- const rollbackCandidate =
177
- sorted.find((b) => b.id.localeCompare(bundleId) < 0) ?? null;
268
+ const findUpdateInfoByScanning = async ({
269
+ args,
270
+ where,
271
+ isCandidate,
272
+ }: {
273
+ args: AppVersionGetBundlesArgs | FingerprintGetBundlesArgs;
274
+ where: DatabaseBundleQueryWhere;
275
+ isCandidate: (row: UpdateSelectRow) => boolean;
276
+ }): Promise<UpdateInfo | null> => {
277
+ if (isMongoAdapter) {
278
+ const rows = await orm.findMany("bundles", {
279
+ select: [
280
+ "id",
281
+ "should_force_update",
282
+ "message",
283
+ "storage_uri",
284
+ "file_hash",
285
+ "rollout_cohort_count",
286
+ "target_cohorts",
287
+ "target_app_version",
288
+ "fingerprint_hash",
289
+ ],
290
+ where: buildBundleWhere(where),
291
+ });
292
+
293
+ rows.sort((a, b) => b.id.localeCompare(a.id));
294
+
295
+ for (const row of rows) {
296
+ if (!isCandidate(row)) {
297
+ continue;
298
+ }
299
+
300
+ if (args.bundleId === NIL_UUID) {
301
+ if (isEligibleForUpdate(row, args.cohort)) {
302
+ return toUpdateInfo(row, "UPDATE");
303
+ }
304
+ continue;
305
+ }
306
+
307
+ const compareResult = row.id.localeCompare(args.bundleId);
308
+
309
+ if (compareResult > 0) {
310
+ if (isEligibleForUpdate(row, args.cohort)) {
311
+ return toUpdateInfo(row, "UPDATE");
312
+ }
313
+ continue;
314
+ }
315
+
316
+ if (compareResult === 0) {
317
+ if (isEligibleForUpdate(row, args.cohort)) {
318
+ return null;
319
+ }
320
+ continue;
321
+ }
322
+
323
+ return toUpdateInfo(row, "ROLLBACK");
324
+ }
178
325
 
179
- if (bundleId === NIL_UUID) {
180
- if (latestCandidate && latestCandidate.id !== bundleId) {
181
- return toUpdateInfo(latestCandidate, "UPDATE");
326
+ if (args.bundleId === NIL_UUID) {
327
+ return null;
182
328
  }
183
- return null;
184
- }
185
329
 
186
- if (currentBundle) {
187
330
  if (
188
- latestCandidate &&
189
- latestCandidate.id.localeCompare(currentBundle.id) > 0
331
+ args.minBundleId &&
332
+ args.bundleId.localeCompare(args.minBundleId) <= 0
190
333
  ) {
191
- return toUpdateInfo(latestCandidate, "UPDATE");
334
+ return null;
192
335
  }
193
- return null;
336
+
337
+ return INIT_BUNDLE_ROLLBACK_UPDATE_INFO;
194
338
  }
195
339
 
196
- if (updateCandidate) {
197
- return toUpdateInfo(updateCandidate, "UPDATE");
340
+ let offset = 0;
341
+
342
+ while (true) {
343
+ const rows = await orm.findMany("bundles", {
344
+ select: [
345
+ "id",
346
+ "should_force_update",
347
+ "message",
348
+ "storage_uri",
349
+ "file_hash",
350
+ "rollout_cohort_count",
351
+ "target_cohorts",
352
+ "target_app_version",
353
+ "fingerprint_hash",
354
+ ],
355
+ where: buildBundleWhere(where),
356
+ orderBy: [["id", "desc"]],
357
+ limit: UPDATE_CHECK_PAGE_SIZE,
358
+ offset,
359
+ });
360
+
361
+ for (const row of rows) {
362
+ if (!isCandidate(row)) {
363
+ continue;
364
+ }
365
+
366
+ if (args.bundleId === NIL_UUID) {
367
+ if (isEligibleForUpdate(row, args.cohort)) {
368
+ return toUpdateInfo(row, "UPDATE");
369
+ }
370
+ continue;
371
+ }
372
+
373
+ const compareResult = row.id.localeCompare(args.bundleId);
374
+
375
+ if (compareResult > 0) {
376
+ if (isEligibleForUpdate(row, args.cohort)) {
377
+ return toUpdateInfo(row, "UPDATE");
378
+ }
379
+ continue;
380
+ }
381
+
382
+ if (compareResult === 0) {
383
+ if (isEligibleForUpdate(row, args.cohort)) {
384
+ return null;
385
+ }
386
+ continue;
387
+ }
388
+
389
+ return toUpdateInfo(row, "ROLLBACK");
390
+ }
391
+
392
+ if (rows.length < UPDATE_CHECK_PAGE_SIZE) {
393
+ break;
394
+ }
395
+
396
+ offset += UPDATE_CHECK_PAGE_SIZE;
198
397
  }
199
- if (rollbackCandidate) {
200
- return toUpdateInfo(rollbackCandidate, "ROLLBACK");
398
+
399
+ if (args.bundleId === NIL_UUID) {
400
+ return null;
201
401
  }
202
402
 
203
- if (minBundleId && bundleId.localeCompare(minBundleId) <= 0) {
403
+ if (
404
+ args.minBundleId &&
405
+ args.bundleId.localeCompare(args.minBundleId) <= 0
406
+ ) {
204
407
  return null;
205
408
  }
409
+
206
410
  return INIT_BUNDLE_ROLLBACK_UPDATE_INFO;
207
411
  };
208
412
 
413
+ const appVersionStrategy = async ({
414
+ platform,
415
+ appVersion,
416
+ bundleId,
417
+ minBundleId = NIL_UUID,
418
+ channel = "production",
419
+ cohort,
420
+ }: AppVersionGetBundlesArgs): Promise<UpdateInfo | null> => {
421
+ return findUpdateInfoByScanning({
422
+ args: {
423
+ _updateStrategy: "appVersion",
424
+ platform,
425
+ appVersion,
426
+ bundleId,
427
+ minBundleId,
428
+ channel,
429
+ cohort,
430
+ },
431
+ where: {
432
+ enabled: true,
433
+ platform,
434
+ channel,
435
+ id: {
436
+ gte: minBundleId,
437
+ },
438
+ targetAppVersionNotNull: true,
439
+ },
440
+ isCandidate: (row) =>
441
+ !!row.target_app_version &&
442
+ semverSatisfies(row.target_app_version, appVersion),
443
+ });
444
+ };
445
+
209
446
  const fingerprintStrategy = async ({
210
447
  platform,
211
448
  fingerprintHash,
212
449
  bundleId,
213
450
  minBundleId = NIL_UUID,
214
451
  channel = "production",
452
+ cohort,
215
453
  }: FingerprintGetBundlesArgs): Promise<UpdateInfo | null> => {
216
- const candidates = await orm.findMany("bundles", {
217
- select: [
218
- "id",
219
- "should_force_update",
220
- "message",
221
- "storage_uri",
222
- "file_hash",
223
- "channel",
224
- "fingerprint_hash",
225
- "enabled",
226
- ],
227
- where: (b) =>
228
- b.and(
229
- b("enabled", "=", true),
230
- b("platform", "=", platform),
231
- b("id", ">=", minBundleId ?? NIL_UUID),
232
- b("channel", "=", channel),
233
- b("fingerprint_hash", "=", fingerprintHash),
234
- ),
454
+ return findUpdateInfoByScanning({
455
+ args: {
456
+ _updateStrategy: "fingerprint",
457
+ platform,
458
+ fingerprintHash,
459
+ bundleId,
460
+ minBundleId,
461
+ channel,
462
+ cohort,
463
+ },
464
+ where: {
465
+ enabled: true,
466
+ platform,
467
+ channel,
468
+ id: {
469
+ gte: minBundleId,
470
+ },
471
+ fingerprintHash,
472
+ },
473
+ isCandidate: (row) => row.fingerprint_hash === fingerprintHash,
235
474
  });
236
-
237
- const byIdDesc = (a: { id: string }, b: { id: string }) =>
238
- b.id.localeCompare(a.id);
239
- const sorted = (candidates ?? []).slice().sort(byIdDesc);
240
-
241
- const latestCandidate = sorted[0] ?? null;
242
- const currentBundle = sorted.find((b) => b.id === bundleId);
243
- const updateCandidate =
244
- sorted.find((b) => b.id.localeCompare(bundleId) > 0) ?? null;
245
- const rollbackCandidate =
246
- sorted.find((b) => b.id.localeCompare(bundleId) < 0) ?? null;
247
-
248
- if (bundleId === NIL_UUID) {
249
- if (latestCandidate && latestCandidate.id !== bundleId) {
250
- return toUpdateInfo(latestCandidate, "UPDATE");
251
- }
252
- return null;
253
- }
254
-
255
- if (currentBundle) {
256
- if (
257
- latestCandidate &&
258
- latestCandidate.id.localeCompare(currentBundle.id) > 0
259
- ) {
260
- return toUpdateInfo(latestCandidate, "UPDATE");
261
- }
262
- return null;
263
- }
264
-
265
- if (updateCandidate) {
266
- return toUpdateInfo(updateCandidate, "UPDATE");
267
- }
268
- if (rollbackCandidate) {
269
- return toUpdateInfo(rollbackCandidate, "ROLLBACK");
270
- }
271
-
272
- if (minBundleId && bundleId.localeCompare(minBundleId) <= 0) {
273
- return null;
274
- }
275
- return INIT_BUNDLE_ROLLBACK_UPDATE_INFO;
276
475
  };
277
476
 
278
477
  if (args._updateStrategy === "appVersion") {
@@ -286,92 +485,117 @@ export function createOrmDatabaseCore({
286
485
 
287
486
  async getAppUpdateInfo(
288
487
  args: GetBundlesArgs,
488
+ context?: HotUpdaterContext<TContext>,
289
489
  ): Promise<AppUpdateInfo | null> {
290
490
  const info = await this.getUpdateInfo(args);
291
491
  if (!info) return null;
292
492
  const { storageUri, ...rest } = info as UpdateInfo & {
293
493
  storageUri: string | null;
294
494
  };
295
- const fileUrl = await resolveFileUrl(storageUri ?? null);
495
+ const fileUrl = await resolveFileUrl(storageUri ?? null, context);
296
496
  return { ...rest, fileUrl };
297
497
  },
298
498
 
299
499
  async getChannels(): Promise<string[]> {
300
- const version = await client.version();
301
- const orm = client.orm(version);
500
+ const orm = await ensureORM();
302
501
  const rows = await orm.findMany("bundles", {
303
502
  select: ["channel"],
503
+ orderBy: [["channel", "asc"]],
304
504
  });
305
505
  const set = new Set(rows?.map((r) => r.channel) ?? []);
306
506
  return Array.from(set);
307
507
  },
308
508
 
309
- async getBundles(options: {
310
- where?: { channel?: string; platform?: string };
311
- limit: number;
312
- offset: number;
313
- }): Promise<{ data: Bundle[]; pagination: PaginationInfo }> {
314
- const version = await client.version();
315
- const orm = client.orm(version);
316
- const { where, limit, offset } = options;
509
+ async getBundles(
510
+ options: DatabaseBundleQueryOptions,
511
+ ): Promise<{ data: Bundle[]; pagination: PaginationInfo }> {
512
+ const orm = await ensureORM();
513
+ const { where, limit, offset, orderBy } = options;
317
514
 
318
- const rows = await orm.findMany("bundles", {
319
- select: [
320
- "id",
321
- "platform",
322
- "should_force_update",
323
- "enabled",
324
- "file_hash",
325
- "git_commit_hash",
326
- "message",
327
- "channel",
328
- "storage_uri",
329
- "target_app_version",
330
- "fingerprint_hash",
331
- "metadata",
332
- ],
333
- where: (b) => {
334
- const conditions = [];
335
- if (where?.channel) {
336
- conditions.push(b("channel", "=", where.channel));
337
- }
338
- if (where?.platform) {
339
- conditions.push(b("platform", "=", where.platform));
340
- }
341
- return conditions.length > 0 ? b.and(...conditions) : true;
342
- },
515
+ const total = await orm.count("bundles", {
516
+ where: buildBundleWhere(where),
343
517
  });
344
518
 
345
- const all: Bundle[] = rows
346
- .map(
347
- (r): Bundle => ({
348
- id: r.id,
349
- platform: r.platform as Platform,
350
- shouldForceUpdate: Boolean(r.should_force_update),
351
- enabled: Boolean(r.enabled),
352
- fileHash: r.file_hash,
353
- gitCommitHash: r.git_commit_hash ?? null,
354
- message: r.message ?? null,
355
- channel: r.channel,
356
- storageUri: r.storage_uri,
357
- targetAppVersion: r.target_app_version ?? null,
358
- fingerprintHash: r.fingerprint_hash ?? null,
359
- }),
360
- )
361
- .sort((a, b) => b.id.localeCompare(a.id));
362
-
363
- const total = all.length;
364
- const sliced = all.slice(offset, offset + limit);
519
+ const selectedColumns: Array<
520
+ | "id"
521
+ | "platform"
522
+ | "should_force_update"
523
+ | "enabled"
524
+ | "file_hash"
525
+ | "git_commit_hash"
526
+ | "message"
527
+ | "channel"
528
+ | "storage_uri"
529
+ | "target_app_version"
530
+ | "fingerprint_hash"
531
+ | "metadata"
532
+ | "rollout_cohort_count"
533
+ | "target_cohorts"
534
+ > = [
535
+ "id",
536
+ "platform",
537
+ "should_force_update",
538
+ "enabled",
539
+ "file_hash",
540
+ "git_commit_hash",
541
+ "message",
542
+ "channel",
543
+ "storage_uri",
544
+ "target_app_version",
545
+ "fingerprint_hash",
546
+ "metadata",
547
+ "rollout_cohort_count",
548
+ "target_cohorts",
549
+ ];
550
+
551
+ const rows = isMongoAdapter
552
+ ? (
553
+ await orm.findMany("bundles", {
554
+ select: selectedColumns,
555
+ where: buildBundleWhere(where),
556
+ })
557
+ )
558
+ .sort((a, b) => {
559
+ const direction = orderBy?.direction ?? "desc";
560
+ const result = a.id.localeCompare(b.id);
561
+ return direction === "asc" ? result : -result;
562
+ })
563
+ .slice(offset, offset + limit)
564
+ : await orm.findMany("bundles", {
565
+ select: selectedColumns,
566
+ where: buildBundleWhere(where),
567
+ orderBy: [[orderBy?.field ?? "id", orderBy?.direction ?? "desc"]],
568
+ limit,
569
+ offset,
570
+ });
571
+
572
+ const data: Bundle[] = rows.map(
573
+ (r): Bundle => ({
574
+ id: r.id,
575
+ platform: r.platform as Platform,
576
+ shouldForceUpdate: Boolean(r.should_force_update),
577
+ enabled: Boolean(r.enabled),
578
+ fileHash: r.file_hash,
579
+ gitCommitHash: r.git_commit_hash ?? null,
580
+ message: r.message ?? null,
581
+ channel: r.channel,
582
+ storageUri: r.storage_uri,
583
+ targetAppVersion: r.target_app_version ?? null,
584
+ fingerprintHash: r.fingerprint_hash ?? null,
585
+ rolloutCohortCount:
586
+ r.rollout_cohort_count ?? DEFAULT_ROLLOUT_COHORT_COUNT,
587
+ targetCohorts: parseTargetCohorts(r.target_cohorts),
588
+ }),
589
+ );
365
590
 
366
591
  return {
367
- data: sliced,
592
+ data,
368
593
  pagination: calculatePagination(total, { limit, offset }),
369
594
  };
370
595
  },
371
596
 
372
597
  async insertBundle(bundle: Bundle): Promise<void> {
373
- const version = await client.version();
374
- const orm = client.orm(version);
598
+ const orm = await ensureORM();
375
599
  const values = {
376
600
  id: bundle.id,
377
601
  platform: bundle.platform,
@@ -385,6 +609,9 @@ export function createOrmDatabaseCore({
385
609
  target_app_version: bundle.targetAppVersion,
386
610
  fingerprint_hash: bundle.fingerprintHash,
387
611
  metadata: bundle.metadata ?? {},
612
+ rollout_cohort_count:
613
+ bundle.rolloutCohortCount ?? DEFAULT_ROLLOUT_COHORT_COUNT,
614
+ target_cohorts: bundle.targetCohorts ?? null,
388
615
  };
389
616
  const { id, ...updateValues } = values;
390
617
  await orm.upsert("bundles", {
@@ -398,8 +625,7 @@ export function createOrmDatabaseCore({
398
625
  bundleId: string,
399
626
  newBundle: Partial<Bundle>,
400
627
  ): Promise<void> {
401
- const version = await client.version();
402
- const orm = client.orm(version);
628
+ const orm = await ensureORM();
403
629
  const current = await this.getBundleById(bundleId);
404
630
  if (!current) throw new Error("targetBundleId not found");
405
631
  const merged: Bundle = { ...current, ...newBundle };
@@ -416,6 +642,9 @@ export function createOrmDatabaseCore({
416
642
  target_app_version: merged.targetAppVersion,
417
643
  fingerprint_hash: merged.fingerprintHash,
418
644
  metadata: merged.metadata ?? {},
645
+ rollout_cohort_count:
646
+ merged.rolloutCohortCount ?? DEFAULT_ROLLOUT_COHORT_COUNT,
647
+ target_cohorts: merged.targetCohorts ?? null,
419
648
  };
420
649
  const { id: id2, ...updateValues2 } = values;
421
650
  await orm.upsert("bundles", {
@@ -426,8 +655,7 @@ export function createOrmDatabaseCore({
426
655
  },
427
656
 
428
657
  async deleteBundleById(bundleId: string): Promise<void> {
429
- const version = await client.version();
430
- const orm = client.orm(version);
658
+ const orm = await ensureORM();
431
659
  await orm.deleteMany("bundles", { where: (b) => b("id", "=", bundleId) });
432
660
  },
433
661
  };