@hot-updater/supabase 0.30.12 → 0.31.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.
@@ -0,0 +1,292 @@
1
+ require("./index.cjs");
2
+ let _hot_updater_core = require("@hot-updater/core");
3
+ let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
4
+ let _supabase_supabase_js = require("@supabase/supabase-js");
5
+ //#region src/supabaseDatabase.ts
6
+ const normalizeMetadata = (value) => {
7
+ if (!value) return {};
8
+ if (typeof value === "string") try {
9
+ return normalizeMetadata(JSON.parse(value));
10
+ } catch {
11
+ return {};
12
+ }
13
+ if (typeof value === "object" && !Array.isArray(value)) return value;
14
+ return {};
15
+ };
16
+ const BUNDLE_SELECT_COLUMNS = "id, channel, enabled, platform, should_force_update, file_hash, git_commit_hash, message, fingerprint_hash, target_app_version, storage_uri, metadata, manifest_storage_uri, manifest_file_hash, asset_base_storage_uri, rollout_cohort_count, target_cohorts";
17
+ const createSupabaseError = (error) => {
18
+ if (error instanceof Error) return error;
19
+ if (error && typeof error === "object") {
20
+ const properties = {};
21
+ let target = error;
22
+ while (target && target !== Object.prototype) {
23
+ for (const key of Object.getOwnPropertyNames(target)) properties[key] = error[key];
24
+ target = Object.getPrototypeOf(target);
25
+ }
26
+ return new Error(JSON.stringify({
27
+ name: error.constructor.name,
28
+ ...properties
29
+ }));
30
+ }
31
+ try {
32
+ return new Error(JSON.stringify(error));
33
+ } catch {
34
+ return new Error(String(error));
35
+ }
36
+ };
37
+ const buildBundlePatchId = (bundleId, baseBundleId) => `${bundleId}:${baseBundleId}`;
38
+ const mapRowToBundle = (row, patchRows = []) => {
39
+ const rawMetadata = normalizeMetadata(row.metadata);
40
+ const patches = patchRows.slice().sort((left, right) => left.order_index - right.order_index || left.base_bundle_id.localeCompare(right.base_bundle_id)).map((patch) => ({
41
+ baseBundleId: patch.base_bundle_id,
42
+ baseFileHash: patch.base_file_hash,
43
+ patchFileHash: patch.patch_file_hash,
44
+ patchStorageUri: patch.patch_storage_uri
45
+ }));
46
+ const primaryPatch = patches[0] ?? null;
47
+ return {
48
+ channel: row.channel,
49
+ enabled: Boolean(row.enabled),
50
+ shouldForceUpdate: Boolean(row.should_force_update),
51
+ fileHash: row.file_hash,
52
+ gitCommitHash: row.git_commit_hash,
53
+ id: row.id,
54
+ message: row.message,
55
+ platform: row.platform,
56
+ targetAppVersion: row.target_app_version,
57
+ fingerprintHash: row.fingerprint_hash,
58
+ storageUri: row.storage_uri,
59
+ metadata: (0, _hot_updater_core.stripBundleArtifactMetadata)(rawMetadata),
60
+ manifestStorageUri: row.manifest_storage_uri ?? null,
61
+ manifestFileHash: row.manifest_file_hash ?? null,
62
+ assetBaseStorageUri: row.asset_base_storage_uri ?? null,
63
+ patches,
64
+ patchBaseBundleId: primaryPatch?.baseBundleId ?? null,
65
+ patchBaseFileHash: primaryPatch?.baseFileHash ?? null,
66
+ patchFileHash: primaryPatch?.patchFileHash ?? null,
67
+ patchStorageUri: primaryPatch?.patchStorageUri ?? null,
68
+ rolloutCohortCount: row.rollout_cohort_count ?? _hot_updater_core.DEFAULT_ROLLOUT_COHORT_COUNT,
69
+ targetCohorts: row.target_cohorts ?? null
70
+ };
71
+ };
72
+ const bundleToRow = (bundle) => ({
73
+ id: bundle.id,
74
+ channel: bundle.channel,
75
+ enabled: bundle.enabled,
76
+ should_force_update: bundle.shouldForceUpdate,
77
+ file_hash: bundle.fileHash,
78
+ git_commit_hash: bundle.gitCommitHash,
79
+ message: bundle.message,
80
+ platform: bundle.platform,
81
+ target_app_version: bundle.targetAppVersion,
82
+ fingerprint_hash: bundle.fingerprintHash,
83
+ storage_uri: bundle.storageUri,
84
+ metadata: (0, _hot_updater_core.stripBundleArtifactMetadata)(bundle.metadata) ?? {},
85
+ manifest_storage_uri: (0, _hot_updater_core.getManifestStorageUri)(bundle),
86
+ manifest_file_hash: (0, _hot_updater_core.getManifestFileHash)(bundle),
87
+ asset_base_storage_uri: (0, _hot_updater_core.getAssetBaseStorageUri)(bundle),
88
+ rollout_cohort_count: bundle.rolloutCohortCount ?? _hot_updater_core.DEFAULT_ROLLOUT_COHORT_COUNT,
89
+ target_cohorts: bundle.targetCohorts ?? null
90
+ });
91
+ const bundleToPatchRows = (bundle) => (0, _hot_updater_core.getBundlePatches)(bundle).map((patch, index) => ({
92
+ id: buildBundlePatchId(bundle.id, patch.baseBundleId),
93
+ bundle_id: bundle.id,
94
+ base_bundle_id: patch.baseBundleId,
95
+ base_file_hash: patch.baseFileHash,
96
+ patch_file_hash: patch.patchFileHash,
97
+ patch_storage_uri: patch.patchStorageUri,
98
+ order_index: index
99
+ }));
100
+ const supabaseDatabase = (0, _hot_updater_plugin_core.createDatabasePlugin)({
101
+ name: "supabaseDatabase",
102
+ factory: (config) => {
103
+ const supabase = (0, _supabase_supabase_js.createClient)(config.supabaseUrl, config.supabaseAnonKey);
104
+ const fetchPatchMap = async (bundleIds) => {
105
+ const patchMap = /* @__PURE__ */ new Map();
106
+ if (bundleIds.length === 0) return patchMap;
107
+ const { data, error } = await supabase.from("bundle_patches").select("*").in("bundle_id", bundleIds).order("order_index", { ascending: true });
108
+ if (error) throw createSupabaseError(error);
109
+ for (const row of data ?? []) {
110
+ const current = patchMap.get(row.bundle_id) ?? [];
111
+ current.push(row);
112
+ patchMap.set(row.bundle_id, current);
113
+ }
114
+ return patchMap;
115
+ };
116
+ const mapRowsToBundles = async (rows) => {
117
+ const patchMap = await fetchPatchMap(rows.map((row) => row.id));
118
+ return rows.map((row) => mapRowToBundle(row, patchMap.get(row.id)));
119
+ };
120
+ return {
121
+ getUpdateInfo: (0, _hot_updater_plugin_core.createDatabasePluginGetUpdateInfo)({
122
+ async listTargetAppVersions({ platform, channel, minBundleId }) {
123
+ const { data, error } = await supabase.from("bundles").select("target_app_version").eq("platform", platform).eq("channel", channel).eq("enabled", true).gte("id", minBundleId).not("target_app_version", "is", null);
124
+ if (error) throw createSupabaseError(error);
125
+ return Array.from(new Set((data ?? []).map((row) => row.target_app_version).filter((version) => Boolean(version))));
126
+ },
127
+ async getBundlesByTargetAppVersions({ platform, channel, minBundleId }, targetAppVersions) {
128
+ const { data, error } = await supabase.from("bundles").select(BUNDLE_SELECT_COLUMNS).eq("platform", platform).eq("channel", channel).eq("enabled", true).gte("id", minBundleId).in("target_app_version", targetAppVersions);
129
+ if (error) throw createSupabaseError(error);
130
+ return mapRowsToBundles(data ?? []);
131
+ },
132
+ async getBundlesByFingerprint({ platform, channel, minBundleId, fingerprintHash }) {
133
+ const { data, error } = await supabase.from("bundles").select(BUNDLE_SELECT_COLUMNS).eq("platform", platform).eq("channel", channel).eq("enabled", true).gte("id", minBundleId).eq("fingerprint_hash", fingerprintHash);
134
+ if (error) throw createSupabaseError(error);
135
+ return mapRowsToBundles(data ?? []);
136
+ }
137
+ }),
138
+ async getBundleById(bundleId) {
139
+ const [{ data, error }, patchMap] = await Promise.all([supabase.from("bundles").select(BUNDLE_SELECT_COLUMNS).eq("id", bundleId).single(), fetchPatchMap([bundleId])]);
140
+ if (!data || error) return null;
141
+ return mapRowToBundle(data, patchMap.get(bundleId) ?? []);
142
+ },
143
+ async getBundles(options) {
144
+ const { where, limit, orderBy } = options ?? {};
145
+ const offset = (options && "offset" in options ? options.offset : void 0) ?? 0;
146
+ if (where?.targetAppVersionIn && where.targetAppVersionIn.length === 0 || where?.id?.in && where.id.in.length === 0) return {
147
+ data: [],
148
+ pagination: (0, _hot_updater_plugin_core.calculatePagination)(0, {
149
+ limit,
150
+ offset
151
+ })
152
+ };
153
+ let countQuery = supabase.from("bundles").select("*", {
154
+ count: "exact",
155
+ head: true
156
+ });
157
+ if (where?.channel) countQuery = countQuery.eq("channel", where.channel);
158
+ if (where?.platform) countQuery = countQuery.eq("platform", where.platform);
159
+ if (where?.enabled !== void 0) countQuery = countQuery.eq("enabled", where.enabled);
160
+ if (where?.fingerprintHash !== void 0) countQuery = where.fingerprintHash === null ? countQuery.is("fingerprint_hash", null) : countQuery.eq("fingerprint_hash", where.fingerprintHash);
161
+ if (where?.targetAppVersion !== void 0) countQuery = where.targetAppVersion === null ? countQuery.is("target_app_version", null) : countQuery.eq("target_app_version", where.targetAppVersion);
162
+ if (where?.targetAppVersionIn) countQuery = countQuery.in("target_app_version", where.targetAppVersionIn);
163
+ if (where?.targetAppVersionNotNull) countQuery = countQuery.not("target_app_version", "is", null);
164
+ if (where?.id?.eq) countQuery = countQuery.eq("id", where.id.eq);
165
+ if (where?.id?.gt) countQuery = countQuery.gt("id", where.id.gt);
166
+ if (where?.id?.gte) countQuery = countQuery.gte("id", where.id.gte);
167
+ if (where?.id?.lt) countQuery = countQuery.lt("id", where.id.lt);
168
+ if (where?.id?.lte) countQuery = countQuery.lte("id", where.id.lte);
169
+ if (where?.id?.in) countQuery = countQuery.in("id", where.id.in);
170
+ const { count: total = 0 } = await countQuery;
171
+ let query = supabase.from("bundles").select(BUNDLE_SELECT_COLUMNS).order("id", { ascending: orderBy?.direction === "asc" });
172
+ if (where?.channel) query = query.eq("channel", where.channel);
173
+ if (where?.platform) query = query.eq("platform", where.platform);
174
+ if (where?.enabled !== void 0) query = query.eq("enabled", where.enabled);
175
+ if (where?.fingerprintHash !== void 0) query = where.fingerprintHash === null ? query.is("fingerprint_hash", null) : query.eq("fingerprint_hash", where.fingerprintHash);
176
+ if (where?.targetAppVersion !== void 0) query = where.targetAppVersion === null ? query.is("target_app_version", null) : query.eq("target_app_version", where.targetAppVersion);
177
+ if (where?.targetAppVersionIn) query = query.in("target_app_version", where.targetAppVersionIn);
178
+ if (where?.targetAppVersionNotNull) query = query.not("target_app_version", "is", null);
179
+ if (where?.id?.eq) query = query.eq("id", where.id.eq);
180
+ if (where?.id?.gt) query = query.gt("id", where.id.gt);
181
+ if (where?.id?.gte) query = query.gte("id", where.id.gte);
182
+ if (where?.id?.lt) query = query.lt("id", where.id.lt);
183
+ if (where?.id?.lte) query = query.lte("id", where.id.lte);
184
+ if (where?.id?.in) query = query.in("id", where.id.in);
185
+ if (limit) query = query.limit(limit);
186
+ if (offset) query = query.range(offset, offset + (limit || 20) - 1);
187
+ const { data } = await query;
188
+ const patchMap = await fetchPatchMap((data ?? []).map((bundle) => bundle.id));
189
+ return {
190
+ data: (data ?? []).map((bundle) => mapRowToBundle(bundle, patchMap.get(bundle.id) ?? [])),
191
+ pagination: (0, _hot_updater_plugin_core.calculatePagination)(total ?? 0, {
192
+ limit,
193
+ offset
194
+ })
195
+ };
196
+ },
197
+ async getChannels() {
198
+ const { data, error } = await supabase.rpc("get_channels");
199
+ if (error) throw error;
200
+ return data.map((bundle) => bundle.channel);
201
+ },
202
+ async commitBundle({ changedSets }) {
203
+ if (changedSets.length === 0) return;
204
+ for (const op of changedSets) if (op.operation === "delete") {
205
+ const { error: patchDeleteError } = await supabase.from("bundle_patches").delete().eq("bundle_id", op.data.id);
206
+ if (patchDeleteError) throw new Error(`Failed to delete bundle patches: ${patchDeleteError.message}`);
207
+ const { error: basePatchDeleteError } = await supabase.from("bundle_patches").delete().eq("base_bundle_id", op.data.id);
208
+ if (basePatchDeleteError) throw new Error(`Failed to delete base bundle patches: ${basePatchDeleteError.message}`);
209
+ const { error } = await supabase.from("bundles").delete().eq("id", op.data.id);
210
+ if (error) throw new Error(`Failed to delete bundle: ${error.message}`);
211
+ } else if (op.operation === "insert" || op.operation === "update") {
212
+ const bundle = op.data;
213
+ const patchRows = bundleToPatchRows(bundle);
214
+ const { error } = await supabase.from("bundles").upsert(bundleToRow(bundle), { onConflict: "id" });
215
+ if (error) throw error;
216
+ const { error: patchDeleteError } = await supabase.from("bundle_patches").delete().eq("bundle_id", bundle.id);
217
+ if (patchDeleteError) throw patchDeleteError;
218
+ if (patchRows.length > 0) {
219
+ const { error: patchInsertError } = await supabase.from("bundle_patches").upsert(patchRows, { onConflict: "id" });
220
+ if (patchInsertError) throw patchInsertError;
221
+ }
222
+ }
223
+ }
224
+ };
225
+ }
226
+ });
227
+ //#endregion
228
+ //#region src/supabaseEdgeFunctionDatabase.ts
229
+ const supabaseEdgeFunctionDatabase = (config, hooks) => {
230
+ return supabaseDatabase({
231
+ supabaseUrl: config.supabaseUrl,
232
+ supabaseAnonKey: config.supabaseServiceRoleKey
233
+ }, hooks);
234
+ };
235
+ //#endregion
236
+ //#region src/supabaseEdgeFunctionStorage.ts
237
+ const parseSupabaseStorageUri = (storageUri) => {
238
+ const storageUrl = new URL(storageUri);
239
+ if (storageUrl.protocol !== "supabase-storage:") throw new Error("Invalid Supabase storage URI protocol");
240
+ const bucketName = storageUrl.host;
241
+ const key = storageUrl.pathname.replace(/^\/+/, "");
242
+ if (!bucketName || !key) throw new Error("Invalid Supabase storage URI");
243
+ return {
244
+ bucketName,
245
+ key
246
+ };
247
+ };
248
+ const supabaseEdgeFunctionStorage = (0, _hot_updater_plugin_core.createRuntimeStoragePlugin)({
249
+ name: "supabaseEdgeFunctionStorage",
250
+ supportedProtocol: "supabase-storage",
251
+ factory: (config) => {
252
+ const supabase = (0, _supabase_supabase_js.createClient)(config.supabaseUrl, config.supabaseServiceRoleKey);
253
+ return {
254
+ async readText(storageUri) {
255
+ const { bucketName, key } = parseSupabaseStorageUri(storageUri);
256
+ const { data, error } = await supabase.storage.from(bucketName).download(key);
257
+ if (error) {
258
+ if (error.message?.includes("not found")) return null;
259
+ throw new Error(`Failed to read storage text: ${error.message}`);
260
+ }
261
+ if (!data) return null;
262
+ return data.text();
263
+ },
264
+ async getDownloadUrl(storageUri) {
265
+ const { bucketName, key } = parseSupabaseStorageUri(storageUri);
266
+ const { data, error } = await supabase.storage.from(bucketName).createSignedUrl(key, config.signedUrlExpiresIn ?? 3600);
267
+ if (error) throw new Error(`Failed to generate download URL: ${error.message}`);
268
+ if (!data?.signedUrl) throw new Error("Failed to generate download URL");
269
+ return { fileUrl: data.signedUrl };
270
+ }
271
+ };
272
+ }
273
+ });
274
+ //#endregion
275
+ Object.defineProperty(exports, "supabaseDatabase", {
276
+ enumerable: true,
277
+ get: function() {
278
+ return supabaseDatabase;
279
+ }
280
+ });
281
+ Object.defineProperty(exports, "supabaseEdgeFunctionDatabase", {
282
+ enumerable: true,
283
+ get: function() {
284
+ return supabaseEdgeFunctionDatabase;
285
+ }
286
+ });
287
+ Object.defineProperty(exports, "supabaseEdgeFunctionStorage", {
288
+ enumerable: true,
289
+ get: function() {
290
+ return supabaseEdgeFunctionStorage;
291
+ }
292
+ });
@@ -1,5 +1,5 @@
1
1
  import * as _$_hot_updater_plugin_core0 from "@hot-updater/plugin-core";
