@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.
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
 
@@ -0,0 +1,172 @@
1
+ import type { Bundle, GetBundlesArgs, UpdateInfo } from "@hot-updater/core";
2
+ import { NIL_UUID } from "@hot-updater/core";
3
+ import type {
4
+ DatabasePlugin,
5
+ RequestEnvContext,
6
+ } from "@hot-updater/plugin-core";
7
+ import { describe, expect, it, vi } from "vitest";
8
+
9
+ import { createPluginDatabaseCore } from "./pluginCore";
10
+
11
+ const baseBundle: Bundle = {
12
+ id: "00000000-0000-0000-0000-000000000001",
13
+ channel: "production",
14
+ enabled: true,
15
+ fileHash: "hash-1",
16
+ fingerprintHash: null,
17
+ gitCommitHash: null,
18
+ message: "bundle",
19
+ platform: "ios",
20
+ shouldForceUpdate: false,
21
+ storageUri: "s3://bucket/bundle.zip",
22
+ targetAppVersion: "1.0.0",
23
+ };
24
+
25
+ const updateArgs: GetBundlesArgs = {
26
+ _updateStrategy: "appVersion",
27
+ appVersion: "1.0.0",
28
+ bundleId: NIL_UUID,
29
+ platform: "ios",
30
+ };
31
+
32
+ type TestContext = RequestEnvContext<{
33
+ assetHost: string;
34
+ }>;
35
+
36
+ describe("createPluginDatabaseCore", () => {
37
+ it("prefers plugin getUpdateInfo fast-path when provided", async () => {
38
+ const getBundles = vi.fn<DatabasePlugin<TestContext>["getBundles"]>();
39
+ const expected: UpdateInfo = {
40
+ fileHash: baseBundle.fileHash,
41
+ id: baseBundle.id,
42
+ message: baseBundle.message,
43
+ shouldForceUpdate: baseBundle.shouldForceUpdate,
44
+ status: "UPDATE",
45
+ storageUri: baseBundle.storageUri,
46
+ };
47
+ const getUpdateInfo = vi.fn<
48
+ NonNullable<DatabasePlugin<TestContext>["getUpdateInfo"]>
49
+ >(async () => expected);
50
+
51
+ const plugin: DatabasePlugin<TestContext> = {
52
+ name: "fast-path-plugin",
53
+ async appendBundle() {},
54
+ async commitBundle() {},
55
+ async deleteBundle() {},
56
+ async getBundleById() {
57
+ return null;
58
+ },
59
+ getBundles,
60
+ getUpdateInfo,
61
+ async getChannels() {
62
+ return ["production"];
63
+ },
64
+ async updateBundle() {},
65
+ };
66
+
67
+ const core = createPluginDatabaseCore(
68
+ () => plugin,
69
+ async () => null,
70
+ );
71
+ const context: TestContext = {
72
+ env: {
73
+ assetHost: "https://assets.example.com",
74
+ },
75
+ request: new Request("https://updates.example.com"),
76
+ };
77
+
78
+ await expect(core.api.getUpdateInfo(updateArgs, context)).resolves.toEqual(
79
+ expected,
80
+ );
81
+ expect(getUpdateInfo).toHaveBeenCalledWith(updateArgs, context);
82
+ expect(getBundles).not.toHaveBeenCalled();
83
+ });
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
+
126
+ it("falls back to scanning when plugin getUpdateInfo is absent", async () => {
127
+ const latestBundle = {
128
+ ...baseBundle,
129
+ id: "00000000-0000-0000-0000-000000000002",
130
+ };
131
+ const getBundles = vi.fn<DatabasePlugin["getBundles"]>(async () => ({
132
+ data: [latestBundle],
133
+ pagination: {
134
+ currentPage: 1,
135
+ hasNextPage: false,
136
+ hasPreviousPage: false,
137
+ total: 1,
138
+ totalPages: 1,
139
+ },
140
+ }));
141
+
142
+ const plugin: DatabasePlugin = {
143
+ name: "scan-plugin",
144
+ async appendBundle() {},
145
+ async commitBundle() {},
146
+ async deleteBundle() {},
147
+ async getBundleById() {
148
+ return null;
149
+ },
150
+ getBundles,
151
+ async getChannels() {
152
+ return ["production"];
153
+ },
154
+ async updateBundle() {},
155
+ };
156
+
157
+ const core = createPluginDatabaseCore(
158
+ () => plugin,
159
+ async () => null,
160
+ );
161
+
162
+ await expect(core.api.getUpdateInfo(updateArgs)).resolves.toEqual({
163
+ fileHash: latestBundle.fileHash,
164
+ id: latestBundle.id,
165
+ message: latestBundle.message,
166
+ shouldForceUpdate: latestBundle.shouldForceUpdate,
167
+ status: "UPDATE",
168
+ storageUri: latestBundle.storageUri,
169
+ });
170
+ expect(getBundles).toHaveBeenCalledOnce();
171
+ });
172
+ });
@@ -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) {
@@ -269,6 +278,14 @@ export function createPluginDatabaseCore<TContext = unknown>(
269
278
  args: GetBundlesArgs,
270
279
  context?: HotUpdaterContext<TContext>,
271
280
  ): Promise<UpdateInfo | null> {
281
+ const plugin = getPlugin();
282
+ const directGetUpdateInfo = plugin.getUpdateInfo;
283
+ if (directGetUpdateInfo) {
284
+ return context === undefined
285
+ ? await directGetUpdateInfo(args)
286
+ : await directGetUpdateInfo(args, context);
287
+ }
288
+
272
289
  const channel = args.channel ?? "production";
273
290
  const minBundleId = args.minBundleId ?? NIL_UUID;
274
291
  const baseWhere = getBaseWhere({
@@ -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);