@hot-updater/cloudflare 0.30.11 → 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,24 @@
1
+ -- Migration number: 0005 2026-04-22T00:00:00.000Z
2
+
3
+ -- HotUpdater.bundle_artifact_columns
4
+
5
+ ALTER TABLE bundles ADD COLUMN manifest_storage_uri TEXT;
6
+ ALTER TABLE bundles ADD COLUMN manifest_file_hash TEXT;
7
+ ALTER TABLE bundles ADD COLUMN asset_base_storage_uri TEXT;
8
+
9
+ CREATE TABLE bundle_patches (
10
+ id TEXT PRIMARY KEY,
11
+ bundle_id TEXT NOT NULL,
12
+ base_bundle_id TEXT NOT NULL,
13
+ base_file_hash TEXT NOT NULL,
14
+ patch_file_hash TEXT NOT NULL,
15
+ patch_storage_uri TEXT NOT NULL,
16
+ order_index INTEGER NOT NULL DEFAULT 0,
17
+ FOREIGN KEY (bundle_id) REFERENCES bundles(id) ON DELETE CASCADE,
18
+ FOREIGN KEY (base_bundle_id) REFERENCES bundles(id) ON DELETE CASCADE
19
+ );
20
+
21
+ CREATE INDEX IF NOT EXISTS bundle_patches_bundle_id_idx
22
+ ON bundle_patches(bundle_id);
23
+ CREATE INDEX IF NOT EXISTS bundle_patches_base_bundle_id_idx
24
+ ON bundle_patches(base_bundle_id);
@@ -1,7 +1,23 @@
1
- import { type Bundle, type GetBundlesArgs, NIL_UUID } from "@hot-updater/core";
2
- import { setupGetUpdateInfoTestSuite } from "@hot-updater/test-utils";
1
+ import {
2
+ getBundlePatches,
3
+ type Bundle,
4
+ type GetBundlesArgs,
5
+ NIL_UUID,
6
+ } from "@hot-updater/core";
7
+ import {
8
+ setupBsdiffManifestUpdateInfoTestSuite,
9
+ setupGetUpdateInfoTestSuite,
10
+ } from "@hot-updater/test-utils";
3
11
  import { env } from "cloudflare:test";
4
- import { beforeAll, beforeEach, describe, expect, inject, it } from "vitest";
12
+ import {
13
+ beforeAll,
14
+ beforeEach,
15
+ describe,
16
+ expect,
17
+ inject,
18
+ it,
19
+ vi,
20
+ } from "vitest";
5
21
 
6
22
  import worker, { HOT_UPDATER_BASE_PATH } from "./index";
7
23
 
