@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.
@@ -79,7 +79,34 @@ function parseTargetCohorts(value) {
79
79
  }
80
80
  return null;
81
81
  }
82
- function transformRowToBundle(row) {
82
+ const parseMetadata = (value) => {
83
+ if (!value) return void 0;
84
+ if (typeof value === "string") try {
85
+ return parseMetadata(JSON.parse(value));
86
+ } catch {
87
+ return;
88
+ }
89
+ return typeof value === "object" && !Array.isArray(value) ? value : void 0;
90
+ };
91
+ const buildBundlePatchId = (bundleId, baseBundleId) => `${bundleId}:${baseBundleId}`;
92
+ const bundleToPatchRows = (bundle) => (0, _hot_updater_core.getBundlePatches)(bundle).map((patch, index) => ({
93
+ id: buildBundlePatchId(bundle.id, patch.baseBundleId),
94
+ bundle_id: bundle.id,
95
+ base_bundle_id: patch.baseBundleId,
96
+ base_file_hash: patch.baseFileHash,
97
+ patch_file_hash: patch.patchFileHash,
98
+ patch_storage_uri: patch.patchStorageUri,
99
+ order_index: index
100
+ }));
101
+ function transformRowToBundle(row, patchRows = []) {
102
+ const rawMetadata = parseMetadata(row.metadata);
103
+ const patches = patchRows.slice().sort((left, right) => (left.order_index ?? 0) - (right.order_index ?? 0) || left.base_bundle_id.localeCompare(right.base_bundle_id)).map((patch) => ({
104
+ baseBundleId: patch.base_bundle_id,
105
+ baseFileHash: patch.base_file_hash,
106
+ patchFileHash: patch.patch_file_hash,
107
+ patchStorageUri: patch.patch_storage_uri
108
+ }));
109
+ const primaryPatch = patches[0] ?? null;
83
110
  return {
84
111
  id: row.id,
85
112
  channel: row.channel,
@@ -92,7 +119,15 @@ function transformRowToBundle(row) {
92
119
  targetAppVersion: row.target_app_version,
93
120
  storageUri: row.storage_uri,
94
121
  fingerprintHash: row.fingerprint_hash,
95
- metadata: row?.metadata ? JSON.parse(row.metadata) : {},
122
+ metadata: (0, _hot_updater_core.stripBundleArtifactMetadata)(rawMetadata),
123
+ manifestStorageUri: row.manifest_storage_uri ?? null,
124
+ manifestFileHash: row.manifest_file_hash ?? null,
125
+ assetBaseStorageUri: row.asset_base_storage_uri ?? null,
126
+ patches,
127
+ patchBaseBundleId: primaryPatch?.baseBundleId ?? null,
128
+ patchBaseFileHash: primaryPatch?.baseFileHash ?? null,
129
+ patchFileHash: primaryPatch?.patchFileHash ?? null,
130
+ patchStorageUri: primaryPatch?.patchStorageUri ?? null,
96
131
  rolloutCohortCount: row.rollout_cohort_count ?? _hot_updater_core.DEFAULT_ROLLOUT_COHORT_COUNT,
97
132
  targetCohorts: parseTargetCohorts(row.target_cohorts)
98
133
  };
@@ -111,12 +146,30 @@ const d1WorkerDatabase = () => (0, _hot_updater_plugin_core.createDatabasePlugin
111
146
  const queryFirst = async (sql, params = [], context) => {
112
147
  return await config.getDb(context).prepare(sql).bind(...params).first() ?? null;
113
148
  };
149
+ const getPatchMap = async (bundleIds, context) => {
150
+ const patchMap = /* @__PURE__ */ new Map();
151
+ if (bundleIds.length === 0) return patchMap;
152
+ const rows = await queryAll(`
153
+ SELECT *
154
+ FROM bundle_patches
155
+ WHERE bundle_id IN (${bundleIds.map(() => "?").join(", ")})
156
+ ORDER BY order_index ASC, base_bundle_id ASC
157
+ `, bundleIds, context);
158
+ for (const row of rows) {
159
+ const current = patchMap.get(row.bundle_id) ?? [];
160
+ current.push(row);
161
+ patchMap.set(row.bundle_id, current);
162
+ }
163
+ return patchMap;
164
+ };
114
165
  const queryBundlesForUpdateInfo = async (conditions, context) => {
115
166
  const { sql: whereClause, params } = buildWhereClause(conditions);
116
- return (await queryAll(`
167
+ const rows = await queryAll(`
117
168
  SELECT * FROM bundles
118
169
  ${whereClause}
119
- `, params, context)).map(transformRowToBundle);
170
+ `, params, context);
171
+ const patchMap = await getPatchMap(rows.map((row) => row.id), context);
172
+ return rows.map((row) => transformRowToBundle(row, patchMap.get(row.id)));
120
173
  };
121
174
  const getTargetAppVersionsForUpdateInfo = async ({ platform, channel, minBundleId }, context) => {
122
175
  return (await queryAll(`
@@ -157,8 +210,8 @@ const d1WorkerDatabase = () => (0, _hot_updater_plugin_core.createDatabasePlugin
157
210
  }
158
211
  }),
159
212
  async getBundleById(bundleId, context) {
160
- const row = await queryFirst("SELECT * FROM bundles WHERE id = ? LIMIT 1", [bundleId], context);
161
- return row ? transformRowToBundle(row) : null;
213
+ const [row, patchMap] = await Promise.all([queryFirst("SELECT * FROM bundles WHERE id = ? LIMIT 1", [bundleId], context), getPatchMap([bundleId], context)]);
214
+ return row ? transformRowToBundle(row, patchMap.get(bundleId)) : null;
162
215
  },
163
216
  async getBundles(options, context) {
164
217
  const { where, limit, orderBy } = options;
@@ -166,12 +219,14 @@ const d1WorkerDatabase = () => (0, _hot_updater_plugin_core.createDatabasePlugin
166
219
  const { sql: whereClause, params } = buildWhereClause(where);
167
220
  const orderSql = orderBy?.direction === "asc" ? "ORDER BY id ASC" : "ORDER BY id DESC";
168
221
  const total = (await queryAll(`SELECT COUNT(*) as total FROM bundles${whereClause}`, params, context))[0]?.total ?? 0;
222
+ const rows = await queryAll(`SELECT * FROM bundles${whereClause} ${orderSql} LIMIT ? OFFSET ?`, [
223
+ ...params,
224
+ limit,
225
+ offset
226
+ ], context);
227
+ const patchMap = await getPatchMap(rows.map((row) => row.id), context);
169
228
  return {
170
- data: (await queryAll(`SELECT * FROM bundles${whereClause} ${orderSql} LIMIT ? OFFSET ?`, [
171
- ...params,
172
- limit,
173
- offset
174
- ], context)).map(transformRowToBundle),
229
+ data: rows.map((row) => transformRowToBundle(row, patchMap.get(row.id))),
175
230
  pagination: (0, _hot_updater_plugin_core.calculatePagination)(total, {
176
231
  limit,
177
232
  offset
@@ -186,6 +241,8 @@ const d1WorkerDatabase = () => (0, _hot_updater_plugin_core.createDatabasePlugin
186
241
  const db = config.getDb(context);
187
242
  for (const operation of changedSets) {
188
243
  if (operation.operation === "delete") {
244
+ await db.prepare("DELETE FROM bundle_patches WHERE bundle_id = ?").bind(operation.data.id).run();
245
+ await db.prepare("DELETE FROM bundle_patches WHERE base_bundle_id = ?").bind(operation.data.id).run();
189
246
  await db.prepare("DELETE FROM bundles WHERE id = ?").bind(operation.data.id).run();
190
247
  continue;
191
248
  }
@@ -204,11 +261,28 @@ const d1WorkerDatabase = () => (0, _hot_updater_plugin_core.createDatabasePlugin
204
261
  storage_uri,
205
262
  fingerprint_hash,
206
263
  metadata,
264
+ manifest_storage_uri,
265
+ manifest_file_hash,
266
+ asset_base_storage_uri,
207
267
  rollout_cohort_count,
208
268
  target_cohorts
209
269
  )
210
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
211
- `).bind(bundle.id, bundle.channel, bundle.enabled ? 1 : 0, bundle.shouldForceUpdate ? 1 : 0, bundle.fileHash, bundle.gitCommitHash || null, bundle.message || null, bundle.platform, bundle.targetAppVersion, bundle.storageUri, bundle.fingerprintHash, JSON.stringify(bundle.metadata ?? {}), bundle.rolloutCohortCount ?? _hot_updater_core.DEFAULT_ROLLOUT_COHORT_COUNT, bundle.targetCohorts ? JSON.stringify(bundle.targetCohorts) : null).run();
270
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
271
+ `).bind(bundle.id, bundle.channel, bundle.enabled ? 1 : 0, bundle.shouldForceUpdate ? 1 : 0, bundle.fileHash, bundle.gitCommitHash || null, bundle.message || null, bundle.platform, bundle.targetAppVersion, bundle.storageUri, bundle.fingerprintHash, JSON.stringify((0, _hot_updater_core.stripBundleArtifactMetadata)(bundle.metadata) ?? {}), (0, _hot_updater_core.getManifestStorageUri)(bundle), (0, _hot_updater_core.getManifestFileHash)(bundle), (0, _hot_updater_core.getAssetBaseStorageUri)(bundle), bundle.rolloutCohortCount ?? _hot_updater_core.DEFAULT_ROLLOUT_COHORT_COUNT, bundle.targetCohorts ? JSON.stringify(bundle.targetCohorts) : null).run();
272
+ await db.prepare("DELETE FROM bundle_patches WHERE bundle_id = ?").bind(bundle.id).run();
273
+ const patchRows = bundleToPatchRows(bundle);
274
+ for (const patchRow of patchRows) await db.prepare(`
275
+ INSERT OR REPLACE INTO bundle_patches (
276
+ id,
277
+ bundle_id,
278
+ base_bundle_id,
279
+ base_file_hash,
280
+ patch_file_hash,
281
+ patch_storage_uri,
282
+ order_index
283
+ )
284
+ VALUES (?, ?, ?, ?, ?, ?, ?)
285
+ `).bind(patchRow.id, patchRow.bundle_id, patchRow.base_bundle_id, patchRow.base_file_hash, patchRow.patch_file_hash, patchRow.patch_storage_uri, patchRow.order_index ?? 0).run();
212
286
  }
213
287
  }
214
288
  };
@@ -224,21 +298,31 @@ const resolveJwtSecretFromContext = (context) => {
224
298
  if (!jwtSecret) throw new Error("r2WorkerStorage requires env.JWT_SECRET in the hot updater context.");
225
299
  return jwtSecret;
226
300
  };
301
+ const resolveR2BucketFromContext = (context) => {
302
+ const bucket = context?.env?.BUCKET;
303
+ if (!bucket) throw new Error("r2WorkerStorage requires env.BUCKET in the hot updater context.");
304
+ return bucket;
305
+ };
306
+ const createPublicObjectPath = (storageUrl) => `${storageUrl.host}${storageUrl.pathname}`;
307
+ const createR2ObjectKey = (storageUrl) => storageUrl.pathname.replace(/^\/+/, "");
227
308
  const r2WorkerStorage = (config) => {
228
- return () => {
229
- return {
230
- name: "r2WorkerStorage",
231
- supportedProtocol: "r2",
232
- async upload() {
233
- throw new Error("r2WorkerStorage does not support upload() in the worker runtime.");
234
- },
235
- async delete() {
236
- throw new Error("r2WorkerStorage does not support delete() in the worker runtime.");
309
+ return (0, _hot_updater_plugin_core.createRuntimeStoragePlugin)({
310
+ name: "r2WorkerStorage",
311
+ supportedProtocol: "r2",
312
+ factory: (config) => ({
313
+ async readText(storageUri, context) {
314
+ const storageUrl = new URL(storageUri);
315
+ if (storageUrl.protocol !== "r2:") throw new Error("Invalid R2 storage URI protocol");
316
+ const bucket = resolveR2BucketFromContext(context);
317
+ const key = createR2ObjectKey(storageUrl);
318
+ const object = await bucket.get(key);
319
+ if (!object) return null;
320
+ return object.text();
237
321
  },
238
322
  async getDownloadUrl(storageUri, context) {
239
323
  const storageUrl = new URL(storageUri);
240
324
  if (storageUrl.protocol !== "r2:") throw new Error("Invalid R2 storage URI protocol");
241
- const key = `${storageUrl.host}${storageUrl.pathname}`;
325
+ const key = createPublicObjectPath(storageUrl);
242
326
  const [jwtSecret, publicBaseUrl] = await Promise.all([resolveContextValue(config.jwtSecret ?? resolveJwtSecretFromContext, context), resolveContextValue(config.publicBaseUrl, context)]);
243
327
  const token = await (0, _hot_updater_js.signToken)(key, jwtSecret);
244
328
  const url = new URL(publicBaseUrl);
@@ -247,8 +331,8 @@ const r2WorkerStorage = (config) => {
247
331
  url.searchParams.set("token", token);
248
332
  return { fileUrl: url.toString() };
249
333
  }
250
- };
251
- };
334
+ })
335
+ })(config);
252
336
  };
253
337
  //#endregion
254
338
  //#region src/worker/index.ts
@@ -23,6 +23,11 @@ interface CloudflareWorkerDatabaseEnv {
23
23
  //#region src/r2WorkerStorage.d.ts
24
24
  interface CloudflareWorkerStorageEnv {
25
25
  JWT_SECRET: string;
26
+ BUCKET: {
27
+ get: (key: string) => Promise<{
28
+ text: () => Promise<string>;
29
+ } | null>;
30
+ };
26
31
  }
27
32
  type ContextResolver<TContext, TValue> = (context?: HotUpdaterContext<TContext>) => TValue | Promise<TValue>;
28
33
  interface CloudflareWorkerStorageConfig<TContext extends RequestEnvContext$1<CloudflareWorkerStorageEnv>> {
@@ -34,6 +39,6 @@ interface CloudflareWorkerStorageConfig<TContext extends RequestEnvContext$1<Clo
34
39
  interface CloudflareWorkerRuntimeEnv extends CloudflareWorkerDatabaseEnv, CloudflareWorkerStorageEnv {}
35
40
  type RequestEnvContext<TEnv = CloudflareWorkerRuntimeEnv> = RequestEnvContext$1<TEnv>;
36
41
  declare const d1Database: <TContext extends RequestEnvContext<CloudflareWorkerRuntimeEnv> = RequestEnvContext<CloudflareWorkerRuntimeEnv>>() => () => _$_hot_updater_plugin_core0.DatabasePlugin<TContext>;
37
- declare const r2Storage: <TContext extends RequestEnvContext<CloudflareWorkerRuntimeEnv> = RequestEnvContext<CloudflareWorkerRuntimeEnv>>(config: CloudflareWorkerStorageConfig<TContext>) => () => _$_hot_updater_plugin_core0.StoragePlugin<TContext>;
42
+ declare const r2Storage: <TContext extends RequestEnvContext<CloudflareWorkerRuntimeEnv> = RequestEnvContext<CloudflareWorkerRuntimeEnv>>(config: CloudflareWorkerStorageConfig<TContext>) => () => _$_hot_updater_plugin_core0.RuntimeStoragePlugin<TContext>;
38
43
  //#endregion
39
44
  export { type CloudflareWorkerDatabaseEnv, CloudflareWorkerRuntimeEnv, type CloudflareWorkerStorageEnv, RequestEnvContext, d1Database, r2Storage, verifyJwtSignedUrl };
@@ -23,6 +23,11 @@ interface CloudflareWorkerDatabaseEnv {
23
23
  //#region src/r2WorkerStorage.d.ts
24
24
  interface CloudflareWorkerStorageEnv {
25
25
  JWT_SECRET: string;
26
+ BUCKET: {
27
+ get: (key: string) => Promise<{
28
+ text: () => Promise<string>;
29
+ } | null>;
30
+ };
26
31
  }
27
32
  type ContextResolver<TContext, TValue> = (context?: HotUpdaterContext<TContext>) => TValue | Promise<TValue>;
28
33
  interface CloudflareWorkerStorageConfig<TContext extends RequestEnvContext$1<CloudflareWorkerStorageEnv>> {
@@ -34,6 +39,6 @@ interface CloudflareWorkerStorageConfig<TContext extends RequestEnvContext$1<Clo
34
39
  interface CloudflareWorkerRuntimeEnv extends CloudflareWorkerDatabaseEnv, CloudflareWorkerStorageEnv {}
35
40
  type RequestEnvContext<TEnv = CloudflareWorkerRuntimeEnv> = RequestEnvContext$1<TEnv>;
36
41
  declare const d1Database: <TContext extends RequestEnvContext<CloudflareWorkerRuntimeEnv> = RequestEnvContext<CloudflareWorkerRuntimeEnv>>() => () => _$_hot_updater_plugin_core0.DatabasePlugin<TContext>;
37
- declare const r2Storage: <TContext extends RequestEnvContext<CloudflareWorkerRuntimeEnv> = RequestEnvContext<CloudflareWorkerRuntimeEnv>>(config: CloudflareWorkerStorageConfig<TContext>) => () => _$_hot_updater_plugin_core0.StoragePlugin<TContext>;
42
+ declare const r2Storage: <TContext extends RequestEnvContext<CloudflareWorkerRuntimeEnv> = RequestEnvContext<CloudflareWorkerRuntimeEnv>>(config: CloudflareWorkerStorageConfig<TContext>) => () => _$_hot_updater_plugin_core0.RuntimeStoragePlugin<TContext>;
38
43
  //#endregion
39
44
  export { type CloudflareWorkerDatabaseEnv, CloudflareWorkerRuntimeEnv, type CloudflareWorkerStorageEnv, RequestEnvContext, d1Database, r2Storage, verifyJwtSignedUrl };
@@ -1,6 +1,6 @@
1
1
  import { signToken, verifyJwtSignedUrl } from "@hot-updater/js";
2
- import { DEFAULT_ROLLOUT_COHORT_COUNT } from "@hot-updater/core";
3
- import { calculatePagination, createDatabasePlugin, createDatabasePluginGetUpdateInfo } from "@hot-updater/plugin-core";
2
+ import { DEFAULT_ROLLOUT_COHORT_COUNT, getAssetBaseStorageUri, getBundlePatches, getManifestFileHash, getManifestStorageUri, stripBundleArtifactMetadata } from "@hot-updater/core";
3
+ import { calculatePagination, createDatabasePlugin, createDatabasePluginGetUpdateInfo, createRuntimeStoragePlugin } from "@hot-updater/plugin-core";
4
4
  //#region src/cloudflareWorkerDatabase.ts
5
5
  function buildWhereClause(conditions) {
6
6
  if (!conditions) return {
@@ -78,7 +78,34 @@ function parseTargetCohorts(value) {
78
78
  }
79
79
  return null;
80
80
  }
81
- function transformRowToBundle(row) {
81
+ const parseMetadata = (value) => {
82
+ if (!value) return void 0;
83
+ if (typeof value === "string") try {
84
+ return parseMetadata(JSON.parse(value));
85
+ } catch {
86
+ return;
87
+ }
88
+ return typeof value === "object" && !Array.isArray(value) ? value : void 0;
89
+ };
90
+ const buildBundlePatchId = (bundleId, baseBundleId) => `${bundleId}:${baseBundleId}`;
91
+ const bundleToPatchRows = (bundle) => 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
+ function transformRowToBundle(row, patchRows = []) {
101
+ const rawMetadata = parseMetadata(row.metadata);
102
+ const patches = patchRows.slice().sort((left, right) => (left.order_index ?? 0) - (right.order_index ?? 0) || left.base_bundle_id.localeCompare(right.base_bundle_id)).map((patch) => ({
103
+ baseBundleId: patch.base_bundle_id,
104
+ baseFileHash: patch.base_file_hash,
105
+ patchFileHash: patch.patch_file_hash,
106
+ patchStorageUri: patch.patch_storage_uri
107
+ }));
108
+ const primaryPatch = patches[0] ?? null;
82
109
  return {
83
110
  id: row.id,
84
111
  channel: row.channel,
@@ -91,7 +118,15 @@ function transformRowToBundle(row) {
91
118
  targetAppVersion: row.target_app_version,
92
119
  storageUri: row.storage_uri,
93
120
  fingerprintHash: row.fingerprint_hash,
94
- metadata: row?.metadata ? JSON.parse(row.metadata) : {},
121
+ metadata: stripBundleArtifactMetadata(rawMetadata),
122
+ manifestStorageUri: row.manifest_storage_uri ?? null,
123
+ manifestFileHash: row.manifest_file_hash ?? null,
124
+ assetBaseStorageUri: row.asset_base_storage_uri ?? null,
125
+ patches,
126
+ patchBaseBundleId: primaryPatch?.baseBundleId ?? null,
127
+ patchBaseFileHash: primaryPatch?.baseFileHash ?? null,
128
+ patchFileHash: primaryPatch?.patchFileHash ?? null,
129
+ patchStorageUri: primaryPatch?.patchStorageUri ?? null,
95
130
  rolloutCohortCount: row.rollout_cohort_count ?? DEFAULT_ROLLOUT_COHORT_COUNT,
96
131
  targetCohorts: parseTargetCohorts(row.target_cohorts)
97
132
  };
@@ -110,12 +145,30 @@ const d1WorkerDatabase = () => createDatabasePlugin({
110
145
  const queryFirst = async (sql, params = [], context) => {
111
146
  return await config.getDb(context).prepare(sql).bind(...params).first() ?? null;
112
147
  };
148
+ const getPatchMap = async (bundleIds, context) => {
149
+ const patchMap = /* @__PURE__ */ new Map();
150
+ if (bundleIds.length === 0) return patchMap;
151
+ const rows = await queryAll(`
152
+ SELECT *
153
+ FROM bundle_patches
154
+ WHERE bundle_id IN (${bundleIds.map(() => "?").join(", ")})
155
+ ORDER BY order_index ASC, base_bundle_id ASC
156
+ `, bundleIds, context);
157
+ for (const row of rows) {
158
+ const current = patchMap.get(row.bundle_id) ?? [];
159
+ current.push(row);
160
+ patchMap.set(row.bundle_id, current);
161
+ }
162
+ return patchMap;
163
+ };
113
164
  const queryBundlesForUpdateInfo = async (conditions, context) => {
114
165
  const { sql: whereClause, params } = buildWhereClause(conditions);
115
- return (await queryAll(`
166
+ const rows = await queryAll(`
116
167
  SELECT * FROM bundles
117
168
  ${whereClause}
118
- `, params, context)).map(transformRowToBundle);
169
+ `, params, context);
170
+ const patchMap = await getPatchMap(rows.map((row) => row.id), context);
171
+ return rows.map((row) => transformRowToBundle(row, patchMap.get(row.id)));
119
172
  };
120
173
  const getTargetAppVersionsForUpdateInfo = async ({ platform, channel, minBundleId }, context) => {
121
174
  return (await queryAll(`
@@ -156,8 +209,8 @@ const d1WorkerDatabase = () => createDatabasePlugin({
156
209
  }
157
210
  }),
158
211
  async getBundleById(bundleId, context) {
159
- const row = await queryFirst("SELECT * FROM bundles WHERE id = ? LIMIT 1", [bundleId], context);
160
- return row ? transformRowToBundle(row) : null;
212
+ const [row, patchMap] = await Promise.all([queryFirst("SELECT * FROM bundles WHERE id = ? LIMIT 1", [bundleId], context), getPatchMap([bundleId], context)]);
213
+ return row ? transformRowToBundle(row, patchMap.get(bundleId)) : null;
161
214
  },
162
215
  async getBundles(options, context) {
163
216
  const { where, limit, orderBy } = options;
@@ -165,12 +218,14 @@ const d1WorkerDatabase = () => createDatabasePlugin({
165
218
  const { sql: whereClause, params } = buildWhereClause(where);
166
219
  const orderSql = orderBy?.direction === "asc" ? "ORDER BY id ASC" : "ORDER BY id DESC";
167
220
  const total = (await queryAll(`SELECT COUNT(*) as total FROM bundles${whereClause}`, params, context))[0]?.total ?? 0;
221
+ const rows = await queryAll(`SELECT * FROM bundles${whereClause} ${orderSql} LIMIT ? OFFSET ?`, [
222
+ ...params,
223
+ limit,
224
+ offset
225
+ ], context);
226
+ const patchMap = await getPatchMap(rows.map((row) => row.id), context);
168
227
  return {
169
- data: (await queryAll(`SELECT * FROM bundles${whereClause} ${orderSql} LIMIT ? OFFSET ?`, [
170
- ...params,
171
- limit,
172
- offset
173
- ], context)).map(transformRowToBundle),
228
+ data: rows.map((row) => transformRowToBundle(row, patchMap.get(row.id))),
174
229
  pagination: calculatePagination(total, {
175
230
  limit,
176
231
  offset
@@ -185,6 +240,8 @@ const d1WorkerDatabase = () => createDatabasePlugin({
185
240
  const db = config.getDb(context);
186
241
  for (const operation of changedSets) {
187
242
  if (operation.operation === "delete") {
243
+ await db.prepare("DELETE FROM bundle_patches WHERE bundle_id = ?").bind(operation.data.id).run();
244
+ await db.prepare("DELETE FROM bundle_patches WHERE base_bundle_id = ?").bind(operation.data.id).run();
188
245
  await db.prepare("DELETE FROM bundles WHERE id = ?").bind(operation.data.id).run();
189
246
  continue;
190
247
  }
@@ -203,11 +260,28 @@ const d1WorkerDatabase = () => createDatabasePlugin({
203
260
  storage_uri,
204
261
  fingerprint_hash,
205
262
  metadata,
263
+ manifest_storage_uri,
264
+ manifest_file_hash,
265
+ asset_base_storage_uri,
206
266
  rollout_cohort_count,
207
267
  target_cohorts
208
268
  )
209
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
210
- `).bind(bundle.id, bundle.channel, bundle.enabled ? 1 : 0, bundle.shouldForceUpdate ? 1 : 0, bundle.fileHash, bundle.gitCommitHash || null, bundle.message || null, bundle.platform, bundle.targetAppVersion, bundle.storageUri, bundle.fingerprintHash, JSON.stringify(bundle.metadata ?? {}), bundle.rolloutCohortCount ?? DEFAULT_ROLLOUT_COHORT_COUNT, bundle.targetCohorts ? JSON.stringify(bundle.targetCohorts) : null).run();
269
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
270
+ `).bind(bundle.id, bundle.channel, bundle.enabled ? 1 : 0, bundle.shouldForceUpdate ? 1 : 0, bundle.fileHash, bundle.gitCommitHash || null, bundle.message || null, bundle.platform, bundle.targetAppVersion, bundle.storageUri, bundle.fingerprintHash, JSON.stringify(stripBundleArtifactMetadata(bundle.metadata) ?? {}), getManifestStorageUri(bundle), getManifestFileHash(bundle), getAssetBaseStorageUri(bundle), bundle.rolloutCohortCount ?? DEFAULT_ROLLOUT_COHORT_COUNT, bundle.targetCohorts ? JSON.stringify(bundle.targetCohorts) : null).run();
271
+ await db.prepare("DELETE FROM bundle_patches WHERE bundle_id = ?").bind(bundle.id).run();
272
+ const patchRows = bundleToPatchRows(bundle);
273
+ for (const patchRow of patchRows) await db.prepare(`
274
+ INSERT OR REPLACE INTO bundle_patches (
275
+ id,
276
+ bundle_id,
277
+ base_bundle_id,
278
+ base_file_hash,
279
+ patch_file_hash,
280
+ patch_storage_uri,
281
+ order_index
282
+ )
283
+ VALUES (?, ?, ?, ?, ?, ?, ?)
284
+ `).bind(patchRow.id, patchRow.bundle_id, patchRow.base_bundle_id, patchRow.base_file_hash, patchRow.patch_file_hash, patchRow.patch_storage_uri, patchRow.order_index ?? 0).run();
211
285
  }
212
286
  }
213
287
  };
@@ -223,21 +297,31 @@ const resolveJwtSecretFromContext = (context) => {
223
297
  if (!jwtSecret) throw new Error("r2WorkerStorage requires env.JWT_SECRET in the hot updater context.");
224
298
  return jwtSecret;
225
299
  };
300
+ const resolveR2BucketFromContext = (context) => {
301
+ const bucket = context?.env?.BUCKET;
302
+ if (!bucket) throw new Error("r2WorkerStorage requires env.BUCKET in the hot updater context.");
303
+ return bucket;
304
+ };
305
+ const createPublicObjectPath = (storageUrl) => `${storageUrl.host}${storageUrl.pathname}`;
306
+ const createR2ObjectKey = (storageUrl) => storageUrl.pathname.replace(/^\/+/, "");
226
307
  const r2WorkerStorage = (config) => {
227
- return () => {
228
- return {
229
- name: "r2WorkerStorage",
230
- supportedProtocol: "r2",
231
- async upload() {
232
- throw new Error("r2WorkerStorage does not support upload() in the worker runtime.");
233
- },
234
- async delete() {
235
- throw new Error("r2WorkerStorage does not support delete() in the worker runtime.");
308
+ return createRuntimeStoragePlugin({
309
+ name: "r2WorkerStorage",
310
+ supportedProtocol: "r2",
311
+ factory: (config) => ({
312
+ async readText(storageUri, context) {
313
+ const storageUrl = new URL(storageUri);
314
+ if (storageUrl.protocol !== "r2:") throw new Error("Invalid R2 storage URI protocol");
315
+ const bucket = resolveR2BucketFromContext(context);
316
+ const key = createR2ObjectKey(storageUrl);
317
+ const object = await bucket.get(key);
318
+ if (!object) return null;
319
+ return object.text();
236
320
  },
237
321
  async getDownloadUrl(storageUri, context) {
238
322
  const storageUrl = new URL(storageUri);
239
323
  if (storageUrl.protocol !== "r2:") throw new Error("Invalid R2 storage URI protocol");
240
- const key = `${storageUrl.host}${storageUrl.pathname}`;
324
+ const key = createPublicObjectPath(storageUrl);
241
325
  const [jwtSecret, publicBaseUrl] = await Promise.all([resolveContextValue(config.jwtSecret ?? resolveJwtSecretFromContext, context), resolveContextValue(config.publicBaseUrl, context)]);
242
326
  const token = await signToken(key, jwtSecret);
243
327
  const url = new URL(publicBaseUrl);
@@ -246,8 +330,8 @@ const r2WorkerStorage = (config) => {
246
330
  url.searchParams.set("token", token);
247
331
  return { fileUrl: url.toString() };
248
332
  }
249
- };
250
- };
333
+ })
334
+ })(config);
251
335
  };
252
336
  //#endregion
253
337
  //#region src/worker/index.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/cloudflare",
3
3
  "type": "module",
4
- "version": "0.30.11",
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",
@@ -50,11 +50,11 @@
50
50
  "cloudflare": "4.2.0",
51
51
  "hono": "4.12.9",
52
52
  "uuidv7": "^1.0.2",
53
- "@hot-updater/cli-tools": "0.30.11",
54
- "@hot-updater/core": "0.30.11",
55
- "@hot-updater/js": "0.30.11",
56
- "@hot-updater/server": "0.30.11",
57
- "@hot-updater/plugin-core": "0.30.11"
53
+ "@hot-updater/core": "0.31.0",
54
+ "@hot-updater/cli-tools": "0.31.0",
55
+ "@hot-updater/plugin-core": "0.31.0",
56
+ "@hot-updater/js": "0.31.0",
57
+ "@hot-updater/server": "0.31.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@cloudflare/vitest-pool-workers": "0.13.0",
@@ -71,7 +71,7 @@
71
71
  "vitest": "4.1.4",
72
72
  "wrangler": "^4.5.0",
73
73
  "xdg-app-paths": "^8.3.0",
74
- "@hot-updater/test-utils": "0.30.11"
74
+ "@hot-updater/test-utils": "0.31.0"
75
75
  },
76
76
  "scripts": {
77
77
  "build": "tsdown && pnpm build:worker",
package/sql/bundles.sql CHANGED
@@ -9,14 +9,34 @@ CREATE TABLE bundles (
9
9
  file_hash TEXT NOT NULL,
10
10
  git_commit_hash TEXT,
11
11
  message TEXT,
12
- channel TEXT NOT NULL,
13
- storage_uri TEXT,
12
+ channel TEXT NOT NULL DEFAULT 'production',
13
+ storage_uri TEXT NOT NULL,
14
14
  fingerprint_hash TEXT,
15
15
  metadata JSONB DEFAULT '{}',
16
+ manifest_storage_uri TEXT,
17
+ manifest_file_hash TEXT,
18
+ asset_base_storage_uri TEXT,
16
19
  rollout_cohort_count INTEGER DEFAULT 1000
17
20
  CHECK (rollout_cohort_count >= 0 AND rollout_cohort_count <= 1000),
18
- target_cohorts TEXT
21
+ target_cohorts TEXT,
22
+ CHECK ((target_app_version IS NOT NULL) OR (fingerprint_hash IS NOT NULL))
23
+ );
24
+
25
+ CREATE TABLE bundle_patches (
26
+ id TEXT PRIMARY KEY,
27
+ bundle_id TEXT NOT NULL,
28
+ base_bundle_id TEXT NOT NULL,
29
+ base_file_hash TEXT NOT NULL,
30
+ patch_file_hash TEXT NOT NULL,
31
+ patch_storage_uri TEXT NOT NULL,
32
+ order_index INTEGER NOT NULL DEFAULT 0,
33
+ FOREIGN KEY (bundle_id) REFERENCES bundles(id) ON DELETE CASCADE,
34
+ FOREIGN KEY (base_bundle_id) REFERENCES bundles(id) ON DELETE CASCADE
19
35
  );
20
36
 
21
37
  CREATE INDEX bundles_target_app_version_idx ON bundles(target_app_version);
22
38
  CREATE INDEX bundles_fingerprint_hash_idx ON bundles(fingerprint_hash);
39
+ CREATE INDEX bundles_channel_idx ON bundles(channel);
40
+ CREATE INDEX bundles_rollout_idx ON bundles(rollout_cohort_count);
41
+ CREATE INDEX bundle_patches_bundle_id_idx ON bundle_patches(bundle_id);
42
+ CREATE INDEX bundle_patches_base_bundle_id_idx ON bundle_patches(base_bundle_id);