2
- import { DatabasePluginHooks, StoragePlugin } from "@hot-updater/plugin-core";
2
+ import { DatabasePluginHooks } from "@hot-updater/plugin-core";
3
3
 
4
4
  //#region src/supabaseEdgeFunctionDatabase.d.ts
5
5
  interface SupabaseEdgeFunctionDatabaseConfig {
@@ -14,6 +14,6 @@ interface SupabaseEdgeFunctionStorageConfig {
14
14
  supabaseServiceRoleKey: string;
15
15
  signedUrlExpiresIn?: number;
16
16
  }
17
- declare const supabaseEdgeFunctionStorage: (config: SupabaseEdgeFunctionStorageConfig) => () => StoragePlugin;
17
+ declare const supabaseEdgeFunctionStorage: (config: SupabaseEdgeFunctionStorageConfig, hooks?: _$_hot_updater_plugin_core0.StoragePluginHooks) => () => _$_hot_updater_plugin_core0.RuntimeStoragePlugin<unknown>;
18
18
  //#endregion
19
19
  export { supabaseEdgeFunctionDatabase as i, supabaseEdgeFunctionStorage as n, SupabaseEdgeFunctionDatabaseConfig as r, SupabaseEdgeFunctionStorageConfig as t };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/supabase",
3
3
  "type": "module",
4
- "version": "0.30.12",
4
+ "version": "0.31.0",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "main": "dist/index.cjs",
7
7
  "module": "dist/index.mjs",
@@ -47,10 +47,10 @@
47
47
  "@supabase/supabase-js": "2.76.1",
48
48
  "hono": "4.12.9",
49
49
  "uuidv7": "^1.0.2",
50
- "@hot-updater/core": "0.30.12",
51
- "@hot-updater/plugin-core": "0.30.12",
52
- "@hot-updater/cli-tools": "0.30.12",
53
- "@hot-updater/server": "0.30.12"
50
+ "@hot-updater/plugin-core": "0.31.0",
51
+ "@hot-updater/server": "0.31.0",
52
+ "@hot-updater/core": "0.31.0",
53
+ "@hot-updater/cli-tools": "0.31.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@electric-sql/pglite": "0.2.17",
@@ -60,10 +60,10 @@
60
60
  "execa": "9.5.2",
61
61
  "@types/node": "^20",
62
62
  "mime": "^4.0.4",
63
- "@hot-updater/js": "0.30.12",
64
- "@hot-updater/mock": "0.30.12",
65
- "@hot-updater/postgres": "0.30.12",
66
- "@hot-updater/test-utils": "0.30.12"
63
+ "@hot-updater/js": "0.31.0",
64
+ "@hot-updater/mock": "0.31.0",
65
+ "@hot-updater/postgres": "0.31.0",
66
+ "@hot-updater/test-utils": "0.31.0"
67
67
  },
