@hot-updater/server 0.32.0 → 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.
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.32.0";
2
+ var version = "0.33.0";
3
3
  //#endregion
4
4
  Object.defineProperty(exports, "version", {
5
5
  enumerable: true,
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.32.0";
2
+ var version = "0.33.0";
3
3
  //#endregion
4
4
  export { version };
package/dist/runtime.cjs CHANGED
@@ -22,23 +22,21 @@ function createHotUpdater(options) {
22
22
  createMutationPlugin: () => database(),
23
23
  readStorageText
24
24
  } : { readStorageText });
25
- const api = {
26
- ...core.api,
27
- handler: require_handler.createHandler(core.api, {
28
- basePath,
29
- routes: options.routes
30
- }),
31
- adapterName: core.adapterName
32
- };
25
+ const internalHandler = require_handler.createHandler(core.api, {
26
+ basePath,
27
+ routes: options.routes
28
+ });
33
29
  const handler = (request, context, ...extraArgs) => {
34
- if (extraArgs.length > 0) return api.handler(request);
35
- return api.handler(request, context);
30
+ if (extraArgs.length > 0) return internalHandler(request);
31
+ return internalHandler(request, context);
36
32
  };
37
- return {
38
- ...api,
33
+ const api = {
39
34
  basePath,
35
+ adapterName: core.adapterName,
40
36
  handler
41
37
  };
38
+ Object.defineProperties(api, Object.getOwnPropertyDescriptors(core.api));
39
+ return api;
42
40
  }
43
41
  //#endregion
44
42
  exports.HOT_UPDATER_SERVER_VERSION = require_version.HOT_UPDATER_SERVER_VERSION;
package/dist/runtime.mjs CHANGED
@@ -20,23 +20,21 @@ function createHotUpdater(options) {
20
20
  createMutationPlugin: () => database(),
21
21
  readStorageText
22
22
  } : { readStorageText });
23
- const api = {
24
- ...core.api,
25
- handler: createHandler(core.api, {
26
- basePath,
27
- routes: options.routes
28
- }),
29
- adapterName: core.adapterName
30
- };
23
+ const internalHandler = createHandler(core.api, {
24
+ basePath,
25
+ routes: options.routes
26
+ });
31
27
  const handler = (request, context, ...extraArgs) => {
32
- if (extraArgs.length > 0) return api.handler(request);
33
- return api.handler(request, context);
28
+ if (extraArgs.length > 0) return internalHandler(request);
29
+ return internalHandler(request, context);
34
30
  };
35
- return {
36
- ...api,
31
+ const api = {
37
32
  basePath,
33
+ adapterName: core.adapterName,
38
34
  handler
39
35
  };
36
+ Object.defineProperties(api, Object.getOwnPropertyDescriptors(core.api));
37
+ return api;
40
38
  }
41
39
  //#endregion
42
40
  export { HOT_UPDATER_SERVER_VERSION, createHandler, createHotUpdater };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/server",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "sideEffects": false,
@@ -55,10 +55,10 @@
55
55
  "mongodb": "6.20.0",
56
56
  "rou3": "0.7.9",
57
57
  "semver": "^7.7.2",
58
- "@hot-updater/bsdiff": "0.32.0",
59
- "@hot-updater/core": "0.32.0",
60
- "@hot-updater/plugin-core": "0.32.0",
61
- "@hot-updater/js": "0.32.0"
58
+ "@hot-updater/core": "0.33.0",
59
+ "@hot-updater/bsdiff": "0.33.0",
60
+ "@hot-updater/plugin-core": "0.33.0",
61
+ "@hot-updater/js": "0.33.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@electric-sql/pglite": "0.2.17",
@@ -68,8 +68,8 @@
68
68
  "kysely-pglite-dialect": "1.2.0",
69
69
  "msw": "^2.7.0",
70
70
  "uuidv7": "^1.0.2",
71
- "@hot-updater/standalone": "0.32.0",
72
- "@hot-updater/test-utils": "0.32.0"
71
+ "@hot-updater/standalone": "0.33.0",
72
+ "@hot-updater/test-utils": "0.33.0"
73
73
  },
74
74
  "inlinedDependencies": {
75
75
  "@noble/hashes": "1.8.0",
@@ -866,6 +866,42 @@ describe("server/db hotUpdater getUpdateInfo (PGlite + Kysely)", async () => {
866
866
  });
867
867
 
868
868
  describe("database plugin factories", () => {
869
+ it("keeps optional maintenance capabilities lazy", () => {
870
+ const factory = vi.fn(() => ({
871
+ async getBundleById() {
872
+ return null;
873
+ },
874
+ async getBundles() {
875
+ return {
876
+ data: [],
877
+ pagination: {
878
+ hasNextPage: false,
879
+ hasPreviousPage: false,
880
+ currentPage: 1,
881
+ totalPages: 1,
882
+ total: 0,
883
+ },
884
+ };
885
+ },
886
+ async getChannels() {
887
+ return [];
888
+ },
889
+ async commitBundle() {},
890
+ }));
891
+ const hotUpdater = createHotUpdater({
892
+ database: createDatabasePlugin({
893
+ name: "lazyPlugin",
894
+ factory,
895
+ })({}),
896
+ });
897
+
898
+ expect(factory).not.toHaveBeenCalled();
899
+ expect(hotUpdater.diagnostics).toBeUndefined();
900
+ expect(factory).not.toHaveBeenCalled();
901
+ expect(hotUpdater.diagnostics).toBeUndefined();
902
+ expect(factory).not.toHaveBeenCalled();
903
+ });
904
+
869
905
  it("isolates pending mutation state between overlapping writes", async () => {
870
906
  const committedBundleIds: string[][] = [];
871
907
  const onUnmount = vi.fn(async () => undefined);
package/src/db/index.ts CHANGED
@@ -104,19 +104,15 @@ export function createHotUpdater<TContext = unknown>(
104
104
  });
105
105
 
106
106
  const api = {
107
- ...core.api,
107
+ basePath,
108
+ adapterName: core.adapterName,
109
+ createMigrator: core.createMigrator,
110
+ generateSchema: core.generateSchema,
108
111
  handler: createHandler(core.api, {
109
112
  basePath,
110
113
  routes: options.routes,
111
114
  }),
112
- adapterName: core.adapterName,
113
- createMigrator: core.createMigrator,
114
- generateSchema: core.generateSchema,
115
- };
116
-
117
- return {
118
- ...api,
119
- basePath,
120
- handler: api.handler,
121
115
  };
116
+ Object.defineProperties(api, Object.getOwnPropertyDescriptors(core.api));
117
+ return api as HotUpdaterAPI<TContext>;
122
118
  }
@@ -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,339 @@ 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
+
198
532
  it("resolves manifest changed assets from deterministic content-addressed storage", async () => {
199
533
  const currentBundle = {
200
534
  ...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
+ });