@hot-updater/server 0.29.5 → 0.29.6

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.
@@ -18,6 +18,40 @@ const parseTargetCohorts = (value) => {
18
18
  };
19
19
  const schemas = [require_v0_21_0.v0_21_0, require_v0_29_0.v0_29_0];
20
20
  const getLastItem = (items) => items.at(-1);
21
+ const DEFAULT_BUNDLE_ORDER = {
22
+ field: "id",
23
+ direction: "desc"
24
+ };
25
+ const mergeIdFilter = (base, patch) => ({
26
+ ...base,
27
+ ...patch
28
+ });
29
+ const mergeWhereWithIdFilter = (where, idFilter) => ({
30
+ ...where,
31
+ id: mergeIdFilter(where?.id, idFilter)
32
+ });
33
+ const buildCursorPageWhere = (where, cursor, orderBy) => {
34
+ const direction = orderBy.direction;
35
+ if (cursor.after) return {
36
+ reverseData: false,
37
+ where: mergeWhereWithIdFilter(where, { [direction === "desc" ? "lt" : "gt"]: cursor.after }),
38
+ orderBy
39
+ };
40
+ if (cursor.before) return {
41
+ reverseData: true,
42
+ where: mergeWhereWithIdFilter(where, { [direction === "desc" ? "gt" : "lt"]: cursor.before }),
43
+ orderBy: {
44
+ field: orderBy.field,
45
+ direction: direction === "desc" ? "asc" : "desc"
46
+ }
47
+ };
48
+ return {
49
+ reverseData: false,
50
+ where: where ?? {},
51
+ orderBy
52
+ };
53
+ };
54
+ const buildCountBeforeWhere = (where, firstBundleId, orderBy) => mergeWhereWithIdFilter(where, { [orderBy.direction === "desc" ? "gt" : "lt"]: firstBundleId });
21
55
  const HotUpdaterDB = (0, fumadb.fumadb)({
22
56
  namespace: "hot_updater",
23
57
  schemas
@@ -264,7 +298,9 @@ function createOrmDatabaseCore({ database, resolveFileUrl }) {
264
298
  },
265
299
  async getBundles(options) {
266
300
  const orm = await ensureORM();
267
- const { where, limit, offset, orderBy } = options;
301
+ const { where, limit } = options;
302
+ const orderBy = options.orderBy ?? DEFAULT_BUNDLE_ORDER;
303
+ const offset = ("offset" in options ? options.offset : void 0) ?? 0;
268
304
  const total = await orm.count("bundles", { where: buildBundleWhere(where) });
269
305
  const selectedColumns = [
270
306
  "id",
@@ -282,39 +318,85 @@ function createOrmDatabaseCore({ database, resolveFileUrl }) {
282
318
  "rollout_cohort_count",
283
319
  "target_cohorts"
284
320
  ];
285
- return {
286
- data: (isMongoAdapter ? (await orm.findMany("bundles", {
321
+ const mapRowsToBundles = (rows) => rows.map((r) => ({
322
+ id: r.id,
323
+ platform: r.platform,
324
+ shouldForceUpdate: Boolean(r.should_force_update),
325
+ enabled: Boolean(r.enabled),
326
+ fileHash: r.file_hash,
327
+ gitCommitHash: r.git_commit_hash ?? null,
328
+ message: r.message ?? null,
329
+ channel: r.channel,
330
+ storageUri: r.storage_uri,
331
+ targetAppVersion: r.target_app_version ?? null,
332
+ fingerprintHash: r.fingerprint_hash ?? null,
333
+ rolloutCohortCount: r.rollout_cohort_count ?? _hot_updater_core.DEFAULT_ROLLOUT_COHORT_COUNT,
334
+ targetCohorts: parseTargetCohorts(r.target_cohorts)
335
+ }));
336
+ const findBundles = async ({ where, orderBy, limit, offset }) => {
337
+ return mapRowsToBundles(isMongoAdapter ? (await orm.findMany("bundles", {
287
338
  select: selectedColumns,
288
339
  where: buildBundleWhere(where)
289
340
  })).sort((a, b) => {
290
- const direction = orderBy?.direction ?? "desc";
291
341
  const result = a.id.localeCompare(b.id);
292
- return direction === "asc" ? result : -result;
342
+ return orderBy.direction === "asc" ? result : -result;
293
343
  }).slice(offset, offset + limit) : await orm.findMany("bundles", {
294
344
  select: selectedColumns,
295
345
  where: buildBundleWhere(where),
296
- orderBy: [[orderBy?.field ?? "id", orderBy?.direction ?? "desc"]],
346
+ orderBy: [[orderBy.field, orderBy.direction]],
297
347
  limit,
298
348
  offset
299
- })).map((r) => ({
300
- id: r.id,
301
- platform: r.platform,
302
- shouldForceUpdate: Boolean(r.should_force_update),
303
- enabled: Boolean(r.enabled),
304
- fileHash: r.file_hash,
305
- gitCommitHash: r.git_commit_hash ?? null,
306
- message: r.message ?? null,
307
- channel: r.channel,
308
- storageUri: r.storage_uri,
309
- targetAppVersion: r.target_app_version ?? null,
310
- fingerprintHash: r.fingerprint_hash ?? null,
311
- rolloutCohortCount: r.rollout_cohort_count ?? _hot_updater_core.DEFAULT_ROLLOUT_COHORT_COUNT,
312
- targetCohorts: parseTargetCohorts(r.target_cohorts)
313
- })),
314
- pagination: require_calculatePagination.calculatePagination(total, {
349
+ }));
350
+ };
351
+ if (!options.cursor?.after && !options.cursor?.before) {
352
+ const data = await findBundles({
353
+ where,
354
+ orderBy,
315
355
  limit,
316
356
  offset
317
- })
357
+ });
358
+ return {
359
+ data,
360
+ pagination: {
361
+ ...require_calculatePagination.calculatePagination(total, {
362
+ limit,
363
+ offset
364
+ }),
365
+ ...data.length > 0 && offset + data.length < total ? { nextCursor: data.at(-1)?.id } : {},
366
+ ...data.length > 0 && offset > 0 ? { previousCursor: data[0]?.id } : {}
367
+ }
368
+ };
369
+ }
370
+ const { where: cursorWhere, orderBy: cursorOrderBy, reverseData } = buildCursorPageWhere(where, options.cursor, orderBy);
371
+ const cursorPage = await findBundles({
372
+ where: cursorWhere,
373
+ orderBy: cursorOrderBy,
374
+ limit,
375
+ offset: 0
376
+ });
377
+ const data = reverseData ? cursorPage.slice().reverse() : cursorPage;
378
+ if (data.length === 0) return {
379
+ data,
380
+ pagination: {
381
+ ...require_calculatePagination.calculatePagination(total, {
382
+ limit,
383
+ offset: options.cursor.after ? total : 0
384
+ }),
385
+ ...options.cursor.after ? { previousCursor: options.cursor.after } : {},
386
+ ...options.cursor.before ? { nextCursor: options.cursor.before } : {}
387
+ }
388
+ };
389
+ const startIndex = await orm.count("bundles", { where: buildBundleWhere(buildCountBeforeWhere(where, data[0].id, orderBy)) });
390
+ return {
391
+ data,
392
+ pagination: {
393
+ ...require_calculatePagination.calculatePagination(total, {
394
+ limit,
395
+ offset: startIndex
396
+ }),
397
+ ...startIndex + data.length < total ? { nextCursor: data.at(-1)?.id } : {},
398
+ ...startIndex > 0 ? { previousCursor: data[0]?.id } : {}
399
+ }
318
400
  };
319
401
  },
320
402
  async insertBundle(bundle) {
@@ -18,6 +18,40 @@ const parseTargetCohorts = (value) => {
18
18
  };
19
19
  const schemas = [v0_21_0, v0_29_0];
20
20
  const getLastItem = (items) => items.at(-1);
21
+ const DEFAULT_BUNDLE_ORDER = {
22
+ field: "id",
23
+ direction: "desc"
24
+ };
25
+ const mergeIdFilter = (base, patch) => ({
26
+ ...base,
27
+ ...patch
28
+ });
29
+ const mergeWhereWithIdFilter = (where, idFilter) => ({
30
+ ...where,
31
+ id: mergeIdFilter(where?.id, idFilter)
32
+ });
33
+ const buildCursorPageWhere = (where, cursor, orderBy) => {
34
+ const direction = orderBy.direction;
35
+ if (cursor.after) return {
36
+ reverseData: false,
37
+ where: mergeWhereWithIdFilter(where, { [direction === "desc" ? "lt" : "gt"]: cursor.after }),
38
+ orderBy
39
+ };
40
+ if (cursor.before) return {
41
+ reverseData: true,
42
+ where: mergeWhereWithIdFilter(where, { [direction === "desc" ? "gt" : "lt"]: cursor.before }),
43
+ orderBy: {
44
+ field: orderBy.field,
45
+ direction: direction === "desc" ? "asc" : "desc"
46
+ }
47
+ };
48
+ return {
49
+ reverseData: false,
50
+ where: where ?? {},
51
+ orderBy
52
+ };
53
+ };
54
+ const buildCountBeforeWhere = (where, firstBundleId, orderBy) => mergeWhereWithIdFilter(where, { [orderBy.direction === "desc" ? "gt" : "lt"]: firstBundleId });
21
55
  const HotUpdaterDB = fumadb({
22
56
  namespace: "hot_updater",
23
57
  schemas
@@ -264,7 +298,9 @@ function createOrmDatabaseCore({ database, resolveFileUrl }) {
264
298
  },
265
299
  async getBundles(options) {
266
300
  const orm = await ensureORM();
267
- const { where, limit, offset, orderBy } = options;
301
+ const { where, limit } = options;
302
+ const orderBy = options.orderBy ?? DEFAULT_BUNDLE_ORDER;
303
+ const offset = ("offset" in options ? options.offset : void 0) ?? 0;
268
304
  const total = await orm.count("bundles", { where: buildBundleWhere(where) });
269
305
  const selectedColumns = [
270
306
  "id",
@@ -282,39 +318,85 @@ function createOrmDatabaseCore({ database, resolveFileUrl }) {
282
318
  "rollout_cohort_count",
283
319
  "target_cohorts"
284
320
  ];
285
- return {
286
- data: (isMongoAdapter ? (await orm.findMany("bundles", {
321
+ const mapRowsToBundles = (rows) => rows.map((r) => ({
322
+ id: r.id,
323
+ platform: r.platform,
324
+ shouldForceUpdate: Boolean(r.should_force_update),
325
+ enabled: Boolean(r.enabled),
326
+ fileHash: r.file_hash,
327
+ gitCommitHash: r.git_commit_hash ?? null,
328
+ message: r.message ?? null,
329
+ channel: r.channel,
330
+ storageUri: r.storage_uri,
331
+ targetAppVersion: r.target_app_version ?? null,
332
+ fingerprintHash: r.fingerprint_hash ?? null,
333
+ rolloutCohortCount: r.rollout_cohort_count ?? DEFAULT_ROLLOUT_COHORT_COUNT,
334
+ targetCohorts: parseTargetCohorts(r.target_cohorts)
335
+ }));
336
+ const findBundles = async ({ where, orderBy, limit, offset }) => {
337
+ return mapRowsToBundles(isMongoAdapter ? (await orm.findMany("bundles", {
287
338
  select: selectedColumns,
288
339
  where: buildBundleWhere(where)
289
340
  })).sort((a, b) => {
290
- const direction = orderBy?.direction ?? "desc";
291
341
  const result = a.id.localeCompare(b.id);
292
- return direction === "asc" ? result : -result;
342
+ return orderBy.direction === "asc" ? result : -result;
293
343
  }).slice(offset, offset + limit) : await orm.findMany("bundles", {
294
344
  select: selectedColumns,
295
345
  where: buildBundleWhere(where),
296
- orderBy: [[orderBy?.field ?? "id", orderBy?.direction ?? "desc"]],
346
+ orderBy: [[orderBy.field, orderBy.direction]],
297
347
  limit,
298
348
  offset
299
- })).map((r) => ({
300
- id: r.id,
301
- platform: r.platform,
302
- shouldForceUpdate: Boolean(r.should_force_update),
303
- enabled: Boolean(r.enabled),
304
- fileHash: r.file_hash,
305
- gitCommitHash: r.git_commit_hash ?? null,
306
- message: r.message ?? null,
307
- channel: r.channel,
308
- storageUri: r.storage_uri,
309
- targetAppVersion: r.target_app_version ?? null,
310
- fingerprintHash: r.fingerprint_hash ?? null,
311
- rolloutCohortCount: r.rollout_cohort_count ?? DEFAULT_ROLLOUT_COHORT_COUNT,
312
- targetCohorts: parseTargetCohorts(r.target_cohorts)
313
- })),
314
- pagination: calculatePagination(total, {
349
+ }));
350
+ };
351
+ if (!options.cursor?.after && !options.cursor?.before) {
352
+ const data = await findBundles({
353
+ where,
354
+ orderBy,
315
355
  limit,
316
356
  offset
317
- })
357
+ });
358
+ return {
359
+ data,
360
+ pagination: {
361
+ ...calculatePagination(total, {
362
+ limit,
363
+ offset
364
+ }),
365
+ ...data.length > 0 && offset + data.length < total ? { nextCursor: data.at(-1)?.id } : {},
366
+ ...data.length > 0 && offset > 0 ? { previousCursor: data[0]?.id } : {}
367
+ }
368
+ };
369
+ }
370
+ const { where: cursorWhere, orderBy: cursorOrderBy, reverseData } = buildCursorPageWhere(where, options.cursor, orderBy);
371
+ const cursorPage = await findBundles({
372
+ where: cursorWhere,
373
+ orderBy: cursorOrderBy,
374
+ limit,
375
+ offset: 0
376
+ });
377
+ const data = reverseData ? cursorPage.slice().reverse() : cursorPage;
378
+ if (data.length === 0) return {
379
+ data,
380
+ pagination: {
381
+ ...calculatePagination(total, {
382
+ limit,
383
+ offset: options.cursor.after ? total : 0
384
+ }),
385
+ ...options.cursor.after ? { previousCursor: options.cursor.after } : {},
386
+ ...options.cursor.before ? { nextCursor: options.cursor.before } : {}
387
+ }
388
+ };
389
+ const startIndex = await orm.count("bundles", { where: buildBundleWhere(buildCountBeforeWhere(where, data[0].id, orderBy)) });
390
+ return {
391
+ data,
392
+ pagination: {
393
+ ...calculatePagination(total, {
394
+ limit,
395
+ offset: startIndex
396
+ }),
397
+ ...startIndex + data.length < total ? { nextCursor: data.at(-1)?.id } : {},
398
+ ...startIndex > 0 ? { previousCursor: data[0]?.id } : {}
399
+ }
318
400
  };
319
401
  },
320
402
  async insertBundle(bundle) {
@@ -69,13 +69,13 @@ function createPluginDatabaseCore(getPlugin, resolveFileUrl, options) {
69
69
  return (0, _hot_updater_core.isCohortEligibleForUpdate)(bundle.id, cohort, bundle.rolloutCohortCount, bundle.targetCohorts);
70
70
  };
71
71
  const findUpdateInfoByScanning = async ({ args, queryWhere, isCandidate, context }) => {
72
- let offset = 0;
72
+ let after;
73
73
  while (true) {
74
74
  const { data, pagination } = await getSortedBundlePage({
75
75
  where: queryWhere,
76
76
  limit: PAGE_SIZE,
77
- offset,
78
- orderBy: DESC_ORDER
77
+ orderBy: DESC_ORDER,
78
+ ...after ? { cursor: { after } } : {}
79
79
  }, context);
80
80
  for (const bundle of data) {
81
81
  if (!bundleMatchesQueryWhere(bundle, queryWhere) || !isCandidate(bundle)) continue;
@@ -95,7 +95,8 @@ function createPluginDatabaseCore(getPlugin, resolveFileUrl, options) {
95
95
  return makeResponse(bundle, "ROLLBACK");
96
96
  }
97
97
  if (!pagination.hasNextPage) break;
98
- offset += PAGE_SIZE;
98
+ after = data.at(-1)?.id;
99
+ if (!after) break;
99
100
  }
100
101
  if (args.bundleId === _hot_updater_core.NIL_UUID) return null;
101
102
  if (args.minBundleId && args.bundleId.localeCompare(args.minBundleId) <= 0) return null;
@@ -69,13 +69,13 @@ function createPluginDatabaseCore(getPlugin, resolveFileUrl, options) {
69
69
  return isCohortEligibleForUpdate(bundle.id, cohort, bundle.rolloutCohortCount, bundle.targetCohorts);
70
70
  };
71
71
  const findUpdateInfoByScanning = async ({ args, queryWhere, isCandidate, context }) => {
72
- let offset = 0;
72
+ let after;
73
73
  while (true) {
74
74
  const { data, pagination } = await getSortedBundlePage({
75
75
  where: queryWhere,
76
76
  limit: PAGE_SIZE,
77
- offset,
78
- orderBy: DESC_ORDER
77
+ orderBy: DESC_ORDER,
78
+ ...after ? { cursor: { after } } : {}
79
79
  }, context);
80
80
  for (const bundle of data) {
81
81
  if (!bundleMatchesQueryWhere(bundle, queryWhere) || !isCandidate(bundle)) continue;
@@ -95,7 +95,8 @@ function createPluginDatabaseCore(getPlugin, resolveFileUrl, options) {
95
95
  return makeResponse(bundle, "ROLLBACK");
96
96
  }
97
97
  if (!pagination.hasNextPage) break;
98
- offset += PAGE_SIZE;
98
+ after = data.at(-1)?.id;
99
+ if (!after) break;
99
100
  }
100
101
  if (args.bundleId === NIL_UUID) return null;
101
102
  if (args.minBundleId && args.bundleId.localeCompare(args.minBundleId) <= 0) return null;
package/dist/handler.cjs CHANGED
@@ -7,7 +7,7 @@ var HandlerBadRequestError = class extends Error {
7
7
  }
8
8
  };
9
9
  const handleVersion = async () => {
10
- return new Response(JSON.stringify({ version: "0.29.5" }), {
10
+ return new Response(JSON.stringify({ version: "0.29.6" }), {
11
11
  status: 200,
12
12
  headers: { "Content-Type": "application/json" }
13
13
  });
@@ -97,7 +97,13 @@ const handleGetBundles = async (_params, request, api, context) => {
97
97
  const channel = url.searchParams.get("channel") ?? void 0;
98
98
  const platform = url.searchParams.get("platform");
99
99
  const limit = Number(url.searchParams.get("limit")) || 50;
100
- const offset = Number(url.searchParams.get("offset")) || 0;
100
+ const pageParam = url.searchParams.get("page");
101
+ const offset = url.searchParams.get("offset");
102
+ const after = url.searchParams.get("after") ?? void 0;
103
+ const before = url.searchParams.get("before") ?? void 0;
104
+ const page = pageParam === null ? void 0 : Number.isInteger(Number(pageParam)) && Number(pageParam) > 0 ? Number(pageParam) : null;
105
+ if (offset !== null) throw new HandlerBadRequestError("The 'offset' query parameter has been removed. Use 'after' or 'before' cursor pagination instead.");
106
+ if (page === null) throw new HandlerBadRequestError("The 'page' query parameter must be a positive integer.");
101
107
  if (platform !== null && !isPlatform(platform)) throw new HandlerBadRequestError(`Invalid platform: ${platform}. Expected 'ios' or 'android'.`);
102
108
  const result = await api.getBundles({
103
109
  where: {
@@ -105,7 +111,11 @@ const handleGetBundles = async (_params, request, api, context) => {
105
111
  ...platform && { platform }
106
112
  },
107
113
  limit,
108
- offset
114
+ page,
115
+ cursor: after || before ? {
116
+ after,
117
+ before
118
+ } : void 0
109
119
  }, context);
110
120
  return new Response(JSON.stringify(result), {
111
121
  status: 200,
package/dist/handler.mjs CHANGED
@@ -7,7 +7,7 @@ var HandlerBadRequestError = class extends Error {
7
7
  }
8
8
  };
9
9
  const handleVersion = async () => {
10
- return new Response(JSON.stringify({ version: "0.29.5" }), {
10
+ return new Response(JSON.stringify({ version: "0.29.6" }), {
11
11
  status: 200,
12
12
  headers: { "Content-Type": "application/json" }
13
13
  });
@@ -97,7 +97,13 @@ const handleGetBundles = async (_params, request, api, context) => {
97
97
  const channel = url.searchParams.get("channel") ?? void 0;
98
98
  const platform = url.searchParams.get("platform");
99
99
  const limit = Number(url.searchParams.get("limit")) || 50;
100
- const offset = Number(url.searchParams.get("offset")) || 0;
100
+ const pageParam = url.searchParams.get("page");
101
+ const offset = url.searchParams.get("offset");
102
+ const after = url.searchParams.get("after") ?? void 0;
103
+ const before = url.searchParams.get("before") ?? void 0;
104
+ const page = pageParam === null ? void 0 : Number.isInteger(Number(pageParam)) && Number(pageParam) > 0 ? Number(pageParam) : null;
105
+ if (offset !== null) throw new HandlerBadRequestError("The 'offset' query parameter has been removed. Use 'after' or 'before' cursor pagination instead.");
106
+ if (page === null) throw new HandlerBadRequestError("The 'page' query parameter must be a positive integer.");
101
107
  if (platform !== null && !isPlatform(platform)) throw new HandlerBadRequestError(`Invalid platform: ${platform}. Expected 'ios' or 'android'.`);
102
108
  const result = await api.getBundles({
103
109
  where: {
@@ -105,7 +111,11 @@ const handleGetBundles = async (_params, request, api, context) => {
105
111
  ...platform && { platform }
106
112
  },
107
113
  limit,
108
- offset
114
+ page,
115
+ cursor: after || before ? {
116
+ after,
117
+ before
118
+ } : void 0
109
119
  }, context);
110
120
  return new Response(JSON.stringify(result), {
111
121
  status: 200,
@@ -8,6 +8,8 @@ interface PaginationInfo {
8
8
  hasPreviousPage: boolean;
9
9
  currentPage: number;
10
10
  totalPages: number;
11
+ nextCursor?: string | null;
12
+ previousCursor?: string | null;
11
13
  }
12
14
  interface PaginationOptions {
13
15
  limit: number;
@@ -8,6 +8,8 @@ interface PaginationInfo {
8
8
  hasPreviousPage: boolean;
9
9
  currentPage: number;
10
10
  totalPages: number;
11
+ nextCursor?: string | null;
12
+ previousCursor?: string | null;
11
13
  }
12
14
  interface PaginationOptions {
13
15
  limit: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/server",
3
- "version": "0.29.5",
3
+ "version": "0.29.6",
4
4
  "type": "module",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "sideEffects": false,
@@ -53,9 +53,9 @@
53
53
  "fumadb": "0.2.2",
54
54
  "rou3": "0.7.9",
55
55
  "semver": "^7.7.2",
56
- "@hot-updater/core": "0.29.5",
57
- "@hot-updater/plugin-core": "0.29.5",
58
- "@hot-updater/js": "0.29.5"
56
+ "@hot-updater/plugin-core": "0.29.6",
57
+ "@hot-updater/js": "0.29.6",
58
+ "@hot-updater/core": "0.29.6"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@electric-sql/pglite": "^0.2.17",
@@ -66,8 +66,8 @@
66
66
  "kysely-pglite-dialect": "^1.2.0",
67
67
  "msw": "^2.7.0",
68
68
  "uuidv7": "^1.0.2",
69
- "@hot-updater/standalone": "0.29.5",
70
- "@hot-updater/test-utils": "0.29.5"
69
+ "@hot-updater/test-utils": "0.29.6",
70
+ "@hot-updater/standalone": "0.29.6"
71
71
  },
72
72
  "scripts": {
73
73
  "build": "tsdown",
package/src/db/ormCore.ts CHANGED
@@ -13,6 +13,9 @@ import {
13
13
  NIL_UUID,
14
14
  } from "@hot-updater/core";
15
15
  import type {
16
+ DatabaseBundleCursor,
17
+ DatabaseBundleIdFilter,
18
+ DatabaseBundleQueryOrder,
16
19
  DatabaseBundleQueryOptions,
17
20
  DatabaseBundleQueryWhere,
18
21
  HotUpdaterContext,
@@ -53,6 +56,74 @@ const getLastItem = <T extends unknown[]>(
53
56
  ): T extends [...infer _, infer Last] ? Last : never =>
54
57
  items.at(-1) as T extends [...infer _, infer Last] ? Last : never;
55
58
 
59
+ const DEFAULT_BUNDLE_ORDER = { field: "id", direction: "desc" } as const;
60
+
61
+ const mergeIdFilter = (
62
+ base: DatabaseBundleIdFilter | undefined,
63
+ patch: DatabaseBundleIdFilter,
64
+ ): DatabaseBundleIdFilter => ({
65
+ ...base,
66
+ ...patch,
67
+ });
68
+
69
+ const mergeWhereWithIdFilter = (
70
+ where: DatabaseBundleQueryWhere | undefined,
71
+ idFilter: DatabaseBundleIdFilter,
72
+ ): DatabaseBundleQueryWhere => ({
73
+ ...where,
74
+ id: mergeIdFilter(where?.id, idFilter),
75
+ });
76
+
77
+ const buildCursorPageWhere = (
78
+ where: DatabaseBundleQueryWhere | undefined,
79
+ cursor: DatabaseBundleCursor,
80
+ orderBy: DatabaseBundleQueryOrder,
81
+ ): {
82
+ reverseData: boolean;
83
+ where: DatabaseBundleQueryWhere;
84
+ orderBy: DatabaseBundleQueryOrder;
85
+ } => {
86
+ const direction = orderBy.direction;
87
+
88
+ if (cursor.after) {
89
+ return {
90
+ reverseData: false,
91
+ where: mergeWhereWithIdFilter(where, {
92
+ [direction === "desc" ? "lt" : "gt"]: cursor.after,
93
+ }),
94
+ orderBy,
95
+ };
96
+ }
97
+
98
+ if (cursor.before) {
99
+ return {
100
+ reverseData: true,
101
+ where: mergeWhereWithIdFilter(where, {
102
+ [direction === "desc" ? "gt" : "lt"]: cursor.before,
103
+ }),
104
+ orderBy: {
105
+ field: orderBy.field,
106
+ direction: direction === "desc" ? "asc" : "desc",
107
+ },
108
+ };
109
+ }
110
+
111
+ return {
112
+ reverseData: false,
113
+ where: where ?? {},
114
+ orderBy,
115
+ };
116
+ };
117
+
118
+ const buildCountBeforeWhere = (
119
+ where: DatabaseBundleQueryWhere | undefined,
120
+ firstBundleId: string,
121
+ orderBy: DatabaseBundleQueryOrder,
122
+ ): DatabaseBundleQueryWhere =>
123
+ mergeWhereWithIdFilter(where, {
124
+ [orderBy.direction === "desc" ? "gt" : "lt"]: firstBundleId,
125
+ });
126
+
56
127
  export const HotUpdaterDB = fumadb({
57
128
  namespace: "hot_updater",
58
129
  schemas,
@@ -511,7 +582,12 @@ export function createOrmDatabaseCore<TContext = unknown>({
511
582
  options: DatabaseBundleQueryOptions,
512
583
  ): Promise<Paginated<Bundle[]>> {
513
584
  const orm = await ensureORM();
514
- const { where, limit, offset, orderBy } = options;
585
+ const { where, limit } = options;
586
+ const orderBy = options.orderBy ?? DEFAULT_BUNDLE_ORDER;
587
+ const offset =
588
+ (("offset" in options ? options.offset : undefined) as
589
+ | number
590
+ | undefined) ?? 0;
515
591
 
516
592
  const total = await orm.count("bundles", {
517
593
  where: buildBundleWhere(where),
@@ -549,49 +625,129 @@ export function createOrmDatabaseCore<TContext = unknown>({
549
625
  "target_cohorts",
550
626
  ];
551
627
 
552
- const rows = isMongoAdapter
553
- ? (
554
- await orm.findMany("bundles", {
628
+ const mapRowsToBundles = (rows: any[]): Bundle[] =>
629
+ rows.map(
630
+ (r): Bundle => ({
631
+ id: r.id,
632
+ platform: r.platform as Platform,
633
+ shouldForceUpdate: Boolean(r.should_force_update),
634
+ enabled: Boolean(r.enabled),
635
+ fileHash: r.file_hash,
636
+ gitCommitHash: r.git_commit_hash ?? null,
637
+ message: r.message ?? null,
638
+ channel: r.channel,
639
+ storageUri: r.storage_uri,
640
+ targetAppVersion: r.target_app_version ?? null,
641
+ fingerprintHash: r.fingerprint_hash ?? null,
642
+ rolloutCohortCount:
643
+ r.rollout_cohort_count ?? DEFAULT_ROLLOUT_COHORT_COUNT,
644
+ targetCohorts: parseTargetCohorts(r.target_cohorts),
645
+ }),
646
+ );
647
+
648
+ const findBundles = async ({
649
+ where,
650
+ orderBy,
651
+ limit,
652
+ offset,
653
+ }: {
654
+ where?: DatabaseBundleQueryWhere;
655
+ orderBy: DatabaseBundleQueryOrder;
656
+ limit: number;
657
+ offset: number;
658
+ }) => {
659
+ const rows = isMongoAdapter
660
+ ? (
661
+ await orm.findMany("bundles", {
662
+ select: selectedColumns,
663
+ where: buildBundleWhere(where),
664
+ })
665
+ )
666
+ .sort((a, b) => {
667
+ const result = a.id.localeCompare(b.id);
668
+ return orderBy.direction === "asc" ? result : -result;
669
+ })
670
+ .slice(offset, offset + limit)
671
+ : await orm.findMany("bundles", {
555
672
  select: selectedColumns,
556
673
  where: buildBundleWhere(where),
557
- })
558
- )
559
- .sort((a, b) => {
560
- const direction = orderBy?.direction ?? "desc";
561
- const result = a.id.localeCompare(b.id);
562
- return direction === "asc" ? result : -result;
563
- })
564
- .slice(offset, offset + limit)
565
- : await orm.findMany("bundles", {
566
- select: selectedColumns,
567
- where: buildBundleWhere(where),
568
- orderBy: [[orderBy?.field ?? "id", orderBy?.direction ?? "desc"]],
569
- limit,
570
- offset,
571
- });
674
+ orderBy: [[orderBy.field, orderBy.direction]],
675
+ limit,
676
+ offset,
677
+ });
572
678
 
573
- const data: Bundle[] = rows.map(
574
- (r): Bundle => ({
575
- id: r.id,
576
- platform: r.platform as Platform,
577
- shouldForceUpdate: Boolean(r.should_force_update),
578
- enabled: Boolean(r.enabled),
579
- fileHash: r.file_hash,
580
- gitCommitHash: r.git_commit_hash ?? null,
581
- message: r.message ?? null,
582
- channel: r.channel,
583
- storageUri: r.storage_uri,
584
- targetAppVersion: r.target_app_version ?? null,
585
- fingerprintHash: r.fingerprint_hash ?? null,
586
- rolloutCohortCount:
587
- r.rollout_cohort_count ?? DEFAULT_ROLLOUT_COHORT_COUNT,
588
- targetCohorts: parseTargetCohorts(r.target_cohorts),
589
- }),
590
- );
679
+ return mapRowsToBundles(rows);
680
+ };
681
+
682
+ if (!options.cursor?.after && !options.cursor?.before) {
683
+ const data = await findBundles({
684
+ where,
685
+ orderBy,
686
+ limit,
687
+ offset,
688
+ });
689
+
690
+ return {
691
+ data,
692
+ pagination: {
693
+ ...calculatePagination(total, { limit, offset }),
694
+ ...(data.length > 0 && offset + data.length < total
695
+ ? { nextCursor: data.at(-1)?.id }
696
+ : {}),
697
+ ...(data.length > 0 && offset > 0
698
+ ? { previousCursor: data[0]?.id }
699
+ : {}),
700
+ },
701
+ };
702
+ }
703
+
704
+ const {
705
+ where: cursorWhere,
706
+ orderBy: cursorOrderBy,
707
+ reverseData,
708
+ } = buildCursorPageWhere(where, options.cursor, orderBy);
709
+ const cursorPage = await findBundles({
710
+ where: cursorWhere,
711
+ orderBy: cursorOrderBy,
712
+ limit,
713
+ offset: 0,
714
+ });
715
+ const data = reverseData ? cursorPage.slice().reverse() : cursorPage;
716
+
717
+ if (data.length === 0) {
718
+ const emptyStartIndex = options.cursor.after ? total : 0;
719
+ return {
720
+ data,
721
+ pagination: {
722
+ ...calculatePagination(total, {
723
+ limit,
724
+ offset: emptyStartIndex,
725
+ }),
726
+ ...(options.cursor.after
727
+ ? { previousCursor: options.cursor.after }
728
+ : {}),
729
+ ...(options.cursor.before
730
+ ? { nextCursor: options.cursor.before }
731
+ : {}),
732
+ },
733
+ };
734
+ }
735
+
736
+ const startIndex = await orm.count("bundles", {
737
+ where: buildBundleWhere(
738
+ buildCountBeforeWhere(where, data[0]!.id, orderBy),
739
+ ),
740
+ });
591
741
 
592
742
  return {
593
743
  data,
594
- pagination: calculatePagination(total, { limit, offset }),
744
+ pagination: {
745
+ ...calculatePagination(total, { limit, offset: startIndex }),
746
+ ...(startIndex + data.length < total
747
+ ? { nextCursor: data.at(-1)?.id }
748
+ : {}),
749
+ ...(startIndex > 0 ? { previousCursor: data[0]?.id } : {}),
750
+ },
595
751
  };
596
752
  },
597
753
 
@@ -82,6 +82,47 @@ describe("createPluginDatabaseCore", () => {
82
82
  expect(getBundles).not.toHaveBeenCalled();
83
83
  });
84
84
 
85
+ it("does not fall back to scanning when plugin getUpdateInfo returns null", async () => {
86
+ const getBundles = vi.fn<DatabasePlugin["getBundles"]>(async () => ({
87
+ data: [baseBundle],
88
+ pagination: {
89
+ currentPage: 1,
90
+ hasNextPage: false,
91
+ hasPreviousPage: false,
92
+ total: 1,
93
+ totalPages: 1,
94
+ },
95
+ }));
96
+ const getUpdateInfo = vi.fn<NonNullable<DatabasePlugin["getUpdateInfo"]>>(
97
+ async () => null,
98
+ );
99
+
100
+ const plugin: DatabasePlugin = {
101
+ name: "null-fast-path-plugin",
102
+ async appendBundle() {},
103
+ async commitBundle() {},
104
+ async deleteBundle() {},
105
+ async getBundleById() {
106
+ return null;
107
+ },
108
+ getBundles,
109
+ getUpdateInfo,
110
+ async getChannels() {
111
+ return ["production"];
112
+ },
113
+ async updateBundle() {},
114
+ };
115
+
116
+ const core = createPluginDatabaseCore(
117
+ () => plugin,
118
+ async () => null,
119
+ );
120
+
121
+ await expect(core.api.getUpdateInfo(updateArgs)).resolves.toBeNull();
122
+ expect(getUpdateInfo).toHaveBeenCalledWith(updateArgs);
123
+ expect(getBundles).not.toHaveBeenCalled();
124
+ });
125
+
85
126
  it("falls back to scanning when plugin getUpdateInfo is absent", async () => {
86
127
  const latestBundle = {
87
128
  ...baseBundle,
@@ -172,15 +172,21 @@ export function createPluginDatabaseCore<TContext = unknown>(
172
172
  isCandidate: (bundle: Bundle) => boolean;
173
173
  context?: HotUpdaterContext<TContext>;
174
174
  }): Promise<UpdateInfo | null> => {
175
- let offset = 0;
175
+ let after: string | undefined;
176
176
 
177
177
  while (true) {
178
178
  const { data, pagination } = await getSortedBundlePage(
179
179
  {
180
180
  where: queryWhere,
181
181
  limit: PAGE_SIZE,
182
- offset,
183
182
  orderBy: DESC_ORDER,
183
+ ...(after
184
+ ? {
185
+ cursor: {
186
+ after,
187
+ },
188
+ }
189
+ : {}),
184
190
  },
185
191
  context,
186
192
  );
@@ -223,7 +229,10 @@ export function createPluginDatabaseCore<TContext = unknown>(
223
229
  break;
224
230
  }
225
231
 
226
- offset += PAGE_SIZE;
232
+ after = data.at(-1)?.id;
233
+ if (!after) {
234
+ break;
235
+ }
227
236
  }
228
237
 
229
238
  if (args.bundleId === NIL_UUID) {
@@ -6,7 +6,7 @@ import type {
6
6
  DatabasePlugin,
7
7
  } from "../../../../plugins/plugin-core/src";
8
8
  import {
9
- calculatePagination,
9
+ paginateBundles,
10
10
  semverSatisfies,
11
11
  } from "../../../../plugins/plugin-core/src";
12
12
  import type {
@@ -124,17 +124,19 @@ const createBenchPlugin = (bundles: Bundle[]): DatabasePlugin => {
124
124
  return bundlesById.get(bundleId) ?? null;
125
125
  },
126
126
  async getBundles(options: DatabaseBundleQueryOptions) {
127
- const { where, limit, offset, orderBy } = options;
127
+ const { where, limit, cursor, orderBy } = options;
128
128
  const source = sortByDirection(orderBy?.direction);
129
129
  const matched = source.filter((bundle) =>
130
130
  bundleMatchesWhere(bundle, where),
131
131
  );
132
- const page = matched.slice(offset, offset + limit).map(cloneBundle);
133
-
134
- return {
135
- data: page,
136
- pagination: calculatePagination(matched.length, { limit, offset }),
137
- };
132
+ const paginated = paginateBundles({
133
+ bundles: matched.map(cloneBundle),
134
+ limit,
135
+ cursor,
136
+ orderBy,
137
+ });
138
+
139
+ return paginated;
138
140
  },
139
141
  async getChannels() {
140
142
  return [...new Set(bundles.map((bundle) => bundle.channel))];
@@ -164,7 +166,6 @@ const oldPluginCoreGetUpdateInfo = async (
164
166
  const { pagination } = await plugin.getBundles({
165
167
  where,
166
168
  limit: 1,
167
- offset: 0,
168
169
  });
169
170
 
170
171
  if (pagination.total === 0) {
@@ -174,7 +175,6 @@ const oldPluginCoreGetUpdateInfo = async (
174
175
  const { data } = await plugin.getBundles({
175
176
  where,
176
177
  limit: pagination.total,
177
- offset: 0,
178
178
  });
179
179
 
180
180
  for (const bundle of data) {
@@ -234,7 +234,7 @@ describe("Handler <-> Standalone Repository Integration", () => {
234
234
  })();
235
235
 
236
236
  // Get all bundles
237
- const result = await repo.getBundles({ limit: 50, offset: 0 });
237
+ const result = await repo.getBundles({ limit: 50 });
238
238
 
239
239
  expect(result.data).toHaveLength(3);
240
240
  expect(result.pagination.total).toBe(3);
@@ -243,7 +243,6 @@ describe("Handler <-> Standalone Repository Integration", () => {
243
243
  const prodResult = await repo.getBundles({
244
244
  where: { channel: "production" },
245
245
  limit: 50,
246
- offset: 0,
247
246
  });
248
247
 
249
248
  expect(prodResult.data).toHaveLength(2);
@@ -179,7 +179,7 @@ describe("createHandler", () => {
179
179
 
180
180
  const response = await handler(
181
181
  new Request(
182
- "http://localhost/hot-updater/api/bundles?channel=production&platform=ios&limit=2&offset=10",
182
+ "http://localhost/hot-updater/api/bundles?channel=production&platform=ios&limit=2",
183
183
  ),
184
184
  );
185
185
 
@@ -202,12 +202,162 @@ describe("createHandler", () => {
202
202
  platform: "ios",
203
203
  },
204
204
  limit: 2,
205
- offset: 10,
205
+ page: undefined,
206
206
  },
207
207
  undefined,
208
208
  );
209
209
  });
210
210
 
211
+ it("passes cursor pagination params through to getBundles", async () => {
212
+ const api = createApi();
213
+ api.getBundles.mockResolvedValue({
214
+ data: [testBundle],
215
+ pagination: {
216
+ total: 51,
217
+ hasNextPage: true,
218
+ hasPreviousPage: true,
219
+ currentPage: 2,
220
+ totalPages: 26,
221
+ nextCursor: "bundle-1",
222
+ previousCursor: "bundle-9",
223
+ },
224
+ });
225
+ const handler = createHandler(api, { basePath: "/hot-updater" });
226
+
227
+ const response = await handler(
228
+ new Request(
229
+ "http://localhost/hot-updater/api/bundles?channel=production&limit=20&after=bundle-20",
230
+ ),
231
+ );
232
+
233
+ expect(response.status).toBe(200);
234
+ expect(api.getBundles).toHaveBeenCalledWith(
235
+ {
236
+ where: {
237
+ channel: "production",
238
+ },
239
+ limit: 20,
240
+ page: undefined,
241
+ cursor: {
242
+ after: "bundle-20",
243
+ before: undefined,
244
+ },
245
+ },
246
+ undefined,
247
+ );
248
+ });
249
+
250
+ it("supports cursor pagination without a legacy offset query param", async () => {
251
+ const api = createApi();
252
+ api.getBundles.mockResolvedValue({
253
+ data: [testBundle],
254
+ pagination: {
255
+ total: 1,
256
+ hasNextPage: false,
257
+ hasPreviousPage: false,
258
+ currentPage: 1,
259
+ totalPages: 1,
260
+ nextCursor: null,
261
+ previousCursor: null,
262
+ },
263
+ });
264
+ const handler = createHandler(api, { basePath: "/hot-updater" });
265
+
266
+ const response = await handler(
267
+ new Request(
268
+ "http://localhost/hot-updater/api/bundles?channel=production&limit=20&after=bundle-20",
269
+ ),
270
+ );
271
+
272
+ expect(response.status).toBe(200);
273
+ expect(api.getBundles).toHaveBeenCalledWith(
274
+ {
275
+ where: {
276
+ channel: "production",
277
+ },
278
+ limit: 20,
279
+ page: undefined,
280
+ cursor: {
281
+ after: "bundle-20",
282
+ before: undefined,
283
+ },
284
+ },
285
+ undefined,
286
+ );
287
+ });
288
+
289
+ it("returns 400 when bundle list requests still send offset pagination", async () => {
290
+ const api = createApi();
291
+ const handler = createHandler(api, { basePath: "/hot-updater" });
292
+
293
+ const response = await handler(
294
+ new Request(
295
+ "http://localhost/hot-updater/api/bundles?limit=20&offset=40",
296
+ ),
297
+ );
298
+
299
+ expect(response.status).toBe(400);
300
+ await expect(response.json()).resolves.toEqual({
301
+ error:
302
+ "The 'offset' query parameter has been removed. Use 'after' or 'before' cursor pagination instead.",
303
+ });
304
+ expect(api.getBundles).not.toHaveBeenCalled();
305
+ });
306
+
307
+ it("passes page-aligned pagination params through to getBundles", async () => {
308
+ const api = createApi();
309
+ api.getBundles.mockResolvedValue({
310
+ data: [testBundle],
311
+ pagination: {
312
+ total: 121,
313
+ hasNextPage: true,
314
+ hasPreviousPage: true,
315
+ currentPage: 2,
316
+ totalPages: 7,
317
+ nextCursor: "bundle-1",
318
+ previousCursor: "bundle-9",
319
+ },
320
+ });
321
+ const handler = createHandler(api, { basePath: "/hot-updater" });
322
+
323
+ const response = await handler(
324
+ new Request(
325
+ "http://localhost/hot-updater/api/bundles?channel=production&limit=20&page=2&after=bundle-20",
326
+ ),
327
+ );
328
+
329
+ expect(response.status).toBe(200);
330
+ expect(api.getBundles).toHaveBeenCalledWith(
331
+ {
332
+ where: {
333
+ channel: "production",
334
+ },
335
+ limit: 20,
336
+ page: 2,
337
+ cursor: {
338
+ after: "bundle-20",
339
+ before: undefined,
340
+ },
341
+ },
342
+ undefined,
343
+ );
344
+ });
345
+
346
+ it("returns 400 when bundle list requests send an invalid page", async () => {
347
+ const api = createApi();
348
+ const handler = createHandler(api, { basePath: "/hot-updater" });
349
+
350
+ const response = await handler(
351
+ new Request("http://localhost/hot-updater/api/bundles?limit=20&page=0"),
352
+ );
353
+
354
+ expect(response.status).toBe(400);
355
+ await expect(response.json()).resolves.toEqual({
356
+ error: "The 'page' query parameter must be a positive integer.",
357
+ });
358
+ expect(api.getBundles).not.toHaveBeenCalled();
359
+ });
360
+
211
361
  it("returns 400 when the platform route parameter is invalid", async () => {
212
362
  const api = createApi();
213
363
  const handler = createHandler(api, { basePath: "/hot-updater" });
package/src/handler.ts CHANGED
@@ -243,7 +243,28 @@ const handleGetBundles: RouteHandler = async (
243
243
  const channel = url.searchParams.get("channel") ?? undefined;
244
244
  const platform = url.searchParams.get("platform");
245
245
  const limit = Number(url.searchParams.get("limit")) || 50;
246
- const offset = Number(url.searchParams.get("offset")) || 0;
246
+ const pageParam = url.searchParams.get("page");
247
+ const offset = url.searchParams.get("offset");
248
+ const after = url.searchParams.get("after") ?? undefined;
249
+ const before = url.searchParams.get("before") ?? undefined;
250
+ const page =
251
+ pageParam === null
252
+ ? undefined
253
+ : Number.isInteger(Number(pageParam)) && Number(pageParam) > 0
254
+ ? Number(pageParam)
255
+ : null;
256
+
257
+ if (offset !== null) {
258
+ throw new HandlerBadRequestError(
259
+ "The 'offset' query parameter has been removed. Use 'after' or 'before' cursor pagination instead.",
260
+ );
261
+ }
262
+
263
+ if (page === null) {
264
+ throw new HandlerBadRequestError(
265
+ "The 'page' query parameter must be a positive integer.",
266
+ );
267
+ }
247
268
 
248
269
  if (platform !== null && !isPlatform(platform)) {
249
270
  throw new HandlerBadRequestError(
@@ -258,7 +279,14 @@ const handleGetBundles: RouteHandler = async (
258
279
  ...(platform && { platform }),
259
280
  },
260
281
  limit,
261
- offset,
282
+ page,
283
+ cursor:
284
+ after || before
285
+ ? {
286
+ after,
287
+ before,
288
+ }
289
+ : undefined,
262
290
  },
263
291
  context,
264
292
  );
@@ -9,6 +9,8 @@ export interface PaginationInfo {
9
9
  hasPreviousPage: boolean;
10
10
  currentPage: number;
11
11
  totalPages: number;
12
+ nextCursor?: string | null;
13
+ previousCursor?: string | null;
12
14
  }
13
15
 
14
16
  export interface PaginationOptions {