68
68
  "scripts": {
69
69
  "build": "tsdown",
@@ -16,7 +16,10 @@ import { fileURLToPath, pathToFileURL } from "node:url";
16
16
  import { transformEnv } from "@hot-updater/cli-tools";
17
17
  import { type Bundle, type GetBundlesArgs, NIL_UUID } from "@hot-updater/core";
18
18
  import { createHotUpdater } from "@hot-updater/server/runtime";
19
- import { setupGetUpdateInfoTestSuite } from "@hot-updater/test-utils";
19
+ import {
20
+ setupBsdiffManifestUpdateInfoTestSuite,
21
+ setupGetUpdateInfoTestSuite,
22
+ } from "@hot-updater/test-utils";
20
23
  import { createClient } from "@supabase/supabase-js";
21
24
  import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
22
25
 
@@ -26,6 +29,7 @@ import {
26
29
  runCheckedCommand,
27
30
  spawnRuntime,
28
31
  stopRuntime,
32
+ formatRuntimeLogs,
29
33
  waitForHttpOk,
30
34
  } from "../../../../packages/test-utils/src/runtimeProcess";
31
35
  import { supabaseDatabase } from "../../src/supabaseDatabase";
@@ -211,7 +215,8 @@ describe.sequential("supabase edge runtime acceptance", () => {
211
215
  );
212
216
  }
213
217
 
214
- await waitForUrlOk(`${gatewayBaseUrl}/storage/v1/status`);
218
+ await waitForRestApiReady(gatewayBaseUrl, 180_000);
219
+ await waitForUrlOk(`${gatewayBaseUrl}/storage/v1/status`, 180_000);
215
220
 
216
221
  supabaseAdmin = createClient(gatewayBaseUrl, SERVICE_ROLE_KEY);
217
222
  await ensureBucketExists(supabaseAdmin);
@@ -242,10 +247,12 @@ describe.sequential("supabase edge runtime acceptance", () => {
242
247
  "--rm",
243
248
  "--network",
244
249
  `${composeProjectName}_default`,
250
+ "--add-host",
251
+ "host.docker.internal:host-gateway",
245
252
  "-p",
246
253
  `127.0.0.1:${edgePort}:8000`,
247
254
  "-e",
248
- `SUPABASE_URL=http://gateway:8000`,
255
+ `SUPABASE_URL=http://host.docker.internal:${gatewayPort}`,
249
256
  "-e",
250
257
  `SUPABASE_SERVICE_ROLE_KEY=${SERVICE_ROLE_KEY}`,
251
258
  "-e",
@@ -280,7 +287,7 @@ describe.sequential("supabase edge runtime acceptance", () => {
280
287
  logs: edgeRuntime.logs,
281
288
  timeoutMs: 90_000,
282
289
  });
283
- }, 180_000);
290
+ }, 300_000);
284
291
 
