@hot-updater/test-utils 1.0.0-rc.16 → 1.0.0-rc.21
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/README.md +104 -17
- package/dist/index.d.mts +236 -158
- package/dist/index.mjs +3051 -1704
- package/dist/node.cjs +210 -0
- package/dist/node.d.cts +49 -4
- package/dist/node.d.mts +49 -4
- package/dist/node.mjs +203 -1
- package/dist/react-native.cjs +177 -0
- package/dist/react-native.d.cts +104 -0
- package/dist/react-native.d.mts +104 -0
- package/dist/react-native.mjs +174 -0
- package/package.json +44 -6
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { NIL_UUID, createReleaseCatalogScopeKey, encodeChannelKey, selectDesiredRelease } from "@hot-updater/
|
|
3
|
-
import { DatabaseAdapterContractError, createMemoryAdapter, findPhysicalIndex,
|
|
2
|
+
import { NIL_UUID, createReleaseCatalogScopeKey, encodeChannelKey, selectDesiredRelease } from "@hot-updater/protocol";
|
|
3
|
+
import { DATABASE_VERSION_COLUMN, DatabaseAdapterContractError, SETTINGS_TABLE, addDistinct, aggregateBatchingTables, assertStorageOperations, bundleToPatchRows, bundleToRow, compareUtf8, coreSchema, countDistinct, createBundleStorageKey, createEngine, createMemoryAdapter, createStorageKeyBuilder, createStorageRootUriWithPath, createStorageUri, createStorageUriWithRelativePath, findPhysicalIndex, getBundleArchiveStorageUri, getContentAddressedAssetStoragePath, indexOrderColumns, meterReads, parseStorageDownloadPath, parseStorageUri, targetBaseCandidateKey, toolingTargetOf, verifyAdapter } from "@hot-updater/plugin-core";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
//#region src/adminApiTestClient.ts
|
|
6
6
|
const jsonRequest = (method, body) => ({
|
|
@@ -80,9 +80,18 @@ const createAdminApiTestClient = (getClient) => {
|
|
|
80
80
|
};
|
|
81
81
|
//#endregion
|
|
82
82
|
//#region src/databaseTestFixtures.ts
|
|
83
|
-
const fixtureId = (suffix) => `00000000-0000-7000-8000-${suffix.padStart(12, "0")}`;
|
|
83
|
+
const fixtureId$1 = (suffix) => `00000000-0000-7000-8000-${suffix.padStart(12, "0")}`;
|
|
84
|
+
const channelFixtureSuffix = (name) => {
|
|
85
|
+
let hash = 0;
|
|
86
|
+
for (const character of name) hash = (hash * 31 + character.codePointAt(0)) % 0xe8d4a51000;
|
|
87
|
+
return String(hash);
|
|
88
|
+
};
|
|
89
|
+
const createChannelRowFixture = (name = "production") => ({
|
|
90
|
+
id: fixtureId$1(channelFixtureSuffix(name)),
|
|
91
|
+
name
|
|
92
|
+
});
|
|
84
93
|
const createBundleRowFixture = (suffix, _channel = "production") => ({
|
|
85
|
-
id: fixtureId(suffix),
|
|
94
|
+
id: fixtureId$1(suffix),
|
|
86
95
|
platform: "ios",
|
|
87
96
|
git_commit_hash: null,
|
|
88
97
|
metadata: { app_version: suffix },
|
|
@@ -100,29 +109,29 @@ const createBundlePatchRowFixture = (suffix, bundleId, baseBundleId, orderIndex
|
|
|
100
109
|
byte_size: 3000000002,
|
|
101
110
|
order_index: orderIndex
|
|
102
111
|
});
|
|
103
|
-
const
|
|
104
|
-
id: fixtureId(suffix),
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
112
|
+
const createReleaseRowFixture = (suffix, bundle, channel) => ({
|
|
113
|
+
id: fixtureId$1(`${Number(suffix) + 5e3}`),
|
|
114
|
+
scope_key: `v1:app-version:ios:${channel.name}`,
|
|
115
|
+
channel_id: channel.id,
|
|
116
|
+
platform: bundle.platform,
|
|
117
|
+
kind: "BUNDLE",
|
|
118
|
+
bundle_id: bundle.id,
|
|
119
|
+
strategy: "APP_VERSION",
|
|
120
|
+
target_app_version: "1.0.0",
|
|
121
|
+
fingerprint_hash: null,
|
|
122
|
+
enabled: true,
|
|
123
|
+
should_force_update: false,
|
|
124
|
+
message: `release-${suffix}`,
|
|
125
|
+
rollout_cohort_count: 1e3,
|
|
126
|
+
target_cohorts: [],
|
|
127
|
+
operation: "DEPLOY",
|
|
128
|
+
source_release_id: null,
|
|
129
|
+
revision: 1,
|
|
130
|
+
created_at_ms: Number(suffix),
|
|
131
|
+
updated_at_ms: Number(suffix)
|
|
123
132
|
});
|
|
124
133
|
const createBundleFixture = (suffix, _channel = "production") => ({
|
|
125
|
-
id: fixtureId(suffix),
|
|
134
|
+
id: fixtureId$1(suffix),
|
|
126
135
|
platform: "ios",
|
|
127
136
|
gitCommitHash: null,
|
|
128
137
|
metadata: { app_version: suffix },
|
|
@@ -374,1175 +383,481 @@ const setupCoreAdminTestSuite = (options) => {
|
|
|
374
383
|
});
|
|
375
384
|
};
|
|
376
385
|
//#endregion
|
|
377
|
-
//#region src/
|
|
378
|
-
/** Allow committed secondary indexes to catch up; query failures still fail. */
|
|
379
|
-
const expectInsightsIndex = async (query, expected) => {
|
|
380
|
-
const deadline = Date.now() + 5e3;
|
|
381
|
-
for (;;) {
|
|
382
|
-
const actual = await query();
|
|
383
|
-
try {
|
|
384
|
-
expect(actual).toEqual(expected);
|
|
385
|
-
return;
|
|
386
|
-
} catch (error) {
|
|
387
|
-
if (Date.now() >= deadline) throw error;
|
|
388
|
-
}
|
|
389
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
390
|
-
}
|
|
391
|
-
};
|
|
392
|
-
//#endregion
|
|
393
|
-
//#region src/setupInsightsHttpTestSuite.ts
|
|
386
|
+
//#region src/setupSemverSatisfiesTestSuite.ts
|
|
394
387
|
/**
|
|
395
|
-
*
|
|
396
|
-
*
|
|
388
|
+
*
|
|
389
|
+
* Filters based on semver. And sorts by the highest bundle version.
|
|
390
|
+
*
|
|
391
|
+
* * Range Expression Table:
|
|
392
|
+
*
|
|
393
|
+
* | Range Expression | Who gets the update |
|
|
394
|
+
* |------------------|------------------------------------------------------------------------|
|
|
395
|
+
* | 1.2.3 | Only devices running the specific binary app store version 1.2.3 of your app |
|
|
396
|
+
* | * | Any device configured to consume updates from your CodePush app |
|
|
397
|
+
* | 1.2.x | Devices running major version 1, minor version 2 and any patch version of your app |
|
|
398
|
+
* | 1.2.3 - 1.2.7 | Devices running any binary version between 1.2.3 (inclusive) and 1.2.7 (inclusive) |
|
|
399
|
+
* | >=1.2.3 <1.2.7 | Devices running any binary version between 1.2.3 (inclusive) and 1.2.7 (exclusive) |
|
|
400
|
+
* | 1.2 | Equivalent to >=1.2.0 <1.3.0 |
|
|
401
|
+
* | ~1.2.3 | Equivalent to >=1.2.3 <1.3.0 |
|
|
402
|
+
* | ^1.2.3 | Equivalent to >=1.2.3 <2.0.0 |
|
|
397
403
|
*/
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const client = options.getClient();
|
|
402
|
-
const installId = `install-${crypto.randomUUID()}`;
|
|
403
|
-
const userId = `user-${crypto.randomUUID()}`;
|
|
404
|
-
const channel = `insights-${crypto.randomUUID()}`;
|
|
405
|
-
const event = {
|
|
406
|
-
appVersion: "1.0.0",
|
|
407
|
-
channel,
|
|
408
|
-
cohort: "default",
|
|
409
|
-
fingerprintHash: null,
|
|
410
|
-
fromBundleId: null,
|
|
411
|
-
fromReleaseId: null,
|
|
412
|
-
installId,
|
|
413
|
-
platform: "ios",
|
|
414
|
-
toBundleId: "00000000-0000-7000-8000-000000000001",
|
|
415
|
-
toReleaseId: null,
|
|
416
|
-
type: "UNCHANGED",
|
|
417
|
-
updateStrategy: null,
|
|
418
|
-
userId,
|
|
419
|
-
username: "Jane",
|
|
420
|
-
sdkVersion: "2.0.0"
|
|
421
|
-
};
|
|
422
|
-
const reported = await client.client("/events", jsonRequest("POST", event));
|
|
423
|
-
expect(reported.status).toBe(204);
|
|
424
|
-
expect(reported.headers.get("x-hot-updater-insights")).toBeNull();
|
|
425
|
-
await reported.text();
|
|
426
|
-
const installation = await client.admin(`/installations/${encodeURIComponent(installId)}`);
|
|
427
|
-
expect(installation.status).toBe(200);
|
|
428
|
-
expect(await installation.json()).toMatchObject({
|
|
429
|
-
installId,
|
|
430
|
-
latestStatus: "UNCHANGED",
|
|
431
|
-
lastKnownBundleId: event.toBundleId,
|
|
432
|
-
channel
|
|
433
|
-
});
|
|
434
|
-
await expectInsightsIndex(async () => {
|
|
435
|
-
return (await (await client.admin(`/installations?userId=${encodeURIComponent(userId)}`)).json()).data.map(({ installId: id }) => id);
|
|
436
|
-
}, [installId]);
|
|
437
|
-
await expectInsightsIndex(async () => {
|
|
438
|
-
return (await (await client.admin(`/overview?platform=ios&channel=${encodeURIComponent(channel)}&window=24h`)).json()).reportingInstallations.count;
|
|
439
|
-
}, 1);
|
|
440
|
-
});
|
|
441
|
-
it("refuses a malformed event", async () => {
|
|
442
|
-
const response = await options.getClient().client("/events", jsonRequest("POST", { type: "UNCHANGED" }));
|
|
443
|
-
expect(response.status).toBe(400);
|
|
444
|
-
await response.text();
|
|
445
|
-
});
|
|
404
|
+
const setupSemverSatisfiesTestSuite = ({ semverSatisfies }) => {
|
|
405
|
+
it("version 1.2.3 should satisfy version 1.2.3", async () => {
|
|
406
|
+
expect(await semverSatisfies("1.2.3", "1.2.3")).toBe(true);
|
|
446
407
|
});
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
//#region src/databaseTestRunner.ts
|
|
450
|
-
var DatabaseUnavailableError = class extends Error {
|
|
451
|
-
constructor() {
|
|
452
|
-
super("The database is unavailable outside the test lifecycle");
|
|
453
|
-
this.name = "DatabaseUnavailableError";
|
|
454
|
-
}
|
|
455
|
-
};
|
|
456
|
-
const setupDatabaseTestRunner = (lifecycle, registerTests) => {
|
|
457
|
-
describe(lifecycle.name, () => {
|
|
458
|
-
let database;
|
|
459
|
-
const getDatabase = () => {
|
|
460
|
-
if (database === void 0) throw new DatabaseUnavailableError();
|
|
461
|
-
return database;
|
|
462
|
-
};
|
|
463
|
-
beforeAll(async () => {
|
|
464
|
-
await lifecycle.migrate();
|
|
465
|
-
database = await lifecycle.createDatabase();
|
|
466
|
-
});
|
|
467
|
-
beforeEach(async () => {
|
|
468
|
-
await lifecycle.reset(getDatabase());
|
|
469
|
-
});
|
|
470
|
-
afterAll(async () => {
|
|
471
|
-
await lifecycle.dispose(getDatabase());
|
|
472
|
-
database = void 0;
|
|
473
|
-
});
|
|
474
|
-
registerTests({ getDatabase });
|
|
408
|
+
it("version 1.2.4 should not satisfy version 1.2.3", async () => {
|
|
409
|
+
expect(await semverSatisfies("1.2.3", "1.2.4")).toBe(false);
|
|
475
410
|
});
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
411
|
+
it("version 1.2.4 should not satisfy version 1.2.3", async () => {
|
|
412
|
+
expect(await semverSatisfies("1.2.3", "1.2.4")).toBe(false);
|
|
413
|
+
});
|
|
414
|
+
it("version 1.2.4 should not satisfy version 1.2.3", async () => {
|
|
415
|
+
expect(await semverSatisfies("1.2.3", "1.2.4")).toBe(false);
|
|
416
|
+
});
|
|
417
|
+
it("1.x.x should satisfy version 1.0", async () => {
|
|
418
|
+
expect(await semverSatisfies("1.x.x", "1.0")).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
it("1.x.x should satisfy version 1.12", async () => {
|
|
421
|
+
expect(await semverSatisfies("1.x.x", "1.12")).toBe(true);
|
|
422
|
+
});
|
|
423
|
+
it("1.x.x should satisfy version 1.0.0", async () => {
|
|
424
|
+
expect(await semverSatisfies("1.x.x", "1.0.0")).toBe(true);
|
|
425
|
+
});
|
|
426
|
+
it("1.x.x should satisfy version 1.2.3", async () => {
|
|
427
|
+
expect(await semverSatisfies("1.x.x", "1.2.3")).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
it("1.x.x should not satisfy version 2.0.0", async () => {
|
|
430
|
+
expect(await semverSatisfies("1.x.x", "2.0.0")).toBe(false);
|
|
431
|
+
});
|
|
432
|
+
it("1.x.x should not satisfy version 2.0.0", async () => {
|
|
433
|
+
expect(await semverSatisfies("1.x.x", "2.0.0")).toBe(false);
|
|
434
|
+
});
|
|
435
|
+
it("1.2.x should satisfy version 1.2.5", async () => {
|
|
436
|
+
expect(await semverSatisfies("1.2.x", "1.2.5")).toBe(true);
|
|
437
|
+
});
|
|
438
|
+
it("1.2.x should not satisfy version 1.3.0", async () => {
|
|
439
|
+
expect(await semverSatisfies("1.2.x", "1.3.0")).toBe(false);
|
|
440
|
+
});
|
|
441
|
+
it("1.2.x should not satisfy version 1.3.0", async () => {
|
|
442
|
+
expect(await semverSatisfies("1.2.x", "1.3.0")).toBe(false);
|
|
443
|
+
});
|
|
444
|
+
it("range 1.2.3-1.2.7 should satisfy version 1.2.5", async () => {
|
|
445
|
+
expect(await semverSatisfies("1.2.3 - 1.2.7", "1.2.5")).toBe(true);
|
|
446
|
+
});
|
|
447
|
+
it("range 1.2.3-1.2.7 should satisfy version 1.2.5", async () => {
|
|
448
|
+
expect(await semverSatisfies("1.2.3 - 1.2.7", "1.2.5")).toBe(true);
|
|
449
|
+
});
|
|
450
|
+
it("range 1.2.3-1.2.7 should not satisfy version 1.3.0", async () => {
|
|
451
|
+
expect(await semverSatisfies("1.2.3 - 1.2.7", "1.3.0")).toBe(false);
|
|
452
|
+
});
|
|
453
|
+
it("range >=1.2.3 <1.2.7 should satisfy version 1.2.5", async () => {
|
|
454
|
+
expect(await semverSatisfies(">=1.2.3 <1.2.7", "1.2.5")).toBe(true);
|
|
455
|
+
});
|
|
456
|
+
it("range >=1.2.3 <1.2.7 should satisfy version 1.2.5", async () => {
|
|
457
|
+
expect(await semverSatisfies(">=1.2.3 <1.2.7", "1.2.5")).toBe(true);
|
|
458
|
+
});
|
|
459
|
+
it("range >=1.2.3 <1.2.7 should not satisfy version 1.2.7", async () => {
|
|
460
|
+
expect(await semverSatisfies(">=1.2.3 <1.2.7", "1.2.7")).toBe(false);
|
|
461
|
+
});
|
|
462
|
+
it("~1.2.3 should satisfy version 1.2.3", async () => {
|
|
463
|
+
expect(await semverSatisfies("~1.2.3", "1.2.3")).toBe(true);
|
|
464
|
+
});
|
|
465
|
+
it("~1.2.3 should satisfy version 1.2.4", async () => {
|
|
466
|
+
expect(await semverSatisfies("~1.2.3", "1.2.4")).toBe(true);
|
|
467
|
+
});
|
|
468
|
+
it("~1.2.3 should not satisfy version 1.3.0", async () => {
|
|
469
|
+
expect(await semverSatisfies("~1.2.3", "1.3.0")).toBe(false);
|
|
470
|
+
});
|
|
471
|
+
it("~1.2.3 should satisfy version 1.2.3", async () => {
|
|
472
|
+
expect(await semverSatisfies("~1.2.3", "1.2.3")).toBe(true);
|
|
473
|
+
});
|
|
474
|
+
it("~1.2.3 should satisfy version 1.2.4", async () => {
|
|
475
|
+
expect(await semverSatisfies("~1.2.3", "1.2.4")).toBe(true);
|
|
476
|
+
});
|
|
477
|
+
it("~1.2.3 should not satisfy version 1.3.0", async () => {
|
|
478
|
+
expect(await semverSatisfies("~1.2.3", "1.3.0")).toBe(false);
|
|
479
|
+
});
|
|
480
|
+
it("^1.2.3 should satisfy version 1.3.0", async () => {
|
|
481
|
+
expect(await semverSatisfies("^1.2.3", "1.3.0")).toBe(true);
|
|
482
|
+
});
|
|
483
|
+
it("^1.2.3 should satisfy version 1.3.0", async () => {
|
|
484
|
+
expect(await semverSatisfies("^1.2.3", "1.3.0")).toBe(true);
|
|
485
|
+
});
|
|
486
|
+
it("^1.2.3 should not satisfy version 2.0.0", async () => {
|
|
487
|
+
expect(await semverSatisfies("^1.2.3", "2.0.0")).toBe(false);
|
|
488
|
+
});
|
|
489
|
+
it("version 1.2 should satisfy version 1.2.0 (equivalent to >=1.2.0 <1.3.0)", async () => {
|
|
490
|
+
expect(await semverSatisfies("1.2", "1.2.0")).toBe(true);
|
|
491
|
+
});
|
|
492
|
+
it("version 1.2 should satisfy version 1.2.99 (equivalent to >=1.2.0 <1.3.0)", async () => {
|
|
493
|
+
expect(await semverSatisfies("1.2", "1.2.99")).toBe(true);
|
|
494
|
+
});
|
|
495
|
+
it("version 1.2 should not satisfy version 1.3.0", async () => {
|
|
496
|
+
expect(await semverSatisfies("1.2", "1.3.0")).toBe(false);
|
|
497
|
+
});
|
|
498
|
+
it("single-major '1' should satisfy version 1.0.0 (equivalent to >=1.0.0 <2.0.0)", async () => {
|
|
499
|
+
expect(await semverSatisfies("1", "1.0.0")).toBe(true);
|
|
500
|
+
});
|
|
501
|
+
it("single-major '1' should satisfy version 1.99.99", async () => {
|
|
502
|
+
expect(await semverSatisfies("1", "1.99.99")).toBe(true);
|
|
503
|
+
});
|
|
504
|
+
it("single-major '1' should not satisfy version 2.0.0", async () => {
|
|
505
|
+
expect(await semverSatisfies("1", "2.0.0")).toBe(false);
|
|
506
|
+
});
|
|
507
|
+
it("0.x.x should satisfy version 0.5.10", async () => {
|
|
508
|
+
expect(await semverSatisfies("0.x.x", "0.5.10")).toBe(true);
|
|
509
|
+
});
|
|
510
|
+
it("0.x.x should not satisfy version 1.0.0", async () => {
|
|
511
|
+
expect(await semverSatisfies("0.x.x", "1.0.0")).toBe(false);
|
|
512
|
+
});
|
|
513
|
+
it("2.x should satisfy version 2.0.0", async () => {
|
|
514
|
+
expect(await semverSatisfies("2.x", "2.0.0")).toBe(true);
|
|
515
|
+
});
|
|
516
|
+
it("2.x should satisfy version 2.10.1", async () => {
|
|
517
|
+
expect(await semverSatisfies("2.x", "2.10.1")).toBe(true);
|
|
518
|
+
});
|
|
519
|
+
it("2.x should not satisfy version 3.0.0", async () => {
|
|
520
|
+
expect(await semverSatisfies("2.x", "3.0.0")).toBe(false);
|
|
521
|
+
});
|
|
522
|
+
it("simple star * should satisfy any version, e.g. 10.11.12", async () => {
|
|
523
|
+
expect(await semverSatisfies("*", "10.11.12")).toBe(true);
|
|
524
|
+
});
|
|
525
|
+
it("simple star * should satisfy any version, e.g. 0.0.1", async () => {
|
|
526
|
+
expect(await semverSatisfies("*", "0.0.1")).toBe(true);
|
|
527
|
+
});
|
|
528
|
+
it("2.x.x should satisfy version 2.0.0", async () => {
|
|
529
|
+
expect(await semverSatisfies("2.x.x", "2.0.0")).toBe(true);
|
|
530
|
+
});
|
|
531
|
+
it("2.x.x should satisfy version 2.9.9", async () => {
|
|
532
|
+
expect(await semverSatisfies("2.x.x", "2.9.9")).toBe(true);
|
|
533
|
+
});
|
|
534
|
+
it("2.x.x should not satisfy version 3.0.0", async () => {
|
|
535
|
+
expect(await semverSatisfies("2.x.x", "3.0.0")).toBe(false);
|
|
536
|
+
});
|
|
537
|
+
it("0.x.x should satisfy version 0.1.0", async () => {
|
|
538
|
+
expect(await semverSatisfies("0.x.x", "0.1.0")).toBe(true);
|
|
539
|
+
});
|
|
540
|
+
it("0.x.x should satisfy version 0.9.9", async () => {
|
|
541
|
+
expect(await semverSatisfies("0.x.x", "0.9.9")).toBe(true);
|
|
542
|
+
});
|
|
543
|
+
it("0.x.x should not satisfy version 1.0.0", async () => {
|
|
544
|
+
expect(await semverSatisfies("0.x.x", "1.0.0")).toBe(false);
|
|
545
|
+
});
|
|
546
|
+
it("range 1.2.3 - 2.0.0 should satisfy version 1.2.4", async () => {
|
|
547
|
+
expect(await semverSatisfies("1.2.3 - 2.0.0", "1.2.4")).toBe(true);
|
|
548
|
+
});
|
|
549
|
+
it("range 1.2.3 - 2.0.0 should satisfy version 2.0.0", async () => {
|
|
550
|
+
expect(await semverSatisfies("1.2.3 - 2.0.0", "2.0.0")).toBe(true);
|
|
551
|
+
});
|
|
552
|
+
it("range 1.2.3 - 2.0.0 should satisfy version 1.5.0", async () => {
|
|
553
|
+
expect(await semverSatisfies("1.2.3 - 2.0.0", "1.5.0")).toBe(true);
|
|
554
|
+
});
|
|
555
|
+
it("range 1.2.3 - 2.0.0 should not satisfy version 2.0.1", async () => {
|
|
556
|
+
expect(await semverSatisfies("1.2.3 - 2.0.0", "2.0.1")).toBe(false);
|
|
557
|
+
});
|
|
558
|
+
it("range >= 5.7.0 <= 5.7.4 should satisfy version 5.7.3", async () => {
|
|
559
|
+
expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.3")).toBe(true);
|
|
560
|
+
});
|
|
561
|
+
it("range >= 5.7.0 <= 5.7.4 should satisfy version 5.7.0", async () => {
|
|
562
|
+
expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.0")).toBe(true);
|
|
563
|
+
});
|
|
564
|
+
it("range >= 5.7.0 <= 5.7.4 should satisfy version 5.7.4", async () => {
|
|
565
|
+
expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.4")).toBe(true);
|
|
566
|
+
});
|
|
567
|
+
it("range >= 5.7.0 <= 5.7.4 should not satisfy version 5.6.9", async () => {
|
|
568
|
+
expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.6.9")).toBe(false);
|
|
569
|
+
});
|
|
570
|
+
it("range >= 5.7.0 <= 5.7.4 should not satisfy version 5.7.5", async () => {
|
|
571
|
+
expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.5")).toBe(false);
|
|
572
|
+
});
|
|
573
|
+
it("range >=5.7.0 <=5.7.4 (no spaces around operators) should satisfy version 5.7.3", async () => {
|
|
574
|
+
expect(await semverSatisfies(">=5.7.0 <=5.7.4", "5.7.3")).toBe(true);
|
|
575
|
+
});
|
|
576
|
+
it("range >= 5.7.0 <= 5.7.4 (multiple spaces) should satisfy version 5.7.3", async () => {
|
|
577
|
+
expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.3")).toBe(true);
|
|
578
|
+
});
|
|
579
|
+
it("range >= 1.0.0 <= 2.0.0 should satisfy version 1.5.0", async () => {
|
|
580
|
+
expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "1.5.0")).toBe(true);
|
|
581
|
+
});
|
|
582
|
+
it("range >= 1.0.0 <= 2.0.0 should satisfy version 1.0.0", async () => {
|
|
583
|
+
expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "1.0.0")).toBe(true);
|
|
584
|
+
});
|
|
585
|
+
it("range >= 1.0.0 <= 2.0.0 should satisfy version 2.0.0", async () => {
|
|
586
|
+
expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "2.0.0")).toBe(true);
|
|
587
|
+
});
|
|
588
|
+
it("range >= 1.0.0 <= 2.0.0 should not satisfy version 0.9.9", async () => {
|
|
589
|
+
expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "0.9.9")).toBe(false);
|
|
590
|
+
});
|
|
591
|
+
it("range >= 1.0.0 <= 2.0.0 should not satisfy version 2.0.1", async () => {
|
|
592
|
+
expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "2.0.1")).toBe(false);
|
|
593
|
+
});
|
|
594
|
+
};
|
|
595
|
+
//#endregion
|
|
596
|
+
//#region src/databaseTestRunner.ts
|
|
597
|
+
var DatabaseUnavailableError = class extends Error {
|
|
598
|
+
constructor() {
|
|
599
|
+
super("The database is unavailable outside the test lifecycle");
|
|
600
|
+
this.name = "DatabaseUnavailableError";
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
const setupDatabaseTestRunner = (lifecycle, registerTests) => {
|
|
604
|
+
describe(lifecycle.name, () => {
|
|
605
|
+
let database;
|
|
606
|
+
const getDatabase = () => {
|
|
607
|
+
if (database === void 0) throw new DatabaseUnavailableError();
|
|
608
|
+
return database;
|
|
609
|
+
};
|
|
610
|
+
beforeAll(async () => {
|
|
611
|
+
await lifecycle.migrate();
|
|
612
|
+
database = await lifecycle.createDatabase();
|
|
618
613
|
});
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const older = {
|
|
622
|
-
...createBundleEventRowFixture("912", 100),
|
|
623
|
-
install_id: "concurrent",
|
|
624
|
-
user_id: "previous"
|
|
625
|
-
};
|
|
626
|
-
const tied = {
|
|
627
|
-
...createBundleEventRowFixture("910", 200),
|
|
628
|
-
install_id: "concurrent",
|
|
629
|
-
user_id: "previous"
|
|
630
|
-
};
|
|
631
|
-
const newest = {
|
|
632
|
-
...createBundleEventRowFixture("911", 200),
|
|
633
|
-
install_id: "concurrent",
|
|
634
|
-
user_id: null
|
|
635
|
-
};
|
|
636
|
-
await Promise.all([
|
|
637
|
-
record(model, newest),
|
|
638
|
-
record(model, older),
|
|
639
|
-
record(model, tied)
|
|
640
|
-
]);
|
|
641
|
-
await record(model, older);
|
|
642
|
-
await expect(model.findLatestEvents({ installId: "concurrent" })).resolves.toEqual([newest]);
|
|
643
|
-
await expect(model.findLatestEvents({
|
|
644
|
-
userId: "previous",
|
|
645
|
-
limit: 10
|
|
646
|
-
})).resolves.toEqual([]);
|
|
647
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
648
|
-
filter: { kind: "all" },
|
|
649
|
-
beforeReceivedAtMs: 201,
|
|
650
|
-
limit: 10
|
|
651
|
-
}), [
|
|
652
|
-
newest,
|
|
653
|
-
tied,
|
|
654
|
-
older
|
|
655
|
-
]);
|
|
614
|
+
beforeEach(async () => {
|
|
615
|
+
await lifecycle.reset(getDatabase());
|
|
656
616
|
});
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
await expect(model.recordEvent({ event: {
|
|
661
|
-
...event,
|
|
662
|
-
metadata: {
|
|
663
|
-
...event.metadata,
|
|
664
|
-
cohort: 123
|
|
665
|
-
}
|
|
666
|
-
} })).rejects.toMatchObject({ code: "invalid-data" });
|
|
667
|
-
await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([]);
|
|
668
|
-
await expect(model.listEvents({
|
|
669
|
-
filter: { kind: "all" },
|
|
670
|
-
beforeReceivedAtMs: 200,
|
|
671
|
-
limit: 10
|
|
672
|
-
})).resolves.toEqual([]);
|
|
617
|
+
afterAll(async () => {
|
|
618
|
+
await lifecycle.dispose(getDatabase());
|
|
619
|
+
database = void 0;
|
|
673
620
|
});
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
toBundleId: bundleB
|
|
764
|
-
}, applied],
|
|
765
|
-
[{
|
|
766
|
-
platform: "ios",
|
|
767
|
-
channel: "production",
|
|
768
|
-
type: "RECOVERED",
|
|
769
|
-
fromBundleId: bundleB
|
|
770
|
-
}, recovered],
|
|
771
|
-
[{
|
|
772
|
-
platform: "ios",
|
|
773
|
-
channel: "production",
|
|
774
|
-
type: "UNCHANGED",
|
|
775
|
-
toBundleId: bundleB
|
|
776
|
-
}, unchanged]
|
|
777
|
-
];
|
|
778
|
-
for (const [filter, expected] of cases) {
|
|
779
|
-
await expectInsightsIndex(() => model.countEvents({
|
|
780
|
-
filter,
|
|
781
|
-
sinceMs: 100,
|
|
782
|
-
beforeReceivedAtMs: 200
|
|
783
|
-
}), 1);
|
|
784
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
785
|
-
filter: {
|
|
786
|
-
kind: "bundle",
|
|
787
|
-
...filter
|
|
788
|
-
},
|
|
789
|
-
sinceMs: 100,
|
|
790
|
-
beforeReceivedAtMs: 200,
|
|
791
|
-
limit: 1
|
|
792
|
-
}), [expected]);
|
|
621
|
+
registerTests({ getDatabase });
|
|
622
|
+
});
|
|
623
|
+
};
|
|
624
|
+
//#endregion
|
|
625
|
+
//#region src/releaseCatalogHttpFixtures.ts
|
|
626
|
+
const releaseCatalogDownloadUrl = (uri) => `https://storage.example.com/${encodeURIComponent(uri)}`;
|
|
627
|
+
const targetId = createBundleRowFixture("302").id;
|
|
628
|
+
const RELEASE_CATALOG_MANIFEST_URIS = {
|
|
629
|
+
small: `storage://test-bucket/small/bundles/${targetId}/manifest.json`,
|
|
630
|
+
large: `storage://test-bucket/large/bundles/${targetId}/manifest.json`
|
|
631
|
+
};
|
|
632
|
+
const RELEASE_CATALOG_DOWNLOAD_HASH = "a".repeat(64);
|
|
633
|
+
const manifestAssets = { "index.ios.bundle": {
|
|
634
|
+
fileHash: "target-hbc-hash",
|
|
635
|
+
downloadFileHash: RELEASE_CATALOG_DOWNLOAD_HASH,
|
|
636
|
+
downloadByteSize: 1e3
|
|
637
|
+
} };
|
|
638
|
+
const releaseCatalogArtifact = (bundle) => ({
|
|
639
|
+
artifactProtocolVersion: 1,
|
|
640
|
+
manifestUrl: releaseCatalogDownloadUrl(bundle.manifest_storage_uri),
|
|
641
|
+
manifestFileHash: bundle.manifest_file_hash,
|
|
642
|
+
assets: { "index.ios.bundle": {
|
|
643
|
+
fileHash: "target-hbc-hash",
|
|
644
|
+
file: {
|
|
645
|
+
compression: "br",
|
|
646
|
+
url: releaseCatalogDownloadUrl(`${bundle.asset_base_storage_uri}/sha256/aa/${RELEASE_CATALOG_DOWNLOAD_HASH}.br`)
|
|
647
|
+
}
|
|
648
|
+
} }
|
|
649
|
+
});
|
|
650
|
+
/** Install these storage objects in the test server, independently of its DB. */
|
|
651
|
+
const RELEASE_CATALOG_STORAGE_FIXTURES = Object.fromEntries(Object.entries(RELEASE_CATALOG_MANIFEST_URIS).map(([size, uri]) => [uri, JSON.stringify({
|
|
652
|
+
bundleId: targetId,
|
|
653
|
+
assets: manifestAssets,
|
|
654
|
+
archive: {
|
|
655
|
+
downloadFileHash: "b".repeat(64),
|
|
656
|
+
downloadByteSize: size === "small" ? 1 : 1e4,
|
|
657
|
+
tarByteSize: 2e4
|
|
658
|
+
}
|
|
659
|
+
})]));
|
|
660
|
+
/** Storage boundary shared by provider and separately spawned server tests. */
|
|
661
|
+
const createReleaseCatalogTestStorage = () => ({
|
|
662
|
+
name: "catalog-test-storage",
|
|
663
|
+
protocol: "storage",
|
|
664
|
+
async get({ storageUri }) {
|
|
665
|
+
const suffix = /^storage:\/\/bundles\/([^/]+)\/manifest\.json$/.exec(storageUri)?.[1];
|
|
666
|
+
const body = RELEASE_CATALOG_STORAGE_FIXTURES[storageUri] ?? (suffix === void 0 ? void 0 : JSON.stringify({
|
|
667
|
+
bundleId: createBundleRowFixture(suffix).id,
|
|
668
|
+
assets: manifestAssets
|
|
669
|
+
}));
|
|
670
|
+
return { response: body === void 0 ? null : new Response(body) };
|
|
671
|
+
},
|
|
672
|
+
async getDownloadUrl({ storageUri }) {
|
|
673
|
+
return { url: releaseCatalogDownloadUrl(storageUri) };
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/releaseCatalogLifecycleTests.ts
|
|
678
|
+
function setupReleaseCatalogLifecycleTests(options) {
|
|
679
|
+
const device = (minimumReleaseId = NIL_UUID) => ({
|
|
680
|
+
builtInBundleId: minimumReleaseId,
|
|
681
|
+
currentBundleId: minimumReleaseId,
|
|
682
|
+
activeReleaseId: null,
|
|
683
|
+
minimumReleaseId,
|
|
684
|
+
cohort: "1",
|
|
685
|
+
crashedBundleIds: []
|
|
686
|
+
});
|
|
687
|
+
const refresh = async (previous) => {
|
|
688
|
+
let next = previous;
|
|
689
|
+
await expect.poll(async () => {
|
|
690
|
+
next = await options.readCatalog();
|
|
691
|
+
return next.generation;
|
|
692
|
+
}, {
|
|
693
|
+
interval: 100,
|
|
694
|
+
timeout: 1e4
|
|
695
|
+
}).toBeGreaterThan(previous.generation);
|
|
696
|
+
expect(next.catalogId).toBe(previous.catalogId);
|
|
697
|
+
expect(next.catalogHash).not.toBe(previous.catalogHash);
|
|
698
|
+
return next;
|
|
699
|
+
};
|
|
700
|
+
const install = async (catalog, app, target, status) => {
|
|
701
|
+
const desired = selectDesiredRelease(catalog, app);
|
|
702
|
+
expect(desired).toMatchObject({
|
|
703
|
+
kind: "BUNDLE",
|
|
704
|
+
releaseId: target.release.id,
|
|
705
|
+
bundleId: target.bundle.id,
|
|
706
|
+
status,
|
|
707
|
+
release: {
|
|
708
|
+
message: target.release.message,
|
|
709
|
+
shouldForceUpdate: target.release.should_force_update
|
|
793
710
|
}
|
|
794
|
-
await expectInsightsIndex(() => model.countLatestEvents({
|
|
795
|
-
platform: "ios",
|
|
796
|
-
channel: "production",
|
|
797
|
-
sinceMs: 100,
|
|
798
|
-
bundle: [{
|
|
799
|
-
field: "to_bundle_id",
|
|
800
|
-
value: bundleA,
|
|
801
|
-
types: [
|
|
802
|
-
"UNCHANGED",
|
|
803
|
-
"UPDATE_APPLIED",
|
|
804
|
-
"RECOVERED"
|
|
805
|
-
]
|
|
806
|
-
}]
|
|
807
|
-
}), 1);
|
|
808
|
-
await expectInsightsIndex(() => model.countLatestEvents({
|
|
809
|
-
platform: "ios",
|
|
810
|
-
channel: "production",
|
|
811
|
-
sinceMs: 100
|
|
812
|
-
}), 4);
|
|
813
711
|
});
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
install_id,
|
|
828
|
-
user_id: "User-é"
|
|
829
|
-
}));
|
|
830
|
-
for (const event of events.toReversed()) await record(model, event);
|
|
831
|
-
const spacedUser = {
|
|
832
|
-
...createBundleEventRowFixture("960", 100),
|
|
833
|
-
install_id: "spaced-user-install",
|
|
834
|
-
user_id: "User-é "
|
|
835
|
-
};
|
|
836
|
-
await record(model, spacedUser);
|
|
837
|
-
await expectInsightsIndex(async () => {
|
|
838
|
-
const found = [];
|
|
839
|
-
let afterInstallId;
|
|
840
|
-
for (let pageIndex = 0; pageIndex <= ids.length; pageIndex++) {
|
|
841
|
-
const page = await model.findLatestEvents({
|
|
842
|
-
userId: "User-é",
|
|
843
|
-
afterInstallId,
|
|
844
|
-
limit: 2
|
|
845
|
-
});
|
|
846
|
-
found.push(...page.map((row) => row.install_id));
|
|
847
|
-
if (page.length < 2) return found;
|
|
848
|
-
const nextCursor = page[page.length - 1].install_id;
|
|
849
|
-
expect(nextCursor, "Insights cursor must advance").not.toBe(afterInstallId);
|
|
850
|
-
afterInstallId = nextCursor;
|
|
851
|
-
}
|
|
852
|
-
throw new Error("Insights pagination did not terminate");
|
|
853
|
-
}, ids);
|
|
854
|
-
await expectInsightsIndex(() => model.findLatestEvents({
|
|
855
|
-
userId: "User-é ",
|
|
856
|
-
limit: 10
|
|
857
|
-
}), [spacedUser]);
|
|
858
|
-
await expect(model.findLatestEvents({
|
|
859
|
-
userId: "user-é",
|
|
860
|
-
limit: 10
|
|
861
|
-
})).resolves.toEqual([]);
|
|
862
|
-
await expect(model.findLatestEvents({ installId: "INSTALL-a" })).resolves.toEqual([]);
|
|
863
|
-
await expect(model.findLatestEvents({ installId: "Install-a" })).resolves.toEqual([events[0]]);
|
|
864
|
-
await expect(model.findLatestEvents({ installId: "install-a " })).resolves.toEqual([events[3]]);
|
|
865
|
-
await expect(model.findLatestEvents({ installId: "install-a" })).resolves.toEqual([events[2]]);
|
|
866
|
-
});
|
|
867
|
-
it("counts overlapping bundle predicates once per latest installation", async () => {
|
|
868
|
-
const model = state.getDatabase();
|
|
869
|
-
const event = createBundleEventRowFixture("9700", 100);
|
|
870
|
-
await record(model, event);
|
|
871
|
-
const predicate = {
|
|
872
|
-
field: "to_bundle_id",
|
|
873
|
-
value: event.to_bundle_id,
|
|
874
|
-
types: [event.type]
|
|
875
|
-
};
|
|
876
|
-
await expectInsightsIndex(() => model.countLatestEvents({
|
|
877
|
-
platform: event.platform,
|
|
878
|
-
channel: event.channel,
|
|
879
|
-
sinceMs: 0,
|
|
880
|
-
bundle: [predicate, predicate]
|
|
881
|
-
}), 1);
|
|
712
|
+
const response = await options.request(`/artifacts/v1/${desired.bundleId}/from/${app.currentBundleId}`);
|
|
713
|
+
expect(response.status).toBe(200);
|
|
714
|
+
expect(await response.json()).toEqual(releaseCatalogArtifact(target.bundle));
|
|
715
|
+
app.currentBundleId = desired.bundleId;
|
|
716
|
+
app.activeReleaseId = desired.releaseId;
|
|
717
|
+
};
|
|
718
|
+
const useBuiltin = (catalog, app) => {
|
|
719
|
+
expect(selectDesiredRelease(catalog, app)).toEqual({
|
|
720
|
+
kind: "BUILTIN",
|
|
721
|
+
releaseId: null,
|
|
722
|
+
bundleId: app.builtInBundleId,
|
|
723
|
+
release: null,
|
|
724
|
+
status: "ROLLBACK"
|
|
882
725
|
});
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
sinceMs: 0,
|
|
891
|
-
bundle: [{
|
|
892
|
-
field: "from_bundle_id",
|
|
893
|
-
value: moved.from_bundle_id,
|
|
894
|
-
types: [moved.type]
|
|
895
|
-
}, {
|
|
896
|
-
field: "to_bundle_id",
|
|
897
|
-
value: moved.to_bundle_id,
|
|
898
|
-
types: [moved.type]
|
|
899
|
-
}]
|
|
900
|
-
}), 1);
|
|
726
|
+
app.currentBundleId = app.builtInBundleId;
|
|
727
|
+
app.activeReleaseId = null;
|
|
728
|
+
};
|
|
729
|
+
const staysOn = (catalog, app) => {
|
|
730
|
+
expect(selectDesiredRelease(catalog, app)).toMatchObject({
|
|
731
|
+
bundleId: app.currentBundleId,
|
|
732
|
+
releaseId: app.activeReleaseId
|
|
901
733
|
});
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
734
|
+
};
|
|
735
|
+
describe("OTA lifecycle through HTTP and the client selector", () => {
|
|
736
|
+
it("runs built-in → OTA A → OTA B → rollback A → built-in after policy changes", async () => {
|
|
737
|
+
const app = device();
|
|
738
|
+
const first = await options.publish("801");
|
|
739
|
+
const second = await options.publish("802", {
|
|
740
|
+
enabled: false,
|
|
741
|
+
should_force_update: true
|
|
742
|
+
});
|
|
743
|
+
let catalog = await options.readCatalog();
|
|
744
|
+
await install(catalog, app, first, "UPDATE");
|
|
745
|
+
staysOn(catalog, app);
|
|
746
|
+
await options.update(second.release.id, { enabled: true });
|
|
747
|
+
catalog = await refresh(catalog);
|
|
748
|
+
await install(catalog, app, second, "UPDATE");
|
|
749
|
+
staysOn(catalog, app);
|
|
750
|
+
await options.update(second.release.id, { enabled: false });
|
|
751
|
+
catalog = await refresh(catalog);
|
|
752
|
+
await install(catalog, app, first, "ROLLBACK");
|
|
753
|
+
staysOn(catalog, app);
|
|
754
|
+
await options.update(first.release.id, { enabled: false });
|
|
755
|
+
catalog = await refresh(catalog);
|
|
756
|
+
useBuiltin(catalog, app);
|
|
757
|
+
staysOn(catalog, app);
|
|
758
|
+
}, 4e4);
|
|
759
|
+
it("rolls a deleted active Release back to older OTA bytes, then to built-in", async () => {
|
|
760
|
+
const app = device();
|
|
761
|
+
const first = await options.publish("811");
|
|
762
|
+
const second = await options.publish("812");
|
|
763
|
+
let catalog = await options.readCatalog();
|
|
764
|
+
await install(catalog, app, second, "UPDATE");
|
|
765
|
+
await options.remove(second.release.id);
|
|
766
|
+
catalog = await refresh(catalog);
|
|
767
|
+
await install(catalog, app, first, "ROLLBACK");
|
|
768
|
+
await options.remove(first.release.id);
|
|
769
|
+
catalog = await refresh(catalog);
|
|
770
|
+
useBuiltin(catalog, app);
|
|
771
|
+
staysOn(catalog, app);
|
|
772
|
+
}, 3e4);
|
|
773
|
+
it("respects the native minimum on fresh install and rollback with or without a Release receipt", async () => {
|
|
774
|
+
await options.publish("821");
|
|
775
|
+
const current = await options.publish("823", { enabled: false });
|
|
776
|
+
const app = device(createBundleRowFixture("822").id);
|
|
777
|
+
const catalog = await options.readCatalog();
|
|
778
|
+
useBuiltin(catalog, app);
|
|
779
|
+
for (const activeReleaseId of [current.release.id, null]) {
|
|
780
|
+
app.currentBundleId = current.bundle.id;
|
|
781
|
+
app.activeReleaseId = activeReleaseId;
|
|
782
|
+
useBuiltin(catalog, app);
|
|
783
|
+
}
|
|
784
|
+
expect(selectDesiredRelease(catalog, {
|
|
785
|
+
...device(current.bundle.id),
|
|
786
|
+
activeReleaseId: current.release.id
|
|
787
|
+
})).toBeNull();
|
|
919
788
|
});
|
|
920
|
-
it("
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
const download = {
|
|
925
|
-
...createBundleEventRowFixture("9801", 1e3),
|
|
926
|
-
type: "UPDATE_DOWNLOADED",
|
|
927
|
-
install_id: "summary-install-a",
|
|
928
|
-
from_release_id: releaseA,
|
|
929
|
-
to_release_id: releaseB
|
|
930
|
-
};
|
|
931
|
-
const appliedA = {
|
|
932
|
-
...download,
|
|
933
|
-
id: createBundleEventRowFixture("9802", 2e3).id,
|
|
934
|
-
type: "UPDATE_APPLIED",
|
|
935
|
-
received_at_ms: 2e3
|
|
936
|
-
};
|
|
937
|
-
const appliedB = {
|
|
938
|
-
...appliedA,
|
|
939
|
-
id: createBundleEventRowFixture("9803", 2100).id,
|
|
940
|
-
install_id: "summary-install-b",
|
|
941
|
-
received_at_ms: 2100
|
|
942
|
-
};
|
|
943
|
-
const recovered = {
|
|
944
|
-
...appliedA,
|
|
945
|
-
id: createBundleEventRowFixture("9804", 3e3).id,
|
|
946
|
-
type: "RECOVERED",
|
|
947
|
-
from_release_id: releaseB,
|
|
948
|
-
to_release_id: releaseA,
|
|
949
|
-
received_at_ms: 3e3
|
|
789
|
+
it("re-evaluates cohort changes and rolls back even when the predecessor rollout is closed", async () => {
|
|
790
|
+
const app = {
|
|
791
|
+
...device(),
|
|
792
|
+
cohort: "staff"
|
|
950
793
|
};
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
appliedB,
|
|
955
|
-
appliedB,
|
|
956
|
-
recovered
|
|
957
|
-
]) await record(model, event);
|
|
958
|
-
const releases = [{
|
|
959
|
-
releaseId: releaseA,
|
|
960
|
-
platform: "ios",
|
|
961
|
-
channel: "production"
|
|
962
|
-
}, {
|
|
963
|
-
releaseId: releaseB,
|
|
964
|
-
platform: "ios",
|
|
965
|
-
channel: "production"
|
|
966
|
-
}];
|
|
967
|
-
const lifetime = await model.getReleaseActivity({ releases });
|
|
968
|
-
expect(lifetime.data.map(({ metrics }) => metrics)).toEqual([{
|
|
969
|
-
downloads: 0,
|
|
970
|
-
launches: 1,
|
|
971
|
-
failedLaunches: 0
|
|
972
|
-
}, {
|
|
973
|
-
downloads: 1,
|
|
974
|
-
launches: 2,
|
|
975
|
-
failedLaunches: 1
|
|
976
|
-
}]);
|
|
977
|
-
const scope = await model.getReleaseActivity({
|
|
978
|
-
scope: {
|
|
979
|
-
platform: "ios",
|
|
980
|
-
channel: "production"
|
|
981
|
-
},
|
|
982
|
-
timeRange: {
|
|
983
|
-
start: 0,
|
|
984
|
-
end: 36e5
|
|
985
|
-
}
|
|
986
|
-
});
|
|
987
|
-
expect(scope.data[0]?.metrics).toMatchObject({
|
|
988
|
-
downloads: 1,
|
|
989
|
-
launches: 3,
|
|
990
|
-
failedLaunches: 1,
|
|
991
|
-
uniqueUsers: 2
|
|
794
|
+
const first = await options.publish("831", {
|
|
795
|
+
rollout_cohort_count: 0,
|
|
796
|
+
target_cohorts: ["staff"]
|
|
992
797
|
});
|
|
993
|
-
const
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
timeRange: {
|
|
997
|
-
start: 0,
|
|
998
|
-
end: 36e5
|
|
999
|
-
},
|
|
1000
|
-
intervalMs: 36e5
|
|
798
|
+
const second = await options.publish("832", {
|
|
799
|
+
rollout_cohort_count: 0,
|
|
800
|
+
target_cohorts: ["staff"]
|
|
1001
801
|
});
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
appVersion: "1.0.0",
|
|
1009
|
-
platform: "ios",
|
|
1010
|
-
releaseId: releaseA,
|
|
1011
|
-
installations: 1
|
|
1012
|
-
}, {
|
|
1013
|
-
appVersion: "1.0.0",
|
|
1014
|
-
platform: "ios",
|
|
1015
|
-
releaseId: releaseB,
|
|
1016
|
-
installations: 1
|
|
1017
|
-
}]));
|
|
1018
|
-
});
|
|
1019
|
-
it("pages insights events newest first with a stable cursor", async () => {
|
|
1020
|
-
const model = state.getDatabase();
|
|
1021
|
-
const first = createBundleEventRowFixture("701", 100);
|
|
1022
|
-
const second = createBundleEventRowFixture("702", 100);
|
|
1023
|
-
const third = createBundleEventRowFixture("703", 200);
|
|
1024
|
-
await model.recordEvent({ event: third });
|
|
1025
|
-
await model.recordEvent({ event: second });
|
|
1026
|
-
await model.recordEvent({ event: first });
|
|
1027
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1028
|
-
filter: { kind: "all" },
|
|
1029
|
-
beforeReceivedAtMs: 201,
|
|
1030
|
-
limit: 2
|
|
1031
|
-
}), [third, second]);
|
|
1032
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1033
|
-
filter: { kind: "all" },
|
|
1034
|
-
after: {
|
|
1035
|
-
receivedAtMs: second.received_at_ms,
|
|
1036
|
-
id: second.id
|
|
1037
|
-
},
|
|
1038
|
-
beforeReceivedAtMs: 201,
|
|
1039
|
-
limit: 2
|
|
1040
|
-
}), [first]);
|
|
1041
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1042
|
-
filter: { kind: "all" },
|
|
1043
|
-
beforeReceivedAtMs: 200,
|
|
1044
|
-
limit: 10
|
|
1045
|
-
}), [second, first]);
|
|
802
|
+
const catalog = await options.readCatalog();
|
|
803
|
+
await install(catalog, app, second, "UPDATE");
|
|
804
|
+
app.cohort = "1";
|
|
805
|
+
await install(catalog, app, first, "ROLLBACK");
|
|
806
|
+
useBuiltin(catalog, app);
|
|
807
|
+
staysOn(catalog, app);
|
|
1046
808
|
});
|
|
1047
|
-
it("
|
|
1048
|
-
const
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
filter: {
|
|
1078
|
-
kind: "installationMovement",
|
|
1079
|
-
installId: "install-target"
|
|
1080
|
-
},
|
|
1081
|
-
after: {
|
|
1082
|
-
receivedAtMs: applied.received_at_ms,
|
|
1083
|
-
id: applied.id
|
|
1084
|
-
},
|
|
1085
|
-
beforeReceivedAtMs: 301,
|
|
1086
|
-
limit: 1
|
|
1087
|
-
}), [recovered]);
|
|
809
|
+
it("does not reinstall crashed OTAs after native recovery to a previous OTA or built-in", async () => {
|
|
810
|
+
const app = device();
|
|
811
|
+
const first = await options.publish("841");
|
|
812
|
+
const second = await options.publish("842", { enabled: false });
|
|
813
|
+
let catalog = await options.readCatalog();
|
|
814
|
+
await install(catalog, app, first, "UPDATE");
|
|
815
|
+
await options.update(second.release.id, { enabled: true });
|
|
816
|
+
catalog = await refresh(catalog);
|
|
817
|
+
await install(catalog, app, second, "UPDATE");
|
|
818
|
+
app.currentBundleId = first.bundle.id;
|
|
819
|
+
app.activeReleaseId = first.release.id;
|
|
820
|
+
app.crashedBundleIds = [second.bundle.id];
|
|
821
|
+
staysOn(catalog, app);
|
|
822
|
+
app.currentBundleId = app.builtInBundleId;
|
|
823
|
+
app.activeReleaseId = null;
|
|
824
|
+
app.crashedBundleIds = [first.bundle.id, second.bundle.id];
|
|
825
|
+
useBuiltin(catalog, app);
|
|
826
|
+
staysOn(catalog, app);
|
|
827
|
+
const fixed = await options.publish("843");
|
|
828
|
+
catalog = await refresh(catalog);
|
|
829
|
+
await install(catalog, app, fixed, "UPDATE");
|
|
830
|
+
staysOn(catalog, app);
|
|
831
|
+
}, 25e3);
|
|
832
|
+
it("selects a compatible OTA and stays on it despite a newer incompatible Release", async () => {
|
|
833
|
+
const app = device();
|
|
834
|
+
const compatible = await options.publish("851");
|
|
835
|
+
await options.publishIncompatible("852");
|
|
836
|
+
const catalog = await options.readCatalog();
|
|
837
|
+
await install(catalog, app, compatible, "UPDATE");
|
|
838
|
+
staysOn(catalog, app);
|
|
1088
839
|
});
|
|
1089
840
|
});
|
|
1090
|
-
}
|
|
1091
|
-
//#endregion
|
|
1092
|
-
//#region src/setupInsightsModelTestSuite.ts
|
|
1093
|
-
/**
|
|
1094
|
-
* The Insights report contract on one database: pass the Insights plugin's
|
|
1095
|
-
* model over it, `createInsightsModel(createDatabasePluginApis(database,
|
|
1096
|
-
* [insights()]).insights)`, as `createDatabase`.
|
|
1097
|
-
*/
|
|
1098
|
-
const setupInsightsModelTestSuite = (lifecycle) => setupDatabaseTestRunner(lifecycle, registerInsightsModelTests);
|
|
841
|
+
}
|
|
1099
842
|
//#endregion
|
|
1100
|
-
//#region src/
|
|
843
|
+
//#region src/setupReleaseCatalogTestSuite.ts
|
|
844
|
+
const NIL_UUID$1 = "00000000-0000-0000-0000-000000000000";
|
|
845
|
+
const FALLBACK_POLICY = "BUILTIN_IF_ACTIVE_INELIGIBLE";
|
|
846
|
+
const channelKey = (channel) => btoa(String.fromCharCode(...new TextEncoder().encode(channel))).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
1101
847
|
/**
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
1107
|
-
* | Range Expression | Who gets the update |
|
|
1108
|
-
* |------------------|------------------------------------------------------------------------|
|
|
1109
|
-
* | 1.2.3 | Only devices running the specific binary app store version 1.2.3 of your app |
|
|
1110
|
-
* | * | Any device configured to consume updates from your CodePush app |
|
|
1111
|
-
* | 1.2.x | Devices running major version 1, minor version 2 and any patch version of your app |
|
|
1112
|
-
* | 1.2.3 - 1.2.7 | Devices running any binary version between 1.2.3 (inclusive) and 1.2.7 (inclusive) |
|
|
1113
|
-
* | >=1.2.3 <1.2.7 | Devices running any binary version between 1.2.3 (inclusive) and 1.2.7 (exclusive) |
|
|
1114
|
-
* | 1.2 | Equivalent to >=1.2.0 <1.3.0 |
|
|
1115
|
-
* | ~1.2.3 | Equivalent to >=1.2.3 <1.3.0 |
|
|
1116
|
-
* | ^1.2.3 | Equivalent to >=1.2.3 <2.0.0 |
|
|
848
|
+
* Server conformance through HTTP only, on admin API protocol 2. The caller
|
|
849
|
+
* owns server, database, and storage setup. Releases are written with
|
|
850
|
+
* `POST /releases` and changed with `PATCH /releases/:id`; lifecycle scenarios
|
|
851
|
+
* feed HTTP catalogs to the production client selector. Each test deletes
|
|
852
|
+
* what it wrote, so a long-lived server can run the suite again.
|
|
1117
853
|
*/
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
it("version 1.2.4 should not satisfy version 1.2.3", async () => {
|
|
1126
|
-
expect(await semverSatisfies("1.2.3", "1.2.4")).toBe(false);
|
|
1127
|
-
});
|
|
1128
|
-
it("version 1.2.4 should not satisfy version 1.2.3", async () => {
|
|
1129
|
-
expect(await semverSatisfies("1.2.3", "1.2.4")).toBe(false);
|
|
1130
|
-
});
|
|
1131
|
-
it("1.x.x should satisfy version 1.0", async () => {
|
|
1132
|
-
expect(await semverSatisfies("1.x.x", "1.0")).toBe(true);
|
|
1133
|
-
});
|
|
1134
|
-
it("1.x.x should satisfy version 1.12", async () => {
|
|
1135
|
-
expect(await semverSatisfies("1.x.x", "1.12")).toBe(true);
|
|
1136
|
-
});
|
|
1137
|
-
it("1.x.x should satisfy version 1.0.0", async () => {
|
|
1138
|
-
expect(await semverSatisfies("1.x.x", "1.0.0")).toBe(true);
|
|
1139
|
-
});
|
|
1140
|
-
it("1.x.x should satisfy version 1.2.3", async () => {
|
|
1141
|
-
expect(await semverSatisfies("1.x.x", "1.2.3")).toBe(true);
|
|
1142
|
-
});
|
|
1143
|
-
it("1.x.x should not satisfy version 2.0.0", async () => {
|
|
1144
|
-
expect(await semverSatisfies("1.x.x", "2.0.0")).toBe(false);
|
|
1145
|
-
});
|
|
1146
|
-
it("1.x.x should not satisfy version 2.0.0", async () => {
|
|
1147
|
-
expect(await semverSatisfies("1.x.x", "2.0.0")).toBe(false);
|
|
1148
|
-
});
|
|
1149
|
-
it("1.2.x should satisfy version 1.2.5", async () => {
|
|
1150
|
-
expect(await semverSatisfies("1.2.x", "1.2.5")).toBe(true);
|
|
1151
|
-
});
|
|
1152
|
-
it("1.2.x should not satisfy version 1.3.0", async () => {
|
|
1153
|
-
expect(await semverSatisfies("1.2.x", "1.3.0")).toBe(false);
|
|
1154
|
-
});
|
|
1155
|
-
it("1.2.x should not satisfy version 1.3.0", async () => {
|
|
1156
|
-
expect(await semverSatisfies("1.2.x", "1.3.0")).toBe(false);
|
|
1157
|
-
});
|
|
1158
|
-
it("range 1.2.3-1.2.7 should satisfy version 1.2.5", async () => {
|
|
1159
|
-
expect(await semverSatisfies("1.2.3 - 1.2.7", "1.2.5")).toBe(true);
|
|
1160
|
-
});
|
|
1161
|
-
it("range 1.2.3-1.2.7 should satisfy version 1.2.5", async () => {
|
|
1162
|
-
expect(await semverSatisfies("1.2.3 - 1.2.7", "1.2.5")).toBe(true);
|
|
1163
|
-
});
|
|
1164
|
-
it("range 1.2.3-1.2.7 should not satisfy version 1.3.0", async () => {
|
|
1165
|
-
expect(await semverSatisfies("1.2.3 - 1.2.7", "1.3.0")).toBe(false);
|
|
1166
|
-
});
|
|
1167
|
-
it("range >=1.2.3 <1.2.7 should satisfy version 1.2.5", async () => {
|
|
1168
|
-
expect(await semverSatisfies(">=1.2.3 <1.2.7", "1.2.5")).toBe(true);
|
|
1169
|
-
});
|
|
1170
|
-
it("range >=1.2.3 <1.2.7 should satisfy version 1.2.5", async () => {
|
|
1171
|
-
expect(await semverSatisfies(">=1.2.3 <1.2.7", "1.2.5")).toBe(true);
|
|
1172
|
-
});
|
|
1173
|
-
it("range >=1.2.3 <1.2.7 should not satisfy version 1.2.7", async () => {
|
|
1174
|
-
expect(await semverSatisfies(">=1.2.3 <1.2.7", "1.2.7")).toBe(false);
|
|
1175
|
-
});
|
|
1176
|
-
it("~1.2.3 should satisfy version 1.2.3", async () => {
|
|
1177
|
-
expect(await semverSatisfies("~1.2.3", "1.2.3")).toBe(true);
|
|
1178
|
-
});
|
|
1179
|
-
it("~1.2.3 should satisfy version 1.2.4", async () => {
|
|
1180
|
-
expect(await semverSatisfies("~1.2.3", "1.2.4")).toBe(true);
|
|
1181
|
-
});
|
|
1182
|
-
it("~1.2.3 should not satisfy version 1.3.0", async () => {
|
|
1183
|
-
expect(await semverSatisfies("~1.2.3", "1.3.0")).toBe(false);
|
|
1184
|
-
});
|
|
1185
|
-
it("~1.2.3 should satisfy version 1.2.3", async () => {
|
|
1186
|
-
expect(await semverSatisfies("~1.2.3", "1.2.3")).toBe(true);
|
|
1187
|
-
});
|
|
1188
|
-
it("~1.2.3 should satisfy version 1.2.4", async () => {
|
|
1189
|
-
expect(await semverSatisfies("~1.2.3", "1.2.4")).toBe(true);
|
|
1190
|
-
});
|
|
1191
|
-
it("~1.2.3 should not satisfy version 1.3.0", async () => {
|
|
1192
|
-
expect(await semverSatisfies("~1.2.3", "1.3.0")).toBe(false);
|
|
1193
|
-
});
|
|
1194
|
-
it("^1.2.3 should satisfy version 1.3.0", async () => {
|
|
1195
|
-
expect(await semverSatisfies("^1.2.3", "1.3.0")).toBe(true);
|
|
1196
|
-
});
|
|
1197
|
-
it("^1.2.3 should satisfy version 1.3.0", async () => {
|
|
1198
|
-
expect(await semverSatisfies("^1.2.3", "1.3.0")).toBe(true);
|
|
1199
|
-
});
|
|
1200
|
-
it("^1.2.3 should not satisfy version 2.0.0", async () => {
|
|
1201
|
-
expect(await semverSatisfies("^1.2.3", "2.0.0")).toBe(false);
|
|
1202
|
-
});
|
|
1203
|
-
it("version 1.2 should satisfy version 1.2.0 (equivalent to >=1.2.0 <1.3.0)", async () => {
|
|
1204
|
-
expect(await semverSatisfies("1.2", "1.2.0")).toBe(true);
|
|
1205
|
-
});
|
|
1206
|
-
it("version 1.2 should satisfy version 1.2.99 (equivalent to >=1.2.0 <1.3.0)", async () => {
|
|
1207
|
-
expect(await semverSatisfies("1.2", "1.2.99")).toBe(true);
|
|
1208
|
-
});
|
|
1209
|
-
it("version 1.2 should not satisfy version 1.3.0", async () => {
|
|
1210
|
-
expect(await semverSatisfies("1.2", "1.3.0")).toBe(false);
|
|
1211
|
-
});
|
|
1212
|
-
it("single-major '1' should satisfy version 1.0.0 (equivalent to >=1.0.0 <2.0.0)", async () => {
|
|
1213
|
-
expect(await semverSatisfies("1", "1.0.0")).toBe(true);
|
|
1214
|
-
});
|
|
1215
|
-
it("single-major '1' should satisfy version 1.99.99", async () => {
|
|
1216
|
-
expect(await semverSatisfies("1", "1.99.99")).toBe(true);
|
|
1217
|
-
});
|
|
1218
|
-
it("single-major '1' should not satisfy version 2.0.0", async () => {
|
|
1219
|
-
expect(await semverSatisfies("1", "2.0.0")).toBe(false);
|
|
1220
|
-
});
|
|
1221
|
-
it("0.x.x should satisfy version 0.5.10", async () => {
|
|
1222
|
-
expect(await semverSatisfies("0.x.x", "0.5.10")).toBe(true);
|
|
1223
|
-
});
|
|
1224
|
-
it("0.x.x should not satisfy version 1.0.0", async () => {
|
|
1225
|
-
expect(await semverSatisfies("0.x.x", "1.0.0")).toBe(false);
|
|
1226
|
-
});
|
|
1227
|
-
it("2.x should satisfy version 2.0.0", async () => {
|
|
1228
|
-
expect(await semverSatisfies("2.x", "2.0.0")).toBe(true);
|
|
1229
|
-
});
|
|
1230
|
-
it("2.x should satisfy version 2.10.1", async () => {
|
|
1231
|
-
expect(await semverSatisfies("2.x", "2.10.1")).toBe(true);
|
|
1232
|
-
});
|
|
1233
|
-
it("2.x should not satisfy version 3.0.0", async () => {
|
|
1234
|
-
expect(await semverSatisfies("2.x", "3.0.0")).toBe(false);
|
|
1235
|
-
});
|
|
1236
|
-
it("simple star * should satisfy any version, e.g. 10.11.12", async () => {
|
|
1237
|
-
expect(await semverSatisfies("*", "10.11.12")).toBe(true);
|
|
1238
|
-
});
|
|
1239
|
-
it("simple star * should satisfy any version, e.g. 0.0.1", async () => {
|
|
1240
|
-
expect(await semverSatisfies("*", "0.0.1")).toBe(true);
|
|
1241
|
-
});
|
|
1242
|
-
it("2.x.x should satisfy version 2.0.0", async () => {
|
|
1243
|
-
expect(await semverSatisfies("2.x.x", "2.0.0")).toBe(true);
|
|
1244
|
-
});
|
|
1245
|
-
it("2.x.x should satisfy version 2.9.9", async () => {
|
|
1246
|
-
expect(await semverSatisfies("2.x.x", "2.9.9")).toBe(true);
|
|
1247
|
-
});
|
|
1248
|
-
it("2.x.x should not satisfy version 3.0.0", async () => {
|
|
1249
|
-
expect(await semverSatisfies("2.x.x", "3.0.0")).toBe(false);
|
|
1250
|
-
});
|
|
1251
|
-
it("0.x.x should satisfy version 0.1.0", async () => {
|
|
1252
|
-
expect(await semverSatisfies("0.x.x", "0.1.0")).toBe(true);
|
|
1253
|
-
});
|
|
1254
|
-
it("0.x.x should satisfy version 0.9.9", async () => {
|
|
1255
|
-
expect(await semverSatisfies("0.x.x", "0.9.9")).toBe(true);
|
|
1256
|
-
});
|
|
1257
|
-
it("0.x.x should not satisfy version 1.0.0", async () => {
|
|
1258
|
-
expect(await semverSatisfies("0.x.x", "1.0.0")).toBe(false);
|
|
1259
|
-
});
|
|
1260
|
-
it("range 1.2.3 - 2.0.0 should satisfy version 1.2.4", async () => {
|
|
1261
|
-
expect(await semverSatisfies("1.2.3 - 2.0.0", "1.2.4")).toBe(true);
|
|
1262
|
-
});
|
|
1263
|
-
it("range 1.2.3 - 2.0.0 should satisfy version 2.0.0", async () => {
|
|
1264
|
-
expect(await semverSatisfies("1.2.3 - 2.0.0", "2.0.0")).toBe(true);
|
|
1265
|
-
});
|
|
1266
|
-
it("range 1.2.3 - 2.0.0 should satisfy version 1.5.0", async () => {
|
|
1267
|
-
expect(await semverSatisfies("1.2.3 - 2.0.0", "1.5.0")).toBe(true);
|
|
1268
|
-
});
|
|
1269
|
-
it("range 1.2.3 - 2.0.0 should not satisfy version 2.0.1", async () => {
|
|
1270
|
-
expect(await semverSatisfies("1.2.3 - 2.0.0", "2.0.1")).toBe(false);
|
|
1271
|
-
});
|
|
1272
|
-
it("range >= 5.7.0 <= 5.7.4 should satisfy version 5.7.3", async () => {
|
|
1273
|
-
expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.3")).toBe(true);
|
|
1274
|
-
});
|
|
1275
|
-
it("range >= 5.7.0 <= 5.7.4 should satisfy version 5.7.0", async () => {
|
|
1276
|
-
expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.0")).toBe(true);
|
|
1277
|
-
});
|
|
1278
|
-
it("range >= 5.7.0 <= 5.7.4 should satisfy version 5.7.4", async () => {
|
|
1279
|
-
expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.4")).toBe(true);
|
|
1280
|
-
});
|
|
1281
|
-
it("range >= 5.7.0 <= 5.7.4 should not satisfy version 5.6.9", async () => {
|
|
1282
|
-
expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.6.9")).toBe(false);
|
|
1283
|
-
});
|
|
1284
|
-
it("range >= 5.7.0 <= 5.7.4 should not satisfy version 5.7.5", async () => {
|
|
1285
|
-
expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.5")).toBe(false);
|
|
1286
|
-
});
|
|
1287
|
-
it("range >=5.7.0 <=5.7.4 (no spaces around operators) should satisfy version 5.7.3", async () => {
|
|
1288
|
-
expect(await semverSatisfies(">=5.7.0 <=5.7.4", "5.7.3")).toBe(true);
|
|
1289
|
-
});
|
|
1290
|
-
it("range >= 5.7.0 <= 5.7.4 (multiple spaces) should satisfy version 5.7.3", async () => {
|
|
1291
|
-
expect(await semverSatisfies(">= 5.7.0 <= 5.7.4", "5.7.3")).toBe(true);
|
|
1292
|
-
});
|
|
1293
|
-
it("range >= 1.0.0 <= 2.0.0 should satisfy version 1.5.0", async () => {
|
|
1294
|
-
expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "1.5.0")).toBe(true);
|
|
1295
|
-
});
|
|
1296
|
-
it("range >= 1.0.0 <= 2.0.0 should satisfy version 1.0.0", async () => {
|
|
1297
|
-
expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "1.0.0")).toBe(true);
|
|
1298
|
-
});
|
|
1299
|
-
it("range >= 1.0.0 <= 2.0.0 should satisfy version 2.0.0", async () => {
|
|
1300
|
-
expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "2.0.0")).toBe(true);
|
|
1301
|
-
});
|
|
1302
|
-
it("range >= 1.0.0 <= 2.0.0 should not satisfy version 0.9.9", async () => {
|
|
1303
|
-
expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "0.9.9")).toBe(false);
|
|
1304
|
-
});
|
|
1305
|
-
it("range >= 1.0.0 <= 2.0.0 should not satisfy version 2.0.1", async () => {
|
|
1306
|
-
expect(await semverSatisfies(">= 1.0.0 <= 2.0.0", "2.0.1")).toBe(false);
|
|
1307
|
-
});
|
|
1308
|
-
};
|
|
1309
|
-
//#endregion
|
|
1310
|
-
//#region src/releaseCatalogHttpFixtures.ts
|
|
1311
|
-
const releaseCatalogDownloadUrl = (uri) => `https://storage.example.com/${encodeURIComponent(uri)}`;
|
|
1312
|
-
const targetId = createBundleRowFixture("302").id;
|
|
1313
|
-
const RELEASE_CATALOG_MANIFEST_URIS = {
|
|
1314
|
-
small: `storage://test-bucket/small/bundles/${targetId}/manifest.json`,
|
|
1315
|
-
large: `storage://test-bucket/large/bundles/${targetId}/manifest.json`
|
|
1316
|
-
};
|
|
1317
|
-
const RELEASE_CATALOG_DOWNLOAD_HASH = "a".repeat(64);
|
|
1318
|
-
const manifestAssets = { "index.ios.bundle": {
|
|
1319
|
-
fileHash: "target-hbc-hash",
|
|
1320
|
-
downloadFileHash: RELEASE_CATALOG_DOWNLOAD_HASH,
|
|
1321
|
-
downloadByteSize: 1e3
|
|
1322
|
-
} };
|
|
1323
|
-
const releaseCatalogArtifact = (bundle) => ({
|
|
1324
|
-
artifactProtocolVersion: 1,
|
|
1325
|
-
manifestUrl: releaseCatalogDownloadUrl(bundle.manifest_storage_uri),
|
|
1326
|
-
manifestFileHash: bundle.manifest_file_hash,
|
|
1327
|
-
assets: { "index.ios.bundle": {
|
|
1328
|
-
fileHash: "target-hbc-hash",
|
|
1329
|
-
file: {
|
|
1330
|
-
compression: "br",
|
|
1331
|
-
url: releaseCatalogDownloadUrl(`${bundle.asset_base_storage_uri}/sha256/aa/${RELEASE_CATALOG_DOWNLOAD_HASH}.br`)
|
|
1332
|
-
}
|
|
1333
|
-
} }
|
|
1334
|
-
});
|
|
1335
|
-
/** Install these storage objects in the test server, independently of its DB. */
|
|
1336
|
-
const RELEASE_CATALOG_STORAGE_FIXTURES = Object.fromEntries(Object.entries(RELEASE_CATALOG_MANIFEST_URIS).map(([size, uri]) => [uri, JSON.stringify({
|
|
1337
|
-
bundleId: targetId,
|
|
1338
|
-
assets: manifestAssets,
|
|
1339
|
-
archive: {
|
|
1340
|
-
downloadFileHash: "b".repeat(64),
|
|
1341
|
-
downloadByteSize: size === "small" ? 1 : 1e4,
|
|
1342
|
-
tarByteSize: 2e4
|
|
1343
|
-
}
|
|
1344
|
-
})]));
|
|
1345
|
-
/** Storage boundary shared by provider and separately spawned server tests. */
|
|
1346
|
-
const createReleaseCatalogTestStorage = () => ({
|
|
1347
|
-
name: "catalog-test-storage",
|
|
1348
|
-
protocol: "storage",
|
|
1349
|
-
async get({ storageUri }) {
|
|
1350
|
-
const suffix = /^storage:\/\/bundles\/([^/]+)\/manifest\.json$/.exec(storageUri)?.[1];
|
|
1351
|
-
const body = RELEASE_CATALOG_STORAGE_FIXTURES[storageUri] ?? (suffix === void 0 ? void 0 : JSON.stringify({
|
|
1352
|
-
bundleId: createBundleRowFixture(suffix).id,
|
|
1353
|
-
assets: manifestAssets
|
|
1354
|
-
}));
|
|
1355
|
-
return { response: body === void 0 ? null : new Response(body) };
|
|
1356
|
-
},
|
|
1357
|
-
async getDownloadUrl({ storageUri }) {
|
|
1358
|
-
return { url: releaseCatalogDownloadUrl(storageUri) };
|
|
1359
|
-
}
|
|
1360
|
-
});
|
|
1361
|
-
//#endregion
|
|
1362
|
-
//#region src/releaseCatalogLifecycleTests.ts
|
|
1363
|
-
function setupReleaseCatalogLifecycleTests(options) {
|
|
1364
|
-
const device = (minimumReleaseId = NIL_UUID) => ({
|
|
1365
|
-
builtInBundleId: minimumReleaseId,
|
|
1366
|
-
currentBundleId: minimumReleaseId,
|
|
1367
|
-
activeReleaseId: null,
|
|
1368
|
-
minimumReleaseId,
|
|
1369
|
-
cohort: "1",
|
|
1370
|
-
crashedBundleIds: []
|
|
1371
|
-
});
|
|
1372
|
-
const refresh = async (previous) => {
|
|
1373
|
-
let next = previous;
|
|
1374
|
-
await expect.poll(async () => {
|
|
1375
|
-
next = await options.readCatalog();
|
|
1376
|
-
return next.generation;
|
|
1377
|
-
}, {
|
|
1378
|
-
interval: 100,
|
|
1379
|
-
timeout: 1e4
|
|
1380
|
-
}).toBeGreaterThan(previous.generation);
|
|
1381
|
-
expect(next.catalogId).toBe(previous.catalogId);
|
|
1382
|
-
expect(next.catalogHash).not.toBe(previous.catalogHash);
|
|
1383
|
-
return next;
|
|
1384
|
-
};
|
|
1385
|
-
const install = async (catalog, app, target, status) => {
|
|
1386
|
-
const desired = selectDesiredRelease(catalog, app);
|
|
1387
|
-
expect(desired).toMatchObject({
|
|
1388
|
-
kind: "BUNDLE",
|
|
1389
|
-
releaseId: target.release.id,
|
|
1390
|
-
bundleId: target.bundle.id,
|
|
1391
|
-
status,
|
|
1392
|
-
release: {
|
|
1393
|
-
message: target.release.message,
|
|
1394
|
-
shouldForceUpdate: target.release.should_force_update
|
|
1395
|
-
}
|
|
1396
|
-
});
|
|
1397
|
-
const response = await options.request(`/artifacts/v1/${desired.bundleId}/from/${app.currentBundleId}`);
|
|
1398
|
-
expect(response.status).toBe(200);
|
|
1399
|
-
expect(await response.json()).toEqual(releaseCatalogArtifact(target.bundle));
|
|
1400
|
-
app.currentBundleId = desired.bundleId;
|
|
1401
|
-
app.activeReleaseId = desired.releaseId;
|
|
1402
|
-
};
|
|
1403
|
-
const useBuiltin = (catalog, app) => {
|
|
1404
|
-
expect(selectDesiredRelease(catalog, app)).toEqual({
|
|
1405
|
-
kind: "BUILTIN",
|
|
1406
|
-
releaseId: null,
|
|
1407
|
-
bundleId: app.builtInBundleId,
|
|
1408
|
-
release: null,
|
|
1409
|
-
status: "ROLLBACK"
|
|
1410
|
-
});
|
|
1411
|
-
app.currentBundleId = app.builtInBundleId;
|
|
1412
|
-
app.activeReleaseId = null;
|
|
1413
|
-
};
|
|
1414
|
-
const staysOn = (catalog, app) => {
|
|
1415
|
-
expect(selectDesiredRelease(catalog, app)).toMatchObject({
|
|
1416
|
-
bundleId: app.currentBundleId,
|
|
1417
|
-
releaseId: app.activeReleaseId
|
|
1418
|
-
});
|
|
1419
|
-
};
|
|
1420
|
-
describe("OTA lifecycle through HTTP and the client selector", () => {
|
|
1421
|
-
it("runs built-in → OTA A → OTA B → rollback A → built-in after policy changes", async () => {
|
|
1422
|
-
const app = device();
|
|
1423
|
-
const first = await options.publish("801");
|
|
1424
|
-
const second = await options.publish("802", {
|
|
1425
|
-
enabled: false,
|
|
1426
|
-
should_force_update: true
|
|
1427
|
-
});
|
|
1428
|
-
let catalog = await options.readCatalog();
|
|
1429
|
-
await install(catalog, app, first, "UPDATE");
|
|
1430
|
-
staysOn(catalog, app);
|
|
1431
|
-
await options.update(second.release.id, { enabled: true });
|
|
1432
|
-
catalog = await refresh(catalog);
|
|
1433
|
-
await install(catalog, app, second, "UPDATE");
|
|
1434
|
-
staysOn(catalog, app);
|
|
1435
|
-
await options.update(second.release.id, { enabled: false });
|
|
1436
|
-
catalog = await refresh(catalog);
|
|
1437
|
-
await install(catalog, app, first, "ROLLBACK");
|
|
1438
|
-
staysOn(catalog, app);
|
|
1439
|
-
await options.update(first.release.id, { enabled: false });
|
|
1440
|
-
catalog = await refresh(catalog);
|
|
1441
|
-
useBuiltin(catalog, app);
|
|
1442
|
-
staysOn(catalog, app);
|
|
1443
|
-
}, 4e4);
|
|
1444
|
-
it("rolls a deleted active Release back to older OTA bytes, then to built-in", async () => {
|
|
1445
|
-
const app = device();
|
|
1446
|
-
const first = await options.publish("811");
|
|
1447
|
-
const second = await options.publish("812");
|
|
1448
|
-
let catalog = await options.readCatalog();
|
|
1449
|
-
await install(catalog, app, second, "UPDATE");
|
|
1450
|
-
await options.remove(second.release.id);
|
|
1451
|
-
catalog = await refresh(catalog);
|
|
1452
|
-
await install(catalog, app, first, "ROLLBACK");
|
|
1453
|
-
await options.remove(first.release.id);
|
|
1454
|
-
catalog = await refresh(catalog);
|
|
1455
|
-
useBuiltin(catalog, app);
|
|
1456
|
-
staysOn(catalog, app);
|
|
1457
|
-
}, 3e4);
|
|
1458
|
-
it("respects the native minimum on fresh install and rollback with or without a Release receipt", async () => {
|
|
1459
|
-
await options.publish("821");
|
|
1460
|
-
const current = await options.publish("823", { enabled: false });
|
|
1461
|
-
const app = device(createBundleRowFixture("822").id);
|
|
1462
|
-
const catalog = await options.readCatalog();
|
|
1463
|
-
useBuiltin(catalog, app);
|
|
1464
|
-
for (const activeReleaseId of [current.release.id, null]) {
|
|
1465
|
-
app.currentBundleId = current.bundle.id;
|
|
1466
|
-
app.activeReleaseId = activeReleaseId;
|
|
1467
|
-
useBuiltin(catalog, app);
|
|
1468
|
-
}
|
|
1469
|
-
expect(selectDesiredRelease(catalog, {
|
|
1470
|
-
...device(current.bundle.id),
|
|
1471
|
-
activeReleaseId: current.release.id
|
|
1472
|
-
})).toBeNull();
|
|
1473
|
-
});
|
|
1474
|
-
it("re-evaluates cohort changes and rolls back even when the predecessor rollout is closed", async () => {
|
|
1475
|
-
const app = {
|
|
1476
|
-
...device(),
|
|
1477
|
-
cohort: "staff"
|
|
1478
|
-
};
|
|
1479
|
-
const first = await options.publish("831", {
|
|
1480
|
-
rollout_cohort_count: 0,
|
|
1481
|
-
target_cohorts: ["staff"]
|
|
1482
|
-
});
|
|
1483
|
-
const second = await options.publish("832", {
|
|
1484
|
-
rollout_cohort_count: 0,
|
|
1485
|
-
target_cohorts: ["staff"]
|
|
1486
|
-
});
|
|
1487
|
-
const catalog = await options.readCatalog();
|
|
1488
|
-
await install(catalog, app, second, "UPDATE");
|
|
1489
|
-
app.cohort = "1";
|
|
1490
|
-
await install(catalog, app, first, "ROLLBACK");
|
|
1491
|
-
useBuiltin(catalog, app);
|
|
1492
|
-
staysOn(catalog, app);
|
|
1493
|
-
});
|
|
1494
|
-
it("does not reinstall crashed OTAs after native recovery to a previous OTA or built-in", async () => {
|
|
1495
|
-
const app = device();
|
|
1496
|
-
const first = await options.publish("841");
|
|
1497
|
-
const second = await options.publish("842", { enabled: false });
|
|
1498
|
-
let catalog = await options.readCatalog();
|
|
1499
|
-
await install(catalog, app, first, "UPDATE");
|
|
1500
|
-
await options.update(second.release.id, { enabled: true });
|
|
1501
|
-
catalog = await refresh(catalog);
|
|
1502
|
-
await install(catalog, app, second, "UPDATE");
|
|
1503
|
-
app.currentBundleId = first.bundle.id;
|
|
1504
|
-
app.activeReleaseId = first.release.id;
|
|
1505
|
-
app.crashedBundleIds = [second.bundle.id];
|
|
1506
|
-
staysOn(catalog, app);
|
|
1507
|
-
app.currentBundleId = app.builtInBundleId;
|
|
1508
|
-
app.activeReleaseId = null;
|
|
1509
|
-
app.crashedBundleIds = [first.bundle.id, second.bundle.id];
|
|
1510
|
-
useBuiltin(catalog, app);
|
|
1511
|
-
staysOn(catalog, app);
|
|
1512
|
-
const fixed = await options.publish("843");
|
|
1513
|
-
catalog = await refresh(catalog);
|
|
1514
|
-
await install(catalog, app, fixed, "UPDATE");
|
|
1515
|
-
staysOn(catalog, app);
|
|
1516
|
-
}, 25e3);
|
|
1517
|
-
it("selects a compatible OTA and stays on it despite a newer incompatible Release", async () => {
|
|
1518
|
-
const app = device();
|
|
1519
|
-
const compatible = await options.publish("851");
|
|
1520
|
-
await options.publishIncompatible("852");
|
|
1521
|
-
const catalog = await options.readCatalog();
|
|
1522
|
-
await install(catalog, app, compatible, "UPDATE");
|
|
1523
|
-
staysOn(catalog, app);
|
|
1524
|
-
});
|
|
1525
|
-
});
|
|
1526
|
-
}
|
|
1527
|
-
//#endregion
|
|
1528
|
-
//#region src/setupReleaseCatalogTestSuite.ts
|
|
1529
|
-
const NIL_UUID$1 = "00000000-0000-0000-0000-000000000000";
|
|
1530
|
-
const FALLBACK_POLICY = "BUILTIN_IF_ACTIVE_INELIGIBLE";
|
|
1531
|
-
const channelKey = (channel) => btoa(String.fromCharCode(...new TextEncoder().encode(channel))).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
1532
|
-
/**
|
|
1533
|
-
* Server conformance through HTTP only, on admin API protocol 2. The caller
|
|
1534
|
-
* owns server, database, and storage setup. Releases are written with
|
|
1535
|
-
* `POST /releases` and changed with `PATCH /releases/:id`; lifecycle scenarios
|
|
1536
|
-
* feed HTTP catalogs to the production client selector. Each test deletes
|
|
1537
|
-
* what it wrote, so a long-lived server can run the suite again.
|
|
1538
|
-
*/
|
|
1539
|
-
const setupReleaseCatalogTestSuite = (options) => {
|
|
1540
|
-
describe("Release Catalog HTTP contract", () => {
|
|
1541
|
-
let channelNamespace;
|
|
1542
|
-
const api = createAdminApiTestClient(options.getClient);
|
|
1543
|
-
const { admin, adminJson } = api;
|
|
1544
|
-
beforeEach(() => {
|
|
1545
|
-
channelNamespace = crypto.randomUUID();
|
|
854
|
+
const setupReleaseCatalogTestSuite = (options) => {
|
|
855
|
+
describe("Release Catalog HTTP contract", () => {
|
|
856
|
+
let channelNamespace;
|
|
857
|
+
const api = createAdminApiTestClient(options.getClient);
|
|
858
|
+
const { admin, adminJson } = api;
|
|
859
|
+
beforeEach(() => {
|
|
860
|
+
channelNamespace = crypto.randomUUID();
|
|
1546
861
|
});
|
|
1547
862
|
afterEach(async () => {
|
|
1548
863
|
await api.cleanup();
|
|
@@ -1656,18 +971,32 @@ const setupReleaseCatalogTestSuite = (options) => {
|
|
|
1656
971
|
});
|
|
1657
972
|
expect((await adminJson(catalogRowPath())).data).toMatchObject({ generation: catalog.generation + 1 });
|
|
1658
973
|
});
|
|
1659
|
-
|
|
974
|
+
const hardDelete = async (release) => {
|
|
975
|
+
await adminJson(`/releases/${release.id}?confirm=${release.id}&expectedRevision=${release.revision}`, jsonRequest("DELETE"));
|
|
976
|
+
api.forget(release.id);
|
|
977
|
+
};
|
|
978
|
+
it("hard deletes a Release, rebuilds its Catalog, and deletes the artifact no other Release uses", async () => {
|
|
1660
979
|
const current = await publish("741", { enabled: false });
|
|
1661
|
-
await
|
|
1662
|
-
api.forget(current.release.id);
|
|
980
|
+
await hardDelete(current.release);
|
|
1663
981
|
expect((await admin(`/releases/${current.release.id}`)).status).toBe(404);
|
|
1664
982
|
expect(await readCatalog()).toMatchObject({
|
|
1665
983
|
releases: [],
|
|
1666
984
|
rollbackReleases: []
|
|
1667
985
|
});
|
|
1668
|
-
|
|
986
|
+
expect((await request(`/artifacts/v1/${current.bundle.id}/from/${NIL_UUID$1}`)).status).toBe(404);
|
|
987
|
+
});
|
|
988
|
+
it("keeps serving an artifact while another Release uses it", async () => {
|
|
989
|
+
const first = await publish("742", { enabled: false });
|
|
990
|
+
const second = await publish("742", {
|
|
991
|
+
bundle_id: first.bundle.id,
|
|
992
|
+
enabled: false
|
|
993
|
+
}, scope("other"));
|
|
994
|
+
await hardDelete(first.release);
|
|
995
|
+
const artifact = await request(`/artifacts/v1/${first.bundle.id}/from/${NIL_UUID$1}`);
|
|
1669
996
|
expect(artifact.status).toBe(200);
|
|
1670
|
-
expect(await artifact.json()).toMatchObject({ manifestUrl: releaseCatalogDownloadUrl(
|
|
997
|
+
expect(await artifact.json()).toMatchObject({ manifestUrl: releaseCatalogDownloadUrl(first.bundle.manifest_storage_uri) });
|
|
998
|
+
await hardDelete(second.release);
|
|
999
|
+
expect((await request(`/artifacts/v1/${first.bundle.id}/from/${NIL_UUID$1}`)).status).toBe(404);
|
|
1671
1000
|
});
|
|
1672
1001
|
it("serves enabled Releases newest first and resolves artifacts by Bundle identity", async () => {
|
|
1673
1002
|
const first = await publish("101");
|
|
@@ -1701,10 +1030,11 @@ const setupReleaseCatalogTestSuite = (options) => {
|
|
|
1701
1030
|
const otherPath = strategy === "APP_VERSION" ? `/release-catalogs/fingerprint/ios/${scope().channelKey}/fingerprint-a` : `/release-catalogs/app-version/ios/${scope().channelKey}/1.2.3`;
|
|
1702
1031
|
expect((await request(otherPath)).status).toBe(404);
|
|
1703
1032
|
});
|
|
1704
|
-
it("
|
|
1033
|
+
it("caches an unknown scope's 404 like a catalog", async () => {
|
|
1705
1034
|
const response = await request(catalogPath());
|
|
1706
1035
|
expect(response.status).toBe(404);
|
|
1707
|
-
expect(response.headers.get("cache-control")).toBe("
|
|
1036
|
+
expect(response.headers.get("cache-control")).toBe("public, max-age=0, s-maxage=5");
|
|
1037
|
+
expect(response.headers.get("x-hot-updater-catalog")).toBe("none");
|
|
1708
1038
|
await publish("121");
|
|
1709
1039
|
expect((await readCatalog()).releases).toHaveLength(1);
|
|
1710
1040
|
});
|
|
@@ -2016,9 +1346,8 @@ const setupReleaseCatalogTestSuite = (options) => {
|
|
|
2016
1346
|
//#region src/setupDatabaseTestSuite.ts
|
|
2017
1347
|
/**
|
|
2018
1348
|
* A provider's database under Hot Updater's server: core's operations, the
|
|
2019
|
-
* Release Catalog contract,
|
|
2020
|
-
*
|
|
2021
|
-
* Insights report contract in process.
|
|
1349
|
+
* Release Catalog contract, and bundles, all through HTTP, on the tables the
|
|
1350
|
+
* provider's tooling creates, then each opted-in plugin's suite.
|
|
2022
1351
|
*/
|
|
2023
1352
|
const setupDatabaseTestSuite = (options) => {
|
|
2024
1353
|
setupDatabaseTestRunner(options, ({ getDatabase }) => {
|
|
@@ -2040,10 +1369,11 @@ const setupDatabaseTestSuite = (options) => {
|
|
|
2040
1369
|
setupCoreAdminTestSuite({ getClient });
|
|
2041
1370
|
setupBundleMethodsTestSuite({ getClient });
|
|
2042
1371
|
setupReleaseCatalogTestSuite({ getClient });
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
1372
|
+
for (const suite of options.plugins ?? []) describe(suite.name, () => {
|
|
1373
|
+
suite.register({
|
|
1374
|
+
getClient,
|
|
1375
|
+
getDatabase
|
|
1376
|
+
});
|
|
2047
1377
|
});
|
|
2048
1378
|
});
|
|
2049
1379
|
};
|
|
@@ -2170,7 +1500,71 @@ const conformanceCounters = {
|
|
|
2170
1500
|
sort: ["shard"]
|
|
2171
1501
|
}]
|
|
2172
1502
|
};
|
|
2173
|
-
|
|
1503
|
+
/** Rows expire 1,000 ms after `at`; a null `at` never expires. */
|
|
1504
|
+
const conformanceExpiring = {
|
|
1505
|
+
name: "conformance_expiring",
|
|
1506
|
+
columns: [
|
|
1507
|
+
{
|
|
1508
|
+
name: "id",
|
|
1509
|
+
type: "string",
|
|
1510
|
+
nullable: false,
|
|
1511
|
+
maxLength: 64
|
|
1512
|
+
},
|
|
1513
|
+
{
|
|
1514
|
+
name: "grp",
|
|
1515
|
+
type: "string",
|
|
1516
|
+
nullable: false,
|
|
1517
|
+
maxLength: 64
|
|
1518
|
+
},
|
|
1519
|
+
{
|
|
1520
|
+
name: "at",
|
|
1521
|
+
type: "integer",
|
|
1522
|
+
nullable: true
|
|
1523
|
+
},
|
|
1524
|
+
{
|
|
1525
|
+
name: "tags",
|
|
1526
|
+
type: "string",
|
|
1527
|
+
nullable: true,
|
|
1528
|
+
maxLength: 64,
|
|
1529
|
+
multi: true
|
|
1530
|
+
},
|
|
1531
|
+
{
|
|
1532
|
+
name: "_v",
|
|
1533
|
+
type: "integer",
|
|
1534
|
+
nullable: false
|
|
1535
|
+
}
|
|
1536
|
+
],
|
|
1537
|
+
key: ["id"],
|
|
1538
|
+
indexes: [{
|
|
1539
|
+
name: "byGroup",
|
|
1540
|
+
eq: ["grp"],
|
|
1541
|
+
sort: ["id"]
|
|
1542
|
+
}, {
|
|
1543
|
+
name: "byTag",
|
|
1544
|
+
eq: ["tags"],
|
|
1545
|
+
sort: []
|
|
1546
|
+
}],
|
|
1547
|
+
retention: {
|
|
1548
|
+
column: "at",
|
|
1549
|
+
ms: 1e3
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
const tables = [
|
|
1553
|
+
conformanceItems,
|
|
1554
|
+
conformanceCounters,
|
|
1555
|
+
conformanceExpiring
|
|
1556
|
+
];
|
|
1557
|
+
const expiring = (id, at) => ({
|
|
1558
|
+
type: "insert",
|
|
1559
|
+
table: conformanceExpiring,
|
|
1560
|
+
row: {
|
|
1561
|
+
id,
|
|
1562
|
+
grp: "g",
|
|
1563
|
+
at,
|
|
1564
|
+
tags: ["t"],
|
|
1565
|
+
_v: 0
|
|
1566
|
+
}
|
|
1567
|
+
});
|
|
2174
1568
|
const item = (id, values = {}) => ({
|
|
2175
1569
|
id,
|
|
2176
1570
|
grp: "g",
|
|
@@ -2565,7 +1959,7 @@ const setupDatabaseAdapterConformanceSuite = (options) => {
|
|
|
2565
1959
|
for (;;) {
|
|
2566
1960
|
const last = read.at(-1);
|
|
2567
1961
|
const bound = last === void 0 ? {} : { [order === "asc" ? "lower" : "upper"]: {
|
|
2568
|
-
values:
|
|
1962
|
+
values: indexOrderColumns(conformanceItems, findPhysicalIndex(conformanceItems, "byGroup")).map((column) => last[column]),
|
|
2569
1963
|
inclusive: false
|
|
2570
1964
|
} };
|
|
2571
1965
|
const page = await query(adapter, {
|
|
@@ -2747,6 +2141,79 @@ const setupDatabaseAdapterConformanceSuite = (options) => {
|
|
|
2747
2141
|
const results = await Promise.all([adapter.write(skew(a, b)), adapter.write(skew(b, a))]);
|
|
2748
2142
|
expect(results.filter((result) => result.ok).length).toBeLessThanOrEqual(1);
|
|
2749
2143
|
});
|
|
2144
|
+
const expiringIds = async (adapter) => ({
|
|
2145
|
+
byGroup: ids(await adapter.query(conformanceExpiring, {
|
|
2146
|
+
index: "byGroup",
|
|
2147
|
+
eq: ["g"],
|
|
2148
|
+
order: "asc",
|
|
2149
|
+
limit: 500
|
|
2150
|
+
})),
|
|
2151
|
+
byTag: ids(await adapter.query(conformanceExpiring, {
|
|
2152
|
+
index: "byTag",
|
|
2153
|
+
eq: ["t"],
|
|
2154
|
+
order: "asc",
|
|
2155
|
+
limit: 500
|
|
2156
|
+
}))
|
|
2157
|
+
});
|
|
2158
|
+
it.skipIf(options.retention === "ttl")("prunes expired rows oldest first, up to the limit, with their index entries", async () => {
|
|
2159
|
+
const adapter = await setup();
|
|
2160
|
+
await ok(adapter, [
|
|
2161
|
+
expiring("a", 300),
|
|
2162
|
+
expiring("b", 100),
|
|
2163
|
+
expiring("c", 200),
|
|
2164
|
+
expiring("d", null),
|
|
2165
|
+
expiring("e", 5e3),
|
|
2166
|
+
expiring("f", 100)
|
|
2167
|
+
]);
|
|
2168
|
+
expect(await adapter.prune(conformanceExpiring, 250, 2)).toBe(2);
|
|
2169
|
+
expect((await expiringIds(adapter)).byGroup).toEqual([
|
|
2170
|
+
"a",
|
|
2171
|
+
"c",
|
|
2172
|
+
"d",
|
|
2173
|
+
"e"
|
|
2174
|
+
]);
|
|
2175
|
+
expect(await adapter.prune(conformanceExpiring, 250, 10)).toBe(1);
|
|
2176
|
+
expect(await adapter.prune(conformanceExpiring, 250, 10)).toBe(0);
|
|
2177
|
+
expect(await expiringIds(adapter)).toEqual({
|
|
2178
|
+
byGroup: [
|
|
2179
|
+
"a",
|
|
2180
|
+
"d",
|
|
2181
|
+
"e"
|
|
2182
|
+
],
|
|
2183
|
+
byTag: [
|
|
2184
|
+
"a",
|
|
2185
|
+
"d",
|
|
2186
|
+
"e"
|
|
2187
|
+
]
|
|
2188
|
+
});
|
|
2189
|
+
expect(await adapter.get(conformanceExpiring, [
|
|
2190
|
+
["b"],
|
|
2191
|
+
["c"],
|
|
2192
|
+
["d"]
|
|
2193
|
+
])).toEqual([
|
|
2194
|
+
null,
|
|
2195
|
+
null,
|
|
2196
|
+
expect.objectContaining({
|
|
2197
|
+
id: "d",
|
|
2198
|
+
at: null
|
|
2199
|
+
})
|
|
2200
|
+
]);
|
|
2201
|
+
await ok(adapter, [insert(item("kept"))]);
|
|
2202
|
+
expect(await adapter.prune(conformanceExpiring, 1e4, 10)).toBe(2);
|
|
2203
|
+
expect(ids(await query(adapter, {
|
|
2204
|
+
index: "all",
|
|
2205
|
+
eq: []
|
|
2206
|
+
}))).toEqual(["kept"]);
|
|
2207
|
+
});
|
|
2208
|
+
it.skipIf(options.retention !== "ttl")("leaves expired rows to the backend's TTL and reads them until it deletes them", async () => {
|
|
2209
|
+
const adapter = await setup();
|
|
2210
|
+
expect(adapter.prune).toBeUndefined();
|
|
2211
|
+
await ok(adapter, [expiring("old", 0), expiring("never", null)]);
|
|
2212
|
+
expect(await expiringIds(adapter)).toEqual({
|
|
2213
|
+
byGroup: ["never", "old"],
|
|
2214
|
+
byTag: ["never", "old"]
|
|
2215
|
+
});
|
|
2216
|
+
});
|
|
2750
2217
|
it.skipIf(options.maxOps === void 0)("rejects an over-limit write before sending it", async () => {
|
|
2751
2218
|
const adapter = await setup();
|
|
2752
2219
|
const ops = Array.from({ length: (options.maxOps ?? 0) + 1 }, (_, index) => insert(item(`over${index}`)));
|
|
@@ -2760,6 +2227,568 @@ const setupDatabaseAdapterConformanceSuite = (options) => {
|
|
|
2760
2227
|
});
|
|
2761
2228
|
};
|
|
2762
2229
|
//#endregion
|
|
2230
|
+
//#region src/storageAdapterTestCases.ts
|
|
2231
|
+
const BUNDLE_ID = "0199a0c3-6f6e-7c3a-9a3e-1b2c3d4e5f60";
|
|
2232
|
+
/** `sha256/9f/<hash>.png`: where deploy keeps a shared asset below `assets/`. */
|
|
2233
|
+
const ASSET_PATH = getContentAddressedAssetStoragePath({
|
|
2234
|
+
assetPath: "assets/logo.png",
|
|
2235
|
+
fileHash: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
|
2236
|
+
});
|
|
2237
|
+
const MANIFEST_KEY = createBundleStorageKey(BUNDLE_ID, "manifest.json");
|
|
2238
|
+
const ARCHIVE_KEY = createBundleStorageKey(BUNDLE_ID, "bundle.tar.br");
|
|
2239
|
+
const ASSET_KEY = `assets/${ASSET_PATH}`;
|
|
2240
|
+
const UNICODE_FOLDER = "unicode/한글 폴더";
|
|
2241
|
+
const UNICODE_KEY = `${UNICODE_FOLDER}/ünïcödé 😀.txt`;
|
|
2242
|
+
/** Keys whose URIs encode spaces, `#`, `%`, `@`, `+`, and Unicode. */
|
|
2243
|
+
const SPECIAL_KEYS = [
|
|
2244
|
+
"with space/file name.txt",
|
|
2245
|
+
"hash#1/100%.txt",
|
|
2246
|
+
"symbols/logo@2x+dark.png",
|
|
2247
|
+
UNICODE_KEY
|
|
2248
|
+
];
|
|
2249
|
+
/** How far a listed modification time may be from its put. */
|
|
2250
|
+
const CLOCK_SKEW_MS = 9e5;
|
|
2251
|
+
const encode = (text) => new TextEncoder().encode(text);
|
|
2252
|
+
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
2253
|
+
/** Deterministic bytes, so a reordered or dropped chunk shows up. */
|
|
2254
|
+
const patternBytes = (length, seed) => {
|
|
2255
|
+
const bytes = new Uint8Array(length);
|
|
2256
|
+
let state = seed;
|
|
2257
|
+
for (let index = 0; index < length; index += 1) {
|
|
2258
|
+
state ^= state << 13;
|
|
2259
|
+
state ^= state >>> 17;
|
|
2260
|
+
state ^= state << 5;
|
|
2261
|
+
bytes[index] = state;
|
|
2262
|
+
}
|
|
2263
|
+
return bytes;
|
|
2264
|
+
};
|
|
2265
|
+
/**
|
|
2266
|
+
* A one-shot body that hands out a chunk only when its reader asks for one,
|
|
2267
|
+
* so `read()` tells whether the adapter read all of it.
|
|
2268
|
+
*/
|
|
2269
|
+
const bodyOf = (bytes, chunkSize = 16384) => {
|
|
2270
|
+
let offset = 0;
|
|
2271
|
+
return {
|
|
2272
|
+
stream: new ReadableStream({ pull(controller) {
|
|
2273
|
+
if (offset >= bytes.byteLength) {
|
|
2274
|
+
controller.close();
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
const end = Math.min(offset + chunkSize, bytes.byteLength);
|
|
2278
|
+
controller.enqueue(bytes.slice(offset, end));
|
|
2279
|
+
offset = end;
|
|
2280
|
+
} }, { highWaterMark: 0 }),
|
|
2281
|
+
read: () => offset === bytes.byteLength
|
|
2282
|
+
};
|
|
2283
|
+
};
|
|
2284
|
+
/** A body that fails after its first chunk, like a file removed mid-upload. */
|
|
2285
|
+
const failingBody = (chunk) => {
|
|
2286
|
+
let sent = false;
|
|
2287
|
+
return new ReadableStream({ pull(controller) {
|
|
2288
|
+
if (sent) {
|
|
2289
|
+
controller.error(/* @__PURE__ */ new Error("The upload source failed."));
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
sent = true;
|
|
2293
|
+
controller.enqueue(chunk);
|
|
2294
|
+
} }, { highWaterMark: 0 });
|
|
2295
|
+
};
|
|
2296
|
+
/** The bucket and base path of a URI `put` returned for `key`. */
|
|
2297
|
+
const locate = (storage, storageUri, key, basePath) => {
|
|
2298
|
+
let parsed;
|
|
2299
|
+
try {
|
|
2300
|
+
parsed = parseStorageUri(storageUri, storage.protocol);
|
|
2301
|
+
} catch (error) {
|
|
2302
|
+
throw new Error(`put("${key}") returned "${storageUri}", which is not a canonical ${storage.protocol} storage URI: ${messageOf(error)}`);
|
|
2303
|
+
}
|
|
2304
|
+
if (basePath !== void 0) {
|
|
2305
|
+
const expected = createStorageKeyBuilder(basePath)(key);
|
|
2306
|
+
expect(parsed.key, `put("${key}") returned "${storageUri}"; below base path "${basePath}" its key is "${expected}"`).toBe(expected);
|
|
2307
|
+
}
|
|
2308
|
+
if (parsed.key === key) return {
|
|
2309
|
+
bucket: parsed.bucket,
|
|
2310
|
+
prefix: ""
|
|
2311
|
+
};
|
|
2312
|
+
if (parsed.key.endsWith(`/${key}`)) return {
|
|
2313
|
+
bucket: parsed.bucket,
|
|
2314
|
+
prefix: parsed.key.slice(0, -key.length - 1)
|
|
2315
|
+
};
|
|
2316
|
+
throw new Error(`put("${key}") returned "${storageUri}", whose key does not end with "${key}". Deploy derives the URIs of other objects from it.`);
|
|
2317
|
+
};
|
|
2318
|
+
/**
|
|
2319
|
+
* Puts objects the way deploy does and checks each URI `put` returns: a
|
|
2320
|
+
* canonical URI of the key, in the same bucket and below the same base path
|
|
2321
|
+
* as every other object.
|
|
2322
|
+
*/
|
|
2323
|
+
const objectsOf = (storage, basePath) => {
|
|
2324
|
+
let root;
|
|
2325
|
+
return {
|
|
2326
|
+
async put(key, bytes, { knownLength = true, chunkSize } = {}) {
|
|
2327
|
+
const body = bodyOf(bytes, chunkSize);
|
|
2328
|
+
const { storageUri } = await storage.put({
|
|
2329
|
+
key,
|
|
2330
|
+
body: body.stream,
|
|
2331
|
+
...knownLength ? { contentLength: bytes.byteLength } : {},
|
|
2332
|
+
contentType: "application/octet-stream"
|
|
2333
|
+
});
|
|
2334
|
+
expect(body.read(), `put("${key}") resolved before it read the whole body`).toBe(true);
|
|
2335
|
+
const location = locate(storage, storageUri, key, basePath);
|
|
2336
|
+
root ??= location;
|
|
2337
|
+
expect(location, `put("${key}") returned "${storageUri}", outside the bucket or base path of the objects before it`).toEqual(root);
|
|
2338
|
+
return storageUri;
|
|
2339
|
+
},
|
|
2340
|
+
/** The URI `put` returns for `key`, which the case has not put. */
|
|
2341
|
+
uriOf(key) {
|
|
2342
|
+
if (root === void 0) throw new Error("Put an object before deriving another one's URI.");
|
|
2343
|
+
return createStorageUri({
|
|
2344
|
+
protocol: storage.protocol,
|
|
2345
|
+
bucket: root.bucket,
|
|
2346
|
+
key: createStorageKeyBuilder(root.prefix)(key)
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
};
|
|
2350
|
+
};
|
|
2351
|
+
/** The object's bytes through `get`, or null when `get` finds none. */
|
|
2352
|
+
const readObject = async (storage, storageUri) => {
|
|
2353
|
+
const { response } = await storage.get({ storageUri });
|
|
2354
|
+
if (response === null) return null;
|
|
2355
|
+
expect(response, `get("${storageUri}") returned no Web Response`).toBeInstanceOf(Response);
|
|
2356
|
+
let bytes;
|
|
2357
|
+
try {
|
|
2358
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
2359
|
+
} catch (error) {
|
|
2360
|
+
throw new Error(`get("${storageUri}") returned a response whose body could not be read: ${messageOf(error)}`, { cause: error });
|
|
2361
|
+
}
|
|
2362
|
+
const contentLength = response.headers.get("content-length");
|
|
2363
|
+
if (contentLength !== null) expect(Number(contentLength), `get("${storageUri}") sent a content-length other than the object's size`).toBe(bytes.byteLength);
|
|
2364
|
+
return bytes;
|
|
2365
|
+
};
|
|
2366
|
+
const expectBytes = (actual, expected, label) => {
|
|
2367
|
+
expect(actual, `${label} found no object`).not.toBeNull();
|
|
2368
|
+
expect(actual.byteLength, `${label} returned a body of the wrong size`).toBe(expected.byteLength);
|
|
2369
|
+
expect(actual.findIndex((byte, index) => byte !== expected[index]), `${label} returned other bytes; first difference at index`).toBe(-1);
|
|
2370
|
+
};
|
|
2371
|
+
const expectObject = async (storage, storageUri, bytes) => {
|
|
2372
|
+
expectBytes(await readObject(storage, storageUri), bytes, `get("${storageUri}")`);
|
|
2373
|
+
if (storage.exists) expect(await storage.exists({ storageUri }), `exists("${storageUri}")`).toEqual({ exists: true });
|
|
2374
|
+
};
|
|
2375
|
+
const expectMissing = async (storage, storageUri) => {
|
|
2376
|
+
expect((await storage.get({ storageUri })).response, `get("${storageUri}") of a missing object`).toBeNull();
|
|
2377
|
+
if (storage.exists) expect((await storage.exists({ storageUri })).exists, `exists("${storageUri}") of a missing object`).toBe(false);
|
|
2378
|
+
};
|
|
2379
|
+
/** Expects a rejected promise: not a result, and not a synchronous throw. */
|
|
2380
|
+
const expectRejection = async (label, operation) => {
|
|
2381
|
+
let pending;
|
|
2382
|
+
try {
|
|
2383
|
+
pending = operation();
|
|
2384
|
+
} catch (error) {
|
|
2385
|
+
throw new Error(`${label} threw instead of returning a rejected promise: ${messageOf(error)}`);
|
|
2386
|
+
}
|
|
2387
|
+
const outcome = await Promise.resolve(pending).then(() => "resolved", () => "rejected");
|
|
2388
|
+
expect(outcome, label).toBe("rejected");
|
|
2389
|
+
};
|
|
2390
|
+
/** Every operation the adapter has that takes a storage URI rejects it. */
|
|
2391
|
+
const expectRejectedUri = async (storage, storageUri, owner) => {
|
|
2392
|
+
const calls = {
|
|
2393
|
+
get: storage.get && (() => storage.get({ storageUri })),
|
|
2394
|
+
exists: storage.exists && (() => storage.exists({ storageUri })),
|
|
2395
|
+
delete: storage.delete && (() => storage.delete({ storageUri })),
|
|
2396
|
+
getDownloadUrl: storage.getDownloadUrl && (() => storage.getDownloadUrl({ storageUri }))
|
|
2397
|
+
};
|
|
2398
|
+
for (const [operation, call] of Object.entries(calls)) {
|
|
2399
|
+
if (call === void 0) continue;
|
|
2400
|
+
await expectRejection(`${operation}("${storageUri}") of ${owner}`, call);
|
|
2401
|
+
}
|
|
2402
|
+
};
|
|
2403
|
+
const byKey = (left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0;
|
|
2404
|
+
const expectListing = (label, listed, expected, putWindow) => {
|
|
2405
|
+
expect(listed.map(({ key, storageUri, size }) => ({
|
|
2406
|
+
key,
|
|
2407
|
+
storageUri,
|
|
2408
|
+
size
|
|
2409
|
+
})).sort(byKey), label).toEqual([...expected].sort(byKey));
|
|
2410
|
+
for (const { key, lastModifiedAt } of listed) {
|
|
2411
|
+
if (lastModifiedAt === void 0) continue;
|
|
2412
|
+
expect(lastModifiedAt, `${label}: lastModifiedAt of "${key}"`).toBeInstanceOf(Date);
|
|
2413
|
+
const time = lastModifiedAt.getTime();
|
|
2414
|
+
expect(time >= putWindow.from - CLOCK_SKEW_MS && time <= putWindow.to + CLOCK_SKEW_MS, `${label}: "${key}" has lastModifiedAt ${String(lastModifiedAt)}, not the time it was put`).toBe(true);
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2417
|
+
const OPERATIONS = [
|
|
2418
|
+
"put",
|
|
2419
|
+
"get",
|
|
2420
|
+
"getDownloadUrl",
|
|
2421
|
+
"exists",
|
|
2422
|
+
"delete",
|
|
2423
|
+
"listObjects",
|
|
2424
|
+
"deleteObjects"
|
|
2425
|
+
];
|
|
2426
|
+
const describeArgument = (value) => {
|
|
2427
|
+
if (Array.isArray(value)) return `[${value.map((item) => JSON.stringify(item)).join(", ")}]`;
|
|
2428
|
+
if (typeof value === "object" && value !== null) {
|
|
2429
|
+
const { key, storageUri } = value;
|
|
2430
|
+
return JSON.stringify(storageUri ?? key);
|
|
2431
|
+
}
|
|
2432
|
+
return value === void 0 ? "" : JSON.stringify(value);
|
|
2433
|
+
};
|
|
2434
|
+
/**
|
|
2435
|
+
* The adapter with each rejection naming its call, such as
|
|
2436
|
+
* `delete("s3://bucket/key") rejected: ...`. A synchronous throw stays one.
|
|
2437
|
+
*/
|
|
2438
|
+
const namingCalls = (storage) => {
|
|
2439
|
+
const named = {
|
|
2440
|
+
name: storage.name,
|
|
2441
|
+
protocol: storage.protocol
|
|
2442
|
+
};
|
|
2443
|
+
for (const operation of OPERATIONS) {
|
|
2444
|
+
const call = storage[operation];
|
|
2445
|
+
if (typeof call !== "function") continue;
|
|
2446
|
+
named[operation] = (...args) => Promise.resolve(call.apply(storage, args)).catch((error) => {
|
|
2447
|
+
throw new Error(`${operation}(${args.map(describeArgument).join(", ")}) rejected: ${messageOf(error)}`, { cause: error });
|
|
2448
|
+
});
|
|
2449
|
+
}
|
|
2450
|
+
return named;
|
|
2451
|
+
};
|
|
2452
|
+
const storageCase = (title, operations, run) => ({
|
|
2453
|
+
title,
|
|
2454
|
+
operations,
|
|
2455
|
+
run: async ({ storage, basePath }) => {
|
|
2456
|
+
assertStorageOperations(storage, [
|
|
2457
|
+
"put",
|
|
2458
|
+
"get",
|
|
2459
|
+
...operations
|
|
2460
|
+
]);
|
|
2461
|
+
await run({
|
|
2462
|
+
storage: namingCalls(storage),
|
|
2463
|
+
basePath
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
});
|
|
2467
|
+
/**
|
|
2468
|
+
* The storage adapter contract, from what its consumers rely on: deploy and
|
|
2469
|
+
* patch (`put`, `get`, `exists`, `delete`), the Console, the server's update
|
|
2470
|
+
* responses and signed downloads (`get`, `getDownloadUrl`), and
|
|
2471
|
+
* `storage prune` (`listObjects`, `deleteObjects`).
|
|
2472
|
+
*/
|
|
2473
|
+
const storageAdapterTestCases = [
|
|
2474
|
+
storageCase("names the adapter and its URI protocol", [], async ({ storage }) => {
|
|
2475
|
+
expect(typeof storage.name, "name").toBe("string");
|
|
2476
|
+
expect(storage.name.length, "name length").toBeGreaterThan(0);
|
|
2477
|
+
expect(storage.protocol, "protocol").toMatch(/^[a-z][a-z\d+.-]*$/);
|
|
2478
|
+
}),
|
|
2479
|
+
storageCase("stores a streamed body and reads it back", [], async ({ storage, basePath }) => {
|
|
2480
|
+
const objects = objectsOf(storage, basePath);
|
|
2481
|
+
const bytes = patternBytes(100003, 1);
|
|
2482
|
+
const storageUri = await objects.put("round-trip/known-length.bin", bytes, { chunkSize: 4096 });
|
|
2483
|
+
expectBytes(await readObject(storage, storageUri), bytes, `get("${storageUri}")`);
|
|
2484
|
+
expectBytes(await readObject(storage, storageUri), bytes, `a second get("${storageUri}")`);
|
|
2485
|
+
}),
|
|
2486
|
+
storageCase("stores a streamed body of unknown length", [], async ({ storage, basePath }) => {
|
|
2487
|
+
const objects = objectsOf(storage, basePath);
|
|
2488
|
+
const bytes = patternBytes(20011, 2);
|
|
2489
|
+
const storageUri = await objects.put("round-trip/unknown-length.bin", bytes, {
|
|
2490
|
+
chunkSize: 1e3,
|
|
2491
|
+
knownLength: false
|
|
2492
|
+
});
|
|
2493
|
+
expectBytes(await readObject(storage, storageUri), bytes, `get("${storageUri}")`);
|
|
2494
|
+
}),
|
|
2495
|
+
storageCase("stores an empty object", [], async ({ storage, basePath }) => {
|
|
2496
|
+
const storageUri = await objectsOf(storage, basePath).put("round-trip/empty.txt", /* @__PURE__ */ new Uint8Array());
|
|
2497
|
+
expectBytes(await readObject(storage, storageUri), /* @__PURE__ */ new Uint8Array(), `get("${storageUri}")`);
|
|
2498
|
+
}),
|
|
2499
|
+
storageCase("keeps concurrent puts of different keys apart", [], async ({ storage, basePath }) => {
|
|
2500
|
+
const objects = objectsOf(storage, basePath);
|
|
2501
|
+
const entries = Array.from({ length: 8 }, (_, index) => ({
|
|
2502
|
+
key: `concurrent/${index}.bin`,
|
|
2503
|
+
bytes: patternBytes(4e3 + index, 10 + index)
|
|
2504
|
+
}));
|
|
2505
|
+
const storageUris = await Promise.all(entries.map(({ key, bytes }) => objects.put(key, bytes, { chunkSize: 512 })));
|
|
2506
|
+
for (const [index, { bytes }] of entries.entries()) expectBytes(await readObject(storage, storageUris[index]), bytes, `get("${storageUris[index]}")`);
|
|
2507
|
+
}),
|
|
2508
|
+
storageCase("returns the canonical URI of each key below the base path", [], async ({ storage, basePath }) => {
|
|
2509
|
+
const objects = objectsOf(storage, basePath);
|
|
2510
|
+
for (const key of [
|
|
2511
|
+
MANIFEST_KEY,
|
|
2512
|
+
ASSET_KEY,
|
|
2513
|
+
"nested/one/two/three/deep.txt",
|
|
2514
|
+
...SPECIAL_KEYS
|
|
2515
|
+
]) {
|
|
2516
|
+
const bytes = encode(`object at ${key}`);
|
|
2517
|
+
const storageUri = await objects.put(key, bytes);
|
|
2518
|
+
expectBytes(await readObject(storage, storageUri), bytes, `get("${storageUri}")`);
|
|
2519
|
+
}
|
|
2520
|
+
}),
|
|
2521
|
+
storageCase("reads objects at the URIs deploy derives from a manifest's URI", [], async ({ storage, basePath }) => {
|
|
2522
|
+
const objects = objectsOf(storage, basePath);
|
|
2523
|
+
const archive = patternBytes(3001, 3);
|
|
2524
|
+
const asset = patternBytes(1234, 4);
|
|
2525
|
+
const manifestUri = await objects.put(MANIFEST_KEY, encode(JSON.stringify({
|
|
2526
|
+
bundleId: BUNDLE_ID,
|
|
2527
|
+
assets: {}
|
|
2528
|
+
})));
|
|
2529
|
+
const archiveUri = await objects.put(ARCHIVE_KEY, archive);
|
|
2530
|
+
const assetUri = await objects.put(ASSET_KEY, asset);
|
|
2531
|
+
const derivedArchiveUri = getBundleArchiveStorageUri({
|
|
2532
|
+
manifestStorageUri: manifestUri,
|
|
2533
|
+
bundleId: BUNDLE_ID
|
|
2534
|
+
});
|
|
2535
|
+
const derivedAssetUri = createStorageUriWithRelativePath({
|
|
2536
|
+
baseStorageUri: createStorageRootUriWithPath(manifestUri, BUNDLE_ID, "assets"),
|
|
2537
|
+
relativePath: ASSET_PATH
|
|
2538
|
+
});
|
|
2539
|
+
expect(derivedArchiveUri, "the archive URI derived from the manifest's").toBe(archiveUri);
|
|
2540
|
+
expect(derivedAssetUri, "the asset URI derived from the manifest's").toBe(assetUri);
|
|
2541
|
+
await expectObject(storage, derivedArchiveUri, archive);
|
|
2542
|
+
await expectObject(storage, derivedAssetUri, asset);
|
|
2543
|
+
}),
|
|
2544
|
+
storageCase("replaces an object put again under the same key", [], async ({ storage, basePath }) => {
|
|
2545
|
+
const objects = objectsOf(storage, basePath);
|
|
2546
|
+
const key = "overwrite/object.txt";
|
|
2547
|
+
const storageUri = await objects.put(key, encode("first"));
|
|
2548
|
+
expect(await objects.put(key, encode("second")), "a second put").toBe(storageUri);
|
|
2549
|
+
expectBytes(await readObject(storage, storageUri), encode("second"), `get("${storageUri}")`);
|
|
2550
|
+
expect(await Promise.all([objects.put(key, encode("shared")), objects.put(key, encode("shared"))]), "concurrent puts of one key").toEqual([storageUri, storageUri]);
|
|
2551
|
+
expectBytes(await readObject(storage, storageUri), encode("shared"), `get("${storageUri}")`);
|
|
2552
|
+
}),
|
|
2553
|
+
storageCase("rejects a put whose body fails and keeps no object", [], async ({ storage, basePath }) => {
|
|
2554
|
+
const objects = objectsOf(storage, basePath);
|
|
2555
|
+
await objects.put("failed-put/other.txt", encode("other"));
|
|
2556
|
+
const key = "failed-put/object.bin";
|
|
2557
|
+
await expectRejection(`put("${key}") of a body that fails`, () => storage.put({
|
|
2558
|
+
key,
|
|
2559
|
+
body: failingBody(patternBytes(4e3, 5)),
|
|
2560
|
+
contentLength: 1e4,
|
|
2561
|
+
contentType: "application/octet-stream"
|
|
2562
|
+
}));
|
|
2563
|
+
await expectMissing(storage, objects.uriOf(key));
|
|
2564
|
+
}),
|
|
2565
|
+
storageCase("returns a null response for a missing object", [], async ({ storage, basePath }) => {
|
|
2566
|
+
const objects = objectsOf(storage, basePath);
|
|
2567
|
+
await objects.put("missing/object.txt", encode("present"));
|
|
2568
|
+
for (const key of [
|
|
2569
|
+
"missing/absent.txt",
|
|
2570
|
+
"missing/object.tx",
|
|
2571
|
+
"missing/object.txt.bak",
|
|
2572
|
+
"missing"
|
|
2573
|
+
]) {
|
|
2574
|
+
const storageUri = objects.uriOf(key);
|
|
2575
|
+
expect(await storage.get({ storageUri }), `get("${storageUri}") of a missing object`).toEqual({ response: null });
|
|
2576
|
+
}
|
|
2577
|
+
}),
|
|
2578
|
+
storageCase("tells whether an object exists", ["exists"], async ({ storage, basePath }) => {
|
|
2579
|
+
const objects = objectsOf(storage, basePath);
|
|
2580
|
+
const storageUri = await objects.put("exists/object.txt", encode("present"));
|
|
2581
|
+
expect(await storage.exists({ storageUri }), `exists("${storageUri}")`).toEqual({ exists: true });
|
|
2582
|
+
for (const key of [
|
|
2583
|
+
"exists/absent.txt",
|
|
2584
|
+
"exists/object.tx",
|
|
2585
|
+
"exists"
|
|
2586
|
+
]) {
|
|
2587
|
+
const missingUri = objects.uriOf(key);
|
|
2588
|
+
expect(await storage.exists({ storageUri: missingUri }), `exists("${missingUri}")`).toEqual({ exists: false });
|
|
2589
|
+
}
|
|
2590
|
+
}),
|
|
2591
|
+
storageCase("deletes exactly one object, and deleting it again succeeds", ["delete"], async ({ storage, basePath }) => {
|
|
2592
|
+
const objects = objectsOf(storage, basePath);
|
|
2593
|
+
const storageUri = await objects.put("delete/object.txt", encode("gone"));
|
|
2594
|
+
const sibling = encode("kept");
|
|
2595
|
+
const siblingUri = await objects.put("delete/object.txt.bak", sibling);
|
|
2596
|
+
const child = encode("kept child");
|
|
2597
|
+
const childUri = await objects.put("delete/object/child.txt", child);
|
|
2598
|
+
expect(await storage.delete({ storageUri }), `delete("${storageUri}")`).toEqual({ deleted: true });
|
|
2599
|
+
await expectMissing(storage, storageUri);
|
|
2600
|
+
expect(await storage.delete({ storageUri }), `a second delete("${storageUri}")`).toEqual({ deleted: true });
|
|
2601
|
+
for (const key of ["delete/never-stored.txt", "delete/object"]) {
|
|
2602
|
+
const missingUri = objects.uriOf(key);
|
|
2603
|
+
expect(await storage.delete({ storageUri: missingUri }), `delete("${missingUri}")`).toEqual({ deleted: true });
|
|
2604
|
+
}
|
|
2605
|
+
await expectObject(storage, siblingUri, sibling);
|
|
2606
|
+
await expectObject(storage, childUri, child);
|
|
2607
|
+
}),
|
|
2608
|
+
storageCase("rejects the URI of an object in another bucket", [], async ({ storage, basePath }) => {
|
|
2609
|
+
const objects = objectsOf(storage, basePath);
|
|
2610
|
+
const bytes = encode("kept");
|
|
2611
|
+
const storageUri = await objects.put("foreign/object.txt", bytes);
|
|
2612
|
+
const { bucket, key } = parseStorageUri(storageUri, storage.protocol);
|
|
2613
|
+
await expectRejectedUri(storage, createStorageUri({
|
|
2614
|
+
protocol: storage.protocol,
|
|
2615
|
+
bucket: `${bucket}-other`,
|
|
2616
|
+
key
|
|
2617
|
+
}), "another bucket");
|
|
2618
|
+
await expectObject(storage, storageUri, bytes);
|
|
2619
|
+
}),
|
|
2620
|
+
storageCase("rejects the URI of another protocol", [], async ({ storage, basePath }) => {
|
|
2621
|
+
const objects = objectsOf(storage, basePath);
|
|
2622
|
+
const bytes = encode("kept");
|
|
2623
|
+
const storageUri = await objects.put("foreign/object.txt", bytes);
|
|
2624
|
+
const { bucket, key } = parseStorageUri(storageUri, storage.protocol);
|
|
2625
|
+
await expectRejectedUri(storage, createStorageUri({
|
|
2626
|
+
protocol: `not-${storage.protocol}`,
|
|
2627
|
+
bucket,
|
|
2628
|
+
key
|
|
2629
|
+
}), "another protocol");
|
|
2630
|
+
await expectObject(storage, storageUri, bytes);
|
|
2631
|
+
}),
|
|
2632
|
+
storageCase("resolves a download URL for a stored object", ["getDownloadUrl"], async ({ storage, basePath }) => {
|
|
2633
|
+
const objects = objectsOf(storage, basePath);
|
|
2634
|
+
for (const key of ["download/object.txt", ...SPECIAL_KEYS]) {
|
|
2635
|
+
const storageUri = await objects.put(key, encode(key));
|
|
2636
|
+
const { url } = await storage.getDownloadUrl({ storageUri });
|
|
2637
|
+
const label = `getDownloadUrl("${storageUri}") returned "${url}"`;
|
|
2638
|
+
expect(typeof url, label).toBe("string");
|
|
2639
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(url)) {
|
|
2640
|
+
expect(["http:", "https:"], `${label}; a URL must use http(s)`).toContain(new URL(url).protocol);
|
|
2641
|
+
continue;
|
|
2642
|
+
}
|
|
2643
|
+
expect(parseStorageDownloadPath(url)?.storageUri, `${label}; a path must be the /storage/ path of the same URI`).toBe(storageUri);
|
|
2644
|
+
expect((await storage.getDownloadUrl({ storageUri })).url, `${label}, then another path`).toBe(url);
|
|
2645
|
+
}
|
|
2646
|
+
}),
|
|
2647
|
+
storageCase("lists every object with its key relative to the base path", ["listObjects"], async ({ storage, basePath }) => {
|
|
2648
|
+
const objects = objectsOf(storage, basePath);
|
|
2649
|
+
const entries = [
|
|
2650
|
+
[MANIFEST_KEY, encode("{}")],
|
|
2651
|
+
[ARCHIVE_KEY, patternBytes(3001, 6)],
|
|
2652
|
+
[ASSET_KEY, patternBytes(1234, 7)],
|
|
2653
|
+
["bundles-other/object.txt", encode("next to bundles/")],
|
|
2654
|
+
...SPECIAL_KEYS.map((key) => [key, encode(key)])
|
|
2655
|
+
];
|
|
2656
|
+
const from = Date.now();
|
|
2657
|
+
const stored = [];
|
|
2658
|
+
for (const [key, bytes] of entries) stored.push({
|
|
2659
|
+
key,
|
|
2660
|
+
storageUri: await objects.put(key, bytes),
|
|
2661
|
+
size: bytes.byteLength
|
|
2662
|
+
});
|
|
2663
|
+
const putWindow = {
|
|
2664
|
+
from,
|
|
2665
|
+
to: Date.now()
|
|
2666
|
+
};
|
|
2667
|
+
const below = (folder) => stored.filter(({ key }) => key.startsWith(`${folder}/`));
|
|
2668
|
+
expectListing("listObjects()", await storage.listObjects(), stored, putWindow);
|
|
2669
|
+
expectListing("listObjects(\"bundles\")", await storage.listObjects("bundles"), below("bundles"), putWindow);
|
|
2670
|
+
expectListing(`listObjects("${UNICODE_FOLDER}")`, await storage.listObjects(UNICODE_FOLDER), below(UNICODE_FOLDER), putWindow);
|
|
2671
|
+
}),
|
|
2672
|
+
storageCase("deletes exactly the keys it receives", ["deleteObjects"], async ({ storage, basePath }) => {
|
|
2673
|
+
const objects = objectsOf(storage, basePath);
|
|
2674
|
+
const put = async (key) => {
|
|
2675
|
+
const bytes = encode(key);
|
|
2676
|
+
return {
|
|
2677
|
+
key,
|
|
2678
|
+
bytes,
|
|
2679
|
+
storageUri: await objects.put(key, bytes)
|
|
2680
|
+
};
|
|
2681
|
+
};
|
|
2682
|
+
const removed = [await put("delete-objects/object.txt"), await put(`delete-objects/${UNICODE_KEY}`)];
|
|
2683
|
+
const kept = [await put("delete-objects/object.txt.bak"), await put("delete-objects/folder/child.txt")];
|
|
2684
|
+
await storage.deleteObjects([]);
|
|
2685
|
+
for (const object of [...removed, ...kept]) await expectObject(storage, object.storageUri, object.bytes);
|
|
2686
|
+
await storage.deleteObjects([
|
|
2687
|
+
...removed.map(({ key }) => key),
|
|
2688
|
+
"delete-objects/never-stored.txt",
|
|
2689
|
+
"delete-objects/folder"
|
|
2690
|
+
]);
|
|
2691
|
+
for (const object of removed) await expectMissing(storage, object.storageUri);
|
|
2692
|
+
for (const object of kept) await expectObject(storage, object.storageUri, object.bytes);
|
|
2693
|
+
if (storage.listObjects) expect((await storage.listObjects()).map(({ key }) => key).sort(), "listObjects() after deleteObjects").toEqual(kept.map(({ key }) => key).sort());
|
|
2694
|
+
})
|
|
2695
|
+
];
|
|
2696
|
+
//#endregion
|
|
2697
|
+
//#region src/setupStorageAdapterTestSuite.ts
|
|
2698
|
+
/**
|
|
2699
|
+
* The storage adapter contract that deploy, patch, the Console, the server,
|
|
2700
|
+
* and `storage prune` rely on. Every official storage adapter runs it.
|
|
2701
|
+
*/
|
|
2702
|
+
const setupStorageAdapterTestSuite = (options) => {
|
|
2703
|
+
const declared = options.operations;
|
|
2704
|
+
const required = [.../* @__PURE__ */ new Set([
|
|
2705
|
+
"put",
|
|
2706
|
+
"get",
|
|
2707
|
+
...declared ?? []
|
|
2708
|
+
])];
|
|
2709
|
+
describe(`${options.name} storage adapter`, () => {
|
|
2710
|
+
it(`implements ${required.join(", ")}`, async () => {
|
|
2711
|
+
const { storage, cleanup } = await options.createStorage();
|
|
2712
|
+
try {
|
|
2713
|
+
assertStorageOperations(storage, required);
|
|
2714
|
+
} finally {
|
|
2715
|
+
await cleanup?.();
|
|
2716
|
+
}
|
|
2717
|
+
});
|
|
2718
|
+
for (const testCase of storageAdapterTestCases) {
|
|
2719
|
+
const undeclared = declared === void 0 ? [] : testCase.operations.filter((operation) => !declared.includes(operation));
|
|
2720
|
+
it.skipIf(undeclared.length > 0)(testCase.title, async (context) => {
|
|
2721
|
+
const { storage, basePath, cleanup } = await options.createStorage();
|
|
2722
|
+
try {
|
|
2723
|
+
const missing = testCase.operations.filter((operation) => typeof storage[operation] !== "function");
|
|
2724
|
+
if (declared === void 0 && missing.length > 0) context.skip(`${storage.name} lacks ${missing.join(", ")}`);
|
|
2725
|
+
await testCase.run({
|
|
2726
|
+
storage,
|
|
2727
|
+
basePath
|
|
2728
|
+
});
|
|
2729
|
+
} finally {
|
|
2730
|
+
await cleanup?.();
|
|
2731
|
+
}
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
});
|
|
2735
|
+
};
|
|
2736
|
+
//#endregion
|
|
2737
|
+
//#region src/createMeasuredDatabase.ts
|
|
2738
|
+
const storageOf = (storage) => ({
|
|
2739
|
+
name: "measuredStorage",
|
|
2740
|
+
protocol: "storage",
|
|
2741
|
+
get: async ({ storageUri }) => {
|
|
2742
|
+
const text = await storage.readStorageText?.(storageUri) ?? null;
|
|
2743
|
+
return { response: text === null ? null : new Response(text) };
|
|
2744
|
+
},
|
|
2745
|
+
getDownloadUrl: async ({ storageUri }) => ({ url: await storage.resolveFileUrl(storageUri) })
|
|
2746
|
+
});
|
|
2747
|
+
/**
|
|
2748
|
+
* Core and the plugins over `adapter` as `createHotUpdater` runs them: what
|
|
2749
|
+
* read-budget suites measure. The database is `meterReads` over
|
|
2750
|
+
* `verifyAdapter(adapter)`, so every adapter call is checked against the
|
|
2751
|
+
* contract and `measureReads` reports each call's reads; the adapter runs
|
|
2752
|
+
* as given, without the schema fence. It loads `@hot-updater/server`, an
|
|
2753
|
+
* optional peer, when called.
|
|
2754
|
+
*/
|
|
2755
|
+
const createMeasuredDatabase = async (adapter, plugins, options = {}) => {
|
|
2756
|
+
const { createHotUpdater } = await import("@hot-updater/server");
|
|
2757
|
+
let clientAuth;
|
|
2758
|
+
const observed = plugins.map((plugin) => Object.defineProperties({}, {
|
|
2759
|
+
...Object.getOwnPropertyDescriptors(plugin),
|
|
2760
|
+
init: {
|
|
2761
|
+
configurable: true,
|
|
2762
|
+
enumerable: true,
|
|
2763
|
+
writable: true,
|
|
2764
|
+
value: (context) => {
|
|
2765
|
+
const instance = plugin.init(options.now === void 0 ? context : {
|
|
2766
|
+
...context,
|
|
2767
|
+
now: options.now
|
|
2768
|
+
});
|
|
2769
|
+
if (instance.clientAuth !== void 0) clientAuth = instance.clientAuth;
|
|
2770
|
+
return instance;
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
}));
|
|
2774
|
+
const database = meterReads({
|
|
2775
|
+
name: "measured",
|
|
2776
|
+
adapter: verifyAdapter(adapter)
|
|
2777
|
+
});
|
|
2778
|
+
const hotUpdater = createHotUpdater({
|
|
2779
|
+
database,
|
|
2780
|
+
storage: options.storage === void 0 ? [] : [storageOf(options.storage)],
|
|
2781
|
+
plugins: observed,
|
|
2782
|
+
...plugins.some((plugin) => plugin.provides?.clientAuth === true) ? {} : { clientAccess: "public" }
|
|
2783
|
+
});
|
|
2784
|
+
return {
|
|
2785
|
+
core: hotUpdater.core,
|
|
2786
|
+
api: hotUpdater.api,
|
|
2787
|
+
...clientAuth === void 0 ? {} : { clientAuth },
|
|
2788
|
+
measureReads: database.measureReads
|
|
2789
|
+
};
|
|
2790
|
+
};
|
|
2791
|
+
//#endregion
|
|
2763
2792
|
//#region src/setupReadBudgetTestSuite.ts
|
|
2764
2793
|
const DAY = 864e5;
|
|
2765
2794
|
const HOUR = 36e5;
|
|
@@ -2792,7 +2821,6 @@ const eventOf = (n, overrides = {}) => ({
|
|
|
2792
2821
|
app_version: "1.0.0",
|
|
2793
2822
|
channel: "production",
|
|
2794
2823
|
metadata: {
|
|
2795
|
-
username: null,
|
|
2796
2824
|
cohort: "1",
|
|
2797
2825
|
update_strategy: "appVersion",
|
|
2798
2826
|
fingerprint_hash: null,
|
|
@@ -2802,13 +2830,38 @@ const eventOf = (n, overrides = {}) => ({
|
|
|
2802
2830
|
...overrides
|
|
2803
2831
|
});
|
|
2804
2832
|
/**
|
|
2805
|
-
*
|
|
2806
|
-
* and
|
|
2833
|
+
* Update failures on day 3, after every other windowed read's range: installs
|
|
2834
|
+
* 3 and 5 fail to download release-b, and install 7's update check fails.
|
|
2807
2835
|
*/
|
|
2808
|
-
const
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2836
|
+
const failureOf = (n, install, check = false) => eventOf(200 + n, {
|
|
2837
|
+
type: "UPDATE_FAILED",
|
|
2838
|
+
install_id: `install-${install}`,
|
|
2839
|
+
from_release_id: "release-a",
|
|
2840
|
+
from_bundle_id: "bundle-a",
|
|
2841
|
+
to_release_id: check ? null : "release-b",
|
|
2842
|
+
to_bundle_id: check ? "bundle-a" : "bundle-b",
|
|
2843
|
+
metadata: {
|
|
2844
|
+
...eventOf(0).metadata,
|
|
2845
|
+
failure: check ? {
|
|
2846
|
+
stage: "check",
|
|
2847
|
+
reason: "http",
|
|
2848
|
+
http_status: 500
|
|
2849
|
+
} : {
|
|
2850
|
+
stage: "download",
|
|
2851
|
+
reason: "http",
|
|
2852
|
+
http_status: 403
|
|
2853
|
+
}
|
|
2854
|
+
},
|
|
2855
|
+
received_at_ms: T0 + 3 * DAY + n * 10 * 6e4
|
|
2856
|
+
});
|
|
2857
|
+
/**
|
|
2858
|
+
* History event `n` of the day before T0, three an hour, on its own release
|
|
2859
|
+
* and bundles: every windowed read, which starts at T0 or later, skips it.
|
|
2860
|
+
*/
|
|
2861
|
+
const historyOf = (n) => eventOf(100 + n, {
|
|
2862
|
+
install_id: `history-${n}`,
|
|
2863
|
+
user_id: null,
|
|
2864
|
+
from_bundle_id: "bundle-history-a",
|
|
2812
2865
|
to_release_id: "release-history",
|
|
2813
2866
|
to_bundle_id: "bundle-history-b",
|
|
2814
2867
|
received_at_ms: T0 - DAY + Math.floor((n - 1) / 3) * HOUR + (n - 1) % 3 * 6e5
|
|
@@ -2839,7 +2892,7 @@ const storage = {
|
|
|
2839
2892
|
bundleId: createBundleFixture("106").id,
|
|
2840
2893
|
assets: { "index.ios.bundle": { fileHash: "hbc-hash-106" } }
|
|
2841
2894
|
}) : null,
|
|
2842
|
-
resolveFileUrl: async (storageUri) =>
|
|
2895
|
+
resolveFileUrl: async (storageUri) => `https://storage.example.com/${encodeURIComponent(storageUri)}`
|
|
2843
2896
|
};
|
|
2844
2897
|
/**
|
|
2845
2898
|
* Core's bundles, releases, channels, and a client API key, and 25 Insights
|
|
@@ -2847,8 +2900,8 @@ const storage = {
|
|
|
2847
2900
|
* days later; and the day of history before T0, as a production database
|
|
2848
2901
|
* holds, so a read that scans beyond its window examines more rows.
|
|
2849
2902
|
*/
|
|
2850
|
-
const seed = async (database
|
|
2851
|
-
const
|
|
2903
|
+
const seed = async (database) => {
|
|
2904
|
+
const core = database.core;
|
|
2852
2905
|
const api = database.api;
|
|
2853
2906
|
for (const name of [
|
|
2854
2907
|
"staging",
|
|
@@ -2894,6 +2947,9 @@ const seed = async (database, server) => {
|
|
|
2894
2947
|
received_at_ms: T0 + 2 * DAY
|
|
2895
2948
|
}));
|
|
2896
2949
|
for (let n = 1; n <= 72; n += 1) await api.insights.recordEvent(historyOf(n));
|
|
2950
|
+
await api.insights.recordEvent(failureOf(1, 3));
|
|
2951
|
+
await api.insights.recordEvent(failureOf(2, 5));
|
|
2952
|
+
await api.insights.recordEvent(failureOf(3, 7, true));
|
|
2897
2953
|
const channel = (name) => core.findChannelByName(name).then((found) => found);
|
|
2898
2954
|
const productionId = (await channel("production")).id;
|
|
2899
2955
|
return {
|
|
@@ -2904,7 +2960,7 @@ const seed = async (database, server) => {
|
|
|
2904
2960
|
productionId,
|
|
2905
2961
|
nightlyId: (await channel("nightly")).id,
|
|
2906
2962
|
/** The auto-patch base key of a new production iOS 1.0.0 bundle. */
|
|
2907
|
-
candidateKey:
|
|
2963
|
+
candidateKey: targetBaseCandidateKey({
|
|
2908
2964
|
channel: "production",
|
|
2909
2965
|
platform: "ios",
|
|
2910
2966
|
fingerprintHash: null,
|
|
@@ -3013,535 +3069,1826 @@ const READ_BUDGETS = [
|
|
|
3013
3069
|
kind: "channelPlatform",
|
|
3014
3070
|
channelId: productionId,
|
|
3015
3071
|
platform: "ios",
|
|
3016
|
-
enabled: true
|
|
3017
|
-
},
|
|
3018
|
-
limit: 3
|
|
3019
|
-
}),
|
|
3020
|
-
adapter: reads(0, 0, 1, 3),
|
|
3021
|
-
engine: {
|
|
3022
|
-
calls: 1,
|
|
3023
|
-
rows: 3
|
|
3024
|
-
},
|
|
3025
|
-
returned: (releases) => releases.length
|
|
3026
|
-
}),
|
|
3027
|
-
budget({
|
|
3028
|
-
api: "deploy base candidates: 1 catalog point read",
|
|
3029
|
-
read: ({ core, candidateKey }) => core.findBaseBundleIds(candidateKey, createBundleFixture("107").id, 2),
|
|
3030
|
-
adapter: reads(1, 1, 0, 0),
|
|
3031
|
-
engine: {
|
|
3032
|
-
calls: 1,
|
|
3033
|
-
rows: 1
|
|
3034
|
-
},
|
|
3035
|
-
check: (ids) => expect(ids).toEqual(["106", "105"].map((suffix) => createBundleFixture(suffix).id))
|
|
3036
|
-
}),
|
|
3037
|
-
budget({
|
|
3038
|
-
api: "deploy latest release id: byScope descending, limit 1",
|
|
3039
|
-
read: ({ core }) => core.latestReleaseId(scopeKey),
|
|
3040
|
-
adapter: reads(0, 0, 1, 1),
|
|
3041
|
-
engine: {
|
|
3042
|
-
calls: 1,
|
|
3043
|
-
rows: 1
|
|
3044
|
-
}
|
|
3045
|
-
}),
|
|
3046
|
-
budget({
|
|
3047
|
-
api: "channel by name: 1 read",
|
|
3048
|
-
read: ({ core }) => core.findChannelByName("staging"),
|
|
3049
|
-
adapter: reads(0, 0, 1, 1),
|
|
3050
|
-
engine: {
|
|
3051
|
-
calls: 1,
|
|
3052
|
-
rows: 1
|
|
3053
|
-
},
|
|
3054
|
-
returned: (channel) => channel === null ? 0 : 1
|
|
3055
|
-
}),
|
|
3056
|
-
budget({
|
|
3057
|
-
api: "list events of a dense day: limit rows from one query",
|
|
3058
|
-
read: ({ insights }) => insights.listEvents({
|
|
3059
|
-
filter: { kind: "all" },
|
|
3060
|
-
sinceMs: T0 - DAY,
|
|
3061
|
-
beforeReceivedAtMs: T0,
|
|
3062
|
-
limit: 5
|
|
3063
|
-
}),
|
|
3064
|
-
adapter: reads(0, 0, 1, 5),
|
|
3065
|
-
engine: {
|
|
3066
|
-
calls: 1,
|
|
3067
|
-
rows: 5
|
|
3068
|
-
},
|
|
3069
|
-
returned: (events) => events.length
|
|
3070
|
-
}),
|
|
3071
|
-
budget({
|
|
3072
|
-
api: "list events across a gap: limit rows, one empty day, and one outcome row",
|
|
3073
|
-
read: ({ insights }) => insights.listEvents({
|
|
3074
|
-
filter: bundleEvents,
|
|
3075
|
-
sinceMs: T0,
|
|
3076
|
-
beforeReceivedAtMs: T0 + 3 * DAY,
|
|
3077
|
-
limit: 5
|
|
3078
|
-
}),
|
|
3079
|
-
adapter: reads(1, 7, 4, 6),
|
|
3080
|
-
engine: {
|
|
3081
|
-
calls: 4,
|
|
3082
|
-
rows: 6
|
|
3083
|
-
},
|
|
3084
|
-
returned: (events) => events.length + 1
|
|
3085
|
-
}),
|
|
3086
|
-
budget({
|
|
3087
|
-
api: "list events over an empty range: its top day and one outcome read, both empty",
|
|
3088
|
-
read: ({ insights }) => insights.listEvents({
|
|
3089
|
-
filter: { kind: "all" },
|
|
3090
|
-
sinceMs: T0 - 91 * DAY,
|
|
3091
|
-
beforeReceivedAtMs: T0 - DAY,
|
|
3092
|
-
limit: 5
|
|
3093
|
-
}),
|
|
3094
|
-
adapter: reads(0, 0, 2, 0),
|
|
3095
|
-
engine: {
|
|
3096
|
-
calls: 2,
|
|
3097
|
-
rows: 0
|
|
3098
|
-
},
|
|
3099
|
-
returned: (events) => events.length
|
|
3100
|
-
}),
|
|
3101
|
-
budget({
|
|
3102
|
-
api: "latest events by install: 1 point read",
|
|
3103
|
-
read: ({ insights }) => insights.findLatestEvents({ installId: "install-1" }),
|
|
3104
|
-
adapter: reads(1, 1, 0, 0),
|
|
3105
|
-
engine: {
|
|
3106
|
-
calls: 1,
|
|
3107
|
-
rows: 1
|
|
3108
|
-
},
|
|
3109
|
-
returned: (events) => events.length
|
|
3110
|
-
}),
|
|
3111
|
-
budget({
|
|
3112
|
-
api: "latest events by user: limit rows",
|
|
3113
|
-
read: ({ insights }) => insights.findLatestEvents({
|
|
3114
|
-
userId: "user-even",
|
|
3115
|
-
limit: 3
|
|
3116
|
-
}),
|
|
3117
|
-
adapter: reads(0, 0, 1, 3),
|
|
3118
|
-
engine: {
|
|
3119
|
-
calls: 1,
|
|
3120
|
-
rows: 3
|
|
3121
|
-
},
|
|
3122
|
-
returned: (events) => events.length
|
|
3123
|
-
}),
|
|
3124
|
-
budget({
|
|
3125
|
-
api: "countEvents: buckets in the window × shards, all used",
|
|
3126
|
-
read: ({ insights }) => insights.countEvents({
|
|
3127
|
-
filter: bundleEvents,
|
|
3128
|
-
sinceMs: T0,
|
|
3129
|
-
beforeReceivedAtMs: T0 + 2 * HOUR
|
|
3130
|
-
}),
|
|
3131
|
-
adapter: reads(0, 0, 1, 11),
|
|
3132
|
-
engine: {
|
|
3133
|
-
calls: 1,
|
|
3134
|
-
rows: 2
|
|
3135
|
-
},
|
|
3136
|
-
check: (count) => expect(count).toBe(11)
|
|
3137
|
-
}),
|
|
3138
|
-
budget({
|
|
3139
|
-
api: "countLatestEvents: buckets in the window × shards, all used",
|
|
3140
|
-
read: ({ insights }) => insights.countLatestEvents({
|
|
3141
|
-
platform: "ios",
|
|
3142
|
-
channel: "production",
|
|
3143
|
-
sinceMs: T0
|
|
3144
|
-
}),
|
|
3145
|
-
adapter: reads(0, 0, 1,
|
|
3146
|
-
engine: {
|
|
3147
|
-
calls: 1,
|
|
3148
|
-
rows:
|
|
3149
|
-
},
|
|
3150
|
-
check: (count) => expect(count).toBe(
|
|
3151
|
-
}),
|
|
3152
|
-
budget({
|
|
3153
|
-
api: "countLatestEvents by bundle: buckets in the window × shards, all used",
|
|
3154
|
-
read: ({ insights }) => insights.countLatestEvents({
|
|
3155
|
-
platform: "ios",
|
|
3156
|
-
channel: "production",
|
|
3157
|
-
sinceMs: T0,
|
|
3158
|
-
bundle: [{
|
|
3072
|
+
enabled: true
|
|
3073
|
+
},
|
|
3074
|
+
limit: 3
|
|
3075
|
+
}),
|
|
3076
|
+
adapter: reads(0, 0, 1, 3),
|
|
3077
|
+
engine: {
|
|
3078
|
+
calls: 1,
|
|
3079
|
+
rows: 3
|
|
3080
|
+
},
|
|
3081
|
+
returned: (releases) => releases.length
|
|
3082
|
+
}),
|
|
3083
|
+
budget({
|
|
3084
|
+
api: "deploy base candidates: 1 catalog point read",
|
|
3085
|
+
read: ({ core, candidateKey }) => core.findBaseBundleIds(candidateKey, createBundleFixture("107").id, 2),
|
|
3086
|
+
adapter: reads(1, 1, 0, 0),
|
|
3087
|
+
engine: {
|
|
3088
|
+
calls: 1,
|
|
3089
|
+
rows: 1
|
|
3090
|
+
},
|
|
3091
|
+
check: (ids) => expect(ids).toEqual(["106", "105"].map((suffix) => createBundleFixture(suffix).id))
|
|
3092
|
+
}),
|
|
3093
|
+
budget({
|
|
3094
|
+
api: "deploy latest release id: byScope descending, limit 1",
|
|
3095
|
+
read: ({ core }) => core.latestReleaseId(scopeKey),
|
|
3096
|
+
adapter: reads(0, 0, 1, 1),
|
|
3097
|
+
engine: {
|
|
3098
|
+
calls: 1,
|
|
3099
|
+
rows: 1
|
|
3100
|
+
}
|
|
3101
|
+
}),
|
|
3102
|
+
budget({
|
|
3103
|
+
api: "channel by name: 1 read",
|
|
3104
|
+
read: ({ core }) => core.findChannelByName("staging"),
|
|
3105
|
+
adapter: reads(0, 0, 1, 1),
|
|
3106
|
+
engine: {
|
|
3107
|
+
calls: 1,
|
|
3108
|
+
rows: 1
|
|
3109
|
+
},
|
|
3110
|
+
returned: (channel) => channel === null ? 0 : 1
|
|
3111
|
+
}),
|
|
3112
|
+
budget({
|
|
3113
|
+
api: "list events of a dense day: limit rows from one query",
|
|
3114
|
+
read: ({ insights }) => insights.listEvents({
|
|
3115
|
+
filter: { kind: "all" },
|
|
3116
|
+
sinceMs: T0 - DAY,
|
|
3117
|
+
beforeReceivedAtMs: T0,
|
|
3118
|
+
limit: 5
|
|
3119
|
+
}),
|
|
3120
|
+
adapter: reads(0, 0, 1, 5),
|
|
3121
|
+
engine: {
|
|
3122
|
+
calls: 1,
|
|
3123
|
+
rows: 5
|
|
3124
|
+
},
|
|
3125
|
+
returned: (events) => events.length
|
|
3126
|
+
}),
|
|
3127
|
+
budget({
|
|
3128
|
+
api: "list events across a gap: limit rows, one empty day, and one outcome row",
|
|
3129
|
+
read: ({ insights }) => insights.listEvents({
|
|
3130
|
+
filter: bundleEvents,
|
|
3131
|
+
sinceMs: T0,
|
|
3132
|
+
beforeReceivedAtMs: T0 + 3 * DAY,
|
|
3133
|
+
limit: 5
|
|
3134
|
+
}),
|
|
3135
|
+
adapter: reads(1, 7, 4, 6),
|
|
3136
|
+
engine: {
|
|
3137
|
+
calls: 4,
|
|
3138
|
+
rows: 6
|
|
3139
|
+
},
|
|
3140
|
+
returned: (events) => events.length + 1
|
|
3141
|
+
}),
|
|
3142
|
+
budget({
|
|
3143
|
+
api: "list events over an empty range: its top day and one outcome read, both empty",
|
|
3144
|
+
read: ({ insights }) => insights.listEvents({
|
|
3145
|
+
filter: { kind: "all" },
|
|
3146
|
+
sinceMs: T0 - 91 * DAY,
|
|
3147
|
+
beforeReceivedAtMs: T0 - DAY,
|
|
3148
|
+
limit: 5
|
|
3149
|
+
}),
|
|
3150
|
+
adapter: reads(0, 0, 2, 0),
|
|
3151
|
+
engine: {
|
|
3152
|
+
calls: 2,
|
|
3153
|
+
rows: 0
|
|
3154
|
+
},
|
|
3155
|
+
returned: (events) => events.length
|
|
3156
|
+
}),
|
|
3157
|
+
budget({
|
|
3158
|
+
api: "latest events by install: 1 point read",
|
|
3159
|
+
read: ({ insights }) => insights.findLatestEvents({ installId: "install-1" }),
|
|
3160
|
+
adapter: reads(1, 1, 0, 0),
|
|
3161
|
+
engine: {
|
|
3162
|
+
calls: 1,
|
|
3163
|
+
rows: 1
|
|
3164
|
+
},
|
|
3165
|
+
returned: (events) => events.length
|
|
3166
|
+
}),
|
|
3167
|
+
budget({
|
|
3168
|
+
api: "latest events by user: limit rows",
|
|
3169
|
+
read: ({ insights }) => insights.findLatestEvents({
|
|
3170
|
+
userId: "user-even",
|
|
3171
|
+
limit: 3
|
|
3172
|
+
}),
|
|
3173
|
+
adapter: reads(0, 0, 1, 3),
|
|
3174
|
+
engine: {
|
|
3175
|
+
calls: 1,
|
|
3176
|
+
rows: 3
|
|
3177
|
+
},
|
|
3178
|
+
returned: (events) => events.length
|
|
3179
|
+
}),
|
|
3180
|
+
budget({
|
|
3181
|
+
api: "countEvents: buckets in the window × shards, all used",
|
|
3182
|
+
read: ({ insights }) => insights.countEvents({
|
|
3183
|
+
filter: bundleEvents,
|
|
3184
|
+
sinceMs: T0,
|
|
3185
|
+
beforeReceivedAtMs: T0 + 2 * HOUR
|
|
3186
|
+
}),
|
|
3187
|
+
adapter: reads(0, 0, 1, 11),
|
|
3188
|
+
engine: {
|
|
3189
|
+
calls: 1,
|
|
3190
|
+
rows: 2
|
|
3191
|
+
},
|
|
3192
|
+
check: (count) => expect(count).toBe(11)
|
|
3193
|
+
}),
|
|
3194
|
+
budget({
|
|
3195
|
+
api: "countLatestEvents: buckets in the window × shards, all used",
|
|
3196
|
+
read: ({ insights }) => insights.countLatestEvents({
|
|
3197
|
+
platform: "ios",
|
|
3198
|
+
channel: "production",
|
|
3199
|
+
sinceMs: T0
|
|
3200
|
+
}),
|
|
3201
|
+
adapter: reads(0, 0, 1, 15),
|
|
3202
|
+
engine: {
|
|
3203
|
+
calls: 1,
|
|
3204
|
+
rows: 2
|
|
3205
|
+
},
|
|
3206
|
+
check: (count) => expect(count).toBe(24)
|
|
3207
|
+
}),
|
|
3208
|
+
budget({
|
|
3209
|
+
api: "countLatestEvents by bundle: buckets in the window × shards, all used",
|
|
3210
|
+
read: ({ insights }) => insights.countLatestEvents({
|
|
3211
|
+
platform: "ios",
|
|
3212
|
+
channel: "production",
|
|
3213
|
+
sinceMs: T0,
|
|
3214
|
+
bundle: [{
|
|
3215
|
+
field: "to_bundle_id",
|
|
3216
|
+
value: "bundle-b",
|
|
3217
|
+
types: ["UPDATE_APPLIED"]
|
|
3218
|
+
}]
|
|
3219
|
+
}),
|
|
3220
|
+
adapter: reads(0, 0, 1, 15),
|
|
3221
|
+
engine: {
|
|
3222
|
+
calls: 1,
|
|
3223
|
+
rows: 2
|
|
3224
|
+
},
|
|
3225
|
+
check: (count) => expect(count).toBe(24)
|
|
3226
|
+
}),
|
|
3227
|
+
budget({
|
|
3228
|
+
api: "release activity: buckets × shards, all used",
|
|
3229
|
+
read: ({ insights }) => insights.getReleaseActivity({ releases: [{
|
|
3230
|
+
releaseId: "release-b",
|
|
3231
|
+
platform: "ios",
|
|
3232
|
+
channel: "production"
|
|
3233
|
+
}] }),
|
|
3234
|
+
adapter: reads(0, 0, 1, 8),
|
|
3235
|
+
engine: {
|
|
3236
|
+
calls: 1,
|
|
3237
|
+
rows: 1
|
|
3238
|
+
},
|
|
3239
|
+
check: ({ data }) => expect(data[0].metrics.launches).toBe(25)
|
|
3240
|
+
}),
|
|
3241
|
+
budget({
|
|
3242
|
+
api: "release activity over a window: buckets × shards, all used",
|
|
3243
|
+
read: ({ insights }) => insights.getReleaseActivity({
|
|
3244
|
+
scope: {
|
|
3245
|
+
platform: "ios",
|
|
3246
|
+
channel: "production"
|
|
3247
|
+
},
|
|
3248
|
+
timeRange: {
|
|
3249
|
+
start: T0,
|
|
3250
|
+
end: T0 + 3 * DAY
|
|
3251
|
+
}
|
|
3252
|
+
}),
|
|
3253
|
+
adapter: reads(0, 0, 2, 24),
|
|
3254
|
+
engine: {
|
|
3255
|
+
calls: 2,
|
|
3256
|
+
rows: 4
|
|
3257
|
+
},
|
|
3258
|
+
check: ({ data }) => expect(data[0].metrics.launches).toBe(25)
|
|
3259
|
+
}),
|
|
3260
|
+
budget({
|
|
3261
|
+
api: "app usage: nonzero distribution and usage-sketch rows in the window, all used",
|
|
3262
|
+
read: ({ insights }) => insights.getAppUsage({
|
|
3263
|
+
channel: "production",
|
|
3264
|
+
platform: "all",
|
|
3265
|
+
timeRange: {
|
|
3266
|
+
start: T0,
|
|
3267
|
+
end: T0 + 3 * DAY
|
|
3268
|
+
},
|
|
3269
|
+
intervalMs: DAY
|
|
3270
|
+
}),
|
|
3271
|
+
adapter: reads(0, 0, 4, 30),
|
|
3272
|
+
engine: {
|
|
3273
|
+
calls: 4,
|
|
3274
|
+
rows: 4
|
|
3275
|
+
},
|
|
3276
|
+
check: ({ versions }) => expect(versions).toEqual([{
|
|
3277
|
+
name: "1.0.0",
|
|
3278
|
+
installations: 24
|
|
3279
|
+
}])
|
|
3280
|
+
}),
|
|
3281
|
+
budget({
|
|
3282
|
+
api: "update failures of a release over a window: buckets × shards, all used",
|
|
3283
|
+
read: ({ insights }) => insights.getUpdateFailures({
|
|
3284
|
+
platform: "ios",
|
|
3285
|
+
channel: "production",
|
|
3286
|
+
releaseId: "release-b",
|
|
3287
|
+
timeRange: {
|
|
3288
|
+
start: T0 + 3 * DAY,
|
|
3289
|
+
end: T0 + 4 * DAY
|
|
3290
|
+
}
|
|
3291
|
+
}),
|
|
3292
|
+
adapter: reads(0, 0, 3, 5),
|
|
3293
|
+
engine: {
|
|
3294
|
+
calls: 3,
|
|
3295
|
+
rows: 3
|
|
3296
|
+
},
|
|
3297
|
+
check: (failures) => {
|
|
3298
|
+
expect(failures).toMatchObject({ failedUpdates: 2 });
|
|
3299
|
+
expect(failures.breakdown).toEqual([{
|
|
3300
|
+
stage: "download",
|
|
3301
|
+
reason: "http",
|
|
3302
|
+
events: 2,
|
|
3303
|
+
details: [expect.anything()]
|
|
3304
|
+
}]);
|
|
3305
|
+
}
|
|
3306
|
+
}),
|
|
3307
|
+
budget({
|
|
3308
|
+
api: "update failures of a channel over a window: buckets × shards, all used",
|
|
3309
|
+
read: ({ insights }) => insights.getUpdateFailures({
|
|
3310
|
+
platform: "ios",
|
|
3311
|
+
channel: "production",
|
|
3312
|
+
timeRange: {
|
|
3313
|
+
start: T0 + 3 * DAY,
|
|
3314
|
+
end: T0 + 4 * DAY
|
|
3315
|
+
}
|
|
3316
|
+
}),
|
|
3317
|
+
adapter: reads(0, 0, 5, 6),
|
|
3318
|
+
engine: {
|
|
3319
|
+
calls: 5,
|
|
3320
|
+
rows: 4
|
|
3321
|
+
},
|
|
3322
|
+
check: (failures) => expect(failures).toMatchObject({
|
|
3323
|
+
failedUpdates: 2,
|
|
3324
|
+
checks: { failures: 1 }
|
|
3325
|
+
})
|
|
3326
|
+
}),
|
|
3327
|
+
budget({
|
|
3328
|
+
api: "update failures of a release since its first: two kept rows × shards, all used",
|
|
3329
|
+
read: ({ insights }) => insights.getUpdateFailures({
|
|
3330
|
+
platform: "ios",
|
|
3331
|
+
channel: "production",
|
|
3332
|
+
releaseId: "release-b"
|
|
3333
|
+
}),
|
|
3334
|
+
adapter: reads(0, 0, 2, 10),
|
|
3335
|
+
engine: {
|
|
3336
|
+
calls: 2,
|
|
3337
|
+
rows: 2
|
|
3338
|
+
},
|
|
3339
|
+
check: (failures) => expect(failures).toMatchObject({
|
|
3340
|
+
failedUpdates: 2,
|
|
3341
|
+
failedInstallations: 2
|
|
3342
|
+
})
|
|
3343
|
+
}),
|
|
3344
|
+
budget({
|
|
3345
|
+
api: "catalog compile: the scope's enabled releases, all used",
|
|
3346
|
+
read: ({ core }) => core.deploy([{
|
|
3347
|
+
bundle: bundleOf("107"),
|
|
3348
|
+
release: production
|
|
3349
|
+
}]),
|
|
3350
|
+
adapter: (copies) => reads(copies ? 5 : 3, copies ? 8 : 3, 3, 6),
|
|
3351
|
+
engine: {
|
|
3352
|
+
calls: 6,
|
|
3353
|
+
rows: 9
|
|
3354
|
+
},
|
|
3355
|
+
writes: 1
|
|
3356
|
+
}),
|
|
3357
|
+
budget({
|
|
3358
|
+
api: "channel insert: 1 write, after the name's read finds nothing",
|
|
3359
|
+
read: ({ core }) => core.ensureChannel("read-budgets"),
|
|
3360
|
+
adapter: reads(0, 0, 1, 0),
|
|
3361
|
+
engine: {
|
|
3362
|
+
calls: 1,
|
|
3363
|
+
rows: 0
|
|
3364
|
+
},
|
|
3365
|
+
writes: 1
|
|
3366
|
+
}),
|
|
3367
|
+
budget({
|
|
3368
|
+
api: "channel delete: 1 read and 1 write",
|
|
3369
|
+
read: ({ core, nightlyId }) => core.deleteChannel(nightlyId),
|
|
3370
|
+
adapter: reads(1, 1, 0, 0),
|
|
3371
|
+
engine: {
|
|
3372
|
+
calls: 1,
|
|
3373
|
+
rows: 1
|
|
3374
|
+
},
|
|
3375
|
+
writes: 1,
|
|
3376
|
+
check: (result) => expect(result).toEqual({ deleted: true })
|
|
3377
|
+
}),
|
|
3378
|
+
budget({
|
|
3379
|
+
api: "record an insights event: 2 dependent rounds of batch gets and 1 write",
|
|
3380
|
+
read: ({ insights }) => insights.recordEvent(eventOf(26, {
|
|
3381
|
+
install_id: "install-2",
|
|
3382
|
+
received_at_ms: T0 + 3 * HOUR
|
|
3383
|
+
})),
|
|
3384
|
+
adapter: reads(4, 7, 0, 0),
|
|
3385
|
+
engine: {
|
|
3386
|
+
calls: 2,
|
|
3387
|
+
rows: 1
|
|
3388
|
+
},
|
|
3389
|
+
writes: 1
|
|
3390
|
+
})
|
|
3391
|
+
];
|
|
3392
|
+
/**
|
|
3393
|
+
* The read-budget suite (PRD S4) on one backend: core and the server's
|
|
3394
|
+
* built-in plugins on `createMeasuredDatabase` over the backend's adapter,
|
|
3395
|
+
* native pages capped at two rows. Every API in the read-budget list reads
|
|
3396
|
+
* exactly its budget at the adapter and at the engine; with `examined`, the
|
|
3397
|
+
* database examines no more rows than the adapter read, within the backend's
|
|
3398
|
+
* native multipliers. It loads `@hot-updater/server`, an optional peer.
|
|
3399
|
+
*/
|
|
3400
|
+
const setupReadBudgetTestSuite = (options) => {
|
|
3401
|
+
describe(`${options.name} read budgets`, () => {
|
|
3402
|
+
let created;
|
|
3403
|
+
let database;
|
|
3404
|
+
let seeded;
|
|
3405
|
+
let writes = 0;
|
|
3406
|
+
beforeAll(async () => {
|
|
3407
|
+
const [{ insights }, { apiKeys }] = await Promise.all([import("@hot-updater/server/plugins/insights"), import("@hot-updater/server/plugins/api-keys")]);
|
|
3408
|
+
const plugins = [insights(), apiKeys()];
|
|
3409
|
+
created = await options.createAdapter({
|
|
3410
|
+
tables: [...toolingTargetOf(plugins).schema.tables, SETTINGS_TABLE],
|
|
3411
|
+
nativePageSize: NATIVE_PAGE_SIZE
|
|
3412
|
+
});
|
|
3413
|
+
const { adapter } = created;
|
|
3414
|
+
database = await createMeasuredDatabase({
|
|
3415
|
+
...adapter,
|
|
3416
|
+
write: (ops) => {
|
|
3417
|
+
writes += 1;
|
|
3418
|
+
return adapter.write(ops);
|
|
3419
|
+
}
|
|
3420
|
+
}, plugins, {
|
|
3421
|
+
now: () => T0 + 10 * DAY,
|
|
3422
|
+
storage
|
|
3423
|
+
});
|
|
3424
|
+
seeded = await seed(database);
|
|
3425
|
+
}, 3e5);
|
|
3426
|
+
afterAll(async () => {
|
|
3427
|
+
await created?.cleanup?.();
|
|
3428
|
+
});
|
|
3429
|
+
for (const budget of READ_BUDGETS) it(budget.api, async () => {
|
|
3430
|
+
const { examined, indexCopies = false } = created;
|
|
3431
|
+
writes = 0;
|
|
3432
|
+
examined?.reset();
|
|
3433
|
+
const measured = await database.measureReads(() => budget.read(seeded));
|
|
3434
|
+
const adapter = typeof budget.adapter === "function" ? budget.adapter(indexCopies) : budget.adapter;
|
|
3435
|
+
expect({
|
|
3436
|
+
adapter: measured.adapter,
|
|
3437
|
+
engine: measured.engine
|
|
3438
|
+
}).toEqual({
|
|
3439
|
+
adapter,
|
|
3440
|
+
engine: budget.engine
|
|
3441
|
+
});
|
|
3442
|
+
expect(writes, "writes").toBe(budget.writes ?? 0);
|
|
3443
|
+
if (budget.returned) expect(budget.returned(measured.result), "rows returned").toBe(measured.engine.rows);
|
|
3444
|
+
budget.check?.(measured.result);
|
|
3445
|
+
if (examined) {
|
|
3446
|
+
const { gets, keys, queries, rows } = measured.adapter;
|
|
3447
|
+
expect(await examined.total(), "rows examined").toBeLessThanOrEqual((examined.perRow ?? 1) * (keys + rows) + (examined.perRead ?? 0) * (gets + queries));
|
|
3448
|
+
}
|
|
3449
|
+
});
|
|
3450
|
+
});
|
|
3451
|
+
};
|
|
3452
|
+
//#endregion
|
|
3453
|
+
//#region src/runContentionHarness.ts
|
|
3454
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3455
|
+
/** Delays every adapter call by `ms`, like a round trip to a remote database. */
|
|
3456
|
+
const withAdapterLatency = (adapter, ms) => ({
|
|
3457
|
+
...adapter,
|
|
3458
|
+
get: async (table, keys) => {
|
|
3459
|
+
await sleep(ms);
|
|
3460
|
+
return adapter.get(table, keys);
|
|
3461
|
+
},
|
|
3462
|
+
query: async (table, request) => {
|
|
3463
|
+
await sleep(ms);
|
|
3464
|
+
return adapter.query(table, request);
|
|
3465
|
+
},
|
|
3466
|
+
write: async (ops) => {
|
|
3467
|
+
await sleep(ms);
|
|
3468
|
+
return adapter.write(ops);
|
|
3469
|
+
}
|
|
3470
|
+
});
|
|
3471
|
+
/**
|
|
3472
|
+
* Starts `transactions` runs at a steady arrival rate through a fixed pool and
|
|
3473
|
+
* reports how they ended. Retry counts come from the engine's `onRetry`.
|
|
3474
|
+
*/
|
|
3475
|
+
const runContentionHarness = async (options) => {
|
|
3476
|
+
const started = Date.now();
|
|
3477
|
+
const errors = {};
|
|
3478
|
+
const waiting = [];
|
|
3479
|
+
let free = options.concurrency;
|
|
3480
|
+
let committed = 0;
|
|
3481
|
+
const acquire = () => free > 0 ? (free -= 1, Promise.resolve()) : new Promise((resolve) => waiting.push(resolve));
|
|
3482
|
+
const release = () => {
|
|
3483
|
+
const next = waiting.shift();
|
|
3484
|
+
if (next) next();
|
|
3485
|
+
else free += 1;
|
|
3486
|
+
};
|
|
3487
|
+
await Promise.all(Array.from({ length: options.transactions }, async (_, index) => {
|
|
3488
|
+
await sleep(index * 1e3 / options.ratePerSecond);
|
|
3489
|
+
await acquire();
|
|
3490
|
+
try {
|
|
3491
|
+
await options.run(index);
|
|
3492
|
+
committed += 1;
|
|
3493
|
+
} catch (error) {
|
|
3494
|
+
const name = error instanceof Error ? error.name : "unknown";
|
|
3495
|
+
errors[name] = (errors[name] ?? 0) + 1;
|
|
3496
|
+
} finally {
|
|
3497
|
+
release();
|
|
3498
|
+
}
|
|
3499
|
+
}));
|
|
3500
|
+
return {
|
|
3501
|
+
transactions: options.transactions,
|
|
3502
|
+
committed,
|
|
3503
|
+
errors,
|
|
3504
|
+
durationMs: Date.now() - started
|
|
3505
|
+
};
|
|
3506
|
+
};
|
|
3507
|
+
//#endregion
|
|
3508
|
+
//#region src/setupAggregateBatchingTestSuite.ts
|
|
3509
|
+
const identity = (name) => ({ [name]: {
|
|
3510
|
+
type: "string",
|
|
3511
|
+
maxLength: 16
|
|
3512
|
+
} });
|
|
3513
|
+
/**
|
|
3514
|
+
* A plugin's tables, shaped like `defineTable` and `defineAggregate` build
|
|
3515
|
+
* them, under their own names.
|
|
3516
|
+
*/
|
|
3517
|
+
const plugin = {
|
|
3518
|
+
id: "batching",
|
|
3519
|
+
schemaVersion: "1",
|
|
3520
|
+
namespace: false,
|
|
3521
|
+
schema: {
|
|
3522
|
+
installs: {
|
|
3523
|
+
kind: "table",
|
|
3524
|
+
fields: {
|
|
3525
|
+
id: {
|
|
3526
|
+
type: "string",
|
|
3527
|
+
maxLength: 64
|
|
3528
|
+
},
|
|
3529
|
+
release: {
|
|
3530
|
+
type: "string",
|
|
3531
|
+
maxLength: 16
|
|
3532
|
+
}
|
|
3533
|
+
},
|
|
3534
|
+
key: ["id"],
|
|
3535
|
+
derived: {},
|
|
3536
|
+
indexes: {}
|
|
3537
|
+
},
|
|
3538
|
+
distribution: {
|
|
3539
|
+
kind: "aggregate",
|
|
3540
|
+
fields: identity("release"),
|
|
3541
|
+
key: ["release"],
|
|
3542
|
+
counters: [],
|
|
3543
|
+
gauges: ["installations"],
|
|
3544
|
+
distinct: [],
|
|
3545
|
+
shards: 8,
|
|
3546
|
+
batched: true,
|
|
3547
|
+
indexes: { byRelease: {
|
|
3548
|
+
eq: ["release"],
|
|
3549
|
+
sort: []
|
|
3550
|
+
} }
|
|
3551
|
+
},
|
|
3552
|
+
opens: {
|
|
3553
|
+
kind: "aggregate",
|
|
3554
|
+
fields: { day: { type: "integer" } },
|
|
3555
|
+
key: ["day"],
|
|
3556
|
+
counters: ["opens"],
|
|
3557
|
+
gauges: [],
|
|
3558
|
+
distinct: [],
|
|
3559
|
+
shards: 4,
|
|
3560
|
+
batched: true,
|
|
3561
|
+
indexes: { all: {
|
|
3562
|
+
eq: [],
|
|
3563
|
+
sort: ["day"]
|
|
3564
|
+
} }
|
|
3565
|
+
},
|
|
3566
|
+
users: {
|
|
3567
|
+
kind: "aggregate",
|
|
3568
|
+
fields: { day: { type: "integer" } },
|
|
3569
|
+
key: ["day"],
|
|
3570
|
+
counters: [],
|
|
3571
|
+
gauges: [],
|
|
3572
|
+
distinct: ["users"],
|
|
3573
|
+
shards: 4,
|
|
3574
|
+
batched: true,
|
|
3575
|
+
indexes: { all: {
|
|
3576
|
+
eq: [],
|
|
3577
|
+
sort: ["day"]
|
|
3578
|
+
} }
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
};
|
|
3582
|
+
const RELEASES = [
|
|
3583
|
+
"A",
|
|
3584
|
+
"B",
|
|
3585
|
+
"C"
|
|
3586
|
+
];
|
|
3587
|
+
/** Step `step`: 12 installations, each moving to the next release every round of 12. */
|
|
3588
|
+
const stepOf = (step) => ({
|
|
3589
|
+
id: `i${step % 12}`,
|
|
3590
|
+
release: RELEASES[(Math.floor(step / 12) + step) % 3],
|
|
3591
|
+
day: 1 + step % 2
|
|
3592
|
+
});
|
|
3593
|
+
/** An installation opens the app on `release`, moving its gauge when the release changed. */
|
|
3594
|
+
const open = (db, step) => {
|
|
3595
|
+
const { id, release, day } = stepOf(step);
|
|
3596
|
+
return db.transaction(async (tx) => {
|
|
3597
|
+
const head = await tx.findOne("installs", { id });
|
|
3598
|
+
if (head === null) tx.create("installs", {
|
|
3599
|
+
id,
|
|
3600
|
+
release
|
|
3601
|
+
});
|
|
3602
|
+
else if (head.release !== release) {
|
|
3603
|
+
tx.update("installs", head, { release });
|
|
3604
|
+
const from = { release: head.release };
|
|
3605
|
+
tx.aggregate("distribution", from, { installations: -1 }, { shardBy: id });
|
|
3606
|
+
}
|
|
3607
|
+
if (head?.release !== release) tx.aggregate("distribution", { release }, { installations: 1 }, { shardBy: id });
|
|
3608
|
+
tx.aggregate("opens", { day }, { opens: 1 }, { shardBy: id });
|
|
3609
|
+
const users = addDistinct(null, id);
|
|
3610
|
+
tx.aggregate("users", { day }, { users }, { shardBy: id });
|
|
3611
|
+
});
|
|
3612
|
+
};
|
|
3613
|
+
/** Every aggregate as reads see it, sketches as their estimates. */
|
|
3614
|
+
const snapshot = async (db) => {
|
|
3615
|
+
const read = (model, index, where = {}) => db.findAggregates(model, {
|
|
3616
|
+
index,
|
|
3617
|
+
where,
|
|
3618
|
+
limit: 100
|
|
3619
|
+
});
|
|
3620
|
+
const releases = await Promise.all(RELEASES.map((release) => read("distribution", "byRelease", { release })));
|
|
3621
|
+
const [opens, users] = await Promise.all([read("opens", "all"), read("users", "all")]);
|
|
3622
|
+
return {
|
|
3623
|
+
distribution: releases.flatMap(({ rows }) => rows),
|
|
3624
|
+
opens: opens.rows,
|
|
3625
|
+
users: users.rows.map((row) => ({
|
|
3626
|
+
day: row.day,
|
|
3627
|
+
users: countDistinct(row.users)
|
|
3628
|
+
}))
|
|
3629
|
+
};
|
|
3630
|
+
};
|
|
3631
|
+
/**
|
|
3632
|
+
* Aggregates declared `batched` on a key-value store: log rows the store
|
|
3633
|
+
* writes with each transaction and a compaction merges once, and a memory
|
|
3634
|
+
* buffer, read back exactly as transactional writes read.
|
|
3635
|
+
*/
|
|
3636
|
+
const setupAggregateBatchingTestSuite = ({ name, createAdapter, oversizedRows }) => {
|
|
3637
|
+
const tables = [...toolingTargetOf([plugin]).schema.tables, ...aggregateBatchingTables];
|
|
3638
|
+
const cleanups = [];
|
|
3639
|
+
const store = async () => {
|
|
3640
|
+
const { adapter, cleanup } = await createAdapter();
|
|
3641
|
+
if (cleanup) cleanups.push(cleanup);
|
|
3642
|
+
await adapter.migrations?.apply(tables);
|
|
3643
|
+
return adapter;
|
|
3644
|
+
};
|
|
3645
|
+
/** A server's engine on `adapter`, batching as `batching` says. */
|
|
3646
|
+
const server = (adapter, batching) => {
|
|
3647
|
+
const engine = createEngine({
|
|
3648
|
+
name,
|
|
3649
|
+
adapter,
|
|
3650
|
+
...batching === void 0 ? {} : { aggregateBatching: batching },
|
|
3651
|
+
retry: { attempts: 64 }
|
|
3652
|
+
}, { plugins: [plugin] });
|
|
3653
|
+
return {
|
|
3654
|
+
db: engine.database(plugin),
|
|
3655
|
+
flush: engine.flush
|
|
3656
|
+
};
|
|
3657
|
+
};
|
|
3658
|
+
/** The reference: transactional writes on a memory adapter. */
|
|
3659
|
+
const expected = async (steps) => {
|
|
3660
|
+
const memory = createMemoryAdapter();
|
|
3661
|
+
await memory.migrations?.apply(tables);
|
|
3662
|
+
const { db } = server(memory);
|
|
3663
|
+
for (let step = 0; step < steps; step += 1) await open(db, step);
|
|
3664
|
+
return snapshot(db);
|
|
3665
|
+
};
|
|
3666
|
+
describe(`${name} aggregate batching`, () => {
|
|
3667
|
+
afterEach(async () => {
|
|
3668
|
+
for (const cleanup of cleanups.splice(0)) await cleanup();
|
|
3669
|
+
});
|
|
3670
|
+
for (const mode of ["log", "memory"]) it(`reads what transactional writes read, in ${mode} mode`, async () => {
|
|
3671
|
+
const { db } = server(await store(), { mode });
|
|
3672
|
+
for (let step = 0; step < 36; step += 1) await open(db, step);
|
|
3673
|
+
expect(await snapshot(db)).toEqual(await expected(36));
|
|
3674
|
+
}, 12e4);
|
|
3675
|
+
it("applies each log row once when two servers compact while 16 writers commit", async () => {
|
|
3676
|
+
const adapter = await store();
|
|
3677
|
+
const first = server(adapter, {
|
|
3678
|
+
mode: "log",
|
|
3679
|
+
windowMs: 0
|
|
3680
|
+
});
|
|
3681
|
+
const second = server(adapter, {
|
|
3682
|
+
mode: "log",
|
|
3683
|
+
windowMs: 0
|
|
3684
|
+
});
|
|
3685
|
+
const report = await runContentionHarness({
|
|
3686
|
+
transactions: 12,
|
|
3687
|
+
ratePerSecond: 1e3,
|
|
3688
|
+
concurrency: 16,
|
|
3689
|
+
run: async (install) => {
|
|
3690
|
+
for (let round = 0; round < 4; round += 1) {
|
|
3691
|
+
const db = (install + round) % 2 === 0 ? first.db : second.db;
|
|
3692
|
+
await open(db, round * 12 + install);
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
});
|
|
3696
|
+
expect(report.errors).toEqual({});
|
|
3697
|
+
await Promise.all([first.flush(), second.flush()]);
|
|
3698
|
+
await first.flush();
|
|
3699
|
+
expect(await snapshot(second.db)).toEqual(await expected(48));
|
|
3700
|
+
}, 3e5);
|
|
3701
|
+
it("applies a log row larger than one write in parts, each change once", async () => {
|
|
3702
|
+
const { db, flush } = server(await store(), { mode: "log" });
|
|
3703
|
+
await db.transaction(async (tx) => {
|
|
3704
|
+
for (let day = 1; day <= oversizedRows; day += 1) tx.aggregate("opens", { day }, { opens: day }, { shardBy: "i" });
|
|
3705
|
+
});
|
|
3706
|
+
await flush();
|
|
3707
|
+
const { rows } = await db.findAggregates("opens", {
|
|
3708
|
+
index: "all",
|
|
3709
|
+
where: {},
|
|
3710
|
+
limit: 500
|
|
3711
|
+
});
|
|
3712
|
+
const days = Math.min(oversizedRows, 500);
|
|
3713
|
+
expect(rows).toHaveLength(days);
|
|
3714
|
+
expect(rows.every((row) => row.opens === row.day)).toBe(true);
|
|
3715
|
+
}, 3e5);
|
|
3716
|
+
});
|
|
3717
|
+
};
|
|
3718
|
+
//#endregion
|
|
3719
|
+
//#region src/sqlRowsExamined.ts
|
|
3720
|
+
/** Wraps executors so each read they run while counting is examined first. */
|
|
3721
|
+
const examineReads = (hooks) => {
|
|
3722
|
+
let counting = false;
|
|
3723
|
+
let examined = 0;
|
|
3724
|
+
return {
|
|
3725
|
+
/** The executor, examining each SELECT it runs outside a transaction. */
|
|
3726
|
+
wrap: (executor) => ({
|
|
3727
|
+
...executor,
|
|
3728
|
+
async execute(statement) {
|
|
3729
|
+
if (counting && /^\s*SELECT\b/iu.test(statement.sql)) {
|
|
3730
|
+
const rows = await hooks.examine(statement);
|
|
3731
|
+
examined += rows;
|
|
3732
|
+
}
|
|
3733
|
+
return executor.execute(statement);
|
|
3734
|
+
}
|
|
3735
|
+
}),
|
|
3736
|
+
examined: {
|
|
3737
|
+
reset() {
|
|
3738
|
+
counting = true;
|
|
3739
|
+
examined = 0;
|
|
3740
|
+
hooks.onReset?.();
|
|
3741
|
+
},
|
|
3742
|
+
async total() {
|
|
3743
|
+
counting = false;
|
|
3744
|
+
return examined;
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
};
|
|
3748
|
+
};
|
|
3749
|
+
/** Rows each table read of a plan examined: rows it produced, filtered out, or rechecked away, over all its loops. */
|
|
3750
|
+
const postgresTableReads = (node, reads = /* @__PURE__ */ new Map()) => {
|
|
3751
|
+
const relation = node["Relation Name"];
|
|
3752
|
+
if (relation !== void 0) {
|
|
3753
|
+
const table = node.Alias ?? relation;
|
|
3754
|
+
const rows = (node["Actual Rows"] ?? 0) + (node["Rows Removed by Filter"] ?? 0) + (node["Rows Removed by Index Recheck"] ?? 0);
|
|
3755
|
+
reads.set(table, (reads.get(table) ?? 0) + rows * (node["Actual Loops"] ?? 0));
|
|
3756
|
+
}
|
|
3757
|
+
for (const child of node.Plans ?? []) postgresTableReads(child, reads);
|
|
3758
|
+
return reads;
|
|
3759
|
+
};
|
|
3760
|
+
/**
|
|
3761
|
+
* Planner settings under which a test's small tables plan as production-sized
|
|
3762
|
+
* ones: no sequential scan, which only a small table affords, and an index
|
|
3763
|
+
* entry weighted like a row, so a plan reads no more entries than the read
|
|
3764
|
+
* needs, as a large index forces.
|
|
3765
|
+
*/
|
|
3766
|
+
const POSTGRES_PLANNER = ["enable_seqscan = off", "cpu_index_tuple_cost = 1"];
|
|
3767
|
+
/**
|
|
3768
|
+
* Rows examined on PostgreSQL (PGlite or a server). Each SELECT a wrapped
|
|
3769
|
+
* executor runs is first run under `EXPLAIN (ANALYZE, FORMAT JSON)` on
|
|
3770
|
+
* `query`, one session with the planner settings above; each `reset` first
|
|
3771
|
+
* vacuums and analyzes, as autovacuum keeps a production database. A plan
|
|
3772
|
+
* then takes the index path a production-sized table takes, and a read no
|
|
3773
|
+
* index serves still scans. A statement counts the rows its busiest table
|
|
3774
|
+
* read examined, so an exact plan counts the rows it returns, a multi-valued
|
|
3775
|
+
* index's own table included.
|
|
3776
|
+
*/
|
|
3777
|
+
const postgresRowsExamined = (query) => {
|
|
3778
|
+
let planner;
|
|
3779
|
+
let stale = true;
|
|
3780
|
+
return examineReads({
|
|
3781
|
+
onReset: () => {
|
|
3782
|
+
stale = true;
|
|
3783
|
+
},
|
|
3784
|
+
async examine({ sql, params }) {
|
|
3785
|
+
planner ??= (async () => {
|
|
3786
|
+
for (const setting of POSTGRES_PLANNER) await query(`SET ${setting}`, []);
|
|
3787
|
+
})();
|
|
3788
|
+
await planner;
|
|
3789
|
+
if (stale) {
|
|
3790
|
+
stale = false;
|
|
3791
|
+
await query("VACUUM ANALYZE", []);
|
|
3792
|
+
}
|
|
3793
|
+
const [row] = await query(`EXPLAIN (ANALYZE, FORMAT JSON) ${sql}`, params);
|
|
3794
|
+
const output = row?.["QUERY PLAN"];
|
|
3795
|
+
const [plan] = typeof output === "string" ? JSON.parse(output) : output;
|
|
3796
|
+
return Math.max(0, ...postgresTableReads(plan.Plan).values());
|
|
3797
|
+
}
|
|
3798
|
+
});
|
|
3799
|
+
};
|
|
3800
|
+
/**
|
|
3801
|
+
* An `EXPLAIN ANALYZE` iterator that reads a table: its table, and its actual
|
|
3802
|
+
* rows and loops. A primary-key read of constants runs while MySQL plans the
|
|
3803
|
+
* statement, and names no table.
|
|
3804
|
+
*/
|
|
3805
|
+
const MYSQL_TABLE_READ = /-> (?:(?:Table scan|(?:Covering )?[Ii]ndex (?:scan|lookup|range scan|skip scan)|Single-row (?:covering )?index lookup|Multi-range index scan) on (\S+)|Rows fetched before execution).*\(actual time=[\d.e+-]+\.\.[\d.e+-]+ rows=([\d.e+-]+) loops=(\d+)\)/u;
|
|
3806
|
+
/**
|
|
3807
|
+
* Rows examined on MySQL. Each SELECT a wrapped executor runs is first run
|
|
3808
|
+
* under `EXPLAIN ANALYZE` on `query`, one session, after `ANALYZE TABLE` of
|
|
3809
|
+
* the tables it reads once per `reset`, and with multi-range reads always
|
|
3810
|
+
* on, so a secondary index's range is costed with sorted row reads, as a
|
|
3811
|
+
* production-sized table's is. A statement counts the rows its busiest table
|
|
3812
|
+
* read examined; a filter above a table read drops rows that read produced.
|
|
3813
|
+
*/
|
|
3814
|
+
const mysqlRowsExamined = (query) => {
|
|
3815
|
+
let planner;
|
|
3816
|
+
const analyzed = /* @__PURE__ */ new Set();
|
|
3817
|
+
return examineReads({
|
|
3818
|
+
onReset: () => {
|
|
3819
|
+
analyzed.clear();
|
|
3820
|
+
},
|
|
3821
|
+
async examine({ sql, params }) {
|
|
3822
|
+
planner ??= query("SET SESSION optimizer_switch = 'mrr=on,mrr_cost_based=off'", []);
|
|
3823
|
+
await planner;
|
|
3824
|
+
const tables = [...sql.matchAll(/(?:FROM|JOIN) (`[^`]+`)/gu)].map(([, table]) => table).filter((table) => !analyzed.has(table));
|
|
3825
|
+
for (const table of tables) analyzed.add(table);
|
|
3826
|
+
if (tables.length > 0) await query(`ANALYZE TABLE ${tables.join(", ")}`, []);
|
|
3827
|
+
const [row] = await query(`EXPLAIN ANALYZE ${sql}`, params);
|
|
3828
|
+
const reads = /* @__PURE__ */ new Map();
|
|
3829
|
+
for (const line of String(Object.values(row ?? {})[0]).split("\n")) {
|
|
3830
|
+
const read = MYSQL_TABLE_READ.exec(line);
|
|
3831
|
+
if (read === null) continue;
|
|
3832
|
+
const [, table = "", rows, loops] = read;
|
|
3833
|
+
reads.set(table, (reads.get(table) ?? 0) + Number(rows) * Number(loops));
|
|
3834
|
+
}
|
|
3835
|
+
return Math.max(0, ...reads.values());
|
|
3836
|
+
}
|
|
3837
|
+
});
|
|
3838
|
+
};
|
|
3839
|
+
//#endregion
|
|
3840
|
+
//#region src/createPluginTestHarness.ts
|
|
3841
|
+
/**
|
|
3842
|
+
* Runs one plugin on `createEngine`, the storage engine `createHotUpdater`
|
|
3843
|
+
* runs its plugins on, so tests can call its API and measure what each call
|
|
3844
|
+
* reads. Every adapter call is checked against the adapter contract
|
|
3845
|
+
* (`verifyAdapter`), and `ctx.core` is empty: core's reads need a server.
|
|
3846
|
+
* The engine gets the adapter without `prune`, as a store that expires rows
|
|
3847
|
+
* itself would give it, so no retention pass adds to what a call reads and
|
|
3848
|
+
* writes; rows stay past their retention.
|
|
3849
|
+
*/
|
|
3850
|
+
const createPluginTestHarness = async (plugin, options = {}) => {
|
|
3851
|
+
const adapter = options.adapter ?? createMemoryAdapter();
|
|
3852
|
+
const core = new Set(coreSchema.tables.map(({ name }) => name));
|
|
3853
|
+
const tables = toolingTargetOf([plugin]).schema.tables.filter(({ name }) => !core.has(name));
|
|
3854
|
+
await adapter.migrations?.apply(tables);
|
|
3855
|
+
let clock = options.now ?? Date.now;
|
|
3856
|
+
const now = () => clock();
|
|
3857
|
+
const database = meterReads({
|
|
3858
|
+
name: "plugin test harness",
|
|
3859
|
+
adapter: verifyAdapter(Object.assign(Object.create(adapter), { prune: void 0 }))
|
|
3860
|
+
});
|
|
3861
|
+
const db = createEngine(database, {
|
|
3862
|
+
plugins: [plugin],
|
|
3863
|
+
now
|
|
3864
|
+
}).database(plugin);
|
|
3865
|
+
const instance = plugin.init({
|
|
3866
|
+
db,
|
|
3867
|
+
core: {},
|
|
3868
|
+
now
|
|
3869
|
+
});
|
|
3870
|
+
return {
|
|
3871
|
+
api: instance.api,
|
|
3872
|
+
instance,
|
|
3873
|
+
/** The plugin's own database handle. */
|
|
3874
|
+
db,
|
|
3875
|
+
adapter,
|
|
3876
|
+
/** The plugin's physical tables. */
|
|
3877
|
+
tables,
|
|
3878
|
+
/** Reads at the adapter and at the engine while `read` runs. */
|
|
3879
|
+
measureReads: database.measureReads,
|
|
3880
|
+
setNow: (next) => {
|
|
3881
|
+
clock = next;
|
|
3882
|
+
}
|
|
3883
|
+
};
|
|
3884
|
+
};
|
|
3885
|
+
//#endregion
|
|
3886
|
+
//#region src/storeBundles.ts
|
|
3887
|
+
/**
|
|
3888
|
+
* Stores bundles and their patches with no release, as a database holds an
|
|
3889
|
+
* artifact that no release uses. `deploy` always adds a release, and
|
|
3890
|
+
* deleting a bundle's last release deletes the bundle, so tests store such
|
|
3891
|
+
* bundles through the engine. A patch's base comes before it in `bundles`.
|
|
3892
|
+
*/
|
|
3893
|
+
const storeBundles = async (database, bundles) => {
|
|
3894
|
+
const engine = createEngine(database);
|
|
3895
|
+
try {
|
|
3896
|
+
await engine.core.transaction(async (tx) => {
|
|
3897
|
+
for (const bundle of bundles) {
|
|
3898
|
+
tx.create("bundles", bundleToRow(bundle));
|
|
3899
|
+
for (const platformKey of [bundle.platform, "*"]) tx.aggregate("bundle_totals", { platform_key: platformKey }, { bundles: 1 });
|
|
3900
|
+
for (const row of bundleToPatchRows(bundle)) tx.create("bundle_patches", row);
|
|
3901
|
+
}
|
|
3902
|
+
});
|
|
3903
|
+
} finally {
|
|
3904
|
+
await engine.dispose();
|
|
3905
|
+
}
|
|
3906
|
+
};
|
|
3907
|
+
//#endregion
|
|
3908
|
+
//#region src/insights/expectInsightsIndex.ts
|
|
3909
|
+
/** Allow committed secondary indexes to catch up; query failures still fail. */
|
|
3910
|
+
const expectInsightsIndex = async (query, expected) => {
|
|
3911
|
+
const deadline = Date.now() + 5e3;
|
|
3912
|
+
for (;;) {
|
|
3913
|
+
const actual = await query();
|
|
3914
|
+
try {
|
|
3915
|
+
expect(actual).toEqual(expected);
|
|
3916
|
+
return;
|
|
3917
|
+
} catch (error) {
|
|
3918
|
+
if (Date.now() >= deadline) throw error;
|
|
3919
|
+
}
|
|
3920
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
3921
|
+
}
|
|
3922
|
+
};
|
|
3923
|
+
//#endregion
|
|
3924
|
+
//#region src/insights/fixtures.ts
|
|
3925
|
+
const fixtureId = (suffix) => `00000000-0000-7000-8000-${suffix.padStart(12, "0")}`;
|
|
3926
|
+
/** An applied update from install `suffix`, received at `receivedAtMs`. */
|
|
3927
|
+
const createBundleEventRowFixture = (suffix, receivedAtMs) => ({
|
|
3928
|
+
id: fixtureId(suffix),
|
|
3929
|
+
type: "UPDATE_APPLIED",
|
|
3930
|
+
install_id: `install-${suffix}`,
|
|
3931
|
+
user_id: null,
|
|
3932
|
+
metadata: {
|
|
3933
|
+
cohort: "0",
|
|
3934
|
+
update_strategy: "appVersion",
|
|
3935
|
+
fingerprint_hash: null,
|
|
3936
|
+
sdk_version: null
|
|
3937
|
+
},
|
|
3938
|
+
from_bundle_id: fixtureId(`${Number(suffix) + 1e3}`),
|
|
3939
|
+
from_release_id: null,
|
|
3940
|
+
to_bundle_id: fixtureId(`${Number(suffix) + 2e3}`),
|
|
3941
|
+
to_release_id: null,
|
|
3942
|
+
platform: "ios",
|
|
3943
|
+
app_version: "1.0.0",
|
|
3944
|
+
channel: "production",
|
|
3945
|
+
received_at_ms: receivedAtMs
|
|
3946
|
+
});
|
|
3947
|
+
//#endregion
|
|
3948
|
+
//#region src/insights/insightsModelTests.ts
|
|
3949
|
+
const record = (model, event) => model.recordEvent({ event });
|
|
3950
|
+
const createMovementEvent = (suffix, receivedAtMs, type, installId) => {
|
|
3951
|
+
const row = createBundleEventRowFixture(suffix, receivedAtMs);
|
|
3952
|
+
return {
|
|
3953
|
+
...row,
|
|
3954
|
+
type,
|
|
3955
|
+
install_id: installId,
|
|
3956
|
+
from_bundle_id: row.to_bundle_id,
|
|
3957
|
+
metadata: {
|
|
3958
|
+
...row.metadata,
|
|
3959
|
+
update_strategy: "appVersion"
|
|
3960
|
+
}
|
|
3961
|
+
};
|
|
3962
|
+
};
|
|
3963
|
+
/**
|
|
3964
|
+
* The Insights report contract on `InsightsModel`: what the Insights plugin
|
|
3965
|
+
* records and reads, through `createInsightsModel`, on one database.
|
|
3966
|
+
*/
|
|
3967
|
+
const registerInsightsModelTests = (state) => {
|
|
3968
|
+
describe("Insights report contract", () => {
|
|
3969
|
+
it("preserves ancillary JSON through event history and latest-event reads", async () => {
|
|
3970
|
+
const model = state.getDatabase();
|
|
3971
|
+
const base = createBundleEventRowFixture("979", 100);
|
|
3972
|
+
const event = {
|
|
3973
|
+
...base,
|
|
3974
|
+
metadata: {
|
|
3975
|
+
...base.metadata,
|
|
3976
|
+
device: {
|
|
3977
|
+
locale: "ko-KR",
|
|
3978
|
+
tags: [
|
|
3979
|
+
"minor",
|
|
3980
|
+
null,
|
|
3981
|
+
true,
|
|
3982
|
+
2
|
|
3983
|
+
]
|
|
3984
|
+
},
|
|
3985
|
+
diagnostic: null
|
|
3986
|
+
}
|
|
3987
|
+
};
|
|
3988
|
+
await record(model, event);
|
|
3989
|
+
await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([event]);
|
|
3990
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
3991
|
+
filter: { kind: "all" },
|
|
3992
|
+
beforeReceivedAtMs: 101,
|
|
3993
|
+
limit: 10
|
|
3994
|
+
}), [event]);
|
|
3995
|
+
});
|
|
3996
|
+
it("selects the latest complete event across download, replacement, apply, and delayed reports", async () => {
|
|
3997
|
+
const model = state.getDatabase();
|
|
3998
|
+
const download = {
|
|
3999
|
+
...createBundleEventRowFixture("980", 100),
|
|
4000
|
+
type: "UPDATE_DOWNLOADED",
|
|
4001
|
+
from_bundle_id: "00000000-0000-7000-8000-000000001980",
|
|
4002
|
+
metadata: {
|
|
4003
|
+
...createBundleEventRowFixture("980", 100).metadata,
|
|
4004
|
+
update_strategy: "appVersion"
|
|
4005
|
+
},
|
|
4006
|
+
to_release_id: "00000000-0000-7000-8000-000000004980"
|
|
4007
|
+
};
|
|
4008
|
+
await record(model, download);
|
|
4009
|
+
await record(model, download);
|
|
4010
|
+
await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([download]);
|
|
4011
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4012
|
+
filter: {
|
|
4013
|
+
kind: "bundle",
|
|
4014
|
+
type: "UPDATE_DOWNLOADED",
|
|
4015
|
+
toBundleId: download.to_bundle_id,
|
|
4016
|
+
platform: "ios",
|
|
4017
|
+
channel: "production"
|
|
4018
|
+
},
|
|
4019
|
+
beforeReceivedAtMs: 200,
|
|
4020
|
+
limit: 10
|
|
4021
|
+
}), [download]);
|
|
4022
|
+
const replacement = {
|
|
4023
|
+
...download,
|
|
4024
|
+
id: createBundleEventRowFixture("981", 110).id,
|
|
4025
|
+
received_at_ms: 110,
|
|
4026
|
+
to_bundle_id: "00000000-0000-7000-8000-000000002981"
|
|
4027
|
+
};
|
|
4028
|
+
await record(model, replacement);
|
|
4029
|
+
await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([replacement]);
|
|
4030
|
+
const applied = {
|
|
4031
|
+
...replacement,
|
|
4032
|
+
type: "UPDATE_APPLIED",
|
|
4033
|
+
id: createBundleEventRowFixture("982", 120).id,
|
|
4034
|
+
received_at_ms: 120
|
|
4035
|
+
};
|
|
4036
|
+
await record(model, applied);
|
|
4037
|
+
await record(model, {
|
|
4038
|
+
...download,
|
|
4039
|
+
id: createBundleEventRowFixture("983", 105).id,
|
|
4040
|
+
received_at_ms: 105
|
|
4041
|
+
});
|
|
4042
|
+
await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([applied]);
|
|
4043
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4044
|
+
filter: {
|
|
4045
|
+
kind: "installationMovement",
|
|
4046
|
+
installId: download.install_id
|
|
4047
|
+
},
|
|
4048
|
+
beforeReceivedAtMs: 200,
|
|
4049
|
+
limit: 10
|
|
4050
|
+
}), [
|
|
4051
|
+
applied,
|
|
4052
|
+
replacement,
|
|
4053
|
+
{
|
|
4054
|
+
...download,
|
|
4055
|
+
id: createBundleEventRowFixture("983", 105).id,
|
|
4056
|
+
received_at_ms: 105
|
|
4057
|
+
},
|
|
4058
|
+
download
|
|
4059
|
+
]);
|
|
4060
|
+
});
|
|
4061
|
+
it("treats duplicate IDs as complete no-ops, including changed retry payloads", async () => {
|
|
4062
|
+
const model = state.getDatabase();
|
|
4063
|
+
const event = createBundleEventRowFixture("901", 100);
|
|
4064
|
+
await Promise.all([
|
|
4065
|
+
record(model, event),
|
|
4066
|
+
record(model, event),
|
|
4067
|
+
record(model, event)
|
|
4068
|
+
]);
|
|
4069
|
+
await record(model, event);
|
|
4070
|
+
const changed = {
|
|
4071
|
+
...event,
|
|
4072
|
+
received_at_ms: 300,
|
|
4073
|
+
user_id: "changed-user"
|
|
4074
|
+
};
|
|
4075
|
+
await record(model, changed);
|
|
4076
|
+
const otherInstallation = {
|
|
4077
|
+
...changed,
|
|
4078
|
+
install_id: "different-install"
|
|
4079
|
+
};
|
|
4080
|
+
await record(model, otherInstallation);
|
|
4081
|
+
await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([event]);
|
|
4082
|
+
await expect(model.findLatestEvents({ installId: otherInstallation.install_id })).resolves.toEqual([]);
|
|
4083
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4084
|
+
filter: { kind: "all" },
|
|
4085
|
+
beforeReceivedAtMs: 400,
|
|
4086
|
+
limit: 10
|
|
4087
|
+
}), [event]);
|
|
4088
|
+
});
|
|
4089
|
+
it("keeps all concurrent events and the greatest timestamp/ID state, including logout", async () => {
|
|
4090
|
+
const model = state.getDatabase();
|
|
4091
|
+
const older = {
|
|
4092
|
+
...createBundleEventRowFixture("912", 100),
|
|
4093
|
+
install_id: "concurrent",
|
|
4094
|
+
user_id: "previous"
|
|
4095
|
+
};
|
|
4096
|
+
const tied = {
|
|
4097
|
+
...createBundleEventRowFixture("910", 200),
|
|
4098
|
+
install_id: "concurrent",
|
|
4099
|
+
user_id: "previous"
|
|
4100
|
+
};
|
|
4101
|
+
const newest = {
|
|
4102
|
+
...createBundleEventRowFixture("911", 200),
|
|
4103
|
+
install_id: "concurrent",
|
|
4104
|
+
user_id: null
|
|
4105
|
+
};
|
|
4106
|
+
await Promise.all([
|
|
4107
|
+
record(model, newest),
|
|
4108
|
+
record(model, older),
|
|
4109
|
+
record(model, tied)
|
|
4110
|
+
]);
|
|
4111
|
+
await record(model, older);
|
|
4112
|
+
await expect(model.findLatestEvents({ installId: "concurrent" })).resolves.toEqual([newest]);
|
|
4113
|
+
await expect(model.findLatestEvents({
|
|
4114
|
+
userId: "previous",
|
|
4115
|
+
limit: 10
|
|
4116
|
+
})).resolves.toEqual([]);
|
|
4117
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4118
|
+
filter: { kind: "all" },
|
|
4119
|
+
beforeReceivedAtMs: 201,
|
|
4120
|
+
limit: 10
|
|
4121
|
+
}), [
|
|
4122
|
+
newest,
|
|
4123
|
+
tied,
|
|
4124
|
+
older
|
|
4125
|
+
]);
|
|
4126
|
+
});
|
|
4127
|
+
it("rejects invalid event metadata before persisting a report", async () => {
|
|
4128
|
+
const model = state.getDatabase();
|
|
4129
|
+
const event = createBundleEventRowFixture("920", 100);
|
|
4130
|
+
await expect(model.recordEvent({ event: {
|
|
4131
|
+
...event,
|
|
4132
|
+
metadata: {
|
|
4133
|
+
...event.metadata,
|
|
4134
|
+
cohort: 123
|
|
4135
|
+
}
|
|
4136
|
+
} })).rejects.toMatchObject({ code: "invalid-data" });
|
|
4137
|
+
await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([]);
|
|
4138
|
+
await expect(model.listEvents({
|
|
4139
|
+
filter: { kind: "all" },
|
|
4140
|
+
beforeReceivedAtMs: 200,
|
|
4141
|
+
limit: 10
|
|
4142
|
+
})).resolves.toEqual([]);
|
|
4143
|
+
});
|
|
4144
|
+
it("shares scoped list/count predicates, recovery attribution, and half-open time bounds", async () => {
|
|
4145
|
+
const model = state.getDatabase();
|
|
4146
|
+
const bundleA = createBundleEventRowFixture("1", 0).to_bundle_id;
|
|
4147
|
+
const bundleB = createBundleEventRowFixture("2", 0).to_bundle_id;
|
|
4148
|
+
const bundleC = createBundleEventRowFixture("3", 0).to_bundle_id;
|
|
4149
|
+
const applied = {
|
|
4150
|
+
...createBundleEventRowFixture("930", 100),
|
|
4151
|
+
install_id: "target",
|
|
4152
|
+
to_bundle_id: bundleB
|
|
4153
|
+
};
|
|
4154
|
+
const recovered = {
|
|
4155
|
+
...createBundleEventRowFixture("931", 120),
|
|
4156
|
+
install_id: "target",
|
|
4157
|
+
type: "RECOVERED",
|
|
4158
|
+
from_bundle_id: bundleB,
|
|
4159
|
+
to_bundle_id: bundleA,
|
|
4160
|
+
metadata: {
|
|
4161
|
+
...createBundleEventRowFixture("931", 120).metadata,
|
|
4162
|
+
update_strategy: "appVersion"
|
|
4163
|
+
}
|
|
4164
|
+
};
|
|
4165
|
+
const unchanged = {
|
|
4166
|
+
...createBundleEventRowFixture("932", 130),
|
|
4167
|
+
type: "UNCHANGED",
|
|
4168
|
+
from_bundle_id: null,
|
|
4169
|
+
to_bundle_id: bundleB,
|
|
4170
|
+
metadata: {
|
|
4171
|
+
...createBundleEventRowFixture("932", 130).metadata,
|
|
4172
|
+
update_strategy: null
|
|
4173
|
+
}
|
|
4174
|
+
};
|
|
4175
|
+
const excluded = [
|
|
4176
|
+
{
|
|
4177
|
+
...applied,
|
|
4178
|
+
id: createBundleEventRowFixture("933", 99).id,
|
|
4179
|
+
install_id: "before",
|
|
4180
|
+
received_at_ms: 99
|
|
4181
|
+
},
|
|
4182
|
+
{
|
|
4183
|
+
...applied,
|
|
4184
|
+
id: createBundleEventRowFixture("934", 200).id,
|
|
4185
|
+
install_id: "after",
|
|
4186
|
+
received_at_ms: 200
|
|
4187
|
+
},
|
|
4188
|
+
{
|
|
4189
|
+
...applied,
|
|
4190
|
+
id: createBundleEventRowFixture("935", 110).id,
|
|
4191
|
+
install_id: "android",
|
|
4192
|
+
platform: "android"
|
|
4193
|
+
},
|
|
4194
|
+
{
|
|
4195
|
+
...applied,
|
|
4196
|
+
id: createBundleEventRowFixture("936", 110).id,
|
|
4197
|
+
install_id: "preview",
|
|
4198
|
+
channel: "preview"
|
|
4199
|
+
},
|
|
4200
|
+
{
|
|
4201
|
+
...recovered,
|
|
4202
|
+
id: createBundleEventRowFixture("937", 125).id,
|
|
4203
|
+
install_id: "other-source",
|
|
4204
|
+
from_bundle_id: bundleC,
|
|
4205
|
+
to_bundle_id: bundleB
|
|
4206
|
+
}
|
|
4207
|
+
];
|
|
4208
|
+
for (const row of [
|
|
4209
|
+
applied,
|
|
4210
|
+
recovered,
|
|
4211
|
+
unchanged,
|
|
4212
|
+
...excluded
|
|
4213
|
+
]) await record(model, row);
|
|
4214
|
+
await expect(async () => record(model, {
|
|
4215
|
+
...applied,
|
|
4216
|
+
type: "RELEASE_ADOPTED"
|
|
4217
|
+
})).rejects.toThrow();
|
|
4218
|
+
await expect(async () => record(model, {
|
|
4219
|
+
...applied,
|
|
4220
|
+
type: "UNCHANGED",
|
|
4221
|
+
from_bundle_id: bundleB,
|
|
4222
|
+
metadata: {
|
|
4223
|
+
...applied.metadata,
|
|
4224
|
+
update_strategy: "appVersion"
|
|
4225
|
+
}
|
|
4226
|
+
})).rejects.toThrow();
|
|
4227
|
+
await expect(model.findLatestEvents({ installId: "target" })).resolves.toEqual([recovered]);
|
|
4228
|
+
const cases = [[{
|
|
4229
|
+
platform: "ios",
|
|
4230
|
+
channel: "production",
|
|
4231
|
+
type: "UPDATE_APPLIED",
|
|
4232
|
+
toBundleId: bundleB
|
|
4233
|
+
}, applied], [{
|
|
4234
|
+
platform: "ios",
|
|
4235
|
+
channel: "production",
|
|
4236
|
+
type: "RECOVERED",
|
|
4237
|
+
fromBundleId: bundleB
|
|
4238
|
+
}, recovered]];
|
|
4239
|
+
for (const [filter, expected] of cases) {
|
|
4240
|
+
await expectInsightsIndex(() => model.countEvents({
|
|
4241
|
+
filter,
|
|
4242
|
+
sinceMs: 100,
|
|
4243
|
+
beforeReceivedAtMs: 200
|
|
4244
|
+
}), 1);
|
|
4245
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4246
|
+
filter: {
|
|
4247
|
+
kind: "bundle",
|
|
4248
|
+
...filter
|
|
4249
|
+
},
|
|
4250
|
+
sinceMs: 100,
|
|
4251
|
+
beforeReceivedAtMs: 200,
|
|
4252
|
+
limit: 1
|
|
4253
|
+
}), [expected]);
|
|
4254
|
+
}
|
|
4255
|
+
await expect(model.findLatestEvents({ installId: unchanged.install_id })).resolves.toEqual([unchanged]);
|
|
4256
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4257
|
+
filter: { kind: "all" },
|
|
4258
|
+
sinceMs: 100,
|
|
4259
|
+
beforeReceivedAtMs: 200,
|
|
4260
|
+
limit: 10
|
|
4261
|
+
}), [
|
|
4262
|
+
excluded[4],
|
|
4263
|
+
recovered,
|
|
4264
|
+
excluded[3],
|
|
4265
|
+
excluded[2],
|
|
4266
|
+
applied
|
|
4267
|
+
]);
|
|
4268
|
+
await expect(model.countEvents({
|
|
4269
|
+
filter: {
|
|
4270
|
+
platform: "ios",
|
|
4271
|
+
channel: "production",
|
|
4272
|
+
type: "UNCHANGED",
|
|
4273
|
+
toBundleId: bundleB
|
|
4274
|
+
},
|
|
4275
|
+
sinceMs: 100,
|
|
4276
|
+
beforeReceivedAtMs: 200
|
|
4277
|
+
})).rejects.toThrow();
|
|
4278
|
+
await expectInsightsIndex(() => model.countLatestEvents({
|
|
4279
|
+
platform: "ios",
|
|
4280
|
+
channel: "production",
|
|
4281
|
+
sinceMs: 0,
|
|
4282
|
+
bundle: [{
|
|
4283
|
+
field: "to_bundle_id",
|
|
4284
|
+
value: bundleA,
|
|
4285
|
+
types: [
|
|
4286
|
+
"UNCHANGED",
|
|
4287
|
+
"UPDATE_APPLIED",
|
|
4288
|
+
"RECOVERED"
|
|
4289
|
+
]
|
|
4290
|
+
}]
|
|
4291
|
+
}), 1);
|
|
4292
|
+
await expectInsightsIndex(() => model.countLatestEvents({
|
|
4293
|
+
platform: "ios",
|
|
4294
|
+
channel: "production",
|
|
4295
|
+
sinceMs: 0
|
|
4296
|
+
}), 5);
|
|
4297
|
+
await expect(model.countLatestEvents({
|
|
4298
|
+
platform: "ios",
|
|
4299
|
+
channel: "production",
|
|
4300
|
+
sinceMs: 100
|
|
4301
|
+
})).rejects.toThrow();
|
|
4302
|
+
});
|
|
4303
|
+
it("uses exact identity and UTF-8 cursor order for user installations", async () => {
|
|
4304
|
+
const model = state.getDatabase();
|
|
4305
|
+
const ids = [
|
|
4306
|
+
"Install-a",
|
|
4307
|
+
"é",
|
|
4308
|
+
"install-a",
|
|
4309
|
+
"install-a ",
|
|
4310
|
+
"é",
|
|
4311
|
+
"",
|
|
4312
|
+
"😀"
|
|
4313
|
+
];
|
|
4314
|
+
const events = ids.map((install_id, index) => ({
|
|
4315
|
+
...createBundleEventRowFixture(String(950 + index), 100),
|
|
4316
|
+
install_id,
|
|
4317
|
+
user_id: "User-é"
|
|
4318
|
+
}));
|
|
4319
|
+
for (const event of events.toReversed()) await record(model, event);
|
|
4320
|
+
const spacedUser = {
|
|
4321
|
+
...createBundleEventRowFixture("960", 100),
|
|
4322
|
+
install_id: "spaced-user-install",
|
|
4323
|
+
user_id: "User-é "
|
|
4324
|
+
};
|
|
4325
|
+
await record(model, spacedUser);
|
|
4326
|
+
await expectInsightsIndex(async () => {
|
|
4327
|
+
const found = [];
|
|
4328
|
+
let afterInstallId;
|
|
4329
|
+
for (let pageIndex = 0; pageIndex <= ids.length; pageIndex++) {
|
|
4330
|
+
const page = await model.findLatestEvents({
|
|
4331
|
+
userId: "User-é",
|
|
4332
|
+
afterInstallId,
|
|
4333
|
+
limit: 2
|
|
4334
|
+
});
|
|
4335
|
+
found.push(...page.map((row) => row.install_id));
|
|
4336
|
+
if (page.length < 2) return found;
|
|
4337
|
+
const nextCursor = page[page.length - 1].install_id;
|
|
4338
|
+
expect(nextCursor, "Insights cursor must advance").not.toBe(afterInstallId);
|
|
4339
|
+
afterInstallId = nextCursor;
|
|
4340
|
+
}
|
|
4341
|
+
throw new Error("Insights pagination did not terminate");
|
|
4342
|
+
}, ids);
|
|
4343
|
+
await expectInsightsIndex(() => model.findLatestEvents({
|
|
4344
|
+
userId: "User-é ",
|
|
4345
|
+
limit: 10
|
|
4346
|
+
}), [spacedUser]);
|
|
4347
|
+
await expect(model.findLatestEvents({
|
|
4348
|
+
userId: "user-é",
|
|
4349
|
+
limit: 10
|
|
4350
|
+
})).resolves.toEqual([]);
|
|
4351
|
+
await expect(model.findLatestEvents({ installId: "INSTALL-a" })).resolves.toEqual([]);
|
|
4352
|
+
await expect(model.findLatestEvents({ installId: "Install-a" })).resolves.toEqual([events[0]]);
|
|
4353
|
+
await expect(model.findLatestEvents({ installId: "install-a " })).resolves.toEqual([events[3]]);
|
|
4354
|
+
await expect(model.findLatestEvents({ installId: "install-a" })).resolves.toEqual([events[2]]);
|
|
4355
|
+
});
|
|
4356
|
+
it("counts overlapping bundle predicates once per latest installation", async () => {
|
|
4357
|
+
const model = state.getDatabase();
|
|
4358
|
+
const event = createBundleEventRowFixture("9700", 100);
|
|
4359
|
+
await record(model, event);
|
|
4360
|
+
const predicate = {
|
|
3159
4361
|
field: "to_bundle_id",
|
|
3160
|
-
value:
|
|
3161
|
-
types: [
|
|
3162
|
-
}
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
4362
|
+
value: event.to_bundle_id,
|
|
4363
|
+
types: [event.type]
|
|
4364
|
+
};
|
|
4365
|
+
await expectInsightsIndex(() => model.countLatestEvents({
|
|
4366
|
+
platform: event.platform,
|
|
4367
|
+
channel: event.channel,
|
|
4368
|
+
sinceMs: 0,
|
|
4369
|
+
bundle: [predicate, predicate]
|
|
4370
|
+
}), 1);
|
|
4371
|
+
});
|
|
4372
|
+
it("counts a latest installation once when a from and a to predicate both match it", async () => {
|
|
4373
|
+
const model = state.getDatabase();
|
|
4374
|
+
const moved = createBundleEventRowFixture("9701", 100);
|
|
4375
|
+
await record(model, moved);
|
|
4376
|
+
await expectInsightsIndex(() => model.countLatestEvents({
|
|
4377
|
+
platform: moved.platform,
|
|
4378
|
+
channel: moved.channel,
|
|
4379
|
+
sinceMs: 0,
|
|
4380
|
+
bundle: [{
|
|
4381
|
+
field: "from_bundle_id",
|
|
4382
|
+
value: moved.from_bundle_id,
|
|
4383
|
+
types: [moved.type]
|
|
4384
|
+
}, {
|
|
4385
|
+
field: "to_bundle_id",
|
|
4386
|
+
value: moved.to_bundle_id,
|
|
4387
|
+
types: [moved.type]
|
|
4388
|
+
}]
|
|
4389
|
+
}), 1);
|
|
4390
|
+
});
|
|
4391
|
+
it("returns zero for successful empty scalar queries", async () => {
|
|
4392
|
+
const model = state.getDatabase();
|
|
4393
|
+
await expect(model.countLatestEvents({
|
|
4394
|
+
platform: "ios",
|
|
4395
|
+
channel: "production",
|
|
4396
|
+
sinceMs: 0
|
|
4397
|
+
})).resolves.toBe(0);
|
|
4398
|
+
await expect(model.countEvents({
|
|
4399
|
+
filter: {
|
|
4400
|
+
platform: "ios",
|
|
4401
|
+
channel: "production",
|
|
4402
|
+
type: "RECOVERED",
|
|
4403
|
+
fromBundleId: createBundleEventRowFixture("1", 0).to_bundle_id
|
|
4404
|
+
},
|
|
4405
|
+
sinceMs: 0,
|
|
4406
|
+
beforeReceivedAtMs: 100
|
|
4407
|
+
})).resolves.toBe(0);
|
|
4408
|
+
});
|
|
4409
|
+
it("maintains idempotent release, scope, usage, and latest-distribution summaries", async () => {
|
|
4410
|
+
const model = state.getDatabase();
|
|
4411
|
+
const releaseA = "00000000-0000-7000-8000-000000008001";
|
|
4412
|
+
const releaseB = "00000000-0000-7000-8000-000000008002";
|
|
4413
|
+
const download = {
|
|
4414
|
+
...createBundleEventRowFixture("9801", 1e3),
|
|
4415
|
+
type: "UPDATE_DOWNLOADED",
|
|
4416
|
+
install_id: "summary-install-a",
|
|
4417
|
+
from_release_id: releaseA,
|
|
4418
|
+
to_release_id: releaseB
|
|
4419
|
+
};
|
|
4420
|
+
const appliedA = {
|
|
4421
|
+
...download,
|
|
4422
|
+
id: createBundleEventRowFixture("9802", 2e3).id,
|
|
4423
|
+
type: "UPDATE_APPLIED",
|
|
4424
|
+
received_at_ms: 2e3
|
|
4425
|
+
};
|
|
4426
|
+
const appliedB = {
|
|
4427
|
+
...appliedA,
|
|
4428
|
+
id: createBundleEventRowFixture("9803", 2100).id,
|
|
4429
|
+
install_id: "summary-install-b",
|
|
4430
|
+
received_at_ms: 2100
|
|
4431
|
+
};
|
|
4432
|
+
const recovered = {
|
|
4433
|
+
...appliedA,
|
|
4434
|
+
id: createBundleEventRowFixture("9804", 3e3).id,
|
|
4435
|
+
type: "RECOVERED",
|
|
4436
|
+
from_release_id: releaseB,
|
|
4437
|
+
to_release_id: releaseA,
|
|
4438
|
+
received_at_ms: 3e3
|
|
4439
|
+
};
|
|
4440
|
+
for (const event of [
|
|
4441
|
+
download,
|
|
4442
|
+
appliedA,
|
|
4443
|
+
appliedB,
|
|
4444
|
+
appliedB,
|
|
4445
|
+
recovered
|
|
4446
|
+
]) await record(model, event);
|
|
4447
|
+
const releases = [{
|
|
4448
|
+
releaseId: releaseA,
|
|
4449
|
+
platform: "ios",
|
|
4450
|
+
channel: "production"
|
|
4451
|
+
}, {
|
|
4452
|
+
releaseId: releaseB,
|
|
4453
|
+
platform: "ios",
|
|
4454
|
+
channel: "production"
|
|
4455
|
+
}];
|
|
4456
|
+
const lifetime = await model.getReleaseActivity({ releases });
|
|
4457
|
+
expect(lifetime.data.map(({ metrics }) => metrics)).toEqual([{
|
|
4458
|
+
downloads: 0,
|
|
4459
|
+
launches: 1,
|
|
4460
|
+
failedLaunches: 0
|
|
4461
|
+
}, {
|
|
4462
|
+
downloads: 1,
|
|
4463
|
+
launches: 2,
|
|
4464
|
+
failedLaunches: 1
|
|
4465
|
+
}]);
|
|
4466
|
+
const scope = await model.getReleaseActivity({
|
|
4467
|
+
scope: {
|
|
4468
|
+
platform: "ios",
|
|
4469
|
+
channel: "production"
|
|
4470
|
+
},
|
|
4471
|
+
timeRange: {
|
|
4472
|
+
start: 0,
|
|
4473
|
+
end: 36e5
|
|
4474
|
+
}
|
|
4475
|
+
});
|
|
4476
|
+
expect(scope.data[0]?.metrics).toMatchObject({
|
|
4477
|
+
downloads: 1,
|
|
4478
|
+
launches: 3,
|
|
4479
|
+
failedLaunches: 1,
|
|
4480
|
+
uniqueUsers: 2
|
|
4481
|
+
});
|
|
4482
|
+
const usage = await model.getAppUsage({
|
|
4483
|
+
channel: "production",
|
|
4484
|
+
platform: "all",
|
|
4485
|
+
timeRange: {
|
|
4486
|
+
start: 0,
|
|
4487
|
+
end: 36e5
|
|
4488
|
+
},
|
|
4489
|
+
intervalMs: 36e5
|
|
4490
|
+
});
|
|
4491
|
+
expect(usage.activeInstallations).toBe(2);
|
|
4492
|
+
expect(usage.points).toEqual([{
|
|
4493
|
+
startMs: 0,
|
|
4494
|
+
installations: 2
|
|
4495
|
+
}]);
|
|
4496
|
+
expect(usage.bundleDistribution).toEqual(expect.arrayContaining([{
|
|
4497
|
+
appVersion: "1.0.0",
|
|
4498
|
+
platform: "ios",
|
|
4499
|
+
releaseId: releaseA,
|
|
4500
|
+
installations: 1
|
|
4501
|
+
}, {
|
|
4502
|
+
appVersion: "1.0.0",
|
|
4503
|
+
platform: "ios",
|
|
4504
|
+
releaseId: releaseB,
|
|
4505
|
+
installations: 1
|
|
4506
|
+
}]));
|
|
4507
|
+
});
|
|
4508
|
+
it("pages insights events newest first with a stable cursor", async () => {
|
|
4509
|
+
const model = state.getDatabase();
|
|
4510
|
+
const first = createBundleEventRowFixture("701", 100);
|
|
4511
|
+
const second = createBundleEventRowFixture("702", 100);
|
|
4512
|
+
const third = createBundleEventRowFixture("703", 200);
|
|
4513
|
+
await model.recordEvent({ event: third });
|
|
4514
|
+
await model.recordEvent({ event: second });
|
|
4515
|
+
await model.recordEvent({ event: first });
|
|
4516
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4517
|
+
filter: { kind: "all" },
|
|
4518
|
+
beforeReceivedAtMs: 201,
|
|
4519
|
+
limit: 2
|
|
4520
|
+
}), [third, second]);
|
|
4521
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4522
|
+
filter: { kind: "all" },
|
|
4523
|
+
after: {
|
|
4524
|
+
receivedAtMs: second.received_at_ms,
|
|
4525
|
+
id: second.id
|
|
4526
|
+
},
|
|
4527
|
+
beforeReceivedAtMs: 201,
|
|
4528
|
+
limit: 2
|
|
4529
|
+
}), [first]);
|
|
4530
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4531
|
+
filter: { kind: "all" },
|
|
4532
|
+
beforeReceivedAtMs: 200,
|
|
4533
|
+
limit: 10
|
|
4534
|
+
}), [second, first]);
|
|
4535
|
+
});
|
|
4536
|
+
it("filters installation movements before applying the page limit", async () => {
|
|
4537
|
+
const model = state.getDatabase();
|
|
4538
|
+
const unchanged = {
|
|
4539
|
+
...createBundleEventRowFixture("711", 300),
|
|
4540
|
+
type: "UNCHANGED",
|
|
4541
|
+
install_id: "install-target",
|
|
4542
|
+
from_bundle_id: null,
|
|
4543
|
+
metadata: {
|
|
4544
|
+
...createBundleEventRowFixture("711", 300).metadata,
|
|
4545
|
+
update_strategy: null
|
|
4546
|
+
}
|
|
4547
|
+
};
|
|
4548
|
+
const unrelated = createMovementEvent("712", 250, "UPDATE_APPLIED", "install-other");
|
|
4549
|
+
const applied = createMovementEvent("713", 200, "UPDATE_APPLIED", "install-target");
|
|
4550
|
+
const recovered = createMovementEvent("714", 100, "RECOVERED", "install-target");
|
|
4551
|
+
for (const row of [
|
|
4552
|
+
unchanged,
|
|
4553
|
+
unrelated,
|
|
4554
|
+
applied,
|
|
4555
|
+
recovered
|
|
4556
|
+
]) await model.recordEvent({ event: row });
|
|
4557
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4558
|
+
filter: {
|
|
4559
|
+
kind: "installationMovement",
|
|
4560
|
+
installId: "install-target"
|
|
4561
|
+
},
|
|
4562
|
+
beforeReceivedAtMs: 301,
|
|
4563
|
+
limit: 1
|
|
4564
|
+
}), [applied]);
|
|
4565
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4566
|
+
filter: {
|
|
4567
|
+
kind: "installationMovement",
|
|
4568
|
+
installId: "install-target"
|
|
4569
|
+
},
|
|
4570
|
+
after: {
|
|
4571
|
+
receivedAtMs: applied.received_at_ms,
|
|
4572
|
+
id: applied.id
|
|
4573
|
+
},
|
|
4574
|
+
beforeReceivedAtMs: 301,
|
|
4575
|
+
limit: 1
|
|
4576
|
+
}), [recovered]);
|
|
4577
|
+
});
|
|
4578
|
+
it("lists an update failure where its target's list and installation history read it, and a failed check in no bundle list, moving no latest event", async () => {
|
|
4579
|
+
const model = state.getDatabase();
|
|
4580
|
+
const applied = createMovementEvent("721", 100, "UPDATE_APPLIED", "install-failing");
|
|
4581
|
+
const failure = (suffix, receivedAtMs, failed) => ({
|
|
4582
|
+
...applied,
|
|
4583
|
+
id: createBundleEventRowFixture(suffix, 0).id,
|
|
4584
|
+
type: "UPDATE_FAILED",
|
|
4585
|
+
from_bundle_id: applied.to_bundle_id,
|
|
4586
|
+
to_bundle_id: failed.stage === "check" ? applied.to_bundle_id : createBundleEventRowFixture("729", 0).to_bundle_id,
|
|
4587
|
+
metadata: {
|
|
4588
|
+
...applied.metadata,
|
|
4589
|
+
failure: failed
|
|
4590
|
+
},
|
|
4591
|
+
received_at_ms: receivedAtMs
|
|
4592
|
+
});
|
|
4593
|
+
const download = failure("722", 200, {
|
|
4594
|
+
stage: "download",
|
|
4595
|
+
reason: "http",
|
|
4596
|
+
http_status: 403,
|
|
4597
|
+
origin_code: "AccessDenied"
|
|
4598
|
+
});
|
|
4599
|
+
const check = failure("723", 300, {
|
|
4600
|
+
stage: "check",
|
|
4601
|
+
reason: "http"
|
|
4602
|
+
});
|
|
4603
|
+
for (const event of [
|
|
4604
|
+
applied,
|
|
4605
|
+
download,
|
|
4606
|
+
check
|
|
4607
|
+
]) await record(model, event);
|
|
4608
|
+
await expect(model.findLatestEvents({ installId: "install-failing" })).resolves.toEqual([applied]);
|
|
4609
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4610
|
+
filter: {
|
|
4611
|
+
kind: "installationMovement",
|
|
4612
|
+
installId: "install-failing"
|
|
4613
|
+
},
|
|
4614
|
+
beforeReceivedAtMs: 301,
|
|
4615
|
+
limit: 10
|
|
4616
|
+
}), [
|
|
4617
|
+
check,
|
|
4618
|
+
download,
|
|
4619
|
+
applied
|
|
4620
|
+
]);
|
|
4621
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4622
|
+
filter: { kind: "all" },
|
|
4623
|
+
beforeReceivedAtMs: 301,
|
|
4624
|
+
limit: 10
|
|
4625
|
+
}), [
|
|
4626
|
+
check,
|
|
4627
|
+
download,
|
|
4628
|
+
applied
|
|
4629
|
+
]);
|
|
4630
|
+
const scope = {
|
|
3189
4631
|
platform: "ios",
|
|
3190
4632
|
channel: "production"
|
|
3191
|
-
}
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
4633
|
+
};
|
|
4634
|
+
for (const [toBundleId, listed] of [[download.to_bundle_id, [download]], [check.to_bundle_id, []]]) {
|
|
4635
|
+
const filter = {
|
|
4636
|
+
...scope,
|
|
4637
|
+
type: "UPDATE_FAILED",
|
|
4638
|
+
toBundleId
|
|
4639
|
+
};
|
|
4640
|
+
await expectInsightsIndex(() => model.listEvents({
|
|
4641
|
+
filter: {
|
|
4642
|
+
kind: "bundle",
|
|
4643
|
+
...filter
|
|
4644
|
+
},
|
|
4645
|
+
beforeReceivedAtMs: 301,
|
|
4646
|
+
limit: 10
|
|
4647
|
+
}), listed);
|
|
4648
|
+
await expect(model.countEvents({
|
|
4649
|
+
filter,
|
|
4650
|
+
sinceMs: 0,
|
|
4651
|
+
beforeReceivedAtMs: 301
|
|
4652
|
+
})).resolves.toBe(listed.length);
|
|
3195
4653
|
}
|
|
3196
|
-
})
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
}
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
read: ({ insights }) => insights.getAppUsage({
|
|
3207
|
-
channel: "production",
|
|
3208
|
-
platform: "all",
|
|
3209
|
-
timeRange: {
|
|
3210
|
-
start: T0,
|
|
3211
|
-
end: T0 + 3 * DAY
|
|
3212
|
-
},
|
|
3213
|
-
intervalMs: DAY
|
|
3214
|
-
}),
|
|
3215
|
-
adapter: reads(0, 0, 3, 39),
|
|
3216
|
-
engine: {
|
|
3217
|
-
calls: 3,
|
|
3218
|
-
rows: 8
|
|
3219
|
-
},
|
|
3220
|
-
check: ({ versions }) => expect(versions).toEqual([{
|
|
3221
|
-
name: "1.0.0",
|
|
3222
|
-
installations: 24
|
|
3223
|
-
}])
|
|
3224
|
-
}),
|
|
3225
|
-
budget({
|
|
3226
|
-
api: "catalog compile: the scope's enabled releases, all used",
|
|
3227
|
-
read: ({ core }) => core.deploy([{
|
|
3228
|
-
bundle: bundleOf("107"),
|
|
3229
|
-
release: production
|
|
3230
|
-
}]),
|
|
3231
|
-
adapter: (copies) => reads(copies ? 5 : 3, copies ? 8 : 3, 3, 6),
|
|
3232
|
-
engine: {
|
|
3233
|
-
calls: 6,
|
|
3234
|
-
rows: 9
|
|
3235
|
-
},
|
|
3236
|
-
writes: 1
|
|
3237
|
-
}),
|
|
3238
|
-
budget({
|
|
3239
|
-
api: "channel insert: 1 write, after the name's read finds nothing",
|
|
3240
|
-
read: ({ core }) => core.ensureChannel("read-budgets"),
|
|
3241
|
-
adapter: reads(0, 0, 1, 0),
|
|
3242
|
-
engine: {
|
|
3243
|
-
calls: 1,
|
|
3244
|
-
rows: 0
|
|
3245
|
-
},
|
|
3246
|
-
writes: 1
|
|
3247
|
-
}),
|
|
3248
|
-
budget({
|
|
3249
|
-
api: "channel delete: 1 read and 1 write",
|
|
3250
|
-
read: ({ core, nightlyId }) => core.deleteChannel(nightlyId),
|
|
3251
|
-
adapter: reads(1, 1, 0, 0),
|
|
3252
|
-
engine: {
|
|
3253
|
-
calls: 1,
|
|
3254
|
-
rows: 1
|
|
3255
|
-
},
|
|
3256
|
-
writes: 1,
|
|
3257
|
-
check: (result) => expect(result).toEqual({ deleted: true })
|
|
3258
|
-
}),
|
|
3259
|
-
budget({
|
|
3260
|
-
api: "record an insights event: 2 dependent rounds of batch gets and 1 write",
|
|
3261
|
-
read: ({ insights }) => insights.recordEvent(eventOf(26, {
|
|
3262
|
-
install_id: "install-2",
|
|
3263
|
-
received_at_ms: T0 + 3 * HOUR
|
|
3264
|
-
})),
|
|
3265
|
-
adapter: reads(5, 21, 0, 0),
|
|
3266
|
-
engine: {
|
|
3267
|
-
calls: 2,
|
|
3268
|
-
rows: 1
|
|
3269
|
-
},
|
|
3270
|
-
writes: 1
|
|
3271
|
-
})
|
|
3272
|
-
];
|
|
4654
|
+
});
|
|
4655
|
+
});
|
|
4656
|
+
};
|
|
4657
|
+
//#endregion
|
|
4658
|
+
//#region src/insights/setupInsightsHttpTestSuite.ts
|
|
4659
|
+
const jsonRequest$1 = (method, body) => ({
|
|
4660
|
+
method,
|
|
4661
|
+
headers: { "content-type": "application/json" },
|
|
4662
|
+
body: JSON.stringify(body)
|
|
4663
|
+
});
|
|
3273
4664
|
/**
|
|
3274
|
-
* The
|
|
3275
|
-
*
|
|
3276
|
-
* two rows. Every API in the read-budget list reads exactly its budget at the
|
|
3277
|
-
* adapter and at the engine; with `examined`, the database examines no more
|
|
3278
|
-
* rows than the adapter read, within the backend's native multipliers.
|
|
4665
|
+
* The Insights plugin's routes on a database: a client reports an event, and
|
|
4666
|
+
* the admin routes read it back. The server must run `insights()`.
|
|
3279
4667
|
*/
|
|
3280
|
-
const
|
|
3281
|
-
describe(
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
4668
|
+
const setupInsightsHttpTestSuite = (options) => {
|
|
4669
|
+
describe("Insights HTTP routes", () => {
|
|
4670
|
+
it("records a reported event and reads it back", async () => {
|
|
4671
|
+
const client = options.getClient();
|
|
4672
|
+
const installId = `install-${crypto.randomUUID()}`;
|
|
4673
|
+
const userId = `user-${crypto.randomUUID()}`;
|
|
4674
|
+
const channel = `insights-${crypto.randomUUID()}`;
|
|
4675
|
+
const event = {
|
|
4676
|
+
appVersion: "1.0.0",
|
|
4677
|
+
channel,
|
|
4678
|
+
cohort: "default",
|
|
4679
|
+
fingerprintHash: null,
|
|
4680
|
+
fromBundleId: null,
|
|
4681
|
+
fromReleaseId: null,
|
|
4682
|
+
installId,
|
|
4683
|
+
platform: "ios",
|
|
4684
|
+
toBundleId: "00000000-0000-7000-8000-000000000001",
|
|
4685
|
+
toReleaseId: null,
|
|
4686
|
+
type: "UNCHANGED",
|
|
4687
|
+
updateStrategy: null,
|
|
4688
|
+
userId,
|
|
4689
|
+
sdkVersion: "2.0.0"
|
|
4690
|
+
};
|
|
4691
|
+
const reported = await client.client("/events", jsonRequest$1("POST", event));
|
|
4692
|
+
expect(reported.status).toBe(204);
|
|
4693
|
+
await reported.text();
|
|
4694
|
+
const installation = await client.admin(`/installations/${encodeURIComponent(installId)}`);
|
|
4695
|
+
expect(installation.status).toBe(200);
|
|
4696
|
+
expect(await installation.json()).toMatchObject({
|
|
4697
|
+
installId,
|
|
4698
|
+
latestStatus: "UNCHANGED",
|
|
4699
|
+
lastKnownBundleId: event.toBundleId,
|
|
4700
|
+
channel
|
|
3290
4701
|
});
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
4702
|
+
await expectInsightsIndex(async () => {
|
|
4703
|
+
return (await (await client.admin(`/installations?userId=${encodeURIComponent(userId)}`)).json()).data.map(({ installId: id }) => id);
|
|
4704
|
+
}, [installId]);
|
|
4705
|
+
await expectInsightsIndex(async () => {
|
|
4706
|
+
return (await (await client.admin(`/overview?platform=ios&channel=${encodeURIComponent(channel)}&window=24h`)).json()).reportingInstallations.count;
|
|
4707
|
+
}, 1);
|
|
4708
|
+
});
|
|
4709
|
+
it("records an update failure in installation history and the failures read", async () => {
|
|
4710
|
+
const client = options.getClient();
|
|
4711
|
+
const installId = `install-${crypto.randomUUID()}`;
|
|
4712
|
+
const channel = `insights-${crypto.randomUUID()}`;
|
|
4713
|
+
const releaseId = "00000000-0000-7000-8000-0000000000b2";
|
|
4714
|
+
const reported = await client.client("/events", jsonRequest$1("POST", {
|
|
4715
|
+
appVersion: "1.0.0",
|
|
4716
|
+
channel,
|
|
4717
|
+
cohort: "default",
|
|
4718
|
+
fingerprintHash: null,
|
|
4719
|
+
fromBundleId: "00000000-0000-7000-8000-000000000001",
|
|
4720
|
+
fromReleaseId: null,
|
|
4721
|
+
installId,
|
|
4722
|
+
platform: "ios",
|
|
4723
|
+
toBundleId: "00000000-0000-7000-8000-000000000002",
|
|
4724
|
+
toReleaseId: releaseId,
|
|
4725
|
+
type: "UPDATE_FAILED",
|
|
4726
|
+
updateStrategy: "appVersion",
|
|
4727
|
+
metadata: { failure: {
|
|
4728
|
+
stage: "download",
|
|
4729
|
+
reason: "http",
|
|
4730
|
+
httpStatus: 403
|
|
4731
|
+
} }
|
|
4732
|
+
}));
|
|
4733
|
+
expect(reported.status).toBe(204);
|
|
4734
|
+
await reported.text();
|
|
4735
|
+
await expectInsightsIndex(async () => {
|
|
4736
|
+
return (await (await client.admin(`/installations/${encodeURIComponent(installId)}/events?limit=10`)).json()).data.map(({ type, failure }) => ({
|
|
4737
|
+
type,
|
|
4738
|
+
failure
|
|
4739
|
+
}));
|
|
4740
|
+
}, [{
|
|
4741
|
+
type: "UPDATE_FAILED",
|
|
4742
|
+
failure: {
|
|
4743
|
+
stage: "download",
|
|
4744
|
+
reason: "http",
|
|
4745
|
+
httpStatus: 403
|
|
3297
4746
|
}
|
|
3298
|
-
}
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
afterAll(async () => {
|
|
3305
|
-
await created?.cleanup?.();
|
|
4747
|
+
}]);
|
|
4748
|
+
const now = Date.now();
|
|
4749
|
+
await expectInsightsIndex(async () => {
|
|
4750
|
+
const body = await (await client.admin(`/failures?platform=ios&channel=${encodeURIComponent(channel)}&releaseId=${releaseId}&start=${now - 864e5}&end=${now + 36e5}`)).json();
|
|
4751
|
+
return [body.failedUpdates, body.failedInstallations];
|
|
4752
|
+
}, [1, 1]);
|
|
3306
4753
|
});
|
|
3307
|
-
|
|
3308
|
-
const
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
const measured = await database.measureReads(() => budget.read(seeded));
|
|
3312
|
-
const adapter = typeof budget.adapter === "function" ? budget.adapter(indexCopies) : budget.adapter;
|
|
3313
|
-
expect({
|
|
3314
|
-
adapter: measured.adapter,
|
|
3315
|
-
engine: measured.engine
|
|
3316
|
-
}).toEqual({
|
|
3317
|
-
adapter,
|
|
3318
|
-
engine: budget.engine
|
|
3319
|
-
});
|
|
3320
|
-
expect(writes, "writes").toBe(budget.writes ?? 0);
|
|
3321
|
-
if (budget.returned) expect(budget.returned(measured.result), "rows returned").toBe(measured.engine.rows);
|
|
3322
|
-
budget.check?.(measured.result);
|
|
3323
|
-
if (examined) {
|
|
3324
|
-
const { gets, keys, queries, rows } = measured.adapter;
|
|
3325
|
-
expect(await examined.total(), "rows examined").toBeLessThanOrEqual((examined.perRow ?? 1) * (keys + rows) + (examined.perRead ?? 0) * (gets + queries));
|
|
3326
|
-
}
|
|
4754
|
+
it("refuses a malformed event", async () => {
|
|
4755
|
+
const response = await options.getClient().client("/events", jsonRequest$1("POST", { type: "UNCHANGED" }));
|
|
4756
|
+
expect(response.status).toBe(400);
|
|
4757
|
+
await response.text();
|
|
3327
4758
|
});
|
|
3328
4759
|
});
|
|
3329
4760
|
};
|
|
3330
4761
|
//#endregion
|
|
3331
|
-
//#region src/
|
|
3332
|
-
/** Wraps executors so each read they run while counting is examined first. */
|
|
3333
|
-
const examineReads = (hooks) => {
|
|
3334
|
-
let counting = false;
|
|
3335
|
-
let examined = 0;
|
|
3336
|
-
return {
|
|
3337
|
-
/** The executor, examining each SELECT it runs outside a transaction. */
|
|
3338
|
-
wrap: (executor) => ({
|
|
3339
|
-
...executor,
|
|
3340
|
-
async execute(statement) {
|
|
3341
|
-
if (counting && /^\s*SELECT\b/iu.test(statement.sql)) {
|
|
3342
|
-
const rows = await hooks.examine(statement);
|
|
3343
|
-
examined += rows;
|
|
3344
|
-
}
|
|
3345
|
-
return executor.execute(statement);
|
|
3346
|
-
}
|
|
3347
|
-
}),
|
|
3348
|
-
examined: {
|
|
3349
|
-
reset() {
|
|
3350
|
-
counting = true;
|
|
3351
|
-
examined = 0;
|
|
3352
|
-
hooks.onReset?.();
|
|
3353
|
-
},
|
|
3354
|
-
async total() {
|
|
3355
|
-
counting = false;
|
|
3356
|
-
return examined;
|
|
3357
|
-
}
|
|
3358
|
-
}
|
|
3359
|
-
};
|
|
3360
|
-
};
|
|
3361
|
-
/** Rows each table read of a plan examined: rows it produced, filtered out, or rechecked away, over all its loops. */
|
|
3362
|
-
const postgresTableReads = (node, reads = /* @__PURE__ */ new Map()) => {
|
|
3363
|
-
const relation = node["Relation Name"];
|
|
3364
|
-
if (relation !== void 0) {
|
|
3365
|
-
const table = node.Alias ?? relation;
|
|
3366
|
-
const rows = (node["Actual Rows"] ?? 0) + (node["Rows Removed by Filter"] ?? 0) + (node["Rows Removed by Index Recheck"] ?? 0);
|
|
3367
|
-
reads.set(table, (reads.get(table) ?? 0) + rows * (node["Actual Loops"] ?? 0));
|
|
3368
|
-
}
|
|
3369
|
-
for (const child of node.Plans ?? []) postgresTableReads(child, reads);
|
|
3370
|
-
return reads;
|
|
3371
|
-
};
|
|
3372
|
-
/**
|
|
3373
|
-
* Planner settings under which a test's small tables plan as production-sized
|
|
3374
|
-
* ones: no sequential scan, which only a small table affords, and an index
|
|
3375
|
-
* entry weighted like a row, so a plan reads no more entries than the read
|
|
3376
|
-
* needs, as a large index forces.
|
|
3377
|
-
*/
|
|
3378
|
-
const POSTGRES_PLANNER = ["enable_seqscan = off", "cpu_index_tuple_cost = 1"];
|
|
4762
|
+
//#region src/insights/setupInsightsModelTestSuite.ts
|
|
3379
4763
|
/**
|
|
3380
|
-
*
|
|
3381
|
-
*
|
|
3382
|
-
*
|
|
3383
|
-
* vacuums and analyzes, as autovacuum keeps a production database. A plan
|
|
3384
|
-
* then takes the index path a production-sized table takes, and a read no
|
|
3385
|
-
* index serves still scans. A statement counts the rows its busiest table
|
|
3386
|
-
* read examined, so an exact plan counts the rows it returns, a multi-valued
|
|
3387
|
-
* index's own table included.
|
|
4764
|
+
* The Insights report contract on one database: pass the Insights plugin's
|
|
4765
|
+
* model over it, `createInsightsModel(createHotUpdater({ database, plugins:
|
|
4766
|
+
* [insights()], clientAccess: "public" }).api.insights)`, as `createDatabase`.
|
|
3388
4767
|
*/
|
|
3389
|
-
const
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
}
|
|
3396
|
-
async
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
return Math.max(0, ...postgresTableReads(plan.Plan).values());
|
|
3409
|
-
}
|
|
4768
|
+
const setupInsightsModelTestSuite = (lifecycle) => {
|
|
4769
|
+
describe(lifecycle.name, () => {
|
|
4770
|
+
let model;
|
|
4771
|
+
const getDatabase = () => {
|
|
4772
|
+
if (model === void 0) throw new Error("The model is unavailable outside the test lifecycle");
|
|
4773
|
+
return model;
|
|
4774
|
+
};
|
|
4775
|
+
beforeAll(async () => {
|
|
4776
|
+
await lifecycle.migrate();
|
|
4777
|
+
model = await lifecycle.createDatabase();
|
|
4778
|
+
});
|
|
4779
|
+
beforeEach(async () => {
|
|
4780
|
+
await lifecycle.reset(getDatabase());
|
|
4781
|
+
});
|
|
4782
|
+
afterAll(async () => {
|
|
4783
|
+
await lifecycle.dispose(getDatabase());
|
|
4784
|
+
model = void 0;
|
|
4785
|
+
});
|
|
4786
|
+
registerInsightsModelTests({ getDatabase });
|
|
3410
4787
|
});
|
|
3411
4788
|
};
|
|
4789
|
+
//#endregion
|
|
4790
|
+
//#region src/insights/index.ts
|
|
3412
4791
|
/**
|
|
3413
|
-
*
|
|
3414
|
-
*
|
|
3415
|
-
*
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
/**
|
|
3419
|
-
* Rows examined on MySQL. Each SELECT a wrapped executor runs is first run
|
|
3420
|
-
* under `EXPLAIN ANALYZE` on `query`, one session, after `ANALYZE TABLE` of
|
|
3421
|
-
* the tables it reads once per `reset`, and with multi-range reads always
|
|
3422
|
-
* on, so a secondary index's range is costed with sorted row reads, as a
|
|
3423
|
-
* production-sized table's is. A statement counts the rows its busiest table
|
|
3424
|
-
* read examined; a filter above a table read drops rows that read produced.
|
|
4792
|
+
* The Insights plugin's tests, for `setupDatabaseTestSuite`'s `plugins`: its
|
|
4793
|
+
* HTTP routes on the provider's server, which must run `insights()`, and
|
|
4794
|
+
* with `createModel` its report contract on the provider's database, such as
|
|
4795
|
+
* `(database) => createInsightsModel(createHotUpdater({ database, plugins:
|
|
4796
|
+
* [insights()], clientAccess: "public" }).api.insights)`.
|
|
3425
4797
|
*/
|
|
3426
|
-
const
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
planner ??= query("SET SESSION optimizer_switch = 'mrr=on,mrr_cost_based=off'", []);
|
|
3435
|
-
await planner;
|
|
3436
|
-
const tables = [...sql.matchAll(/(?:FROM|JOIN) (`[^`]+`)/gu)].map(([, table]) => table).filter((table) => !analyzed.has(table));
|
|
3437
|
-
for (const table of tables) analyzed.add(table);
|
|
3438
|
-
if (tables.length > 0) await query(`ANALYZE TABLE ${tables.join(", ")}`, []);
|
|
3439
|
-
const [row] = await query(`EXPLAIN ANALYZE ${sql}`, params);
|
|
3440
|
-
const reads = /* @__PURE__ */ new Map();
|
|
3441
|
-
for (const line of String(Object.values(row ?? {})[0]).split("\n")) {
|
|
3442
|
-
const read = MYSQL_TABLE_READ.exec(line);
|
|
3443
|
-
if (read === null) continue;
|
|
3444
|
-
const [, table = "", rows, loops] = read;
|
|
3445
|
-
reads.set(table, (reads.get(table) ?? 0) + Number(rows) * Number(loops));
|
|
3446
|
-
}
|
|
3447
|
-
return Math.max(0, ...reads.values());
|
|
3448
|
-
}
|
|
3449
|
-
});
|
|
3450
|
-
};
|
|
3451
|
-
//#endregion
|
|
3452
|
-
//#region src/runContentionHarness.ts
|
|
3453
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3454
|
-
/** Delays every adapter call by `ms`, like a round trip to a remote database. */
|
|
3455
|
-
const withAdapterLatency = (adapter, ms) => ({
|
|
3456
|
-
...adapter,
|
|
3457
|
-
get: async (table, keys) => {
|
|
3458
|
-
await sleep(ms);
|
|
3459
|
-
return adapter.get(table, keys);
|
|
3460
|
-
},
|
|
3461
|
-
query: async (table, request) => {
|
|
3462
|
-
await sleep(ms);
|
|
3463
|
-
return adapter.query(table, request);
|
|
3464
|
-
},
|
|
3465
|
-
write: async (ops) => {
|
|
3466
|
-
await sleep(ms);
|
|
3467
|
-
return adapter.write(ops);
|
|
4798
|
+
const insightsTestSuite = (options = {}) => ({
|
|
4799
|
+
name: "Insights plugin",
|
|
4800
|
+
register: (context) => {
|
|
4801
|
+
setupInsightsHttpTestSuite({ getClient: context.getClient });
|
|
4802
|
+
const { createModel } = options;
|
|
4803
|
+
if (createModel !== void 0) describe("Insights model", () => {
|
|
4804
|
+
registerInsightsModelTests({ getDatabase: () => createModel(context.getDatabase()) });
|
|
4805
|
+
});
|
|
3468
4806
|
}
|
|
3469
4807
|
});
|
|
3470
|
-
/**
|
|
3471
|
-
* Starts `transactions` runs at a steady arrival rate through a fixed pool and
|
|
3472
|
-
* reports how they ended. Retry counts come from the engine's `onRetry`.
|
|
3473
|
-
*/
|
|
3474
|
-
const runContentionHarness = async (options) => {
|
|
3475
|
-
const started = Date.now();
|
|
3476
|
-
const errors = {};
|
|
3477
|
-
const waiting = [];
|
|
3478
|
-
let free = options.concurrency;
|
|
3479
|
-
let committed = 0;
|
|
3480
|
-
const acquire = () => free > 0 ? (free -= 1, Promise.resolve()) : new Promise((resolve) => waiting.push(resolve));
|
|
3481
|
-
const release = () => {
|
|
3482
|
-
const next = waiting.shift();
|
|
3483
|
-
if (next) next();
|
|
3484
|
-
else free += 1;
|
|
3485
|
-
};
|
|
3486
|
-
await Promise.all(Array.from({ length: options.transactions }, async (_, index) => {
|
|
3487
|
-
await sleep(index * 1e3 / options.ratePerSecond);
|
|
3488
|
-
await acquire();
|
|
3489
|
-
try {
|
|
3490
|
-
await options.run(index);
|
|
3491
|
-
committed += 1;
|
|
3492
|
-
} catch (error) {
|
|
3493
|
-
const name = error instanceof Error ? error.name : "unknown";
|
|
3494
|
-
errors[name] = (errors[name] ?? 0) + 1;
|
|
3495
|
-
} finally {
|
|
3496
|
-
release();
|
|
3497
|
-
}
|
|
3498
|
-
}));
|
|
3499
|
-
return {
|
|
3500
|
-
transactions: options.transactions,
|
|
3501
|
-
committed,
|
|
3502
|
-
errors,
|
|
3503
|
-
durationMs: Date.now() - started
|
|
3504
|
-
};
|
|
3505
|
-
};
|
|
3506
4808
|
//#endregion
|
|
3507
|
-
//#region src/
|
|
3508
|
-
const builtInPlugin = Symbol.for("@hot-updater/server/built-in-plugin");
|
|
4809
|
+
//#region src/kvTestStore.ts
|
|
3509
4810
|
/**
|
|
3510
|
-
*
|
|
3511
|
-
*
|
|
4811
|
+
* An in-memory key-value store that behaves like DynamoDB where the adapter
|
|
4812
|
+
* can tell: a write checks every condition against the state before it,
|
|
4813
|
+
* refuses two ops on one item, and applies all ops or none.
|
|
3512
4814
|
*/
|
|
3513
|
-
const
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
4815
|
+
const createMemoryKeyValueStore = (options = {}) => {
|
|
4816
|
+
let partitions = /* @__PURE__ */ new Map();
|
|
4817
|
+
let expiries = /* @__PURE__ */ new Map();
|
|
4818
|
+
const read = (key) => partitions.get(key.pk)?.get(key.sk) ?? null;
|
|
4819
|
+
const holds = (key, condition) => {
|
|
4820
|
+
if (condition === void 0) return true;
|
|
4821
|
+
const item = read(key);
|
|
4822
|
+
return "v" in condition ? item !== null && item[DATABASE_VERSION_COLUMN] === condition.v : item !== null === condition.exists;
|
|
3519
4823
|
};
|
|
3520
|
-
const schema = options.engine.resolveSchema([module]);
|
|
3521
|
-
await adapter.migrations?.apply(schema.tables);
|
|
3522
|
-
const engine = options.engine.createDatabaseEngine({
|
|
3523
|
-
adapter,
|
|
3524
|
-
schema,
|
|
3525
|
-
verify: true
|
|
3526
|
-
});
|
|
3527
|
-
let clock = options.now ?? Date.now;
|
|
3528
|
-
const db = engine.database(module);
|
|
3529
|
-
const instance = plugin.init({
|
|
3530
|
-
db,
|
|
3531
|
-
core: {},
|
|
3532
|
-
now: () => clock()
|
|
3533
|
-
});
|
|
3534
4824
|
return {
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
4825
|
+
id: "memory-kv",
|
|
4826
|
+
limits: options.limits ?? {
|
|
4827
|
+
items: 100,
|
|
4828
|
+
bytes: 4e6
|
|
4829
|
+
},
|
|
4830
|
+
items: () => [...partitions.values()].reduce((sum, items) => sum + items.size, 0),
|
|
4831
|
+
expiries: () => expiries,
|
|
4832
|
+
async get(keys) {
|
|
4833
|
+
return keys.map((key) => structuredClone(read(key)));
|
|
4834
|
+
},
|
|
4835
|
+
async query({ pk, gte, lt, order, limit, after }) {
|
|
4836
|
+
const direction = order === "asc" ? 1 : -1;
|
|
4837
|
+
const matching = [...partitions.get(pk) ?? /* @__PURE__ */ new Map()].filter(([sk]) => (gte === void 0 || compareUtf8(sk, gte) >= 0) && (lt === void 0 || compareUtf8(sk, lt) < 0) && (after === void 0 || direction * compareUtf8(sk, after) > 0)).sort(([left], [right]) => direction * compareUtf8(left, right));
|
|
4838
|
+
const size = Math.min(limit, options.nativePageSize ?? limit);
|
|
4839
|
+
return {
|
|
4840
|
+
items: matching.slice(0, size).map(([sk, value]) => ({
|
|
4841
|
+
sk,
|
|
4842
|
+
value: structuredClone(value)
|
|
4843
|
+
})),
|
|
4844
|
+
more: matching.length > size
|
|
4845
|
+
};
|
|
4846
|
+
},
|
|
4847
|
+
async write(ops) {
|
|
4848
|
+
if (new Set(ops.map(({ key }) => JSON.stringify(key))).size !== ops.length) throw new Error("A write may touch each item once.");
|
|
4849
|
+
const failed = ops.findIndex((op) => !holds(op.key, op.condition));
|
|
4850
|
+
if (failed !== -1) return {
|
|
4851
|
+
ok: false,
|
|
4852
|
+
failedOp: failed
|
|
4853
|
+
};
|
|
4854
|
+
const next = new Map(partitions);
|
|
4855
|
+
const stamped = new Map(expiries);
|
|
4856
|
+
for (const op of ops) {
|
|
4857
|
+
if (op.type === "check") continue;
|
|
4858
|
+
const items = new Map(next.get(op.key.pk));
|
|
4859
|
+
next.set(op.key.pk, items);
|
|
4860
|
+
const id = JSON.stringify([op.key.pk, op.key.sk]);
|
|
4861
|
+
if (op.type === "delete") {
|
|
4862
|
+
items.delete(op.key.sk);
|
|
4863
|
+
stamped.delete(id);
|
|
4864
|
+
} else if (op.type === "put") {
|
|
4865
|
+
items.set(op.key.sk, structuredClone(op.value));
|
|
4866
|
+
if (op.expiresAt === void 0) stamped.delete(id);
|
|
4867
|
+
else stamped.set(id, op.expiresAt);
|
|
4868
|
+
} else {
|
|
4869
|
+
if (!items.has(op.key.sk) && op.expiresAt !== void 0) stamped.set(id, op.expiresAt);
|
|
4870
|
+
const item = structuredClone(items.get(op.key.sk) ?? op.init ?? {});
|
|
4871
|
+
for (const [name, delta] of Object.entries(op.by)) item[name] = Number(item[name] ?? 0) + delta;
|
|
4872
|
+
items.set(op.key.sk, item);
|
|
4873
|
+
}
|
|
4874
|
+
}
|
|
4875
|
+
partitions = next;
|
|
4876
|
+
expiries = stamped;
|
|
4877
|
+
return { ok: true };
|
|
4878
|
+
},
|
|
4879
|
+
async deleteConsumed(keys) {
|
|
4880
|
+
const next = new Map(partitions);
|
|
4881
|
+
for (const { pk, sk } of keys) {
|
|
4882
|
+
const items = new Map(next.get(pk));
|
|
4883
|
+
items.delete(sk);
|
|
4884
|
+
next.set(pk, items);
|
|
4885
|
+
expiries.delete(JSON.stringify([pk, sk]));
|
|
4886
|
+
}
|
|
4887
|
+
partitions = next;
|
|
4888
|
+
},
|
|
4889
|
+
async dispose() {
|
|
4890
|
+
partitions = /* @__PURE__ */ new Map();
|
|
4891
|
+
expiries = /* @__PURE__ */ new Map();
|
|
3545
4892
|
}
|
|
3546
4893
|
};
|
|
3547
4894
|
};
|
|
@@ -3615,4 +4962,4 @@ async function startHttpTestServer(handlers) {
|
|
|
3615
4962
|
};
|
|
3616
4963
|
}
|
|
3617
4964
|
//#endregion
|
|
3618
|
-
export { RELEASE_CATALOG_DOWNLOAD_HASH, RELEASE_CATALOG_MANIFEST_URIS, RELEASE_CATALOG_STORAGE_FIXTURES, conformanceCounters, conformanceItems, createAdminApiTestClient, createHandlerHttpTestClient, createHttpTestClient, createPluginTestHarness, createReleaseCatalogTestStorage, jsonRequest, mysqlRowsExamined, postgresRowsExamined, releaseCatalogArtifact, releaseCatalogDownloadUrl, runContentionHarness, setupBundleMethodsTestSuite, setupCoreAdminTestSuite, setupDatabaseAdapterConformanceSuite, setupDatabaseTestSuite, setupInsightsHttpTestSuite, setupInsightsModelTestSuite, setupReadBudgetTestSuite, setupReleaseCatalogTestSuite, setupSemverSatisfiesTestSuite, startHttpTestServer, toDeployBundle, withAdapterLatency };
|
|
4965
|
+
export { RELEASE_CATALOG_DOWNLOAD_HASH, RELEASE_CATALOG_MANIFEST_URIS, RELEASE_CATALOG_STORAGE_FIXTURES, conformanceCounters, conformanceExpiring, conformanceItems, createAdminApiTestClient, createBundleEventRowFixture, createBundleFixture, createBundlePatchRowFixture, createBundleRowFixture, createChannelRowFixture, createHandlerHttpTestClient, createHttpTestClient, createMeasuredDatabase, createMemoryKeyValueStore, createPluginTestHarness, createReleaseCatalogTestStorage, createReleaseRowFixture, expectInsightsIndex, insightsTestSuite, jsonRequest, mysqlRowsExamined, postgresRowsExamined, releaseCatalogArtifact, releaseCatalogDownloadUrl, runContentionHarness, setupAggregateBatchingTestSuite, setupBundleMethodsTestSuite, setupCoreAdminTestSuite, setupDatabaseAdapterConformanceSuite, setupDatabaseTestSuite, setupInsightsHttpTestSuite, setupInsightsModelTestSuite, setupReadBudgetTestSuite, setupReleaseCatalogTestSuite, setupSemverSatisfiesTestSuite, setupStorageAdapterTestSuite, startHttpTestServer, storeBundles, toDeployBundle, withAdapterLatency };
|