@hot-updater/server 0.31.4 → 0.33.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 (45) hide show
  1. package/dist/_virtual/_rolldown/runtime.cjs +1 -1
  2. package/dist/_virtual/_rolldown/runtime.mjs +1 -1
  3. package/dist/db/createBundleDiff.cjs +19 -13
  4. package/dist/db/createBundleDiff.mjs +15 -9
  5. package/dist/db/index.cjs +7 -10
  6. package/dist/db/index.mjs +7 -10
  7. package/dist/db/pluginCore.cjs +136 -96
  8. package/dist/db/pluginCore.mjs +137 -97
  9. package/dist/db/requestBundleIdentityMap.cjs +29 -0
  10. package/dist/db/requestBundleIdentityMap.mjs +29 -0
  11. package/dist/db/schemaEnhancements.cjs +1 -1
  12. package/dist/db/types.d.cts +2 -1
  13. package/dist/db/types.d.mts +2 -1
  14. package/dist/db/updateArtifacts.cjs +6 -6
  15. package/dist/db/updateArtifacts.mjs +6 -6
  16. package/dist/handler.cjs +18 -8
  17. package/dist/handler.d.cts +9 -10
  18. package/dist/handler.d.mts +9 -10
  19. package/dist/handler.mjs +17 -7
  20. package/dist/index.d.cts +1 -1
  21. package/dist/index.d.mts +1 -1
  22. package/dist/node_modules/.pnpm/fumadb@0.2.2_drizzle-orm@0.44.7_@cloudflare_workers-types@4.20260313.1_@electric-sql_pg_c72c8c754becd21f6d6662e8fbd28e7f/node_modules/fumadb/dist/index.d.cts +1 -1
  23. package/dist/node_modules/.pnpm/fumadb@0.2.2_drizzle-orm@0.44.7_@cloudflare_workers-types@4.20260313.1_@electric-sql_pg_c72c8c754becd21f6d6662e8fbd28e7f/node_modules/fumadb/dist/index.d.mts +1 -1
  24. package/dist/node_modules/.pnpm/fumadb@0.2.2_drizzle-orm@0.44.7_@cloudflare_workers-types@4.20260313.1_@electric-sql_pg_c72c8c754becd21f6d6662e8fbd28e7f/node_modules/fumadb/dist/query/index.d.cts +1 -1
  25. package/dist/node_modules/.pnpm/fumadb@0.2.2_drizzle-orm@0.44.7_@cloudflare_workers-types@4.20260313.1_@electric-sql_pg_c72c8c754becd21f6d6662e8fbd28e7f/node_modules/fumadb/dist/query/index.d.mts +1 -1
  26. package/dist/packages/server/package.cjs +1 -1
  27. package/dist/packages/server/package.mjs +1 -1
  28. package/dist/runtime.cjs +10 -12
  29. package/dist/runtime.mjs +10 -12
  30. package/package.json +7 -7
  31. package/src/db/createBundleDiff.spec.ts +3 -0
  32. package/src/db/createBundleDiff.ts +27 -21
  33. package/src/db/index.spec.ts +36 -0
  34. package/src/db/index.ts +6 -10
  35. package/src/db/pluginCore.spec.ts +443 -0
  36. package/src/db/pluginCore.ts +63 -7
  37. package/src/db/requestBundleIdentityMap.spec.ts +56 -0
  38. package/src/db/requestBundleIdentityMap.ts +61 -0
  39. package/src/db/types.ts +2 -0
  40. package/src/db/updateArtifacts.ts +8 -19
  41. package/src/handler-standalone.integration.spec.ts +12 -0
  42. package/src/handler.spec.ts +117 -19
  43. package/src/handler.ts +47 -21
  44. package/src/runtime.spec.ts +46 -4
  45. package/src/runtime.ts +10 -12
@@ -4,6 +4,7 @@ import type {
4
4
  DatabasePlugin,
5
5
  RequestEnvContext,
6
6
  } from "@hot-updater/plugin-core";
7
+ import { createDatabasePluginGetUpdateInfo } from "@hot-updater/plugin-core";
7
8
  import { describe, expect, it, vi } from "vitest";
8
9
 
9
10
  import { createPluginDatabaseCore } from "./pluginCore";
@@ -195,6 +196,448 @@ describe("createPluginDatabaseCore", () => {
195
196
  });
196
197
  });
197
198
 
