@hot-updater/cloudflare 0.36.6 → 1.0.0-rc.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.
Files changed (49) hide show
  1. package/dist/iac/index.cjs +989 -11
  2. package/dist/iac/index.mjs +992 -15
  3. package/dist/index.cjs +919 -6989
  4. package/dist/index.d.cts +11 -47
  5. package/dist/index.d.mts +11 -47
  6. package/dist/index.mjs +917 -7006
  7. package/dist/worker/index.cjs +898 -336
  8. package/dist/worker/index.d.cts +18 -33
  9. package/dist/worker/index.d.mts +18 -33
  10. package/dist/worker/index.mjs +899 -331
  11. package/package.json +7 -10
  12. package/sql/bundles.sql +183 -35
  13. package/src/d1Database.spec.ts +143 -392
  14. package/src/d1Database.ts +55 -650
  15. package/src/d1Implementation.spec.ts +252 -0
  16. package/src/d1Implementation.ts +742 -0
  17. package/src/d1Query.spec.ts +46 -0
  18. package/src/d1Query.ts +89 -0
  19. package/src/d1Rows.spec.ts +111 -0
  20. package/src/d1Rows.ts +326 -0
  21. package/src/d1Sql.spec.ts +30 -0
  22. package/src/d1Sql.ts +175 -0
  23. package/src/r2S3Storage.ts +93 -137
  24. package/src/r2Storage.spec.ts +120 -218
  25. package/src/r2Storage.ts +13 -61
  26. package/src/r2WorkerStorage.spec.ts +161 -50
  27. package/src/r2WorkerStorage.ts +85 -118
  28. package/src/worker/d1Database.spec.ts +33 -0
  29. package/src/worker/d1Database.ts +52 -0
  30. package/src/worker/index.ts +3 -31
  31. package/worker/dist/README.md +1 -1
  32. package/worker/dist/index.js +5968 -4701
  33. package/worker/dist/index.js.map +4 -4
  34. package/worker/migrations/0001_hot-updater_1.0.0.sql +197 -0
  35. package/worker/migrations/0002_hot-updater_artifact_byte_sizes.sql +11 -0
  36. package/worker/src/databaseMigration.integration.spec.ts +114 -0
  37. package/worker/src/databasePlugins.integration.spec.ts +292 -0
  38. package/worker/src/index.integration.spec.ts +102 -294
  39. package/worker/src/index.ts +14 -66
  40. package/worker/src/storagePlugin.integration.spec.ts +59 -0
  41. package/worker/wrangler.json +5 -2
  42. package/src/cloudflareWorkerDatabase.spec.ts +0 -260
  43. package/src/cloudflareWorkerDatabase.ts +0 -651
  44. package/src/r2WranglerStorage.ts +0 -193
  45. package/worker/migrations/0001_hot-updater_init.sql +0 -16
  46. package/worker/migrations/0002_hot-updater_0.13.0.sql +0 -8
  47. package/worker/migrations/0003_hot-updater_0.18.0.sql +0 -38
  48. package/worker/migrations/0004_hot-updater_0.29.0.sql +0 -11
  49. package/worker/migrations/0005_hot-updater_0.31.0.sql +0 -24
