@hot-updater/supabase 0.17.0 → 0.18.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/iac/index.cjs +7771 -8209
- package/dist/iac/index.d.cts +25 -21
- package/dist/iac/index.d.ts +25 -21
- package/dist/iac/index.js +7772 -8211
- package/dist/index.cjs +1445 -255
- package/dist/index.d.cts +13 -9
- package/dist/index.d.ts +13 -9
- package/dist/index.js +1428 -225
- package/package.json +6 -6
- package/supabase/edge-functions/index.ts +331 -0
- package/supabase/migrations/20250516000000_hot-updater_0.18.0.sql +194 -0
- package/supabase/functions/update-server/index.ts +0 -140
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hot-updater/supabase",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.18.0",
|
|
5
5
|
"description": "React Native OTA solution for self-hosted",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
7
7
|
"module": "dist/index.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"import": "./dist/index.js",
|
|
12
12
|
"require": "./dist/index.cjs"
|
|
13
13
|
},
|
|
14
|
-
"./
|
|
14
|
+
"./scaffold": {
|
|
15
15
|
"import": "./supabase/index.ts",
|
|
16
16
|
"require": "./supabase/index.ts"
|
|
17
17
|
},
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
],
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@supabase/supabase-js": "^2.47.10",
|
|
43
|
-
"@hot-updater/core": "0.
|
|
44
|
-
"@hot-updater/plugin-core": "0.
|
|
43
|
+
"@hot-updater/core": "0.18.0",
|
|
44
|
+
"@hot-updater/plugin-core": "0.18.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@clack/prompts": "0.10.0",
|
|
@@ -50,10 +50,10 @@
|
|
|
50
50
|
"execa": "^9.5.2",
|
|
51
51
|
"mime": "^4.0.4",
|
|
52
52
|
"picocolors": "^1.0.0",
|
|
53
|
-
"@hot-updater/postgres": "0.
|
|
53
|
+
"@hot-updater/postgres": "0.18.0"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"build": "
|
|
56
|
+
"build": "tsdown",
|
|
57
57
|
"test:type": "tsc --noEmit",
|
|
58
58
|
"make-migrations": "node --experimental-strip-types ./scripts/make-migrations.ts"
|
|
59
59
|
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
|
2
|
+
import semver from "npm:semver@7.7.1";
|
|
3
|
+
import { Hono } from "jsr:@hono/hono";
|
|
4
|
+
import {
|
|
5
|
+
type SupabaseClient,
|
|
6
|
+
createClient,
|
|
7
|
+
} from "jsr:@supabase/supabase-js@2.49.4";
|
|
8
|
+
import type { GetBundlesArgs, UpdateInfo } from "@hot-updater/core";
|
|
9
|
+
|
|
10
|
+
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
11
|
+
|
|
12
|
+
const semverSatisfies = (targetAppVersion: string, currentVersion: string) => {
|
|
13
|
+
const currentCoerce = semver.coerce(currentVersion);
|
|
14
|
+
if (!currentCoerce) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return semver.satisfies(currentCoerce.version, targetAppVersion);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Filters target app versions that are compatible with the current app version.
|
|
23
|
+
* Returns only versions that are compatible with the current version according to semver rules.
|
|
24
|
+
*
|
|
25
|
+
* @param targetAppVersionList - List of target app versions to filter
|
|
26
|
+
* @param currentVersion - Current app version
|
|
27
|
+
* @returns Array of target app versions compatible with the current version
|
|
28
|
+
*/
|
|
29
|
+
export const filterCompatibleAppVersions = (
|
|
30
|
+
targetAppVersionList: string[],
|
|
31
|
+
currentVersion: string,
|
|
32
|
+
) => {
|
|
33
|
+
const compatibleAppVersionList = targetAppVersionList.filter((version) =>
|
|
34
|
+
semverSatisfies(version, currentVersion),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return compatibleAppVersionList.sort((a, b) => b.localeCompare(a));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const appVersionStrategy = async (
|
|
41
|
+
supabase: SupabaseClient<any, "public", any>,
|
|
42
|
+
{
|
|
43
|
+
appPlatform,
|
|
44
|
+
minBundleId,
|
|
45
|
+
bundleId,
|
|
46
|
+
appVersion,
|
|
47
|
+
channel,
|
|
48
|
+
}: {
|
|
49
|
+
appPlatform: string;
|
|
50
|
+
minBundleId: string;
|
|
51
|
+
bundleId: string;
|
|
52
|
+
appVersion: string;
|
|
53
|
+
channel: string;
|
|
54
|
+
},
|
|
55
|
+
) => {
|
|
56
|
+
const { data: appVersionList } = await supabase.rpc(
|
|
57
|
+
"get_target_app_version_list",
|
|
58
|
+
{
|
|
59
|
+
app_platform: appPlatform,
|
|
60
|
+
min_bundle_id: minBundleId || NIL_UUID,
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
const compatibleAppVersionList = filterCompatibleAppVersions(
|
|
64
|
+
appVersionList?.map((group) => group.target_app_version) ?? [],
|
|
65
|
+
appVersion,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return supabase.rpc("get_update_info_by_app_version", {
|
|
69
|
+
app_platform: appPlatform,
|
|
70
|
+
app_version: appVersion,
|
|
71
|
+
bundle_id: bundleId,
|
|
72
|
+
min_bundle_id: minBundleId || NIL_UUID,
|
|
73
|
+
target_channel: channel || "production",
|
|
74
|
+
target_app_version_list: compatibleAppVersionList,
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const fingerprintHashStrategy = async (
|
|
79
|
+
supabase: SupabaseClient<any, "public", any>,
|
|
80
|
+
{
|
|
81
|
+
appPlatform,
|
|
82
|
+
minBundleId,
|
|
83
|
+
bundleId,
|
|
84
|
+
channel,
|
|
85
|
+
fingerprintHash,
|
|
86
|
+
}: {
|
|
87
|
+
appPlatform: string;
|
|
88
|
+
bundleId: string;
|
|
89
|
+
minBundleId: string | null;
|
|
90
|
+
channel: string | null;
|
|
91
|
+
fingerprintHash: string;
|
|
92
|
+
},
|
|
93
|
+
) => {
|
|
94
|
+
return supabase.rpc("get_update_info_by_fingerprint_hash", {
|
|
95
|
+
app_platform: appPlatform,
|
|
96
|
+
bundle_id: bundleId,
|
|
97
|
+
min_bundle_id: minBundleId || NIL_UUID,
|
|
98
|
+
target_channel: channel || "production",
|
|
99
|
+
target_fingerprint_hash: fingerprintHash,
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleUpdateRequest = async (
|
|
104
|
+
supabase: SupabaseClient<any, "public", any>,
|
|
105
|
+
updateConfig: GetBundlesArgs,
|
|
106
|
+
) => {
|
|
107
|
+
const { data, error } =
|
|
108
|
+
updateConfig._updateStrategy === "fingerprint"
|
|
109
|
+
? await fingerprintHashStrategy(supabase, {
|
|
110
|
+
appPlatform: updateConfig.platform,
|
|
111
|
+
minBundleId: updateConfig.minBundleId!,
|
|
112
|
+
bundleId: updateConfig.bundleId,
|
|
113
|
+
channel: updateConfig.channel!,
|
|
114
|
+
fingerprintHash: updateConfig.fingerprintHash!,
|
|
115
|
+
})
|
|
116
|
+
: await appVersionStrategy(supabase, {
|
|
117
|
+
appPlatform: updateConfig.platform,
|
|
118
|
+
minBundleId: updateConfig.minBundleId!,
|
|
119
|
+
bundleId: updateConfig.bundleId,
|
|
120
|
+
appVersion: updateConfig.appVersion!,
|
|
121
|
+
channel: updateConfig.channel!,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (error) {
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const storageUri = data[0]?.storage_uri;
|
|
129
|
+
const response = data[0]
|
|
130
|
+
? ({
|
|
131
|
+
id: data[0].id,
|
|
132
|
+
shouldForceUpdate: data[0].should_force_update,
|
|
133
|
+
message: data[0].message,
|
|
134
|
+
status: data[0].status,
|
|
135
|
+
} as UpdateInfo)
|
|
136
|
+
: null;
|
|
137
|
+
|
|
138
|
+
if (!response) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (response.id === NIL_UUID) {
|
|
143
|
+
return {
|
|
144
|
+
...response,
|
|
145
|
+
fileUrl: null,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const storageURL = new URL(storageUri);
|
|
150
|
+
const storageBucket = storageURL.host;
|
|
151
|
+
const storagePath = storageURL.pathname;
|
|
152
|
+
|
|
153
|
+
const { data: signedUrlData } = await supabase.storage
|
|
154
|
+
.from(storageBucket)
|
|
155
|
+
.createSignedUrl(storagePath, 60);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
...response,
|
|
159
|
+
fileUrl: signedUrlData?.signedUrl ?? null,
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
declare global {
|
|
164
|
+
var HotUpdater: {
|
|
165
|
+
FUNCTION_NAME: string;
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const functionName = HotUpdater.FUNCTION_NAME;
|
|
170
|
+
const app = new Hono().basePath(`/${functionName}`);
|
|
171
|
+
|
|
172
|
+
app.get("/ping", (c) => c.text("pong"));
|
|
173
|
+
|
|
174
|
+
app.get("/", async (c) => {
|
|
175
|
+
try {
|
|
176
|
+
const bundleId = c.req.header("x-bundle-id");
|
|
177
|
+
const appPlatform = c.req.header("x-app-platform") as "ios" | "android";
|
|
178
|
+
const appVersion = c.req.header("x-app-version");
|
|
179
|
+
const fingerprintHash = c.req.header("x-fingerprint-hash");
|
|
180
|
+
const minBundleId = c.req.header("x-min-bundle-id");
|
|
181
|
+
const channel = c.req.header("x-channel");
|
|
182
|
+
|
|
183
|
+
if (!appVersion && !fingerprintHash) {
|
|
184
|
+
return c.json(
|
|
185
|
+
{
|
|
186
|
+
error:
|
|
187
|
+
"Missing required headers (x-app-version or x-fingerprint-hash).",
|
|
188
|
+
},
|
|
189
|
+
400,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!bundleId || !appPlatform) {
|
|
194
|
+
return c.json(
|
|
195
|
+
{ error: "Missing required headers (x-app-platform, x-bundle-id)." },
|
|
196
|
+
400,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const supabase = createClient(
|
|
201
|
+
Deno.env.get("SUPABASE_URL") ?? "",
|
|
202
|
+
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
|
|
203
|
+
{
|
|
204
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const updateConfig = fingerprintHash
|
|
209
|
+
? ({
|
|
210
|
+
platform: appPlatform,
|
|
211
|
+
fingerprintHash,
|
|
212
|
+
bundleId,
|
|
213
|
+
minBundleId: minBundleId || NIL_UUID,
|
|
214
|
+
channel: channel || "production",
|
|
215
|
+
_updateStrategy: "fingerprint" as const,
|
|
216
|
+
} satisfies GetBundlesArgs)
|
|
217
|
+
: ({
|
|
218
|
+
platform: appPlatform,
|
|
219
|
+
appVersion: appVersion!,
|
|
220
|
+
bundleId,
|
|
221
|
+
minBundleId: minBundleId || NIL_UUID,
|
|
222
|
+
channel: channel || "production",
|
|
223
|
+
_updateStrategy: "appVersion" as const,
|
|
224
|
+
} satisfies GetBundlesArgs);
|
|
225
|
+
|
|
226
|
+
const result = await handleUpdateRequest(supabase, updateConfig);
|
|
227
|
+
return c.json(result);
|
|
228
|
+
} catch (err: unknown) {
|
|
229
|
+
return c.json(
|
|
230
|
+
{
|
|
231
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
232
|
+
},
|
|
233
|
+
500,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
app.get(
|
|
239
|
+
"/app-version/:platform/:app-version/:channel/:minBundleId/:bundleId",
|
|
240
|
+
async (c) => {
|
|
241
|
+
try {
|
|
242
|
+
const {
|
|
243
|
+
platform,
|
|
244
|
+
"app-version": appVersion,
|
|
245
|
+
channel,
|
|
246
|
+
minBundleId,
|
|
247
|
+
bundleId,
|
|
248
|
+
} = c.req.param();
|
|
249
|
+
|
|
250
|
+
if (!bundleId || !platform) {
|
|
251
|
+
return c.json(
|
|
252
|
+
{ error: "Missing required parameters (platform, bundleId)." },
|
|
253
|
+
400,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const supabase = createClient(
|
|
258
|
+
Deno.env.get("SUPABASE_URL") ?? "",
|
|
259
|
+
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
|
|
260
|
+
{
|
|
261
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const updateConfig = {
|
|
266
|
+
platform: platform as "ios" | "android",
|
|
267
|
+
appVersion,
|
|
268
|
+
bundleId,
|
|
269
|
+
minBundleId: minBundleId || NIL_UUID,
|
|
270
|
+
channel: channel || "production",
|
|
271
|
+
_updateStrategy: "appVersion" as const,
|
|
272
|
+
} satisfies GetBundlesArgs;
|
|
273
|
+
|
|
274
|
+
const result = await handleUpdateRequest(supabase, updateConfig);
|
|
275
|
+
return c.json(result);
|
|
276
|
+
} catch (err: unknown) {
|
|
277
|
+
return c.json(
|
|
278
|
+
{
|
|
279
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
280
|
+
},
|
|
281
|
+
500,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
app.get(
|
|
288
|
+
"/fingerprint/:platform/:fingerprintHash/:channel/:minBundleId/:bundleId",
|
|
289
|
+
async (c) => {
|
|
290
|
+
try {
|
|
291
|
+
const { platform, fingerprintHash, channel, minBundleId, bundleId } =
|
|
292
|
+
c.req.param();
|
|
293
|
+
|
|
294
|
+
if (!bundleId || !platform) {
|
|
295
|
+
return c.json(
|
|
296
|
+
{ error: "Missing required parameters (platform, bundleId)." },
|
|
297
|
+
400,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const supabase = createClient(
|
|
302
|
+
Deno.env.get("SUPABASE_URL") ?? "",
|
|
303
|
+
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
|
|
304
|
+
{
|
|
305
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const updateConfig = {
|
|
310
|
+
platform: platform as "ios" | "android",
|
|
311
|
+
fingerprintHash,
|
|
312
|
+
bundleId,
|
|
313
|
+
minBundleId: minBundleId || NIL_UUID,
|
|
314
|
+
channel: channel || "production",
|
|
315
|
+
_updateStrategy: "fingerprint" as const,
|
|
316
|
+
} satisfies GetBundlesArgs;
|
|
317
|
+
|
|
318
|
+
const result = await handleUpdateRequest(supabase, updateConfig);
|
|
319
|
+
return c.json(result);
|
|
320
|
+
} catch (err: unknown) {
|
|
321
|
+
return c.json(
|
|
322
|
+
{
|
|
323
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
324
|
+
},
|
|
325
|
+
500,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
Deno.serve(app.fetch);
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
|
|
2
|
+
ALTER TABLE bundles ADD COLUMN IF NOT EXISTS fingerprint_hash text;
|
|
3
|
+
ALTER TABLE bundles ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb;
|
|
4
|
+
|
|
5
|
+
ALTER TABLE bundles ADD COLUMN IF NOT EXISTS storage_uri TEXT;
|
|
6
|
+
|
|
7
|
+
UPDATE bundles
|
|
8
|
+
SET storage_uri = 'supabase-storage://%%BUCKET_NAME%%/' || id || '/bundle.zip'
|
|
9
|
+
WHERE storage_uri IS NULL;
|
|
10
|
+
|
|
11
|
+
ALTER TABLE bundles ALTER COLUMN storage_uri SET NOT NULL;
|
|
12
|
+
ALTER TABLE bundles ALTER COLUMN target_app_version DROP NOT NULL;
|
|
13
|
+
|
|
14
|
+
ALTER TABLE bundles ADD CONSTRAINT check_version_or_fingerprint CHECK (
|
|
15
|
+
(target_app_version IS NOT NULL) OR (fingerprint_hash IS NOT NULL)
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
CREATE INDEX bundles_fingerprint_hash_idx ON bundles(fingerprint_hash);
|
|
19
|
+
|
|
20
|
+
DROP FUNCTION IF EXISTS get_update_info;
|
|
21
|
+
|
|
22
|
+
-- HotUpdater.get_update_info
|
|
23
|
+
CREATE OR REPLACE FUNCTION get_update_info_by_fingerprint_hash (
|
|
24
|
+
app_platform platforms,
|
|
25
|
+
bundle_id uuid,
|
|
26
|
+
min_bundle_id uuid,
|
|
27
|
+
target_channel text,
|
|
28
|
+
target_fingerprint_hash text
|
|
29
|
+
)
|
|
30
|
+
RETURNS TABLE (
|
|
31
|
+
id uuid,
|
|
32
|
+
should_force_update boolean,
|
|
33
|
+
message text,
|
|
34
|
+
status text,
|
|
35
|
+
storage_uri text
|
|
36
|
+
)
|
|
37
|
+
LANGUAGE plpgsql
|
|
38
|
+
AS
|
|
39
|
+
$$
|
|
40
|
+
DECLARE
|
|
41
|
+
NIL_UUID CONSTANT uuid := '00000000-0000-0000-0000-000000000000';
|
|
42
|
+
BEGIN
|
|
43
|
+
RETURN QUERY
|
|
44
|
+
WITH update_candidate AS (
|
|
45
|
+
SELECT
|
|
46
|
+
b.id,
|
|
47
|
+
b.should_force_update,
|
|
48
|
+
b.message,
|
|
49
|
+
'UPDATE' AS status,
|
|
50
|
+
b.storage_uri
|
|
51
|
+
FROM bundles b
|
|
52
|
+
WHERE b.enabled = TRUE
|
|
53
|
+
AND b.platform = app_platform
|
|
54
|
+
AND b.id >= bundle_id
|
|
55
|
+
AND b.id > min_bundle_id
|
|
56
|
+
AND b.channel = target_channel
|
|
57
|
+
AND b.fingerprint_hash = target_fingerprint_hash
|
|
58
|
+
ORDER BY b.id DESC
|
|
59
|
+
LIMIT 1
|
|
60
|
+
),
|
|
61
|
+
rollback_candidate AS (
|
|
62
|
+
SELECT
|
|
63
|
+
b.id,
|
|
64
|
+
TRUE AS should_force_update,
|
|
65
|
+
b.message,
|
|
66
|
+
'ROLLBACK' AS status,
|
|
67
|
+
b.storage_uri
|
|
68
|
+
FROM bundles b
|
|
69
|
+
WHERE b.enabled = TRUE
|
|
70
|
+
AND b.platform = app_platform
|
|
71
|
+
AND b.id < bundle_id
|
|
72
|
+
AND b.id > min_bundle_id
|
|
73
|
+
AND b.channel = target_channel
|
|
74
|
+
AND b.fingerprint_hash = target_fingerprint_hash
|
|
75
|
+
ORDER BY b.id DESC
|
|
76
|
+
LIMIT 1
|
|
77
|
+
),
|
|
78
|
+
final_result AS (
|
|
79
|
+
SELECT * FROM update_candidate
|
|
80
|
+
UNION ALL
|
|
81
|
+
SELECT * FROM rollback_candidate
|
|
82
|
+
WHERE NOT EXISTS (SELECT 1 FROM update_candidate)
|
|
83
|
+
)
|
|
84
|
+
SELECT *
|
|
85
|
+
FROM final_result
|
|
86
|
+
WHERE final_result.id != bundle_id
|
|
87
|
+
|
|
88
|
+
UNION ALL
|
|
89
|
+
|
|
90
|
+
SELECT
|
|
91
|
+
NIL_UUID AS id,
|
|
92
|
+
TRUE AS should_force_update,
|
|
93
|
+
NULL AS message,
|
|
94
|
+
'ROLLBACK' AS status,
|
|
95
|
+
NULL AS storage_uri
|
|
96
|
+
WHERE (SELECT COUNT(*) FROM final_result) = 0
|
|
97
|
+
AND bundle_id != NIL_UUID
|
|
98
|
+
AND bundle_id > min_bundle_id
|
|
99
|
+
AND NOT EXISTS (
|
|
100
|
+
SELECT 1
|
|
101
|
+
FROM bundles b
|
|
102
|
+
WHERE b.id = bundle_id
|
|
103
|
+
AND b.enabled = TRUE
|
|
104
|
+
AND b.platform = app_platform
|
|
105
|
+
);
|
|
106
|
+
END;
|
|
107
|
+
$$;
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
-- HotUpdater.get_update_info
|
|
111
|
+
CREATE OR REPLACE FUNCTION get_update_info_by_app_version (
|
|
112
|
+
app_platform platforms,
|
|
113
|
+
app_version text,
|
|
114
|
+
bundle_id uuid,
|
|
115
|
+
min_bundle_id uuid,
|
|
116
|
+
target_channel text,
|
|
117
|
+
target_app_version_list text[]
|
|
118
|
+
)
|
|
119
|
+
RETURNS TABLE (
|
|
120
|
+
id uuid,
|
|
121
|
+
should_force_update boolean,
|
|
122
|
+
message text,
|
|
123
|
+
status text,
|
|
124
|
+
storage_uri text
|
|
125
|
+
)
|
|
126
|
+
LANGUAGE plpgsql
|
|
127
|
+
AS
|
|
128
|
+
$$
|
|
129
|
+
DECLARE
|
|
130
|
+
NIL_UUID CONSTANT uuid := '00000000-0000-0000-0000-000000000000';
|
|
131
|
+
BEGIN
|
|
132
|
+
RETURN QUERY
|
|
133
|
+
WITH update_candidate AS (
|
|
134
|
+
SELECT
|
|
135
|
+
b.id,
|
|
136
|
+
b.should_force_update,
|
|
137
|
+
b.message,
|
|
138
|
+
'UPDATE' AS status,
|
|
139
|
+
b.storage_uri
|
|
140
|
+
FROM bundles b
|
|
141
|
+
WHERE b.enabled = TRUE
|
|
142
|
+
AND b.platform = app_platform
|
|
143
|
+
AND b.id >= bundle_id
|
|
144
|
+
AND b.id > min_bundle_id
|
|
145
|
+
AND b.target_app_version IN (SELECT unnest(target_app_version_list))
|
|
146
|
+
AND b.channel = target_channel
|
|
147
|
+
ORDER BY b.id DESC
|
|
148
|
+
LIMIT 1
|
|
149
|
+
),
|
|
150
|
+
rollback_candidate AS (
|
|
151
|
+
SELECT
|
|
152
|
+
b.id,
|
|
153
|
+
TRUE AS should_force_update,
|
|
154
|
+
b.message,
|
|
155
|
+
'ROLLBACK' AS status,
|
|
156
|
+
b.storage_uri
|
|
157
|
+
FROM bundles b
|
|
158
|
+
WHERE b.enabled = TRUE
|
|
159
|
+
AND b.platform = app_platform
|
|
160
|
+
AND b.id < bundle_id
|
|
161
|
+
AND b.id > min_bundle_id
|
|
162
|
+
ORDER BY b.id DESC
|
|
163
|
+
LIMIT 1
|
|
164
|
+
),
|
|
165
|
+
final_result AS (
|
|
166
|
+
SELECT * FROM update_candidate
|
|
167
|
+
UNION ALL
|
|
168
|
+
SELECT * FROM rollback_candidate
|
|
169
|
+
WHERE NOT EXISTS (SELECT 1 FROM update_candidate)
|
|
170
|
+
)
|
|
171
|
+
SELECT *
|
|
172
|
+
FROM final_result
|
|
173
|
+
WHERE final_result.id != bundle_id
|
|
174
|
+
|
|
175
|
+
UNION ALL
|
|
176
|
+
|
|
177
|
+
SELECT
|
|
178
|
+
NIL_UUID AS id,
|
|
179
|
+
TRUE AS should_force_update,
|
|
180
|
+
NULL AS message,
|
|
181
|
+
'ROLLBACK' AS status,
|
|
182
|
+
NULL AS storage_uri
|
|
183
|
+
WHERE (SELECT COUNT(*) FROM final_result) = 0
|
|
184
|
+
AND bundle_id != NIL_UUID
|
|
185
|
+
AND bundle_id > min_bundle_id
|
|
186
|
+
AND NOT EXISTS (
|
|
187
|
+
SELECT 1
|
|
188
|
+
FROM bundles b
|
|
189
|
+
WHERE b.id = bundle_id
|
|
190
|
+
AND b.enabled = TRUE
|
|
191
|
+
AND b.platform = app_platform
|
|
192
|
+
);
|
|
193
|
+
END;
|
|
194
|
+
$$;
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
|
2
|
-
import camelcaseKeys from "npm:camelcase-keys@9.1.3";
|
|
3
|
-
import semver from "npm:semver@7.7.1";
|
|
4
|
-
import { createClient } from "jsr:@supabase/supabase-js@2.49.1";
|
|
5
|
-
|
|
6
|
-
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
7
|
-
|
|
8
|
-
const semverSatisfies = (targetAppVersion: string, currentVersion: string) => {
|
|
9
|
-
const currentCoerce = semver.coerce(currentVersion);
|
|
10
|
-
if (!currentCoerce) {
|
|
11
|
-
return false;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
return semver.satisfies(currentCoerce.version, targetAppVersion);
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Filters target app versions that are compatible with the current app version.
|
|
19
|
-
* Returns only versions that are compatible with the current version according to semver rules.
|
|
20
|
-
*
|
|
21
|
-
* @param targetAppVersionList - List of target app versions to filter
|
|
22
|
-
* @param currentVersion - Current app version
|
|
23
|
-
* @returns Array of target app versions compatible with the current version
|
|
24
|
-
*/
|
|
25
|
-
export const filterCompatibleAppVersions = (
|
|
26
|
-
targetAppVersionList: string[],
|
|
27
|
-
currentVersion: string,
|
|
28
|
-
) => {
|
|
29
|
-
const compatibleAppVersionList = targetAppVersionList.filter((version) =>
|
|
30
|
-
semverSatisfies(version, currentVersion),
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
return compatibleAppVersionList.sort((a, b) => b.localeCompare(a));
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const createErrorResponse = (message: string, statusCode: number) => {
|
|
37
|
-
return new Response(JSON.stringify({ code: statusCode, message }), {
|
|
38
|
-
headers: { "Content-Type": "application/json" },
|
|
39
|
-
status: statusCode,
|
|
40
|
-
});
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
declare global {
|
|
44
|
-
var HotUpdater: {
|
|
45
|
-
BUCKET_NAME: string;
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
Deno.serve(async (req) => {
|
|
50
|
-
try {
|
|
51
|
-
const supabase = createClient(
|
|
52
|
-
Deno.env.get("SUPABASE_URL") ?? "",
|
|
53
|
-
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
|
|
54
|
-
{
|
|
55
|
-
auth: { autoRefreshToken: false, persistSession: false },
|
|
56
|
-
},
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
const bundleId = req.headers.get("x-bundle-id") as string;
|
|
60
|
-
const appPlatform = req.headers.get("x-app-platform") as "ios" | "android";
|
|
61
|
-
const appVersion = req.headers.get("x-app-version") as string;
|
|
62
|
-
const minBundleId = req.headers.get("x-min-bundle-id") as
|
|
63
|
-
| string
|
|
64
|
-
| undefined;
|
|
65
|
-
const channel = req.headers.get("x-channel") as string | undefined;
|
|
66
|
-
|
|
67
|
-
if (!bundleId || !appPlatform || !appVersion) {
|
|
68
|
-
return createErrorResponse(
|
|
69
|
-
"Missing bundleId, appPlatform, or appVersion",
|
|
70
|
-
400,
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const { data: appVersionList } = await supabase.rpc(
|
|
75
|
-
"get_target_app_version_list",
|
|
76
|
-
{
|
|
77
|
-
app_platform: appPlatform,
|
|
78
|
-
min_bundle_id: minBundleId || NIL_UUID,
|
|
79
|
-
},
|
|
80
|
-
);
|
|
81
|
-
const compatibleAppVersionList = filterCompatibleAppVersions(
|
|
82
|
-
appVersionList?.map((group) => group.target_app_version) ?? [],
|
|
83
|
-
appVersion,
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
const { data, error } = await supabase.rpc("get_update_info", {
|
|
87
|
-
app_platform: appPlatform,
|
|
88
|
-
app_version: appVersion,
|
|
89
|
-
bundle_id: bundleId,
|
|
90
|
-
min_bundle_id: minBundleId || NIL_UUID,
|
|
91
|
-
target_channel: channel || "production",
|
|
92
|
-
target_app_version_list: compatibleAppVersionList,
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
if (error) {
|
|
96
|
-
throw error;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const response = data[0] ? camelcaseKeys(data[0]) : null;
|
|
100
|
-
if (!response) {
|
|
101
|
-
return new Response(JSON.stringify(null), {
|
|
102
|
-
headers: { "Content-Type": "application/json" },
|
|
103
|
-
status: 200,
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (response.id === NIL_UUID) {
|
|
108
|
-
return new Response(
|
|
109
|
-
JSON.stringify({
|
|
110
|
-
...response,
|
|
111
|
-
fileUrl: null,
|
|
112
|
-
}),
|
|
113
|
-
{
|
|
114
|
-
headers: { "Content-Type": "application/json" },
|
|
115
|
-
status: 200,
|
|
116
|
-
},
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const { data: signedUrlData } = await supabase.storage
|
|
121
|
-
.from(HotUpdater.BUCKET_NAME)
|
|
122
|
-
.createSignedUrl([response.id, "bundle.zip"].join("/"), 60);
|
|
123
|
-
|
|
124
|
-
return new Response(
|
|
125
|
-
JSON.stringify({
|
|
126
|
-
...response,
|
|
127
|
-
fileUrl: signedUrlData?.signedUrl ?? null,
|
|
128
|
-
}),
|
|
129
|
-
{
|
|
130
|
-
headers: { "Content-Type": "application/json" },
|
|
131
|
-
status: 200,
|
|
132
|
-
},
|
|
133
|
-
);
|
|
134
|
-
} catch (err: unknown) {
|
|
135
|
-
return createErrorResponse(
|
|
136
|
-
err instanceof Error ? err.message : "Unknown error",
|
|
137
|
-
500,
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
});
|