@@ -21,29 +37,38 @@ declare module "cloudflare:test" {
21
37
 
22
38
  const PUBLIC_BASE_URL = "https://updates.example.com";
23
39
 
40
+ const sqlString = (value: string) => `'${value.replaceAll("'", "''")}'`;
41
+
24
42
  const createInsertBundleQuery = (bundle: Bundle) => {
25
43
  const rolloutCohortCount = bundle.rolloutCohortCount ?? 1000;
26
44
  const targetCohorts = bundle.targetCohorts
27
- ? `'${JSON.stringify(bundle.targetCohorts)}'`
45
+ ? sqlString(JSON.stringify(bundle.targetCohorts))
28
46
  : "null";
47
+ const metadata = sqlString(JSON.stringify(bundle.metadata ?? {}));
29
48
 
30
49
  return `
31
50
  INSERT INTO bundles (
32
51
  id, file_hash, platform, target_app_version,
33
52
  should_force_update, enabled, git_commit_hash, message, channel,
34
- storage_uri, fingerprint_hash, rollout_cohort_count, target_cohorts
53
+ storage_uri, fingerprint_hash, metadata, manifest_storage_uri,
54
+ manifest_file_hash, asset_base_storage_uri, rollout_cohort_count,
55
+ target_cohorts
35
56
  ) VALUES (
36
- '${bundle.id}',
37
- '${bundle.fileHash}',
38
- '${bundle.platform}',
39
- ${bundle.targetAppVersion ? `'${bundle.targetAppVersion}'` : "null"},
57
+ ${sqlString(bundle.id)},
58
+ ${sqlString(bundle.fileHash)},
59
+ ${sqlString(bundle.platform)},
60
+ ${bundle.targetAppVersion ? sqlString(bundle.targetAppVersion) : "null"},
40
61
  ${bundle.shouldForceUpdate},
41
62
  ${bundle.enabled},
42
- ${bundle.gitCommitHash ? `'${bundle.gitCommitHash}'` : "null"},
43
- ${bundle.message ? `'${bundle.message}'` : "null"},
44
- '${bundle.channel}',
45
- ${bundle.storageUri ? `'${bundle.storageUri}'` : "null"},
46
- ${bundle.fingerprintHash ? `'${bundle.fingerprintHash}'` : "null"},
63
+ ${bundle.gitCommitHash ? sqlString(bundle.gitCommitHash) : "null"},
64
+ ${bundle.message ? sqlString(bundle.message) : "null"},
65
+ ${sqlString(bundle.channel)},
66
+ ${bundle.storageUri ? sqlString(bundle.storageUri) : "null"},
67
+ ${bundle.fingerprintHash ? sqlString(bundle.fingerprintHash) : "null"},
68
+ ${metadata},
69
+ ${bundle.manifestStorageUri ? sqlString(bundle.manifestStorageUri) : "null"},
70
+ ${bundle.manifestFileHash ? sqlString(bundle.manifestFileHash) : "null"},
71
+ ${bundle.assetBaseStorageUri ? sqlString(bundle.assetBaseStorageUri) : "null"},
47
72
  ${rolloutCohortCount},
48
73
  ${targetCohorts}
49
74
  ) ON CONFLICT(id) DO UPDATE SET
@@ -57,11 +82,44 @@ const createInsertBundleQuery = (bundle: Bundle) => {
57
82
  channel = excluded.channel,
58
83
  storage_uri = excluded.storage_uri,
59
84
  fingerprint_hash = excluded.fingerprint_hash,
85
+ metadata = excluded.metadata,
86
+ manifest_storage_uri = excluded.manifest_storage_uri,
87
+ manifest_file_hash = excluded.manifest_file_hash,
88
+ asset_base_storage_uri = excluded.asset_base_storage_uri,
60
89
  rollout_cohort_count = excluded.rollout_cohort_count,
61
90
  target_cohorts = excluded.target_cohorts;
62
91
  `;
63
92
  };
64
93
 
94
+ const createInsertBundlePatchQueries = (bundle: Bundle) =>
95
+ getBundlePatches(bundle).map(
96
+ (patch, index) => `
97
+ INSERT INTO bundle_patches (
98
+ id,
99
+ bundle_id,
100
+ base_bundle_id,
101
+ base_file_hash,
102
+ patch_file_hash,
103
+ patch_storage_uri,
104
+ order_index
105
+ ) VALUES (
106
+ ${sqlString(`${bundle.id}:${patch.baseBundleId}`)},
107
+ ${sqlString(bundle.id)},
108
+ ${sqlString(patch.baseBundleId)},
109
+ ${sqlString(patch.baseFileHash)},
110
+ ${sqlString(patch.patchFileHash)},
111
+ ${sqlString(patch.patchStorageUri)},
112
+ ${index}
113
+ ) ON CONFLICT(id) DO UPDATE SET
114
+ bundle_id = excluded.bundle_id,
115
+ base_bundle_id = excluded.base_bundle_id,
116
+ base_file_hash = excluded.base_file_hash,
117
+ patch_file_hash = excluded.patch_file_hash,
118
+ patch_storage_uri = excluded.patch_storage_uri,
119
+ order_index = excluded.order_index;
120
+ `,
121
+ );
122
+
65
123
  const toRuntimeBundle = (bundle: Bundle): Bundle => {
66
124
  return {
67
125
  ...bundle,
@@ -72,9 +130,20 @@ const toRuntimeBundle = (bundle: Bundle): Bundle => {
72
130
  const seedBundles = async (bundles: Bundle[]) => {
73
131
  for (const bundle of bundles.map(toRuntimeBundle)) {
74
132
  await env.DB.prepare(createInsertBundleQuery(bundle)).run();
133
+ for (const patchSql of createInsertBundlePatchQueries(bundle)) {
134
+ await env.DB.prepare(patchSql).run();
135
+ }
75
136
  }
76
137
  };
77
138
 
139
+ const putR2Object = async (key: string, value: string, contentType: string) => {
140
+ await env.BUCKET.put(key, value, {
141
+ httpMetadata: {
142
+ contentType,
143
+ },
144
+ });
145
+ };
146
+
78
147
  const createCanonicalPath = (args: GetBundlesArgs) => {
79
148
  const channel = args.channel ?? "production";
80
149
  const minBundleId = args.minBundleId ?? NIL_UUID;
@@ -95,12 +164,11 @@ describe.sequential("cloudflare worker runtime acceptance", () => {
95
164
  });
96
165
 
97
166
  beforeEach(async () => {
167
+ await env.DB.prepare("DELETE FROM bundle_patches").run();
98
168
  await env.DB.prepare("DELETE FROM bundles").run();
99
169
  });
100
170
 
101
- const getUpdateInfo = async (bundles: Bundle[], args: GetBundlesArgs) => {
102
- await seedBundles(bundles);
103
-
171
+ const requestUpdateInfo = async (args: GetBundlesArgs) => {
104
172
  const response = await worker.fetch(
105
173
  new Request(`${PUBLIC_BASE_URL}${createCanonicalPath(args)}`),
106
174
  env,
@@ -109,7 +177,130 @@ describe.sequential("cloudflare worker runtime acceptance", () => {
109
177
  return (await response.json()) as any;
110
178
  };
111
179
 
112
- setupGetUpdateInfoTestSuite({ getUpdateInfo });
180
+ const getUpdateInfo = async (bundles: Bundle[], args: GetBundlesArgs) => {
181
+ await seedBundles(bundles);
182
+
183
+ return requestUpdateInfo(args);
184
+ };
185
+
186
+ setupGetUpdateInfoTestSuite({
187
+ getUpdateInfo,
188
+ manifestArtifacts: {
189
+ prepareArtifacts: async (fixture) => {
190
+ await Promise.all([
191
+ putR2Object(
192
+ `${fixture.currentBundleId}/manifest.json`,
193
+ JSON.stringify(fixture.currentManifest),
194
+ "application/json",
195
+ ),
196
+ putR2Object(
197
+ `${fixture.nextBundleId}/manifest.json`,
198
+ JSON.stringify(fixture.nextManifest),
199
+ "application/json",
200
+ ),
201
+ ]);
202
+
203
+ vi.stubGlobal(
204
+ "fetch",
205
+ vi.fn<typeof fetch>(async () => {
206
+ return new Response("worker subrequest failed", { status: 502 });
207
+ }),
208
+ );
209
+
210
+ return {
211
+ cleanup: () => {
212
+ vi.unstubAllGlobals();
213
+ },
214
+ currentArtifacts: {
215
+ assetBaseStorageUri: `r2://bundles/${fixture.currentBundleId}/files`,
216
+ manifestFileHash: "sig:manifest-current",
217
+ manifestStorageUri: `r2://bundles/${fixture.currentBundleId}/manifest.json`,
218
+ },
219
+ nextArtifacts: {
220
+ assetBaseStorageUri: `r2://bundles/${fixture.nextBundleId}/files`,
221
+ manifestFileHash: "sig:manifest-next",
222
+ manifestStorageUri: `r2://bundles/${fixture.nextBundleId}/manifest.json`,
223
+ },
224
+ };
225
+ },
226
+ expectFileUrl: (fileUrl, fixture) => {
227
+ expect(fileUrl).toContain(
228
+ `/bundles/${fixture.nextBundleId}/files/${fixture.changedAssetPath}`,
229
+ );
230
+ },
231
+ expectManifestUrl: (manifestUrl, fixture) => {
232
+ expect(manifestUrl).toContain(`/${fixture.nextBundleId}/manifest.json`);
233
+ },
234
+ },
235
+ });
236
+
237
+ setupBsdiffManifestUpdateInfoTestSuite({
238
+ seedBundles,
239
+ getUpdateInfo: requestUpdateInfo,
240
+ prepareArtifacts: async (fixture) => {
241
+ await Promise.all([
242
+ putR2Object(
243
+ `${fixture.currentBundleId}/manifest.json`,
244
+ JSON.stringify(fixture.currentManifest),
245
+ "application/json",
246
+ ),
247
+ putR2Object(
248
+ `${fixture.nextBundleId}/manifest.json`,
249
+ JSON.stringify(fixture.nextManifest),
250
+ "application/json",
251
+ ),
252
+ putR2Object(
253
+ fixture.patchPath,
254
+ "patch-bytes",
255
+ "application/octet-stream",
256
+ ),
257
+ putR2Object(
258
+ `${fixture.currentBundleId}/bundle.zip`,
259
+ "zip",
260
+ "application/zip",
261
+ ),
262
+ putR2Object(
263
+ `${fixture.nextBundleId}/bundle.zip`,
264
+ "zip",
265
+ "application/zip",
266
+ ),
267
+ ]);
268
+
269
+ vi.stubGlobal(
270
+ "fetch",
271
+ vi.fn<typeof fetch>(async () => {
272
+ return new Response("worker subrequest failed", { status: 502 });
273
+ }),
274
+ );
275
+
276
+ return {
277
+ cleanup: () => {
278
+ vi.unstubAllGlobals();
279
+ },
280
+ currentArtifacts: {
281
+ assetBaseStorageUri: `r2://bundles/${fixture.currentBundleId}/files`,
282
+ manifestFileHash: "sig:manifest-current",
283
+ manifestStorageUri: `r2://bundles/${fixture.currentBundleId}/manifest.json`,
284
+ },
285
+ nextArtifacts: {
286
+ assetBaseStorageUri: `r2://bundles/${fixture.nextBundleId}/files`,
287
+ manifestFileHash: "sig:manifest-next",
288
+ manifestStorageUri: `r2://bundles/${fixture.nextBundleId}/manifest.json`,
289
+ patches: [
290
+ {
291
+ baseBundleId: fixture.currentBundleId,
292
+ baseFileHash: "hash-old-bundle",
293
+ patchFileHash: "hash-bsdiff",
294
+ patchStorageUri: `r2://bundles/${fixture.patchPath}`,
295
+ },
296
+ ],
297
+ },
298
+ };
299
+ },
300
+ expectPatchUrl: (patchUrl, fixture) => {
301
+ expect(patchUrl).toContain(`/bundles/${fixture.patchPath}`);
302
+ },
303
+ });
113
304
 
114
305
  it("serves canonical routes from the worker entrypoint", async () => {
115
306
  await seedBundles([