@hot-updater/server 0.27.1 → 0.29.0
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/dist/adapters/drizzle.cjs +7 -7
- package/dist/adapters/drizzle.mjs +2 -0
- package/dist/adapters/kysely.cjs +7 -7
- package/dist/adapters/kysely.mjs +2 -0
- package/dist/adapters/mongodb.cjs +7 -7
- package/dist/adapters/mongodb.mjs +2 -0
- package/dist/adapters/prisma.cjs +7 -7
- package/dist/adapters/prisma.mjs +2 -0
- package/dist/calculatePagination.cjs +1 -3
- package/dist/{calculatePagination.js → calculatePagination.mjs} +1 -2
- package/dist/db/index.cjs +24 -15
- package/dist/db/index.d.cts +12 -9
- package/dist/db/index.d.mts +30 -0
- package/dist/db/index.mjs +45 -0
- package/dist/db/ormCore.cjs +247 -138
- package/dist/db/ormCore.d.cts +35 -17
- package/dist/db/ormCore.d.mts +44 -0
- package/dist/db/ormCore.mjs +386 -0
- package/dist/db/pluginCore.cjs +145 -40
- package/dist/db/pluginCore.mjs +176 -0
- package/dist/db/types.cjs +1 -3
- package/dist/db/types.d.cts +14 -21
- package/dist/db/types.d.mts +24 -0
- package/dist/db/{types.js → types.mjs} +1 -2
- package/dist/handler.cjs +117 -48
- package/dist/handler.d.cts +28 -18
- package/dist/handler.d.mts +47 -0
- package/dist/handler.mjs +217 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.cts +3 -3
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +4 -0
- package/dist/internalRouter.cjs +54 -0
- package/dist/internalRouter.mjs +52 -0
- package/dist/node.cjs +2 -3
- package/dist/node.d.cts +0 -1
- package/dist/{node.d.ts → node.d.mts} +1 -2
- package/dist/{node.js → node.mjs} +1 -2
- package/dist/route.cjs +7 -0
- package/dist/route.mjs +7 -0
- package/dist/runtime.cjs +42 -0
- package/dist/runtime.d.cts +21 -0
- package/dist/runtime.d.mts +21 -0
- package/dist/runtime.mjs +40 -0
- package/dist/schema/v0_21_0.cjs +1 -5
- package/dist/schema/{v0_21_0.js → v0_21_0.mjs} +1 -3
- package/dist/schema/v0_29_0.cjs +24 -0
- package/dist/schema/v0_29_0.mjs +24 -0
- package/dist/types/{index.d.ts → index.d.mts} +1 -1
- package/package.json +18 -18
- package/src/db/index.spec.ts +64 -29
- package/src/db/index.ts +55 -35
- package/src/db/ormCore.ts +438 -210
- package/src/db/ormUpdateCheck.bench.ts +261 -0
- package/src/db/pluginCore.ts +298 -49
- package/src/db/pluginUpdateCheck.bench.ts +250 -0
- package/src/db/types.ts +52 -27
- package/src/{handler-standalone-integration.spec.ts → handler-standalone.integration.spec.ts} +106 -0
- package/src/handler.spec.ts +156 -0
- package/src/handler.ts +296 -77
- package/src/internalRouter.ts +104 -0
- package/src/route.ts +7 -0
- package/src/runtime.spec.ts +277 -0
- package/src/runtime.ts +121 -0
- package/src/schema/v0_29_0.ts +26 -0
- package/dist/_virtual/rolldown_runtime.cjs +0 -25
- package/dist/adapters/drizzle.js +0 -3
- package/dist/adapters/kysely.js +0 -3
- package/dist/adapters/mongodb.js +0 -3
- package/dist/adapters/prisma.js +0 -3
- package/dist/db/index.d.ts +0 -27
- package/dist/db/index.js +0 -36
- package/dist/db/ormCore.d.ts +0 -26
- package/dist/db/ormCore.js +0 -273
- package/dist/db/pluginCore.js +0 -69
- package/dist/db/types.d.ts +0 -31
- package/dist/handler.d.ts +0 -37
- package/dist/handler.js +0 -146
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -5
- /package/dist/adapters/{drizzle.d.ts → drizzle.d.mts} +0 -0
- /package/dist/adapters/{kysely.d.ts → kysely.d.mts} +0 -0
- /package/dist/adapters/{mongodb.d.ts → mongodb.d.mts} +0 -0
- /package/dist/adapters/{prisma.d.ts → prisma.d.mts} +0 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { bench, describe } from "vitest";
|
|
2
|
+
import type {
|
|
3
|
+
DatabaseBundleQueryOptions,
|
|
4
|
+
DatabaseBundleQueryWhere,
|
|
5
|
+
DatabasePlugin,
|
|
6
|
+
} from "../../../../plugins/plugin-core/src";
|
|
7
|
+
import {
|
|
8
|
+
calculatePagination,
|
|
9
|
+
semverSatisfies,
|
|
10
|
+
} from "../../../../plugins/plugin-core/src";
|
|
11
|
+
import type {
|
|
12
|
+
AppVersionGetBundlesArgs,
|
|
13
|
+
Bundle,
|
|
14
|
+
Platform,
|
|
15
|
+
UpdateInfo,
|
|
16
|
+
} from "../../../core/src";
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_ROLLOUT_COHORT_COUNT,
|
|
19
|
+
isCohortEligibleForUpdate,
|
|
20
|
+
NIL_UUID,
|
|
21
|
+
} from "../../../core/src";
|
|
22
|
+
import { createPluginDatabaseCore } from "./pluginCore";
|
|
23
|
+
|
|
24
|
+
const BUNDLE_COUNT = 20_000;
|
|
25
|
+
const BENCH_APP_VERSION = "1.0.0";
|
|
26
|
+
const BENCH_PLATFORM = "ios" as const;
|
|
27
|
+
const BENCH_CHANNEL = "production";
|
|
28
|
+
|
|
29
|
+
const cloneBundle = (bundle: Bundle): Bundle => ({
|
|
30
|
+
...bundle,
|
|
31
|
+
metadata: bundle.metadata
|
|
32
|
+
? structuredClone(bundle.metadata)
|
|
33
|
+
: bundle.metadata,
|
|
34
|
+
targetCohorts: bundle.targetCohorts ? [...bundle.targetCohorts] : null,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const bundleMatchesWhere = (
|
|
38
|
+
bundle: Bundle,
|
|
39
|
+
where: DatabaseBundleQueryWhere | undefined,
|
|
40
|
+
) => {
|
|
41
|
+
if (!where) return true;
|
|
42
|
+
if (where.channel !== undefined && bundle.channel !== where.channel)
|
|
43
|
+
return false;
|
|
44
|
+
if (where.platform !== undefined && bundle.platform !== where.platform)
|
|
45
|
+
return false;
|
|
46
|
+
if (where.enabled !== undefined && bundle.enabled !== where.enabled)
|
|
47
|
+
return false;
|
|
48
|
+
if (where.id?.eq !== undefined && bundle.id !== where.id.eq) return false;
|
|
49
|
+
if (where.id?.gt !== undefined && bundle.id.localeCompare(where.id.gt) <= 0)
|
|
50
|
+
return false;
|
|
51
|
+
if (where.id?.gte !== undefined && bundle.id.localeCompare(where.id.gte) < 0)
|
|
52
|
+
return false;
|
|
53
|
+
if (where.id?.lt !== undefined && bundle.id.localeCompare(where.id.lt) >= 0)
|
|
54
|
+
return false;
|
|
55
|
+
if (where.id?.lte !== undefined && bundle.id.localeCompare(where.id.lte) > 0)
|
|
56
|
+
return false;
|
|
57
|
+
if (where.id?.in && !where.id.in.includes(bundle.id)) return false;
|
|
58
|
+
if (where.targetAppVersionNotNull && bundle.targetAppVersion === null) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
where.targetAppVersion !== undefined &&
|
|
63
|
+
bundle.targetAppVersion !== where.targetAppVersion
|
|
64
|
+
) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
where.targetAppVersionIn &&
|
|
69
|
+
!where.targetAppVersionIn.includes(bundle.targetAppVersion ?? "")
|
|
70
|
+
) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
where.fingerprintHash !== undefined &&
|
|
75
|
+
bundle.fingerprintHash !== where.fingerprintHash
|
|
76
|
+
) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const createBundle = (
|
|
83
|
+
index: number,
|
|
84
|
+
{
|
|
85
|
+
platform = BENCH_PLATFORM,
|
|
86
|
+
channel = BENCH_CHANNEL,
|
|
87
|
+
targetAppVersion = "*",
|
|
88
|
+
enabled = true,
|
|
89
|
+
}: {
|
|
90
|
+
platform?: Platform;
|
|
91
|
+
channel?: string;
|
|
92
|
+
targetAppVersion?: string | null;
|
|
93
|
+
enabled?: boolean;
|
|
94
|
+
} = {},
|
|
95
|
+
): Bundle => ({
|
|
96
|
+
id: `00000000-0000-0000-0000-${String(index).padStart(12, "0")}`,
|
|
97
|
+
platform,
|
|
98
|
+
shouldForceUpdate: false,
|
|
99
|
+
enabled,
|
|
100
|
+
fileHash: `hash-${index}`,
|
|
101
|
+
gitCommitHash: `commit-${index}`,
|
|
102
|
+
message: `bundle-${index}`,
|
|
103
|
+
channel,
|
|
104
|
+
storageUri: `s3://bench/bundles/${index}.zip`,
|
|
105
|
+
targetAppVersion,
|
|
106
|
+
fingerprintHash: `fingerprint-${index % 10}`,
|
|
107
|
+
metadata: { app_version: String(index) },
|
|
108
|
+
rolloutCohortCount: DEFAULT_ROLLOUT_COHORT_COUNT,
|
|
109
|
+
targetCohorts: null,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const createBenchPlugin = (bundles: Bundle[]): DatabasePlugin => {
|
|
113
|
+
const bundlesById = new Map(bundles.map((bundle) => [bundle.id, bundle]));
|
|
114
|
+
|
|
115
|
+
const sortByDirection = (direction: "asc" | "desc" | undefined): Bundle[] => {
|
|
116
|
+
const sorted = bundles.slice().sort((a, b) => a.id.localeCompare(b.id));
|
|
117
|
+
return direction === "asc" ? sorted : sorted.reverse();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
name: "bench-plugin",
|
|
122
|
+
async getBundleById(bundleId) {
|
|
123
|
+
return bundlesById.get(bundleId) ?? null;
|
|
124
|
+
},
|
|
125
|
+
async getBundles(options: DatabaseBundleQueryOptions) {
|
|
126
|
+
const { where, limit, offset, orderBy } = options;
|
|
127
|
+
const source = sortByDirection(orderBy?.direction);
|
|
128
|
+
const matched = source.filter((bundle) =>
|
|
129
|
+
bundleMatchesWhere(bundle, where),
|
|
130
|
+
);
|
|
131
|
+
const page = matched.slice(offset, offset + limit).map(cloneBundle);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
data: page,
|
|
135
|
+
pagination: calculatePagination(matched.length, { limit, offset }),
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
async getChannels() {
|
|
139
|
+
return [...new Set(bundles.map((bundle) => bundle.channel))];
|
|
140
|
+
},
|
|
141
|
+
async updateBundle() {
|
|
142
|
+
throw new Error("Not implemented for benchmark");
|
|
143
|
+
},
|
|
144
|
+
async appendBundle() {
|
|
145
|
+
throw new Error("Not implemented for benchmark");
|
|
146
|
+
},
|
|
147
|
+
async commitBundle() {},
|
|
148
|
+
async deleteBundle() {
|
|
149
|
+
throw new Error("Not implemented for benchmark");
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const oldPluginCoreGetUpdateInfo = async (
|
|
155
|
+
plugin: DatabasePlugin,
|
|
156
|
+
args: AppVersionGetBundlesArgs,
|
|
157
|
+
): Promise<UpdateInfo | null> => {
|
|
158
|
+
const where: DatabaseBundleQueryWhere = {
|
|
159
|
+
channel: args.channel ?? BENCH_CHANNEL,
|
|
160
|
+
platform: args.platform,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const { pagination } = await plugin.getBundles({
|
|
164
|
+
where,
|
|
165
|
+
limit: 1,
|
|
166
|
+
offset: 0,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (pagination.total === 0) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const { data } = await plugin.getBundles({
|
|
174
|
+
where,
|
|
175
|
+
limit: pagination.total,
|
|
176
|
+
offset: 0,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
for (const bundle of data) {
|
|
180
|
+
if (!bundle.enabled) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (bundle.platform !== args.platform) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (bundle.channel !== (args.channel ?? BENCH_CHANNEL)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!bundle.targetAppVersion) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!semverSatisfies(bundle.targetAppVersion, args.appVersion)) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (
|
|
196
|
+
!isCohortEligibleForUpdate(
|
|
197
|
+
bundle.id,
|
|
198
|
+
args.cohort,
|
|
199
|
+
bundle.rolloutCohortCount,
|
|
200
|
+
bundle.targetCohorts,
|
|
201
|
+
)
|
|
202
|
+
) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id: bundle.id,
|
|
208
|
+
message: bundle.message,
|
|
209
|
+
shouldForceUpdate: bundle.shouldForceUpdate,
|
|
210
|
+
status: "UPDATE",
|
|
211
|
+
storageUri: bundle.storageUri,
|
|
212
|
+
fileHash: bundle.fileHash,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return null;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
describe("plugin update check benchmark", () => {
|
|
220
|
+
const bundles = Array.from({ length: BUNDLE_COUNT }, (_, index) =>
|
|
221
|
+
createBundle(index + 1),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const args: AppVersionGetBundlesArgs = {
|
|
225
|
+
_updateStrategy: "appVersion",
|
|
226
|
+
appVersion: BENCH_APP_VERSION,
|
|
227
|
+
bundleId: NIL_UUID,
|
|
228
|
+
platform: BENCH_PLATFORM,
|
|
229
|
+
channel: BENCH_CHANNEL,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const plugin = createBenchPlugin(bundles);
|
|
233
|
+
const currentApi = createPluginDatabaseCore(plugin, async () => null).api;
|
|
234
|
+
|
|
235
|
+
bench(
|
|
236
|
+
"pluginCore legacy full fetch",
|
|
237
|
+
async () => {
|
|
238
|
+
await oldPluginCoreGetUpdateInfo(plugin, args);
|
|
239
|
+
},
|
|
240
|
+
{ warmupIterations: 5, iterations: 20 },
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
bench(
|
|
244
|
+
"pluginCore current paged fetch",
|
|
245
|
+
async () => {
|
|
246
|
+
await currentApi.getUpdateInfo(args);
|
|
247
|
+
},
|
|
248
|
+
{ warmupIterations: 5, iterations: 20 },
|
|
249
|
+
);
|
|
250
|
+
});
|
package/src/db/types.ts
CHANGED
|
@@ -4,26 +4,32 @@ import type {
|
|
|
4
4
|
GetBundlesArgs,
|
|
5
5
|
UpdateInfo,
|
|
6
6
|
} from "@hot-updater/core";
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
DatabaseBundleQueryOptions,
|
|
9
|
+
DatabasePlugin,
|
|
10
|
+
HotUpdaterContext,
|
|
11
|
+
StoragePlugin,
|
|
12
|
+
} from "@hot-updater/plugin-core";
|
|
8
13
|
import type { FumaDBAdapter } from "fumadb/adapters";
|
|
9
14
|
import type { PaginationInfo } from "../types";
|
|
10
15
|
|
|
11
|
-
export type DatabasePluginFactory =
|
|
16
|
+
export type DatabasePluginFactory<TContext = unknown> =
|
|
17
|
+
() => DatabasePlugin<TContext>;
|
|
12
18
|
|
|
13
|
-
export type DatabaseAdapter =
|
|
19
|
+
export type DatabaseAdapter<TContext = unknown> =
|
|
14
20
|
| FumaDBAdapter
|
|
15
|
-
| DatabasePlugin
|
|
16
|
-
| DatabasePluginFactory
|
|
21
|
+
| DatabasePlugin<TContext>
|
|
22
|
+
| DatabasePluginFactory<TContext>;
|
|
17
23
|
|
|
18
|
-
export function isDatabasePluginFactory(
|
|
19
|
-
adapter: DatabaseAdapter
|
|
20
|
-
): adapter is DatabasePluginFactory {
|
|
24
|
+
export function isDatabasePluginFactory<TContext = unknown>(
|
|
25
|
+
adapter: DatabaseAdapter<TContext>,
|
|
26
|
+
): adapter is DatabasePluginFactory<TContext> {
|
|
21
27
|
return typeof adapter === "function";
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
export function isDatabasePlugin(
|
|
25
|
-
adapter: DatabaseAdapter
|
|
26
|
-
): adapter is DatabasePlugin {
|
|
30
|
+
export function isDatabasePlugin<TContext = unknown>(
|
|
31
|
+
adapter: DatabaseAdapter<TContext>,
|
|
32
|
+
): adapter is DatabasePlugin<TContext> {
|
|
27
33
|
return (
|
|
28
34
|
typeof adapter === "object" &&
|
|
29
35
|
adapter !== null &&
|
|
@@ -33,25 +39,44 @@ export function isDatabasePlugin(
|
|
|
33
39
|
);
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
export function isFumaAdapter(
|
|
37
|
-
adapter: DatabaseAdapter
|
|
42
|
+
export function isFumaAdapter<TContext = unknown>(
|
|
43
|
+
adapter: DatabaseAdapter<TContext>,
|
|
38
44
|
): adapter is FumaDBAdapter {
|
|
39
45
|
return !isDatabasePluginFactory(adapter) && !isDatabasePlugin(adapter);
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
export interface DatabaseAPI {
|
|
43
|
-
getBundleById(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
export interface DatabaseAPI<TContext = unknown> {
|
|
49
|
+
getBundleById(
|
|
50
|
+
id: string,
|
|
51
|
+
context?: HotUpdaterContext<TContext>,
|
|
52
|
+
): Promise<Bundle | null>;
|
|
53
|
+
getUpdateInfo(
|
|
54
|
+
args: GetBundlesArgs,
|
|
55
|
+
context?: HotUpdaterContext<TContext>,
|
|
56
|
+
): Promise<UpdateInfo | null>;
|
|
57
|
+
getAppUpdateInfo(
|
|
58
|
+
args: GetBundlesArgs,
|
|
59
|
+
context?: HotUpdaterContext<TContext>,
|
|
60
|
+
): Promise<AppUpdateInfo | null>;
|
|
61
|
+
getChannels(context?: HotUpdaterContext<TContext>): Promise<string[]>;
|
|
62
|
+
getBundles(
|
|
63
|
+
options: DatabaseBundleQueryOptions,
|
|
64
|
+
context?: HotUpdaterContext<TContext>,
|
|
65
|
+
): Promise<{ data: Bundle[]; pagination: PaginationInfo }>;
|
|
66
|
+
insertBundle(
|
|
67
|
+
bundle: Bundle,
|
|
68
|
+
context?: HotUpdaterContext<TContext>,
|
|
69
|
+
): Promise<void>;
|
|
70
|
+
updateBundleById(
|
|
71
|
+
bundleId: string,
|
|
72
|
+
newBundle: Partial<Bundle>,
|
|
73
|
+
context?: HotUpdaterContext<TContext>,
|
|
74
|
+
): Promise<void>;
|
|
75
|
+
deleteBundleById(
|
|
76
|
+
bundleId: string,
|
|
77
|
+
context?: HotUpdaterContext<TContext>,
|
|
78
|
+
): Promise<void>;
|
|
55
79
|
}
|
|
56
80
|
|
|
57
|
-
export type StoragePluginFactory =
|
|
81
|
+
export type StoragePluginFactory<TContext = unknown> =
|
|
82
|
+
() => StoragePlugin<TContext>;
|
package/src/{handler-standalone-integration.spec.ts → handler-standalone.integration.spec.ts}
RENAMED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PGlite } from "@electric-sql/pglite";
|
|
2
2
|
import type { Bundle } from "@hot-updater/core";
|
|
3
3
|
import { NIL_UUID } from "@hot-updater/core";
|
|
4
|
+
import { createBlobDatabasePlugin } from "@hot-updater/plugin-core";
|
|
4
5
|
import { standaloneRepository } from "@hot-updater/standalone";
|
|
5
6
|
import { Kysely } from "kysely";
|
|
6
7
|
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
@@ -71,6 +72,9 @@ beforeAll(async () => {
|
|
|
71
72
|
http.post(`${baseUrl}/hot-updater/api/bundles`, ({ request }) =>
|
|
72
73
|
handleRequest(request),
|
|
73
74
|
),
|
|
75
|
+
http.patch(`${baseUrl}/hot-updater/api/bundles/:id`, ({ request }) =>
|
|
76
|
+
handleRequest(request),
|
|
77
|
+
),
|
|
74
78
|
http.delete(`${baseUrl}/hot-updater/api/bundles/:id`, ({ request }) =>
|
|
75
79
|
handleRequest(request),
|
|
76
80
|
),
|
|
@@ -103,6 +107,27 @@ const createTestBundle = (overrides?: Partial<Bundle>): Bundle => ({
|
|
|
103
107
|
...overrides,
|
|
104
108
|
});
|
|
105
109
|
|
|
110
|
+
const createInMemoryBlobDatabase = (store: Record<string, string>) =>
|
|
111
|
+
createBlobDatabasePlugin({
|
|
112
|
+
name: "blob-test",
|
|
113
|
+
factory: () => ({
|
|
114
|
+
apiBasePath: "/api/check-update",
|
|
115
|
+
listObjects: async (prefix: string) =>
|
|
116
|
+
Object.keys(store).filter((key) => key.startsWith(prefix)),
|
|
117
|
+
loadObject: async <T>(key: string) => {
|
|
118
|
+
const value = store[key];
|
|
119
|
+
return value ? (JSON.parse(value) as T) : null;
|
|
120
|
+
},
|
|
121
|
+
uploadObject: async <T>(key: string, data: T) => {
|
|
122
|
+
store[key] = JSON.stringify(data);
|
|
123
|
+
},
|
|
124
|
+
deleteObject: async (key: string) => {
|
|
125
|
+
delete store[key];
|
|
126
|
+
},
|
|
127
|
+
invalidatePaths: async () => {},
|
|
128
|
+
}),
|
|
129
|
+
})({});
|
|
130
|
+
|
|
106
131
|
describe("Handler <-> Standalone Repository Integration", () => {
|
|
107
132
|
it("Real integration: appendBundle + commitBundle → handler POST /bundles", async () => {
|
|
108
133
|
// Create standalone repository pointing to our test server
|
|
@@ -314,6 +339,15 @@ describe("Handler <-> Standalone Repository Integration", () => {
|
|
|
314
339
|
},
|
|
315
340
|
);
|
|
316
341
|
}),
|
|
342
|
+
http.patch(`${baseUrl}/api/v2/*`, async ({ request }) => {
|
|
343
|
+
const response = await customApi.handler(request);
|
|
344
|
+
return HttpResponse.json(
|
|
345
|
+
(await response.json()) as Record<string, unknown>,
|
|
346
|
+
{
|
|
347
|
+
status: response.status,
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
}),
|
|
317
351
|
);
|
|
318
352
|
|
|
319
353
|
// Create standalone repository with matching basePath
|
|
@@ -347,4 +381,76 @@ describe("Handler <-> Standalone Repository Integration", () => {
|
|
|
347
381
|
// Standalone should return null gracefully
|
|
348
382
|
expect(result).toBeNull();
|
|
349
383
|
});
|
|
384
|
+
|
|
385
|
+
it("updates targetAppVersion through standalone without creating a duplicate blob entry", async () => {
|
|
386
|
+
const store: Record<string, string> = {};
|
|
387
|
+
const blobApi = createHotUpdater({
|
|
388
|
+
database: createInMemoryBlobDatabase(store),
|
|
389
|
+
basePath: "/blob-hot-updater",
|
|
390
|
+
});
|
|
391
|
+
const handleBlobRequest = async (request: Request) => {
|
|
392
|
+
const response = await blobApi.handler(request);
|
|
393
|
+
return HttpResponse.json(
|
|
394
|
+
(await response.json()) as Record<string, unknown>,
|
|
395
|
+
{
|
|
396
|
+
status: response.status,
|
|
397
|
+
headers: response.headers,
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
server.use(
|
|
403
|
+
http.get(`${baseUrl}/blob-hot-updater/api/bundles`, ({ request }) =>
|
|
404
|
+
handleBlobRequest(request),
|
|
405
|
+
),
|
|
406
|
+
http.get(`${baseUrl}/blob-hot-updater/api/bundles/:id`, ({ request }) =>
|
|
407
|
+
handleBlobRequest(request),
|
|
408
|
+
),
|
|
409
|
+
http.post(`${baseUrl}/blob-hot-updater/api/bundles`, ({ request }) =>
|
|
410
|
+
handleBlobRequest(request),
|
|
411
|
+
),
|
|
412
|
+
http.patch(`${baseUrl}/blob-hot-updater/api/bundles/:id`, ({ request }) =>
|
|
413
|
+
handleBlobRequest(request),
|
|
414
|
+
),
|
|
415
|
+
http.delete(
|
|
416
|
+
`${baseUrl}/blob-hot-updater/api/bundles/:id`,
|
|
417
|
+
({ request }) => handleBlobRequest(request),
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const repo = standaloneRepository({
|
|
422
|
+
baseUrl: `${baseUrl}/blob-hot-updater`,
|
|
423
|
+
})();
|
|
424
|
+
|
|
425
|
+
const bundleId = uuidv7();
|
|
426
|
+
await repo.appendBundle(
|
|
427
|
+
createTestBundle({
|
|
428
|
+
id: bundleId,
|
|
429
|
+
platform: "ios",
|
|
430
|
+
targetAppVersion: "1.x.x",
|
|
431
|
+
storageUri: "s3://test-bucket/original.zip",
|
|
432
|
+
}),
|
|
433
|
+
);
|
|
434
|
+
await repo.commitBundle();
|
|
435
|
+
|
|
436
|
+
await repo.updateBundle(bundleId, { targetAppVersion: "1.0.2" });
|
|
437
|
+
await repo.commitBundle();
|
|
438
|
+
|
|
439
|
+
const updatedBundle = await repo.getBundleById(bundleId);
|
|
440
|
+
expect(updatedBundle?.targetAppVersion).toBe("1.0.2");
|
|
441
|
+
|
|
442
|
+
expect(store["production/ios/1.x.x/update.json"]).toBeUndefined();
|
|
443
|
+
|
|
444
|
+
const nextBundles = JSON.parse(
|
|
445
|
+
store["production/ios/1.0.2/update.json"] ?? "[]",
|
|
446
|
+
) as Bundle[];
|
|
447
|
+
expect(nextBundles).toHaveLength(1);
|
|
448
|
+
expect(nextBundles[0]?.id).toBe(bundleId);
|
|
449
|
+
expect(nextBundles[0]?.targetAppVersion).toBe("1.0.2");
|
|
450
|
+
|
|
451
|
+
const targetVersions = JSON.parse(
|
|
452
|
+
store["production/ios/target-app-versions.json"] ?? "[]",
|
|
453
|
+
) as string[];
|
|
454
|
+
expect(targetVersions).toEqual(["1.0.2"]);
|
|
455
|
+
});
|
|
350
456
|
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { NIL_UUID } from "@hot-updater/core";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createHandler, type HandlerAPI } from "./handler";
|
|
4
|
+
|
|
5
|
+
type TestEnv = {
|
|
6
|
+
tenantId: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type TestContext = {
|
|
10
|
+
env: TestEnv;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const createApi = (): HandlerAPI<TestContext> => ({
|
|
14
|
+
getAppUpdateInfo: vi.fn().mockResolvedValue({
|
|
15
|
+
fileHash: null,
|
|
16
|
+
fileUrl: null,
|
|
17
|
+
id: NIL_UUID,
|
|
18
|
+
message: null,
|
|
19
|
+
shouldForceUpdate: true,
|
|
20
|
+
status: "ROLLBACK",
|
|
21
|
+
}),
|
|
22
|
+
getBundleById: vi.fn(),
|
|
23
|
+
getBundles: vi.fn(),
|
|
24
|
+
getChannels: vi.fn(),
|
|
25
|
+
insertBundle: vi.fn(),
|
|
26
|
+
updateBundleById: vi.fn(),
|
|
27
|
+
deleteBundleById: vi.fn(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("createHandler", () => {
|
|
31
|
+
it("supports the app-version route without a cohort segment", async () => {
|
|
32
|
+
const api = createApi();
|
|
33
|
+
const handler = createHandler(api, { basePath: "/hot-updater" });
|
|
34
|
+
|
|
35
|
+
const response = await handler(
|
|
36
|
+
new Request(
|
|
37
|
+
"http://localhost/hot-updater/app-version/ios/1.0.0/production/default/default",
|
|
38
|
+
),
|
|
39
|
+
{
|
|
40
|
+
env: {
|
|
41
|
+
tenantId: "tenant-a",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(response.status).toBe(200);
|
|
47
|
+
expect(api.getAppUpdateInfo).toHaveBeenCalledWith(
|
|
48
|
+
{
|
|
49
|
+
_updateStrategy: "appVersion",
|
|
50
|
+
appVersion: "1.0.0",
|
|
51
|
+
bundleId: "default",
|
|
52
|
+
channel: "production",
|
|
53
|
+
cohort: undefined,
|
|
54
|
+
minBundleId: "default",
|
|
55
|
+
platform: "ios",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
env: {
|
|
59
|
+
tenantId: "tenant-a",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("supports the fingerprint route without a cohort segment", async () => {
|
|
66
|
+
const api = createApi();
|
|
67
|
+
const handler = createHandler(api, { basePath: "/hot-updater" });
|
|
68
|
+
|
|
69
|
+
const response = await handler(
|
|
70
|
+
new Request(
|
|
71
|
+
"http://localhost/hot-updater/fingerprint/android/fingerprint-123/production/default/default",
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(response.status).toBe(200);
|
|
76
|
+
expect(api.getAppUpdateInfo).toHaveBeenCalledWith(
|
|
77
|
+
{
|
|
78
|
+
_updateStrategy: "fingerprint",
|
|
79
|
+
bundleId: "default",
|
|
80
|
+
channel: "production",
|
|
81
|
+
cohort: undefined,
|
|
82
|
+
fingerprintHash: "fingerprint-123",
|
|
83
|
+
minBundleId: "default",
|
|
84
|
+
platform: "android",
|
|
85
|
+
},
|
|
86
|
+
undefined,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("can mount only update-check routes", async () => {
|
|
91
|
+
const api = createApi();
|
|
92
|
+
const handler = createHandler(api, {
|
|
93
|
+
basePath: "/hot-updater",
|
|
94
|
+
routes: {
|
|
95
|
+
updateCheck: true,
|
|
96
|
+
bundles: false,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const versionResponse = await handler(
|
|
101
|
+
new Request("http://localhost/hot-updater/version"),
|
|
102
|
+
);
|
|
103
|
+
const bundlesResponse = await handler(
|
|
104
|
+
new Request("http://localhost/hot-updater/api/bundles"),
|
|
105
|
+
);
|
|
106
|
+
const updateResponse = await handler(
|
|
107
|
+
new Request(
|
|
108
|
+
"http://localhost/hot-updater/app-version/ios/1.0.0/production/default/default",
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(versionResponse.status).toBe(404);
|
|
113
|
+
expect(bundlesResponse.status).toBe(404);
|
|
114
|
+
expect(updateResponse.status).toBe(200);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("can mount only bundle routes", async () => {
|
|
118
|
+
const api = createApi();
|
|
119
|
+
const handler = createHandler(api, {
|
|
120
|
+
basePath: "/hot-updater",
|
|
121
|
+
routes: {
|
|
122
|
+
updateCheck: false,
|
|
123
|
+
bundles: true,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const channelsResponse = await handler(
|
|
128
|
+
new Request("http://localhost/hot-updater/api/bundles/channels"),
|
|
129
|
+
);
|
|
130
|
+
const updateResponse = await handler(
|
|
131
|
+
new Request(
|
|
132
|
+
"http://localhost/hot-updater/app-version/ios/1.0.0/production/default/default",
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(channelsResponse.status).toBe(200);
|
|
137
|
+
expect(updateResponse.status).toBe(404);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("returns 400 when the platform route parameter is invalid", async () => {
|
|
141
|
+
const api = createApi();
|
|
142
|
+
const handler = createHandler(api, { basePath: "/hot-updater" });
|
|
143
|
+
|
|
144
|
+
const response = await handler(
|
|
145
|
+
new Request(
|
|
146
|
+
"http://localhost/hot-updater/app-version/web/1.0.0/production/default/default",
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await expect(response.json()).resolves.toEqual({
|
|
151
|
+
error: "Invalid platform: web. Expected 'ios' or 'android'.",
|
|
152
|
+
});
|
|
153
|
+
expect(response.status).toBe(400);
|
|
154
|
+
expect(api.getAppUpdateInfo).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
});
|