199
+ it("uses request bundle identity map for manifest artifact lookups", async () => {
200
+ const currentBundle = {
201
+ ...baseBundle,
202
+ id: "00000000-0000-0000-0000-000000000001",
203
+ manifestStorageUri: "r2://bucket/current/manifest.json",
204
+ manifestFileHash: "sig:current-manifest",
205
+ assetBaseStorageUri: "r2://bucket/current/files",
206
+ };
207
+ const targetBundle = {
208
+ ...baseBundle,
209
+ id: "00000000-0000-0000-0000-000000000002",
210
+ fileHash: "hash-2",
211
+ manifestStorageUri: "r2://bucket/target/manifest.json",
212
+ manifestFileHash: "sig:target-manifest",
213
+ assetBaseStorageUri: "r2://bucket/target/files",
214
+ };
215
+ const manifests = new Map([
216
+ [
217
+ currentBundle.manifestStorageUri,
218
+ JSON.stringify({
219
+ bundleId: currentBundle.id,
220
+ assets: {
221
+ "index.ios.bundle": {
222
+ fileHash: "old-bundle-hash",
223
+ },
224
+ "shared.png": {
225
+ fileHash: "same-image-hash",
226
+ },
227
+ },
228
+ }),
229
+ ],
230
+ [
231
+ targetBundle.manifestStorageUri,
232
+ JSON.stringify({
233
+ bundleId: targetBundle.id,
234
+ assets: {
235
+ "index.ios.bundle": {
236
+ fileHash: "target-bundle-hash",
237
+ },
238
+ "shared.png": {
239
+ fileHash: "same-image-hash",
240
+ },
241
+ },
242
+ }),
243
+ ],
244
+ ]);
245
+ const getBundleById = vi.fn<DatabasePlugin<TestContext>["getBundleById"]>(
246
+ async (bundleId) => {
247
+ if (bundleId === currentBundle.id) return currentBundle;
248
+ if (bundleId === targetBundle.id) return targetBundle;
249
+ return null;
250
+ },
251
+ );
252
+ const getUpdateInfo = vi.fn<
253
+ NonNullable<DatabasePlugin<TestContext>["getUpdateInfo"]>
254
+ >(async () => ({
255
+ fileHash: targetBundle.fileHash,
256
+ id: targetBundle.id,
257
+ message: targetBundle.message,
258
+ shouldForceUpdate: targetBundle.shouldForceUpdate,
259
+ status: "UPDATE",
260
+ storageUri: targetBundle.storageUri,
261
+ }));
262
+
263
+ const plugin: DatabasePlugin<TestContext> = {
264
+ name: "identity-map-plugin",
265
+ async appendBundle() {},
266
+ async commitBundle() {},
267
+ async deleteBundle() {},
268
+ getBundleById,
269
+ getUpdateInfo,
270
+ async getBundles() {
271
+ return {
272
+ data: [targetBundle],
273
+ pagination: {
274
+ currentPage: 1,
275
+ hasNextPage: false,
276
+ hasPreviousPage: false,
277
+ total: 1,
278
+ totalPages: 1,
279
+ },
280
+ };
281
+ },
282
+ async getChannels() {
283
+ return ["production"];
284
+ },
285
+ async updateBundle() {},
286
+ };
287
+
288
+ const core = createPluginDatabaseCore(
289
+ () => plugin,
290
+ async (storageUri) => {
291
+ if (!storageUri) return null;
292
+ const url = new URL(storageUri);
293
+ return `https://assets.example.com/${url.host}${url.pathname}`;
294
+ },
295
+ {
296
+ readStorageText: async (storageUri) =>
297
+ manifests.get(storageUri) ?? null,
298
+ },
299
+ );
300
+
301
+ const updateInfo = await core.api.getAppUpdateInfo({
302
+ ...updateArgs,
303
+ bundleId: currentBundle.id,
304
+ });
305
+ expect(updateInfo).not.toBeNull();
306
+ if (!updateInfo) {
307
+ throw new Error("expected app update info");
308
+ }
309
+
310
+ expect(updateInfo).toMatchObject({
311
+ changedAssets: {
312
+ "index.ios.bundle": {
313
+ file: {
314
+ compression: "br",
315
+ url: "https://assets.example.com/bucket/target/files/index.ios.bundle.br",
316
+ },
317
+ fileHash: "target-bundle-hash",
318
+ },
319
+ },
320
+ manifestFileHash: "sig:target-manifest",
321
+ manifestUrl: "https://assets.example.com/bucket/target/manifest.json",
322
+ });
323
+ expect(updateInfo.changedAssets).not.toHaveProperty("shared.png");
324
+ expect(getUpdateInfo).toHaveBeenCalledOnce();
325
+ expect(getBundleById).toHaveBeenCalledTimes(2);
326
+ expect(getBundleById).toHaveBeenCalledWith(targetBundle.id, undefined);
327
+ expect(getBundleById).toHaveBeenCalledWith(currentBundle.id, undefined);
328
+ expect(Object.keys(updateInfo)).not.toContain("__hotUpdaterBundle");
329
+ expect(Object.keys(updateInfo)).not.toContain("__hotUpdaterCurrentBundle");
330
+ expect(JSON.stringify(updateInfo)).not.toContain("__hotUpdaterBundle");
331
+ expect(JSON.stringify(updateInfo)).not.toContain(
332
+ "__hotUpdaterCurrentBundle",
333
+ );
334
+ });
335
+
336
+ it("seeds request bundle identity map from provider update lookups", async () => {
337
+ const targetBundle = {
338
+ ...baseBundle,
339
+ id: "00000000-0000-0000-0000-000000000002",
340
+ fileHash: "hash-2",
341
+ manifestStorageUri: "s3://bucket/target/manifest.json",
342
+ manifestFileHash: "sig:target-manifest",
343
+ assetBaseStorageUri: "s3://bucket/target/files",
344
+ };
345
+ const manifests = new Map([
346
+ [
347
+ targetBundle.manifestStorageUri,
348
+ JSON.stringify({
349
+ bundleId: targetBundle.id,
350
+ assets: {
351
+ "index.ios.bundle": {
352
+ fileHash: "target-bundle-hash",
353
+ },
354
+ },
355
+ }),
356
+ ],
357
+ ]);
358
+ const getBundleById = vi.fn<DatabasePlugin<TestContext>["getBundleById"]>(
359
+ async () => {
360
+ throw new Error("unexpected provider bundle reread");
361
+ },
362
+ );
363
+ const getUpdateInfo = createDatabasePluginGetUpdateInfo<TestContext>({
364
+ getBundlesByFingerprint: async () => [],
365
+ getBundlesByTargetAppVersions: async () => [targetBundle],
366
+ listTargetAppVersions: async () => ["1.0.0"],
367
+ });
368
+ const plugin: DatabasePlugin<TestContext> = {
369
+ name: "seeded-fast-path-plugin",
370
+ async appendBundle() {},
371
+ async commitBundle() {},
372
+ async deleteBundle() {},
373
+ getBundleById,
374
+ async getBundles() {
375
+ return {
376
+ data: [targetBundle],
377
+ pagination: {
378
+ currentPage: 1,
379
+ hasNextPage: false,
380
+ hasPreviousPage: false,
381
+ total: 1,
382
+ totalPages: 1,
383
+ },
384
+ };
385
+ },
386
+ getUpdateInfo,
387
+ async getChannels() {
388
+ return ["production"];
389
+ },
390
+ async updateBundle() {},
391
+ };
392
+
393
+ const core = createPluginDatabaseCore(
394
+ () => plugin,
395
+ async (storageUri) => {
396
+ if (!storageUri) return null;
397
+ const url = new URL(storageUri);
398
+ return `https://assets.example.com/${url.host}${url.pathname}`;
399
+ },
400
+ {
401
+ readStorageText: async (storageUri) =>
402
+ manifests.get(storageUri) ?? null,
403
+ },
404
+ );
405
+ const context: TestContext = {
406
+ env: {
407
+ assetHost: "https://assets.example.com",
408
+ },
409
+ request: new Request("https://updates.example.com"),
410
+ };
411
+
412
+ const updateInfo = await core.api.getAppUpdateInfo(updateArgs, context);
413
+
414
+ expect(updateInfo).toMatchObject({
415
+ changedAssets: {
416
+ "index.ios.bundle": {
417
+ file: {
418
+ compression: "br",
419
+ url: "https://assets.example.com/bucket/target/files/index.ios.bundle.br",
420
+ },
421
+ fileHash: "target-bundle-hash",
422
+ },
423
+ },
424
+ id: targetBundle.id,
425
+ manifestFileHash: "sig:target-manifest",
426
+ manifestUrl: "https://assets.example.com/bucket/target/manifest.json",
427
+ });
428
+ expect(getBundleById).not.toHaveBeenCalled();
429
+ });
430
+
431
+ it("does not reread providers for current bundles outside direct update lookup results", async () => {
432
+ const targetBundle = {
433
+ ...baseBundle,
434
+ id: "00000000-0000-0000-0000-000000000003",
435
+ fileHash: "hash-3",
436
+ manifestStorageUri: "s3://bucket/target/manifest.json",
437
+ manifestFileHash: "sig:target-manifest",
438
+ assetBaseStorageUri: "s3://bucket/target/files",
439
+ };
440
+ const manifests = new Map([
441
+ [
442
+ targetBundle.manifestStorageUri,
443
+ JSON.stringify({
444
+ bundleId: targetBundle.id,
445
+ assets: {
446
+ "index.ios.bundle": {
447
+ fileHash: "target-bundle-hash",
448
+ },
449
+ "image.png": {
450
+ fileHash: "target-image-hash",
451
+ },
452
+ },
453
+ }),
454
+ ],
455
+ ]);
456
+ const getBundleById = vi.fn<DatabasePlugin<TestContext>["getBundleById"]>(
457
+ async () => {
458
+ throw new Error("unexpected provider current bundle reread");
459
+ },
460
+ );
461
+ const getUpdateInfo = createDatabasePluginGetUpdateInfo<TestContext>({
462
+ getBundlesByFingerprint: async () => [],
463
+ getBundlesByTargetAppVersions: async () => [targetBundle],
464
+ listTargetAppVersions: async () => ["1.0.0"],
465
+ });
466
+ const plugin: DatabasePlugin<TestContext> = {
467
+ name: "seeded-current-miss-plugin",
468
+ async appendBundle() {},
469
+ async commitBundle() {},
470
+ async deleteBundle() {},
471
+ getBundleById,
472
+ async getBundles() {
473
+ throw new Error("unexpected provider scan");
474
+ },
475
+ getUpdateInfo,
476
+ async getChannels() {
477
+ return ["production"];
478
+ },
479
+ async updateBundle() {},
480
+ };
481
+
482
+ const core = createPluginDatabaseCore(
483
+ () => plugin,
484
+ async (storageUri) => {
485
+ if (!storageUri) return null;
486
+ const url = new URL(storageUri);
487
+ return `https://assets.example.com/${url.host}${url.pathname}`;
488
+ },
489
+ {
490
+ readStorageText: async (storageUri) =>
491
+ manifests.get(storageUri) ?? null,
492
+ },
493
+ );
494
+ const context: TestContext = {
495
+ env: {
496
+ assetHost: "https://assets.example.com",
497
+ },
498
+ request: new Request("https://updates.example.com"),
499
+ };
500
+
501
+ const updateInfo = await core.api.getAppUpdateInfo(
502
+ {
503
+ ...updateArgs,
504
+ bundleId: "00000000-0000-0000-0000-0000000000aa",
505
+ },
506
+ context,
507
+ );
508
+
509
+ expect(updateInfo).toMatchObject({
510
+ changedAssets: {
511
+ "image.png": {
512
+ file: {
513
+ url: "https://assets.example.com/bucket/target/files/image.png",
514
+ },
515
+ fileHash: "target-image-hash",
516
+ },
517
+ "index.ios.bundle": {
518
+ file: {
519
+ compression: "br",
520
+ url: "https://assets.example.com/bucket/target/files/index.ios.bundle.br",
521
+ },
522
+ fileHash: "target-bundle-hash",
523
+ },
524
+ },
525
+ id: targetBundle.id,
526
+ manifestFileHash: "sig:target-manifest",
527
+ manifestUrl: "https://assets.example.com/bucket/target/manifest.json",
528
+ });
529
+ expect(getBundleById).not.toHaveBeenCalled();
530
+ });
531
+
532
+ it("resolves manifest changed assets from deterministic content-addressed storage", async () => {
533
+ const currentBundle = {
534
+ ...baseBundle,
535
+ id: "00000000-0000-0000-0000-000000000001",
536
+ manifestStorageUri: "r2://bucket/current/manifest.json",
537
+ manifestFileHash: "sig:current-manifest",
538
+ assetBaseStorageUri: "r2://bucket/assets",
539
+ };
540
+ const targetBundle = {
541
+ ...baseBundle,
542
+ id: "00000000-0000-0000-0000-000000000002",
543
+ fileHash: "hash-2",
544
+ manifestStorageUri: "r2://bucket/target/manifest.json",
545
+ manifestFileHash: "sig:target-manifest",
546
+ assetBaseStorageUri: "r2://bucket/assets",
547
+ };
548
+ const manifests = new Map([
549
+ [
550
+ currentBundle.manifestStorageUri,
551
+ JSON.stringify({
552
+ bundleId: currentBundle.id,
553
+ assets: {
554
+ "index.ios.bundle": {
555
+ fileHash: "old-bundle-hash",
556
+ },
557
+ },
558
+ }),
559
+ ],
560
+ [
561
+ targetBundle.manifestStorageUri,
562
+ JSON.stringify({
563
+ bundleId: targetBundle.id,
564
+ assets: {
565
+ "index.ios.bundle": {
566
+ fileHash: "new-bundle-hash",
567
+ },
568
+ },
569
+ }),
570
+ ],
571
+ ]);
572
+ const plugin: DatabasePlugin<TestContext> = {
573
+ name: "content-addressed-manifest-plugin",
574
+ async appendBundle() {},
575
+ async commitBundle() {},
576
+ async deleteBundle() {},
577
+ async getBundleById(bundleId) {
578
+ if (bundleId === currentBundle.id) return currentBundle;
579
+ if (bundleId === targetBundle.id) return targetBundle;
580
+ return null;
581
+ },
582
+ async getUpdateInfo() {
583
+ return {
584
+ fileHash: targetBundle.fileHash,
585
+ id: targetBundle.id,
586
+ message: targetBundle.message,
587
+ shouldForceUpdate: targetBundle.shouldForceUpdate,
588
+ status: "UPDATE",
589
+ storageUri: targetBundle.storageUri,
590
+ };
591
+ },
592
+ async getBundles() {
593
+ return {
594
+ data: [targetBundle],
595
+ pagination: {
596
+ currentPage: 1,
597
+ hasNextPage: false,
598
+ hasPreviousPage: false,
599
+ total: 1,
600
+ totalPages: 1,
601
+ },
602
+ };
603
+ },
604
+ async getChannels() {
605
+ return ["production"];
606
+ },
607
+ async updateBundle() {},
608
+ };
609
+
610
+ const core = createPluginDatabaseCore(
611
+ () => plugin,
612
+ async (storageUri) => {
613
+ if (!storageUri) return null;
614
+ const url = new URL(storageUri);
615
+ return `https://assets.example.com/${url.host}${url.pathname}`;
616
+ },
617
+ {
618
+ readStorageText: async (storageUri) =>
619
+ manifests.get(storageUri) ?? null,
620
+ },
621
+ );
622
+
623
+ await expect(
624
+ core.api.getAppUpdateInfo({
625
+ ...updateArgs,
626
+ bundleId: currentBundle.id,
627
+ }),
628
+ ).resolves.toMatchObject({
629
+ changedAssets: {
630
+ "index.ios.bundle": {
631
+ file: {
632
+ compression: "br",
633
+ url: "https://assets.example.com/bucket/assets/sha256/ne/new-bundle-hash.br",
634
+ },
635
+ fileHash: "new-bundle-hash",
636
+ },
637
+ },
638
+ });
639
+ });
640
+
198
641
  it("falls back to archive metadata when manifest changed assets cannot be resolved", async () => {
199
642
  const currentBundle = {
200
643
  ...baseBundle,
@@ -13,10 +13,12 @@ import {
13
13
  type DatabaseBundleQueryOrder,
14
14
  type DatabaseBundleQueryWhere,
15
15
  type DatabasePlugin,
16
+ getRequestUpdateBundleSeeds,
16
17
  type HotUpdaterContext,
17
18
  semverSatisfies,
18
19
  } from "@hot-updater/plugin-core";
19
20
 
21
+ import { createRequestBundleIdentityMap } from "./requestBundleIdentityMap";
20
22
  import { assertBundlePersistenceConstraints } from "./schemaEnhancements";
21
23
  import type { DatabaseAPI } from "./types";
22
24
  import { resolveManifestArtifacts } from "./updateArtifacts";
@@ -347,9 +349,7 @@ export function createPluginDatabaseCore<TContext = unknown>(
347
349
  if (!info) {
348
350
  return null;
349
351
  }
350
- const { storageUri, ...rest } = info as UpdateInfo & {
351
- storageUri: string | null;
352
- };
352
+ const { storageUri, ...rest } = info;
353
353
 
354
354
  const readStorageText = options?.readStorageText;
355
355
  if (info.id === NIL_UUID || !readStorageText) {
@@ -358,12 +358,29 @@ export function createPluginDatabaseCore<TContext = unknown>(
358
358
  return baseResponse;
359
359
  }
360
360
 
361
+ const requestBundleSeeds = getRequestUpdateBundleSeeds(context);
362
+ const requestBundles = createRequestBundleIdentityMap({
363
+ context,
364
+ loadBundleById: (bundleId, requestContext) =>
365
+ getPlugin().getBundleById(bundleId, requestContext),
366
+ seeds: requestBundleSeeds,
367
+ });
368
+ const getCurrentBundle = () => {
369
+ if (args.bundleId === NIL_UUID) {
370
+ return null;
371
+ }
372
+
373
+ const seededCurrentBundle = requestBundles.peek(args.bundleId);
374
+ if (seededCurrentBundle || requestBundleSeeds.length > 0) {
375
+ return seededCurrentBundle;
376
+ }
377
+
378
+ return requestBundles.get(args.bundleId);
379
+ };
361
380
  const [fileUrl, targetBundle, currentBundle] = await Promise.all([
362
381
  resolveFileUrl(storageUri ?? null, context),
363
- getPlugin().getBundleById(info.id, context),
364
- args.bundleId !== NIL_UUID
365
- ? getPlugin().getBundleById(args.bundleId, context)
366
- : null,
382
+ requestBundles.get(info.id),
383
+ getCurrentBundle(),
367
384
  ]);
368
385
  const baseResponse: AppUpdateAvailableInfo = { ...rest, fileUrl };
369
386
  const manifestArtifacts = await resolveManifestArtifacts({
@@ -435,6 +452,45 @@ export function createPluginDatabaseCore<TContext = unknown>(
435
452
  },
436
453
  };
437
454
 
455
+ Object.defineProperty(api, "diagnostics", {
456
+ configurable: true,
457
+ enumerable: true,
458
+ get(this: DatabaseAPI<TContext>) {
459
+ const diagnostics = getPlugin().diagnostics;
460
+ if (!diagnostics) {
461
+ Object.defineProperty(this, "diagnostics", {
462
+ configurable: true,
463
+ enumerable: true,
464
+ value: undefined,
465
+ });
466
+ return undefined;
467
+ }
468
+
469
+ const wrappedDiagnostics: NonNullable<
470
+ DatabaseAPI<TContext>["diagnostics"]
471
+ > = {};
472
+ if (diagnostics.bundleIndex) {
473
+ wrappedDiagnostics.bundleIndex = {
474
+ check: (context?: HotUpdaterContext<TContext>) =>
475
+ getPlugin().diagnostics!.bundleIndex!.check(context),
476
+ ...(diagnostics.bundleIndex.repair
477
+ ? {
478
+ repair: (context?: HotUpdaterContext<TContext>) =>
479
+ getPlugin().diagnostics!.bundleIndex!.repair!(context),
480
+ }
481
+ : {}),
482
+ };
483
+ }
484
+
485
+ Object.defineProperty(this, "diagnostics", {
486
+ configurable: true,
487
+ enumerable: true,
488
+ value: wrappedDiagnostics,
489
+ });
490
+ return wrappedDiagnostics;
491
+ },
492
+ });
493
+
438
494
  return {
439
495
  api,
440
496
  adapterName: getPlugin().name,
@@ -0,0 +1,56 @@
1
+ import type { Bundle } from "@hot-updater/core";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { createRequestBundleIdentityMap } from "./requestBundleIdentityMap";
5
+
6
+ const baseBundle: Bundle = {
7
+ channel: "production",
8
+ enabled: true,
9
+ fileHash: "file-hash",
10
+ fingerprintHash: null,
11
+ gitCommitHash: null,
12
+ id: "00000000-0000-0000-0000-000000000001",
13
+ message: "bundle",
14
+ platform: "ios",
15
+ shouldForceUpdate: false,
16
+ storageUri: "s3://bucket/bundle.zip",
17
+ targetAppVersion: "1.0.0",
18
+ };
19
+
20
+ describe("createRequestBundleIdentityMap", () => {
21
+ it("returns seeded bundles without loading them again", async () => {
22
+ const loadBundleById = vi.fn(async () => null);
23
+ const identityMap = createRequestBundleIdentityMap({
24
+ loadBundleById,
25
+ seeds: [baseBundle],
26
+ });
27
+
28
+ await expect(identityMap.get(baseBundle.id)).resolves.toBe(baseBundle);
29
+ expect(loadBundleById).not.toHaveBeenCalled();
30
+ });
31
+
32
+ it("shares one fallback lookup for repeated bundle reads", async () => {
33
+ const loadedBundle: Bundle = {
34
+ ...baseBundle,
35
+ id: "00000000-0000-0000-0000-000000000002",
36
+ message: "loaded",
37
+ };
38
+ const loadBundleById = vi.fn(async () => loadedBundle);
39
+ const identityMap = createRequestBundleIdentityMap({
40
+ loadBundleById,
41
+ seeds: [],
42
+ });
43
+
44
+ const [first, second] = await Promise.all([
45
+ identityMap.get(loadedBundle.id),
46
+ identityMap.get(loadedBundle.id),
47
+ ]);
48
+ const third = await identityMap.get(loadedBundle.id);
49
+
50
+ expect(first).toBe(loadedBundle);
51
+ expect(second).toBe(loadedBundle);
52
+ expect(third).toBe(loadedBundle);
53
+ expect(loadBundleById).toHaveBeenCalledTimes(1);
54
+ expect(loadBundleById).toHaveBeenCalledWith(loadedBundle.id, undefined);
55
+ });
56
+ });
@@ -0,0 +1,61 @@
1
+ import type { Bundle } from "@hot-updater/core";
2
+ import type { HotUpdaterContext } from "@hot-updater/plugin-core";
3
+
4
+ type LoadBundleById<TContext> = (
5
+ bundleId: string,
6
+ context?: HotUpdaterContext<TContext>,
7
+ ) => Promise<Bundle | null>;
8
+
9
+ type RequestBundleIdentityMapOptions<TContext> = {
10
+ readonly context?: HotUpdaterContext<TContext>;
11
+ readonly loadBundleById: LoadBundleById<TContext>;
12
+ readonly seeds: readonly (Bundle | null | undefined)[];
13
+ };
14
+
15
+ export const createRequestBundleIdentityMap = <TContext = unknown>({
16
+ context,
17
+ loadBundleById,
18
+ seeds,
19
+ }: RequestBundleIdentityMapOptions<TContext>) => {
20
+ const bundles = new Map<string, Bundle>();
21
+ const pendingBundles = new Map<string, Promise<Bundle | null>>();
22
+
23
+ for (const seed of seeds) {
24
+ if (seed) {
25
+ bundles.set(seed.id, seed);
26
+ }
27
+ }
28
+
29
+ const get = async (bundleId: string): Promise<Bundle | null> => {
30
+ const cachedBundle = bundles.get(bundleId);
31
+ if (cachedBundle) {
32
+ return cachedBundle;
33
+ }
34
+
35
+ const pendingBundle = pendingBundles.get(bundleId);
36
+ if (pendingBundle) {
37
+ return pendingBundle;
38
+ }
39
+
40
+ const lookup = loadBundleById(bundleId, context).then(
41
+ (bundle) => {
42
+ pendingBundles.delete(bundleId);
43
+ if (bundle) {
44
+ bundles.set(bundle.id, bundle);
45
+ }
46
+ return bundle;
47
+ },
48
+ (error: unknown) => {
49
+ pendingBundles.delete(bundleId);
50
+ throw error;
51
+ },
52
+ );
53
+ pendingBundles.set(bundleId, lookup);
54
+ return lookup;
55
+ };
56
+
57
+ const peek = (bundleId: string): Bundle | null =>
58
+ bundles.get(bundleId) ?? null;
59
+
60
+ return { get, peek };
61
+ };
package/src/db/types.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  UpdateInfo,
6
6
  } from "@hot-updater/core";
7
7
  import type {
8
+ DatabaseDiagnostics,
8
9
  DatabaseBundleQueryOptions,
9
10
  DatabasePlugin,
10
11
  HotUpdaterContext,
@@ -108,6 +109,7 @@ export interface DatabaseAPI<TContext = unknown> {
108
109
  bundleId: string,
109
110
  context?: HotUpdaterContext<TContext>,
110
111
  ): Promise<void>;
112
+ diagnostics?: DatabaseDiagnostics<TContext>;
111
113
  }
112
114
 
113
115
  export type StoragePluginFactory<TContext = unknown> =