285
292
  beforeEach(async () => {
286
293
  if (!supabaseAdmin) {
@@ -328,24 +335,153 @@ describe.sequential("supabase edge runtime acceptance", () => {
328
335
  }
329
336
  }, 60_000);
330
337
 
331
- const getUpdateInfo = async (bundles: Bundle[], args: GetBundlesArgs) => {
332
- if (!supabaseAdmin) {
333
- throw new Error("Supabase admin client was not initialized.");
334
- }
335
-
338
+ const seedRuntimeBundles = async (bundles: Bundle[]) => {
336
339
  for (const bundle of bundles.map(toRuntimeBundle)) {
337
- await uploadBundleObject(supabaseAdmin, bundle.id);
338
340
  await seedHotUpdater.insertBundle(bundle);
339
341
  }
342
+ };
340
343
 
344
+ const requestUpdateInfo = async (args: GetBundlesArgs) => {
341
345
  const response = await fetch(
342
346
  `http://127.0.0.1:${edgePort}${FUNCTION_BASE_PATH}${createCanonicalPath(args)}`,
343
347
  );
344
348
 
349
+ if (!response.ok) {
350
+ throw new Error(
351
+ [
352
+ `Edge runtime returned ${response.status} ${response.statusText}`,
353
+ await response.text(),
354
+ edgeRuntime ? formatRuntimeLogs(edgeRuntime.logs) : "",
355
+ ].join("\n\n"),
356
+ );
357
+ }
358
+
345
359
  return (await response.json()) as any;
346
360
  };
347
361
 
348
- setupGetUpdateInfoTestSuite({ getUpdateInfo });
362
+ const getUpdateInfo = async (bundles: Bundle[], args: GetBundlesArgs) => {
363
+ if (!supabaseAdmin) {
364
+ throw new Error("Supabase admin client was not initialized.");
365
+ }
366
+
367
+ for (const bundle of bundles) {
368
+ await uploadBundleObject(supabaseAdmin, bundle.id);
369
+ }
370
+ await seedRuntimeBundles(bundles);
371
+ return requestUpdateInfo(args);
372
+ };
373
+
374
+ setupGetUpdateInfoTestSuite({
375
+ getUpdateInfo,
376
+ manifestArtifacts: {
377
+ prepareArtifacts: async (fixture) => {
378
+ await Promise.all([
379
+ uploadStorageObject(
380
+ supabaseAdmin,
381
+ `${fixture.currentBundleId}/manifest.json`,
382
+ JSON.stringify(fixture.currentManifest),
383
+ "application/json",
384
+ ),
385
+ uploadStorageObject(
386
+ supabaseAdmin,
387
+ `${fixture.nextBundleId}/manifest.json`,
388
+ JSON.stringify(fixture.nextManifest),
389
+ "application/json",
390
+ ),
391
+ uploadStorageObject(
392
+ supabaseAdmin,
393
+ `${fixture.nextBundleId}/files/${fixture.changedAssetPath}.br`,
394
+ "next-bundle-bytes",
395
+ "application/javascript",
396
+ ),
397
+ ]);
398
+
399
+ return {
400
+ currentArtifacts: {
401
+ assetBaseStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.currentBundleId}/files`,
402
+ manifestFileHash: "sig:manifest-current",
403
+ manifestStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.currentBundleId}/manifest.json`,
404
+ },
405
+ nextArtifacts: {
406
+ assetBaseStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.nextBundleId}/files`,
407
+ manifestFileHash: "sig:manifest-next",
408
+ manifestStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.nextBundleId}/manifest.json`,
409
+ },
410
+ };
411
+ },
412
+ expectFileUrl: (fileUrl, fixture) => {
413
+ expect(fileUrl).toContain(
414
+ `/storage/v1/object/sign/${BUCKET_NAME}/${fixture.nextBundleId}/files/${fixture.changedAssetPath}.br`,
415
+ );
416
+ },
417
+ expectManifestUrl: (manifestUrl, fixture) => {
418
+ expect(manifestUrl).toContain(
419
+ `/storage/v1/object/sign/${BUCKET_NAME}/${fixture.nextBundleId}/manifest.json`,
420
+ );
421
+ },
422
+ },
423
+ });
424
+
425
+ setupBsdiffManifestUpdateInfoTestSuite({
426
+ seedBundles: seedRuntimeBundles,
427
+ getUpdateInfo: requestUpdateInfo,
428
+ prepareArtifacts: async (fixture) => {
429
+ await Promise.all([
430
+ uploadStorageObject(
431
+ supabaseAdmin,
432
+ `${fixture.currentBundleId}/manifest.json`,
433
+ JSON.stringify(fixture.currentManifest),
434
+ "application/json",
435
+ ),
436
+ uploadStorageObject(
437
+ supabaseAdmin,
438
+ `${fixture.nextBundleId}/manifest.json`,
439
+ JSON.stringify(fixture.nextManifest),
440
+ "application/json",
441
+ ),
442
+ uploadStorageObject(
443
+ supabaseAdmin,
444
+ `${fixture.nextBundleId}/files/${fixture.assetPath}`,
445
+ "next-bundle-bytes",
446
+ "application/javascript",
447
+ ),
448
+ uploadStorageObject(
449
+ supabaseAdmin,
450
+ fixture.patchPath,
451
+ "patch-bytes",
452
+ "application/octet-stream",
453
+ ),
454
+ uploadBundleObject(supabaseAdmin, fixture.currentBundleId),
455
+ uploadBundleObject(supabaseAdmin, fixture.nextBundleId),
456
+ ]);
457
+
458
+ return {
459
+ currentArtifacts: {
460
+ assetBaseStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.currentBundleId}/files`,
461
+ manifestFileHash: "sig:manifest-current",
462
+ manifestStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.currentBundleId}/manifest.json`,
463
+ },
464
+ nextArtifacts: {
465
+ assetBaseStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.nextBundleId}/files`,
466
+ manifestFileHash: "sig:manifest-next",
467
+ manifestStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.nextBundleId}/manifest.json`,
468
+ patches: [
469
+ {
470
+ baseBundleId: fixture.currentBundleId,
471
+ baseFileHash: "hash-old-bundle",
472
+ patchFileHash: "hash-bsdiff",
473
+ patchStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.patchPath}`,
474
+ },
475
+ ],
476
+ },
477
+ };
478
+ },
479
+ expectPatchUrl: (patchUrl, fixture) => {
480
+ expect(patchUrl).toContain(
481
+ `/storage/v1/object/sign/${BUCKET_NAME}/${fixture.patchPath}`,
482
+ );
483
+ },
484
+ });
349
485
 
350
486
  it("serves canonical routes from the edge function entrypoint", async () => {
351
487
  const bundle = toRuntimeBundle({
@@ -451,6 +587,36 @@ const waitForUrlOk = async (url: string, timeoutMs = 90_000) => {
451
587
  throw new Error(`Timed out waiting for ${url}: ${lastError}`);
452
588
  };
453
589
 
590
+ const waitForRestApiReady = async (baseUrl: string, timeoutMs = 90_000) => {
591
+ const deadline = Date.now() + timeoutMs;
592
+ let lastError = "no response";
593
+
594
+ while (Date.now() < deadline) {
595
+ try {
596
+ const response = await fetch(
597
+ `${baseUrl}/rest/v1/bundles?select=id&limit=1`,
598
+ {
599
+ headers: {
600
+ apikey: SERVICE_ROLE_KEY,
601
+ Authorization: `Bearer ${SERVICE_ROLE_KEY}`,
602
+ },
603
+ },
604
+ );
605
+ if (response.ok) {
606
+ return;
607
+ }
608
+
609
+ lastError = `${response.status} ${response.statusText}: ${await response.text()}`;
610
+ } catch (error) {
611
+ lastError = error instanceof Error ? error.message : String(error);
612
+ }
613
+
614
+ await sleep(500);
615
+ }
616
+
617
+ throw new Error(`Timed out waiting for PostgREST: ${lastError}`);
618
+ };
619
+
454
620
  const sleep = async (ms: number) => {
455
621
  await new Promise((resolve) => setTimeout(resolve, ms));
456
622
  };
@@ -479,11 +645,25 @@ const ensureBucketExists = async (
479
645
  const uploadBundleObject = async (
480
646
  supabaseAdmin: ReturnType<typeof createClient>,
481
647
  bundleId: string,
648
+ ) => {
649
+ await uploadStorageObject(
650
+ supabaseAdmin,
651
+ `${bundleId}/bundle.zip`,
652
+ Buffer.from("zip"),
653
+ "application/zip",
654
+ );
655
+ };
656
+
657
+ const uploadStorageObject = async (
658
+ supabaseAdmin: ReturnType<typeof createClient>,
659
+ key: string,
660
+ body: string | Buffer,
661
+ contentType: string,
482
662
  ) => {
483
663
  const { error } = await supabaseAdmin.storage
484
664
  .from(BUCKET_NAME)
485
- .upload(`${bundleId}/bundle.zip`, Buffer.from("zip"), {
486
- contentType: "application/zip",
665
+ .upload(key, body, {
666
+ contentType,
487
667
  cacheControl: "31536000",
488
668
  upsert: true,
489
669
  });
@@ -664,7 +844,7 @@ services:
664
844
  rest:
665
845
  condition: service_started
666
846
  ports:
667
- - "127.0.0.1:${gatewayPort}:8000"
847
+ - "0.0.0.0:${gatewayPort}:8000"
668
848
  volumes:
669
849
  - ${path.join(runtimeRoot, "nginx.conf")}:/etc/nginx/nginx.conf:ro
670
850
 
@@ -0,0 +1,20 @@
1
+ -- HotUpdater.bundle_artifact_columns
2
+
3
+ ALTER TABLE bundles ADD COLUMN IF NOT EXISTS manifest_storage_uri text;
4
+ ALTER TABLE bundles ADD COLUMN IF NOT EXISTS manifest_file_hash text;
5
+ ALTER TABLE bundles ADD COLUMN IF NOT EXISTS asset_base_storage_uri text;
6
+
7
+ CREATE TABLE IF NOT EXISTS bundle_patches (
8
+ id text PRIMARY KEY,
9
+ bundle_id uuid NOT NULL REFERENCES bundles(id) ON DELETE CASCADE,
10
+ base_bundle_id uuid NOT NULL REFERENCES bundles(id) ON DELETE CASCADE,
11
+ base_file_hash text NOT NULL,
12
+ patch_file_hash text NOT NULL,
13
+ patch_storage_uri text NOT NULL,
14
+ order_index integer NOT NULL DEFAULT 0
15
+ );
16
+
17
+ CREATE INDEX IF NOT EXISTS bundle_patches_bundle_id_idx
18
+ ON bundle_patches(bundle_id);
19
+ CREATE INDEX IF NOT EXISTS bundle_patches_base_bundle_id_idx
20
+ ON bundle_patches(base_bundle_id);