@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.
- package/dist/_virtual/_rolldown/runtime.cjs +1 -1
- package/dist/_virtual/_rolldown/runtime.mjs +1 -1
- package/dist/db/createBundleDiff.cjs +19 -13
- package/dist/db/createBundleDiff.mjs +15 -9
- package/dist/db/index.cjs +7 -10
- package/dist/db/index.mjs +7 -10
- package/dist/db/pluginCore.cjs +136 -96
- package/dist/db/pluginCore.mjs +137 -97
- package/dist/db/requestBundleIdentityMap.cjs +29 -0
- package/dist/db/requestBundleIdentityMap.mjs +29 -0
- package/dist/db/schemaEnhancements.cjs +1 -1
- package/dist/db/types.d.cts +2 -1
- package/dist/db/types.d.mts +2 -1
- package/dist/db/updateArtifacts.cjs +6 -6
- package/dist/db/updateArtifacts.mjs +6 -6
- package/dist/handler.cjs +18 -8
- package/dist/handler.d.cts +9 -10
- package/dist/handler.d.mts +9 -10
- package/dist/handler.mjs +17 -7
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- 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
- 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
- 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
- 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
- package/dist/packages/server/package.cjs +1 -1
- package/dist/packages/server/package.mjs +1 -1
- package/dist/runtime.cjs +10 -12
- package/dist/runtime.mjs +10 -12
- package/package.json +7 -7
- package/src/db/createBundleDiff.spec.ts +3 -0
- package/src/db/createBundleDiff.ts +27 -21
- package/src/db/index.spec.ts +36 -0
- package/src/db/index.ts +6 -10
- package/src/db/pluginCore.spec.ts +443 -0
- package/src/db/pluginCore.ts +63 -7
- package/src/db/requestBundleIdentityMap.spec.ts +56 -0
- package/src/db/requestBundleIdentityMap.ts +61 -0
- package/src/db/types.ts +2 -0
- package/src/db/updateArtifacts.ts +8 -19
- package/src/handler-standalone.integration.spec.ts +12 -0
- package/src/handler.spec.ts +117 -19
- package/src/handler.ts +47 -21
- package/src/runtime.spec.ts +46 -4
- 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,
|
package/src/db/pluginCore.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
364
|
-
|
|
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> =
|