@hot-updater/server 0.29.4 → 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.
@@ -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
  );
@@ -31,6 +31,111 @@ type TestEnv = {
31
31
  type TestContext = RequestEnvContext<TestEnv>;
32
32
 
33
33
  describe("runtime createHotUpdater", () => {
34
+ it("resolves storage URLs with handler context when database fast-path is used", async () => {
35
+ const request = new Request(
36
+ "https://updates.example.com/api/check-update/app-version/ios/1.0.0/production/" +
37
+ `${NIL_UUID}/${NIL_UUID}`,
38
+ );
39
+ const getBundles = vi.fn<DatabasePlugin<TestContext>["getBundles"]>();
40
+ const getUpdateInfo = vi.fn<
41
+ NonNullable<DatabasePlugin<TestContext>["getUpdateInfo"]>
42
+ >(async () => ({
43
+ fileHash: bundle.fileHash,
44
+ id: bundle.id,
45
+ message: bundle.message,
46
+ shouldForceUpdate: bundle.shouldForceUpdate,
47
+ status: "UPDATE",
48
+ storageUri: bundle.storageUri,
49
+ }));
50
+ const getDownloadUrl = vi.fn<StoragePlugin<TestContext>["getDownloadUrl"]>(
51
+ async (_storageUri, context) => {
52
+ return {
53
+ fileUrl: new URL("/bundle.zip", context?.env?.assetHost).toString(),
54
+ };
55
+ },
56
+ );
57
+
58
+ const database: DatabasePlugin<TestContext> = {
59
+ name: "testDatabase",
60
+ async appendBundle() {},
61
+ async commitBundle() {},
62
+ async deleteBundle() {},
63
+ async getBundleById(id) {
64
+ return id === bundle.id ? bundle : null;
65
+ },
66
+ getBundles,
67
+ getUpdateInfo,
68
+ async getChannels() {
69
+ return ["production"];
70
+ },
71
+ async onUnmount() {},
72
+ async updateBundle() {},
73
+ };
74
+ const storage: StoragePlugin<TestContext> = {
75
+ name: "testStorage",
76
+ supportedProtocol: "s3",
77
+ async upload(key) {
78
+ return { storageUri: `s3://test-bucket/${key}` };
79
+ },
80
+ async delete() {},
81
+ getDownloadUrl,
82
+ };
83
+
84
+ const hotUpdater = createHotUpdater({
85
+ database,
86
+ storages: [storage],
87
+ basePath: "/api/check-update",
88
+ routes: {
89
+ updateCheck: true,
90
+ bundles: false,
91
+ },
92
+ });
93
+
94
+ const response = await hotUpdater.handler(request, {
95
+ env: {
96
+ assetHost: "https://assets.example.com",
97
+ },
98
+ request,
99
+ });
100
+
101
+ expect(response.status).toBe(200);
102
+ await expect(response.json()).resolves.toEqual({
103
+ fileHash: "hash123",
104
+ fileUrl: "https://assets.example.com/bundle.zip",
105
+ id: "00000000-0000-0000-0000-000000000001",
106
+ message: "Test bundle",
107
+ shouldForceUpdate: false,
108
+ status: "UPDATE",
109
+ });
110
+ expect(getUpdateInfo).toHaveBeenCalledWith(
111
+ {
112
+ _updateStrategy: "appVersion",
113
+ appVersion: "1.0.0",
114
+ bundleId: NIL_UUID,
115
+ channel: "production",
116
+ cohort: undefined,
117
+ minBundleId: NIL_UUID,
118
+ platform: "ios",
119
+ },
120
+ expect.objectContaining({
121
+ env: {
122
+ assetHost: "https://assets.example.com",
123
+ },
124
+ request: expect.any(Request),
125
+ }),
126
+ );
127
+ expect(getBundles).not.toHaveBeenCalled();
128
+ expect(getDownloadUrl).toHaveBeenCalledWith(
129
+ "s3://test-bucket/bundles/bundle.zip",
130
+ expect.objectContaining({
131
+ env: {
132
+ assetHost: "https://assets.example.com",
133
+ },
134
+ request: expect.any(Request),
135
+ }),
136
+ );
137
+ });
138
+
34
139
  it("passes the handler context to database and storage resolution", async () => {
35
140
  const request = new Request(
36
141
  "https://updates.example.com/api/check-update/app-version/ios/1.0.0/production/" +
@@ -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 {