@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/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/core";
3
- import { DatabaseAdapterContractError, createMemoryAdapter, findPhysicalIndex, indexOrderTuple, verifyAdapter } from "@hot-updater/plugin-core/internal";
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 createBundleEventRowFixture = (suffix, receivedAtMs) => ({
104
- id: fixtureId(suffix),
105
- type: "UPDATE_APPLIED",
106
- install_id: `install-${suffix}`,
107
- user_id: null,
108
- metadata: {
109
- username: null,
110
- cohort: "0",
111
- update_strategy: "appVersion",
112
- fingerprint_hash: null,
113
- sdk_version: null
114
- },
115
- from_bundle_id: fixtureId(`${Number(suffix) + 1e3}`),
116
- from_release_id: null,
117
- to_bundle_id: fixtureId(`${Number(suffix) + 2e3}`),
118
- to_release_id: null,
119
- platform: "ios",
120
- app_version: "1.0.0",
121
- channel: "production",
122
- received_at_ms: receivedAtMs
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/expectInsightsIndex.ts
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
- * The Insights plugin's routes on a database: a client reports an event, and
396
- * the admin routes read it back. The server must run `insights()`.
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 setupInsightsHttpTestSuite = (options) => {
399
- describe("Insights HTTP routes", () => {
400
- it("records a reported event and reads it back", async () => {
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
- //#endregion
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
- //#endregion
478
- //#region src/insightsModelTests.ts
479
- const record = (model, event) => model.recordEvent({ event });
480
- const createMovementEvent = (suffix, receivedAtMs, type, installId) => {
481
- const row = createBundleEventRowFixture(suffix, receivedAtMs);
482
- return {
483
- ...row,
484
- type,
485
- install_id: installId,
486
- from_bundle_id: row.to_bundle_id,
487
- metadata: {
488
- ...row.metadata,
489
- update_strategy: "appVersion"
490
- }
491
- };
492
- };
493
- /**
494
- * The Insights report contract on `InsightsModel`: what the Insights plugin
495
- * records and reads, through `createInsightsModel`, on one database.
496
- */
497
- const registerInsightsModelTests = (state) => {
498
- describe("Insights report contract", () => {
499
- it("preserves ancillary JSON through event history and latest-event reads", async () => {
500
- const model = state.getDatabase();
501
- const base = createBundleEventRowFixture("979", 100);
502
- const event = {
503
- ...base,
504
- metadata: {
505
- ...base.metadata,
506
- device: {
507
- locale: "ko-KR",
508
- tags: [
509
- "minor",
510
- null,
511
- true,
512
- 2
513
- ]
514
- },
515
- diagnostic: null
516
- }
517
- };
518
- await record(model, event);
519
- await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([event]);
520
- await expectInsightsIndex(() => model.listEvents({
521
- filter: { kind: "all" },
522
- beforeReceivedAtMs: 101,
523
- limit: 10
524
- }), [event]);
525
- });
526
- it("selects the latest complete event across download, replacement, apply, and delayed reports", async () => {
527
- const model = state.getDatabase();
528
- const download = {
529
- ...createBundleEventRowFixture("980", 100),
530
- type: "UPDATE_DOWNLOADED",
531
- from_bundle_id: "00000000-0000-7000-8000-000000001980",
532
- metadata: {
533
- ...createBundleEventRowFixture("980", 100).metadata,
534
- update_strategy: "appVersion"
535
- },
536
- to_release_id: "00000000-0000-7000-8000-000000004980"
537
- };
538
- await record(model, download);
539
- await record(model, download);
540
- await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([download]);
541
- await expectInsightsIndex(() => model.listEvents({
542
- filter: {
543
- kind: "bundle",
544
- type: "UPDATE_DOWNLOADED",
545
- toBundleId: download.to_bundle_id,
546
- platform: "ios",
547
- channel: "production"
548
- },
549
- beforeReceivedAtMs: 200,
550
- limit: 10
551
- }), [download]);
552
- const replacement = {
553
- ...download,
554
- id: createBundleEventRowFixture("981", 110).id,
555
- received_at_ms: 110,
556
- to_bundle_id: "00000000-0000-7000-8000-000000002981"
557
- };
558
- await record(model, replacement);
559
- await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([replacement]);
560
- const applied = {
561
- ...replacement,
562
- type: "UPDATE_APPLIED",
563
- id: createBundleEventRowFixture("982", 120).id,
564
- received_at_ms: 120
565
- };
566
- await record(model, applied);
567
- await record(model, {
568
- ...download,
569
- id: createBundleEventRowFixture("983", 105).id,
570
- received_at_ms: 105
571
- });
572
- await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([applied]);
573
- await expectInsightsIndex(() => model.listEvents({
574
- filter: {
575
- kind: "installationMovement",
576
- installId: download.install_id
577
- },
578
- beforeReceivedAtMs: 200,
579
- limit: 10
580
- }), [
581
- applied,
582
- replacement,
583
- {
584
- ...download,
585
- id: createBundleEventRowFixture("983", 105).id,
586
- received_at_ms: 105
587
- },
588
- download
589
- ]);
590
- });
591
- it("treats duplicate IDs as complete no-ops, including changed retry payloads", async () => {
592
- const model = state.getDatabase();
593
- const event = createBundleEventRowFixture("901", 100);
594
- await Promise.all([
595
- record(model, event),
596
- record(model, event),
597
- record(model, event)
598
- ]);
599
- await record(model, event);
600
- const changed = {
601
- ...event,
602
- received_at_ms: 300,
603
- user_id: "changed-user"
604
- };
605
- await record(model, changed);
606
- const otherInstallation = {
607
- ...changed,
608
- install_id: "different-install"
609
- };
610
- await record(model, otherInstallation);
611
- await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([event]);
612
- await expect(model.findLatestEvents({ installId: otherInstallation.install_id })).resolves.toEqual([]);
613
- await expectInsightsIndex(() => model.listEvents({
614
- filter: { kind: "all" },
615
- beforeReceivedAtMs: 400,
616
- limit: 10
617
- }), [event]);
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
- it("keeps all concurrent events and the greatest timestamp/ID state, including logout", async () => {
620
- const model = state.getDatabase();
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
- it("rejects invalid event metadata before persisting a report", async () => {
658
- const model = state.getDatabase();
659
- const event = createBundleEventRowFixture("920", 100);
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
- it("shares scoped list/count predicates, recovery attribution, and half-open time bounds", async () => {
675
- const model = state.getDatabase();
676
- const bundleA = createBundleEventRowFixture("1", 0).to_bundle_id;
677
- const bundleB = createBundleEventRowFixture("2", 0).to_bundle_id;
678
- const bundleC = createBundleEventRowFixture("3", 0).to_bundle_id;
679
- const applied = {
680
- ...createBundleEventRowFixture("930", 100),
681
- install_id: "target",
682
- to_bundle_id: bundleB
683
- };
684
- const recovered = {
685
- ...createBundleEventRowFixture("931", 120),
686
- install_id: "target",
687
- type: "RECOVERED",
688
- from_bundle_id: bundleB,
689
- to_bundle_id: bundleA,
690
- metadata: {
691
- ...createBundleEventRowFixture("931", 120).metadata,
692
- update_strategy: "appVersion"
693
- }
694
- };
695
- const unchanged = {
696
- ...createBundleEventRowFixture("932", 130),
697
- type: "UNCHANGED",
698
- from_bundle_id: null,
699
- to_bundle_id: bundleB,
700
- metadata: {
701
- ...createBundleEventRowFixture("932", 130).metadata,
702
- update_strategy: null
703
- }
704
- };
705
- const excluded = [
706
- {
707
- ...applied,
708
- id: createBundleEventRowFixture("933", 99).id,
709
- install_id: "before",
710
- received_at_ms: 99
711
- },
712
- {
713
- ...applied,
714
- id: createBundleEventRowFixture("934", 200).id,
715
- install_id: "after",
716
- received_at_ms: 200
717
- },
718
- {
719
- ...applied,
720
- id: createBundleEventRowFixture("935", 110).id,
721
- install_id: "android",
722
- platform: "android"
723
- },
724
- {
725
- ...applied,
726
- id: createBundleEventRowFixture("936", 110).id,
727
- install_id: "preview",
728
- channel: "preview"
729
- },
730
- {
731
- ...recovered,
732
- id: createBundleEventRowFixture("937", 125).id,
733
- install_id: "other-source",
734
- from_bundle_id: bundleC,
735
- to_bundle_id: bundleB
736
- }
737
- ];
738
- for (const row of [
739
- applied,
740
- recovered,
741
- unchanged,
742
- ...excluded
743
- ]) await record(model, row);
744
- await expect(async () => record(model, {
745
- ...applied,
746
- type: "RELEASE_ADOPTED"
747
- })).rejects.toThrow();
748
- await expect(async () => record(model, {
749
- ...applied,
750
- type: "UNCHANGED",
751
- from_bundle_id: bundleB,
752
- metadata: {
753
- ...applied.metadata,
754
- update_strategy: "appVersion"
755
- }
756
- })).rejects.toThrow();
757
- await expect(model.findLatestEvents({ installId: "target" })).resolves.toEqual([recovered]);
758
- const cases = [
759
- [{
760
- platform: "ios",
761
- channel: "production",
762
- type: "UPDATE_APPLIED",
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
- it("uses exact identity and UTF-8 cursor order for user installations", async () => {
815
- const model = state.getDatabase();
816
- const ids = [
817
- "Install-a",
818
- "é",
819
- "install-a",
820
- "install-a ",
821
- "é",
822
- "",
823
- "😀"
824
- ];
825
- const events = ids.map((install_id, index) => ({
826
- ...createBundleEventRowFixture(String(950 + index), 100),
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
- it("counts a latest installation once when a from and a to predicate both match it", async () => {
884
- const model = state.getDatabase();
885
- const moved = createBundleEventRowFixture("9701", 100);
886
- await record(model, moved);
887
- await expectInsightsIndex(() => model.countLatestEvents({
888
- platform: moved.platform,
889
- channel: moved.channel,
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
- it("returns zero for successful empty scalar queries", async () => {
903
- const model = state.getDatabase();
904
- await expect(model.countLatestEvents({
905
- platform: "ios",
906
- channel: "production",
907
- sinceMs: 0
908
- })).resolves.toBe(0);
909
- await expect(model.countEvents({
910
- filter: {
911
- platform: "ios",
912
- channel: "production",
913
- type: "RECOVERED",
914
- fromBundleId: createBundleEventRowFixture("1", 0).to_bundle_id
915
- },
916
- sinceMs: 0,
917
- beforeReceivedAtMs: 100
918
- })).resolves.toBe(0);
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("maintains idempotent release, scope, usage, and latest-distribution summaries", async () => {
921
- const model = state.getDatabase();
922
- const releaseA = "00000000-0000-7000-8000-000000008001";
923
- const releaseB = "00000000-0000-7000-8000-000000008002";
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
- for (const event of [
952
- download,
953
- appliedA,
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 usage = await model.getAppUsage({
994
- channel: "production",
995
- platform: "all",
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
- expect(usage.activeInstallations).toBe(2);
1003
- expect(usage.points).toEqual([{
1004
- startMs: 0,
1005
- installations: 2
1006
- }]);
1007
- expect(usage.bundleDistribution).toEqual(expect.arrayContaining([{
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("filters installation movements before applying the page limit", async () => {
1048
- const model = state.getDatabase();
1049
- const unchanged = {
1050
- ...createBundleEventRowFixture("711", 300),
1051
- type: "UNCHANGED",
1052
- install_id: "install-target",
1053
- from_bundle_id: null,
1054
- metadata: {
1055
- ...createBundleEventRowFixture("711", 300).metadata,
1056
- update_strategy: null
1057
- }
1058
- };
1059
- const unrelated = createMovementEvent("712", 250, "UPDATE_APPLIED", "install-other");
1060
- const applied = createMovementEvent("713", 200, "UPDATE_APPLIED", "install-target");
1061
- const recovered = createMovementEvent("714", 100, "RECOVERED", "install-target");
1062
- for (const row of [
1063
- unchanged,
1064
- unrelated,
1065
- applied,
1066
- recovered
1067
- ]) await model.recordEvent({ event: row });
1068
- await expectInsightsIndex(() => model.listEvents({
1069
- filter: {
1070
- kind: "installationMovement",
1071
- installId: "install-target"
1072
- },
1073
- beforeReceivedAtMs: 301,
1074
- limit: 1
1075
- }), [applied]);
1076
- await expectInsightsIndex(() => model.listEvents({
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/setupSemverSatisfiesTestSuite.ts
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
- * Filters based on semver. And sorts by the highest bundle version.
1104
- *
1105
- * * Range Expression Table:
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 setupSemverSatisfiesTestSuite = ({ semverSatisfies }) => {
1119
- it("version 1.2.3 should satisfy version 1.2.3", async () => {
1120
- expect(await semverSatisfies("1.2.3", "1.2.3")).toBe(true);
1121
- });
1122
- it("version 1.2.4 should not satisfy version 1.2.3", async () => {
1123
- expect(await semverSatisfies("1.2.3", "1.2.4")).toBe(false);
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
- it("hard deletes a Release, rebuilds its Catalog, and retains Bundle bytes", async () => {
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 adminJson(`/releases/${current.release.id}?confirm=${current.release.id}&expectedRevision=1`, jsonRequest("DELETE"));
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
- const artifact = await request(`/artifacts/v1/${current.bundle.id}/from/${NIL_UUID$1}`);
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(current.bundle.manifest_storage_uri) });
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("returns an uncached 404 for an unknown scope", async () => {
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("private, no-store");
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, bundles, and Insights, all through HTTP, on the
2020
- * tables the provider's tooling creates; with `createInsightsModel`, also the
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
- setupInsightsHttpTestSuite({ getClient });
2044
- const { createInsightsModel } = options;
2045
- if (createInsightsModel !== void 0) describe("Insights model", () => {
2046
- registerInsightsModelTests({ getDatabase: () => createInsightsModel(getDatabase()) });
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
- const tables = [conformanceItems, conformanceCounters];
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: indexOrderTuple(conformanceItems, findPhysicalIndex(conformanceItems, "byGroup"), last),
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
- * History event `n` of the day before T0, three an hour, on its own release
2806
- * and bundles: every windowed read, which starts at T0 or later, skips it.
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 historyOf = (n) => eventOf(100 + n, {
2809
- install_id: `history-${n}`,
2810
- user_id: null,
2811
- from_bundle_id: "bundle-history-a",
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) => storageUri === null ? null : `https://storage.example.com/${encodeURIComponent(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, server) => {
2851
- const { core } = database;
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: server.targetBaseCandidateKey({
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 + HOUR
3144
- }),
3145
- adapter: reads(0, 0, 1, 20),
3146
- engine: {
3147
- calls: 1,
3148
- rows: 5
3149
- },
3150
- check: (count) => expect(count).toBe(20)
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: "bundle-b",
3161
- types: ["UPDATE_APPLIED"]
3162
- }]
3163
- }),
3164
- adapter: reads(0, 0, 1, 24),
3165
- engine: {
3166
- calls: 1,
3167
- rows: 6
3168
- },
3169
- check: (count) => expect(count).toBe(24)
3170
- }),
3171
- budget({
3172
- api: "release activity: buckets × shards, all used",
3173
- read: ({ insights }) => insights.getReleaseActivity({ releases: [{
3174
- releaseId: "release-b",
3175
- platform: "ios",
3176
- channel: "production"
3177
- }] }),
3178
- adapter: reads(0, 0, 1, 8),
3179
- engine: {
3180
- calls: 1,
3181
- rows: 1
3182
- },
3183
- check: ({ data }) => expect(data[0].metrics.launches).toBe(25)
3184
- }),
3185
- budget({
3186
- api: "release activity over a window: buckets × shards, all used",
3187
- read: ({ insights }) => insights.getReleaseActivity({
3188
- scope: {
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
- timeRange: {
3193
- start: T0,
3194
- end: T0 + 3 * DAY
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
- adapter: reads(0, 0, 2, 24),
3198
- engine: {
3199
- calls: 2,
3200
- rows: 4
3201
- },
3202
- check: ({ data }) => expect(data[0].metrics.launches).toBe(25)
3203
- }),
3204
- budget({
3205
- api: "app usage: nonzero distribution and usage-sketch rows in the window, all used",
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 read-budget suite (PRD S4) on one backend: core and the built-in
3275
- * plugins in verify mode over the backend's adapter, native pages capped at
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 setupReadBudgetTestSuite = (options) => {
3281
- describe(`${options.name} read budgets`, () => {
3282
- let created;
3283
- let database;
3284
- let seeded;
3285
- let writes = 0;
3286
- beforeAll(async () => {
3287
- created = await options.createAdapter({
3288
- tables: options.server.builtInSchema.tables,
3289
- nativePageSize: NATIVE_PAGE_SIZE
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
- const { adapter } = created;
3292
- database = options.server.createMeasuredDatabase({
3293
- ...adapter,
3294
- write: (ops) => {
3295
- writes += 1;
3296
- return adapter.write(ops);
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
- }, options.server.plugins, {
3299
- now: () => T0 + 10 * DAY,
3300
- storage
3301
- });
3302
- seeded = await seed(database, options.server);
3303
- }, 3e5);
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
- for (const budget of READ_BUDGETS) it(budget.api, async () => {
3308
- const { examined, indexCopies = false } = created;
3309
- writes = 0;
3310
- examined?.reset();
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/sqlRowsExamined.ts
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
- * Rows examined on PostgreSQL (PGlite or a server). Each SELECT a wrapped
3381
- * executor runs is first run under `EXPLAIN (ANALYZE, FORMAT JSON)` on
3382
- * `query`, one session with the planner settings above; each `reset` first
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 postgresRowsExamined = (query) => {
3390
- let planner;
3391
- let stale = true;
3392
- return examineReads({
3393
- onReset: () => {
3394
- stale = true;
3395
- },
3396
- async examine({ sql, params }) {
3397
- planner ??= (async () => {
3398
- for (const setting of POSTGRES_PLANNER) await query(`SET ${setting}`, []);
3399
- })();
3400
- await planner;
3401
- if (stale) {
3402
- stale = false;
3403
- await query("VACUUM ANALYZE", []);
3404
- }
3405
- const [row] = await query(`EXPLAIN (ANALYZE, FORMAT JSON) ${sql}`, params);
3406
- const output = row?.["QUERY PLAN"];
3407
- const [plan] = typeof output === "string" ? JSON.parse(output) : output;
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
- * An `EXPLAIN ANALYZE` iterator that reads a table: its table, and its actual
3414
- * rows and loops. A primary-key read of constants runs while MySQL plans the
3415
- * statement, and names no table.
3416
- */
3417
- 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;
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 mysqlRowsExamined = (query) => {
3427
- let planner;
3428
- const analyzed = /* @__PURE__ */ new Set();
3429
- return examineReads({
3430
- onReset: () => {
3431
- analyzed.clear();
3432
- },
3433
- async examine({ sql, params }) {
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/createPluginTestHarness.ts
3508
- const builtInPlugin = Symbol.for("@hot-updater/server/built-in-plugin");
4809
+ //#region src/kvTestStore.ts
3509
4810
  /**
3510
- * Runs one plugin the way `createHotUpdater` does, on a memory adapter in
3511
- * verify mode, so tests can call its API and measure what each call reads.
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 createPluginTestHarness = async (plugin, options) => {
3514
- const adapter = options.adapter ?? createMemoryAdapter();
3515
- const module = {
3516
- id: plugin.id,
3517
- schema: plugin.schema,
3518
- ...builtInPlugin in plugin ? {} : { namespace: plugin.id }
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
- api: instance.api,
3536
- instance,
3537
- /** The plugin's own database handle; cast it to its `HotUpdaterDatabase` type. */
3538
- db,
3539
- adapter,
3540
- tables: schema.tables,
3541
- /** Reads at both boundaries while `read` runs. */
3542
- measureReads: (read) => engine.measureReads(read),
3543
- setNow: (now) => {
3544
- clock = now;
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 };