@@ -1,651 +0,0 @@
1
- import {
2
- DEFAULT_ROLLOUT_COHORT_COUNT,
3
- getAssetBaseStorageUri,
4
- getBundlePatches,
5
- getManifestFileHash,
6
- getManifestStorageUri,
7
- NIL_UUID,
8
- stripBundleArtifactMetadata,
9
- } from "@hot-updater/core";
10
- import type {
11
- Bundle,
12
- DatabaseBundleQueryWhere,
13
- HotUpdaterContext,
14
- PaginationOptions,
15
- RequestEnvContext,
16
- } from "@hot-updater/plugin-core";
17
- import {
18
- calculatePagination,
19
- createDatabasePlugin,
20
- filterCompatibleAppVersions,
21
- resolveUpdateInfoFromBundles,
22
- } from "@hot-updater/plugin-core";
23
-
24
- type D1Result<T> = {
25
- results?: T[];
26
- };
27
-
28
- type D1PreparedStatement = {
29
- bind: (...values: unknown[]) => {
30
- all: <T>() => Promise<D1Result<T>>;
31
- first: <T>() => Promise<T | null>;
32
- run: () => Promise<unknown>;
33
- };
34
- };
35
-
36
- type D1Like = {
37
- prepare: (sql: string) => D1PreparedStatement;
38
- };
39
-
40
- export interface CloudflareWorkerDatabaseEnv {
41
- DB: D1Like;
42
- }
43
-
44
- interface CloudflareWorkerDatabaseConfig<
45
- TContext extends RequestEnvContext<CloudflareWorkerDatabaseEnv>,
46
- > {
47
- getDb: (context?: HotUpdaterContext<TContext>) => D1Like;
48
- }
49
-
50
- type QueryConditions = DatabaseBundleQueryWhere;
51
-
52
- interface BuildQueryResult {
53
- sql: string;
54
- params: unknown[];
55
- }
56
-
57
- const buildJsonEachInClause = (
58
- columnName: string,
59
- values: string[],
60
- params: unknown[],
61
- ) => {
62
- if (values.length === 0) {
63
- return "1 = 0";
64
- }
65
-
66
- params.push(JSON.stringify(values));
67
- return `${columnName} IN (SELECT value FROM json_each(?))`;
68
- };
69
-
70
- interface D1WorkerBundleRow {
71
- id: string;
72
- channel: string;
73
- enabled: number | boolean;
74
- should_force_update: number | boolean;
75
- file_hash: string;
76
- git_commit_hash: string | null;
77
- message: string | null;
78
- platform: "ios" | "android";
79
- target_app_version: string | null;
80
- storage_uri: string;
81
- fingerprint_hash: string | null;
82
- metadata: unknown;
83
- manifest_storage_uri?: string | null;
84
- manifest_file_hash?: string | null;
85
- asset_base_storage_uri?: string | null;
86
- rollout_cohort_count: number | null;
87
- target_cohorts: string | null;
88
- }
89
-
90
- interface D1WorkerBundlePatchRow {
91
- id: string;
92
- bundle_id: string;
93
- base_bundle_id: string;
94
- base_file_hash: string;
95
- patch_file_hash: string;
96
- patch_storage_uri: string;
97
- order_index: number | null;
98
- }
99
-
100
- function buildWhereClause(
101
- conditions: QueryConditions | undefined,
102
- ): BuildQueryResult {
103
- if (!conditions) {
104
- return { sql: "", params: [] };
105
- }
106
-
107
- const clauses: string[] = [];
108
- const params: unknown[] = [];
109
-
110
- if (conditions.channel) {
111
- clauses.push("channel = ?");
112
- params.push(conditions.channel);
113
- }
114
-
115
- if (conditions.platform) {
116
- clauses.push("platform = ?");
117
- params.push(conditions.platform);
118
- }
119
-
120
- if (conditions.enabled !== undefined) {
121
- clauses.push("enabled = ?");
122
- params.push(conditions.enabled ? 1 : 0);
123
- }
124
-
125
- if (conditions.id?.in) {
126
- clauses.push(buildJsonEachInClause("id", conditions.id.in, params));
127
- }
128
-
129
- if (conditions.id?.eq) {
130
- clauses.push("id = ?");
131
- params.push(conditions.id.eq);
132
- }
133
-
134
- if (conditions.id?.gt) {
135
- clauses.push("id > ?");
136
- params.push(conditions.id.gt);
137
- }
138
-
139
- if (conditions.id?.gte) {
140
- clauses.push("id >= ?");
141
- params.push(conditions.id.gte);
142
- }
143
-
144
- if (conditions.id?.lt) {
145
- clauses.push("id < ?");
146
- params.push(conditions.id.lt);
147
- }
148
-
149
- if (conditions.id?.lte) {
150
- clauses.push("id <= ?");
151
- params.push(conditions.id.lte);
152
- }
153
-
154
- if (conditions.targetAppVersionNotNull) {
155
- clauses.push("target_app_version IS NOT NULL");
156
- }
157
-
158
- if (conditions.targetAppVersion !== undefined) {
159
- if (conditions.targetAppVersion === null) {
160
- clauses.push("target_app_version IS NULL");
161
- } else {
162
- clauses.push("target_app_version = ?");
163
- params.push(conditions.targetAppVersion);
164
- }
165
- }
166
-
167
- if (conditions.targetAppVersionIn) {
168
- clauses.push(
169
- buildJsonEachInClause(
170
- "target_app_version",
171
- conditions.targetAppVersionIn,
172
- params,
173
- ),
174
- );
175
- }
176
-
177
- if (conditions.fingerprintHash !== undefined) {
178
- if (conditions.fingerprintHash === null) {
179
- clauses.push("fingerprint_hash IS NULL");
180
- } else {
181
- clauses.push("fingerprint_hash = ?");
182
- params.push(conditions.fingerprintHash);
183
- }
184
- }
185
-
186
- const whereClause =
187
- clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
188
-
189
- return { sql: whereClause, params };
190
- }
191
-
192
- function parseTargetCohorts(value: unknown): string[] | null {
193
- if (!value) return null;
194
- if (Array.isArray(value)) {
195
- return value.filter((item): item is string => typeof item === "string");
196
- }
197
- if (typeof value === "string") {
198
- try {
199
- const parsed = JSON.parse(value) as unknown;
200
- if (Array.isArray(parsed)) {
201
- return parsed.filter(
202
- (item): item is string => typeof item === "string",
203
- );
204
- }
205
- } catch {
206
- return null;
207
- }
208
- }
209
- return null;
210
- }
211
-
212
- const parseMetadata = (value: unknown): Bundle["metadata"] => {
213
- if (!value) return undefined;
214
- if (typeof value === "string") {
215
- try {
216
- return parseMetadata(JSON.parse(value) as unknown);
217
- } catch {
218
- return undefined;
219
- }
220
- }
221
- return typeof value === "object" && !Array.isArray(value)
222
- ? (value as Bundle["metadata"])
223
- : undefined;
224
- };
225
-
226
- const buildBundlePatchId = (bundleId: string, baseBundleId: string) =>
227
- `${bundleId}:${baseBundleId}`;
228
-
229
- const bundleToPatchRows = (bundle: Bundle): D1WorkerBundlePatchRow[] =>
230
- getBundlePatches(bundle).map((patch, index) => ({
231
- id: buildBundlePatchId(bundle.id, patch.baseBundleId),
232
- bundle_id: bundle.id,
233
- base_bundle_id: patch.baseBundleId,
234
- base_file_hash: patch.baseFileHash,
235
- patch_file_hash: patch.patchFileHash,
236
- patch_storage_uri: patch.patchStorageUri,
237
- order_index: index,
238
- }));
239
-
240
- function transformRowToBundle(
241
- row: D1WorkerBundleRow,
242
- patchRows: D1WorkerBundlePatchRow[] = [],
243
- ): Bundle {
244
- const rawMetadata = parseMetadata(row.metadata);
245
- const patches = patchRows
246
- .slice()
247
- .sort(
248
- (left, right) =>
249
- (left.order_index ?? 0) - (right.order_index ?? 0) ||
250
- left.base_bundle_id.localeCompare(right.base_bundle_id),
251
- )
252
- .map((patch) => ({
253
- baseBundleId: patch.base_bundle_id,
254
- baseFileHash: patch.base_file_hash,
255
- patchFileHash: patch.patch_file_hash,
256
- patchStorageUri: patch.patch_storage_uri,
257
- }));
258
- const primaryPatch = patches[0] ?? null;
259
-
260
- return {
261
- id: row.id,
262
- channel: row.channel,
263
- enabled: Boolean(row.enabled),
264
- shouldForceUpdate: Boolean(row.should_force_update),
265
- fileHash: row.file_hash,
266
- gitCommitHash: row.git_commit_hash,
267
- message: row.message,
268
- platform: row.platform,
269
- targetAppVersion: row.target_app_version,
270
- storageUri: row.storage_uri,
271
- fingerprintHash: row.fingerprint_hash,
272
- metadata: stripBundleArtifactMetadata(rawMetadata),
273
- manifestStorageUri: row.manifest_storage_uri ?? null,
274
- manifestFileHash: row.manifest_file_hash ?? null,
275
- assetBaseStorageUri: row.asset_base_storage_uri ?? null,
276
- patches,
277
- patchBaseBundleId: primaryPatch?.baseBundleId ?? null,
278
- patchBaseFileHash: primaryPatch?.baseFileHash ?? null,
279
- patchFileHash: primaryPatch?.patchFileHash ?? null,
280
- patchStorageUri: primaryPatch?.patchStorageUri ?? null,
281
- rolloutCohortCount:
282
- (row.rollout_cohort_count as number | null) ??
283
- DEFAULT_ROLLOUT_COHORT_COUNT,
284
- targetCohorts: parseTargetCohorts(row.target_cohorts as unknown),
285
- };
286
- }
287
-
288
- const resolveDbFromContext = (
289
- context?: RequestEnvContext<CloudflareWorkerDatabaseEnv>,
290
- ) => {
291
- const db = context?.env?.DB;
292
-
293
- if (!db) {
294
- throw new Error(
295
- "d1WorkerDatabase requires env.DB in the hot updater context.",
296
- );
297
- }
298
-
299
- return db;
300
- };
301
-
302
- export const d1WorkerDatabase = <
303
- TContext extends RequestEnvContext<CloudflareWorkerDatabaseEnv> =
304
- RequestEnvContext<CloudflareWorkerDatabaseEnv>,
305
- >() =>
306
- createDatabasePlugin<CloudflareWorkerDatabaseConfig<TContext>, TContext>({
307
- name: "d1WorkerDatabase",
308
- factory: (config) => {
309
- const queryAll = async <TRow>(
310
- sql: string,
311
- params: unknown[] = [],
312
- context?: HotUpdaterContext<TContext>,
313
- ): Promise<TRow[]> => {
314
- const result = await config
315
- .getDb(context)
316
- .prepare(sql)
317
- .bind(...params)
318
- .all<TRow>();
319
- return result.results ?? [];
320
- };
321
-
322
- const queryFirst = async <TRow>(
323
- sql: string,
324
- params: unknown[] = [],
325
- context?: HotUpdaterContext<TContext>,
326
- ): Promise<TRow | null> => {
327
- const result = await config
328
- .getDb(context)
329
- .prepare(sql)
330
- .bind(...params)
331
- .first<TRow>();
332
- return result ?? null;
333
- };
334
-
335
- const getPatchMap = async (
336
- bundleIds: string[],
337
- context?: HotUpdaterContext<TContext>,
338
- ) => {
339
- const patchMap = new Map<string, D1WorkerBundlePatchRow[]>();
340
-
341
- if (bundleIds.length === 0) {
342
- return patchMap;
343
- }
344
-
345
- const rows = await queryAll<D1WorkerBundlePatchRow>(
346
- `
347
- SELECT *
348
- FROM bundle_patches
349
- WHERE bundle_id IN (SELECT value FROM json_each(?))
350
- ORDER BY order_index ASC, base_bundle_id ASC
351
- `,
352
- [JSON.stringify(bundleIds)],
353
- context,
354
- );
355
-
356
- for (const row of rows) {
357
- const current = patchMap.get(row.bundle_id) ?? [];
358
- current.push(row);
359
- patchMap.set(row.bundle_id, current);
360
- }
361
-
362
- return patchMap;
363
- };
364
-
365
- const queryBundlesForUpdateInfo = async (
366
- conditions: QueryConditions,
367
- context?: HotUpdaterContext<TContext>,
368
- ): Promise<Bundle[]> => {
369
- const { sql: whereClause, params } = buildWhereClause(conditions);
370
- const rows = await queryAll<D1WorkerBundleRow>(
371
- `
372
- SELECT * FROM bundles
373
- ${whereClause}
374
- `,
375
- params,
376
- context,
377
- );
378
- const patchMap = await getPatchMap(
379
- rows.map((row) => row.id),
380
- context,
381
- );
382
-
383
- return rows.map((row) =>
384
- transformRowToBundle(row, patchMap.get(row.id)),
385
- );
386
- };
387
-
388
- const getTargetAppVersionsForUpdateInfo = async (
389
- {
390
- platform,
391
- channel,
392
- minBundleId,
393
- }: {
394
- platform: Bundle["platform"];
395
- channel: string;
396
- minBundleId: string;
397
- },
398
- context?: HotUpdaterContext<TContext>,
399
- ): Promise<string[]> => {
400
- const rows = await queryAll<{ target_app_version: string }>(
401
- `
402
- SELECT target_app_version
403
- FROM bundles
404
- WHERE channel = ?
405
- AND platform = ?
406
- AND enabled = 1
407
- AND id >= ?
408
- AND target_app_version IS NOT NULL
409
- GROUP BY target_app_version
410
- `,
411
- [channel, platform, minBundleId],
412
- context,
413
- );
414
-
415
- return rows.map((row) => row.target_app_version);
416
- };
417
-
418
- return {
419
- async getUpdateInfo(args, context) {
420
- const channel = args.channel ?? "production";
421
- const minBundleId = args.minBundleId ?? NIL_UUID;
422
-
423
- if (args._updateStrategy === "appVersion") {
424
- const targetAppVersions = await getTargetAppVersionsForUpdateInfo(
425
- {
426
- platform: args.platform,
427
- channel,
428
- minBundleId,
429
- },
430
- context,
431
- );
432
- const compatibleAppVersions = filterCompatibleAppVersions(
433
- targetAppVersions,
434
- args.appVersion,
435
- );
436
- const bundles =
437
- compatibleAppVersions.length > 0
438
- ? await queryBundlesForUpdateInfo(
439
- {
440
- enabled: true,
441
- platform: args.platform,
442
- channel,
443
- id: {
444
- gte: minBundleId,
445
- },
446
- targetAppVersionIn: compatibleAppVersions,
447
- },
448
- context,
449
- )
450
- : [];
451
-
452
- return resolveUpdateInfoFromBundles({
453
- args: { ...args, channel, minBundleId },
454
- bundles,
455
- context,
456
- });
457
- }
458
-
459
- const bundles = await queryBundlesForUpdateInfo(
460
- {
461
- enabled: true,
462
- platform: args.platform,
463
- channel,
464
- id: {
465
- gte: minBundleId,
466
- },
467
- fingerprintHash: args.fingerprintHash,
468
- },
469
- context,
470
- );
471
-
472
- return resolveUpdateInfoFromBundles({
473
- args: { ...args, channel, minBundleId },
474
- bundles,
475
- context,
476
- });
477
- },
478
-
479
- async getBundleById(bundleId, context) {
480
- const [row, patchMap] = await Promise.all([
481
- queryFirst<D1WorkerBundleRow>(
482
- "SELECT * FROM bundles WHERE id = ? LIMIT 1",
483
- [bundleId],
484
- context,
485
- ),
486
- getPatchMap([bundleId], context),
487
- ]);
488
-
489
- return row ? transformRowToBundle(row, patchMap.get(bundleId)) : null;
490
- },
491
-
492
- async getBundles(options, context) {
493
- const { where, limit, orderBy } = options;
494
- const offset =
495
- (("offset" in options ? options.offset : undefined) as
496
- | number
497
- | undefined) ?? 0;
498
- const { sql: whereClause, params } = buildWhereClause(where);
499
- const orderSql =
500
- orderBy?.direction === "asc"
501
- ? "ORDER BY id ASC"
502
- : "ORDER BY id DESC";
503
-
504
- const countRows = await queryAll<{ total: number }>(
505
- `SELECT COUNT(*) as total FROM bundles${whereClause}`,
506
- params,
507
- context,
508
- );
509
- const total = countRows[0]?.total ?? 0;
510
-
511
- const rows = await queryAll<D1WorkerBundleRow>(
512
- `SELECT * FROM bundles${whereClause} ${orderSql} LIMIT ? OFFSET ?`,
513
- [...params, limit, offset],
514
- context,
515
- );
516
-
517
- const patchMap = await getPatchMap(
518
- rows.map((row) => row.id),
519
- context,
520
- );
521
- const bundles = rows.map((row) =>
522
- transformRowToBundle(row, patchMap.get(row.id)),
523
- );
524
-
525
- const paginationOptions: PaginationOptions = { limit, offset };
526
- return {
527
- data: bundles,
528
- pagination: calculatePagination(total, paginationOptions),
529
- };
530
- },
531
-
532
- async getChannels(context) {
533
- const rows = await queryAll<{ channel: string }>(
534
- "SELECT channel FROM bundles GROUP BY channel",
535
- [],
536
- context,
537
- );
538
- return rows.map((row) => row.channel);
539
- },
540
-
541
- async commitBundle({ changedSets }, context) {
542
- if (changedSets.length === 0) {
543
- return;
544
- }
545
-
546
- const db = config.getDb(context);
547
-
548
- for (const operation of changedSets) {
549
- if (operation.operation === "delete") {
550
- await db
551
- .prepare("DELETE FROM bundle_patches WHERE bundle_id = ?")
552
- .bind(operation.data.id)
553
- .run();
554
- await db
555
- .prepare("DELETE FROM bundle_patches WHERE base_bundle_id = ?")
556
- .bind(operation.data.id)
557
- .run();
558
- await db
559
- .prepare("DELETE FROM bundles WHERE id = ?")
560
- .bind(operation.data.id)
561
- .run();
562
- continue;
563
- }
564
-
565
- const bundle = operation.data;
566
- await db
567
- .prepare(`
568
- INSERT OR REPLACE INTO bundles (
569
- id,
570
- channel,
571
- enabled,
572
- should_force_update,
573
- file_hash,
574
- git_commit_hash,
575
- message,
576
- platform,
577
- target_app_version,
578
- storage_uri,
579
- fingerprint_hash,
580
- metadata,
581
- manifest_storage_uri,
582
- manifest_file_hash,
583
- asset_base_storage_uri,
584
- rollout_cohort_count,
585
- target_cohorts
586
- )
587
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
588
- `)
589
- .bind(
590
- bundle.id,
591
- bundle.channel,
592
- bundle.enabled ? 1 : 0,
593
- bundle.shouldForceUpdate ? 1 : 0,
594
- bundle.fileHash,
595
- bundle.gitCommitHash || null,
596
- bundle.message || null,
597
- bundle.platform,
598
- bundle.targetAppVersion,
599
- bundle.storageUri,
600
- bundle.fingerprintHash,
601
- JSON.stringify(
602
- stripBundleArtifactMetadata(bundle.metadata) ?? {},
603
- ),
604
- getManifestStorageUri(bundle),
605
- getManifestFileHash(bundle),
606
- getAssetBaseStorageUri(bundle),
607
- bundle.rolloutCohortCount ?? DEFAULT_ROLLOUT_COHORT_COUNT,
608
- bundle.targetCohorts
609
- ? JSON.stringify(bundle.targetCohorts)
610
- : null,
611
- )
612
- .run();
613
-
614
- await db
615
- .prepare("DELETE FROM bundle_patches WHERE bundle_id = ?")
616
- .bind(bundle.id)
617
- .run();
618
-
619
- const patchRows = bundleToPatchRows(bundle);
620
- for (const patchRow of patchRows) {
621
- await db
622
- .prepare(`
623
- INSERT OR REPLACE INTO bundle_patches (
624
- id,
625
- bundle_id,
626
- base_bundle_id,
627
- base_file_hash,
628
- patch_file_hash,
629
- patch_storage_uri,
630
- order_index
631
- )
632
- VALUES (?, ?, ?, ?, ?, ?, ?)
633
- `)
634
- .bind(
635
- patchRow.id,
636
- patchRow.bundle_id,
637
- patchRow.base_bundle_id,
638
- patchRow.base_file_hash,
639
- patchRow.patch_file_hash,
640
- patchRow.patch_storage_uri,
641
- patchRow.order_index ?? 0,
642
- )
643
- .run();
644
- }
645
- }
646
- },
647
- };
648
- },
649
- })({
650
- getDb: resolveDbFromContext,
651
- });