@hot-updater/test-utils 1.0.0-rc.16 → 1.0.0-rc.17

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
2
  import { NIL_UUID, createReleaseCatalogScopeKey, encodeChannelKey, selectDesiredRelease } from "@hot-updater/core";
3
- import { DatabaseAdapterContractError, createMemoryAdapter, findPhysicalIndex, indexOrderTuple, verifyAdapter } from "@hot-updater/plugin-core/internal";
3
+ import { DatabaseAdapterContractError, addDistinct, countDistinct, createMemoryAdapter, findPhysicalIndex, indexOrderTuple, verifyAdapter } from "@hot-updater/plugin-core/internal";
4
4
  import { createServer } from "node:http";
5
5
  //#region src/adminApiTestClient.ts
6
6
  const jsonRequest = (method, body) => ({
@@ -100,27 +100,6 @@ const createBundlePatchRowFixture = (suffix, bundleId, baseBundleId, orderIndex
100
100
  byte_size: 3000000002,
101
101
  order_index: orderIndex
102
102
  });
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
123
- });
124
103
  const createBundleFixture = (suffix, _channel = "production") => ({
125
104
  id: fixtureId(suffix),
126
105
  platform: "ios",
@@ -374,729 +353,6 @@ const setupCoreAdminTestSuite = (options) => {
374
353
  });
375
354
  };
376
355
  //#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
394
- /**
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()`.
397
- */
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
- });
446
- });
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 });
475
- });
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]);
618
- });
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
- ]);
656
- });
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([]);
673
- });
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]);
793
- }
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
- });
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);
882
- });
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);
901
- });
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);
919
- });
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
950
- };
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
992
- });
993
- const usage = await model.getAppUsage({
994
- channel: "production",
995
- platform: "all",
996
- timeRange: {
997
- start: 0,
998
- end: 36e5
999
- },
1000
- intervalMs: 36e5
1001
- });
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]);
1046
- });
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]);
1088
- });
1089
- });
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);
1099
- //#endregion
1100
356
  //#region src/setupSemverSatisfiesTestSuite.ts
1101
357
  /**
1102
358
  *
@@ -1307,6 +563,35 @@ const setupSemverSatisfiesTestSuite = ({ semverSatisfies }) => {
1307
563
  });
1308
564
  };
1309
565
  //#endregion
566
+ //#region src/databaseTestRunner.ts
567
+ var DatabaseUnavailableError = class extends Error {
568
+ constructor() {
569
+ super("The database is unavailable outside the test lifecycle");
570
+ this.name = "DatabaseUnavailableError";
571
+ }
572
+ };
573
+ const setupDatabaseTestRunner = (lifecycle, registerTests) => {
574
+ describe(lifecycle.name, () => {
575
+ let database;
576
+ const getDatabase = () => {
577
+ if (database === void 0) throw new DatabaseUnavailableError();
578
+ return database;
579
+ };
580
+ beforeAll(async () => {
581
+ await lifecycle.migrate();
582
+ database = await lifecycle.createDatabase();
583
+ });
584
+ beforeEach(async () => {
585
+ await lifecycle.reset(getDatabase());
586
+ });
587
+ afterAll(async () => {
588
+ await lifecycle.dispose(getDatabase());
589
+ database = void 0;
590
+ });
591
+ registerTests({ getDatabase });
592
+ });
593
+ };
594
+ //#endregion
1310
595
  //#region src/releaseCatalogHttpFixtures.ts
1311
596
  const releaseCatalogDownloadUrl = (uri) => `https://storage.example.com/${encodeURIComponent(uri)}`;
1312
597
  const targetId = createBundleRowFixture("302").id;
@@ -1701,10 +986,11 @@ const setupReleaseCatalogTestSuite = (options) => {
1701
986
  const otherPath = strategy === "APP_VERSION" ? `/release-catalogs/fingerprint/ios/${scope().channelKey}/fingerprint-a` : `/release-catalogs/app-version/ios/${scope().channelKey}/1.2.3`;
1702
987
  expect((await request(otherPath)).status).toBe(404);
1703
988
  });
1704
- it("returns an uncached 404 for an unknown scope", async () => {
989
+ it("caches an unknown scope's 404 like a catalog", async () => {
1705
990
  const response = await request(catalogPath());
1706
991
  expect(response.status).toBe(404);
1707
- expect(response.headers.get("cache-control")).toBe("private, no-store");
992
+ expect(response.headers.get("cache-control")).toBe("public, max-age=0, s-maxage=5");
993
+ expect(response.headers.get("x-hot-updater-catalog")).toBe("none");
1708
994
  await publish("121");
1709
995
  expect((await readCatalog()).releases).toHaveLength(1);
1710
996
  });
@@ -2016,9 +1302,8 @@ const setupReleaseCatalogTestSuite = (options) => {
2016
1302
  //#region src/setupDatabaseTestSuite.ts
2017
1303
  /**
2018
1304
  * 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.
1305
+ * Release Catalog contract, and bundles, all through HTTP, on the tables the
1306
+ * provider's tooling creates, then each opted-in plugin's suite.
2022
1307
  */
2023
1308
  const setupDatabaseTestSuite = (options) => {
2024
1309
  setupDatabaseTestRunner(options, ({ getDatabase }) => {
@@ -2040,10 +1325,11 @@ const setupDatabaseTestSuite = (options) => {
2040
1325
  setupCoreAdminTestSuite({ getClient });
2041
1326
  setupBundleMethodsTestSuite({ getClient });
2042
1327
  setupReleaseCatalogTestSuite({ getClient });
2043
- setupInsightsHttpTestSuite({ getClient });
2044
- const { createInsightsModel } = options;
2045
- if (createInsightsModel !== void 0) describe("Insights model", () => {
2046
- registerInsightsModelTests({ getDatabase: () => createInsightsModel(getDatabase()) });
1328
+ for (const suite of options.plugins ?? []) describe(suite.name, () => {
1329
+ suite.register({
1330
+ getClient,
1331
+ getDatabase
1332
+ });
2047
1333
  });
2048
1334
  });
2049
1335
  };
@@ -2170,7 +1456,71 @@ const conformanceCounters = {
2170
1456
  sort: ["shard"]
2171
1457
  }]
2172
1458
  };
2173
- const tables = [conformanceItems, conformanceCounters];
1459
+ /** Rows expire 1,000 ms after `at`; a null `at` never expires. */
1460
+ const conformanceExpiring = {
1461
+ name: "conformance_expiring",
1462
+ columns: [
1463
+ {
1464
+ name: "id",
1465
+ type: "string",
1466
+ nullable: false,
1467
+ maxLength: 64
1468
+ },
1469
+ {
1470
+ name: "grp",
1471
+ type: "string",
1472
+ nullable: false,
1473
+ maxLength: 64
1474
+ },
1475
+ {
1476
+ name: "at",
1477
+ type: "integer",
1478
+ nullable: true
1479
+ },
1480
+ {
1481
+ name: "tags",
1482
+ type: "string",
1483
+ nullable: true,
1484
+ maxLength: 64,
1485
+ multi: true
1486
+ },
1487
+ {
1488
+ name: "_v",
1489
+ type: "integer",
1490
+ nullable: false
1491
+ }
1492
+ ],
1493
+ key: ["id"],
1494
+ indexes: [{
1495
+ name: "byGroup",
1496
+ eq: ["grp"],
1497
+ sort: ["id"]
1498
+ }, {
1499
+ name: "byTag",
1500
+ eq: ["tags"],
1501
+ sort: []
1502
+ }],
1503
+ retention: {
1504
+ column: "at",
1505
+ ms: 1e3
1506
+ }
1507
+ };
1508
+ const tables = [
1509
+ conformanceItems,
1510
+ conformanceCounters,
1511
+ conformanceExpiring
1512
+ ];
1513
+ const expiring = (id, at) => ({
1514
+ type: "insert",
1515
+ table: conformanceExpiring,
1516
+ row: {
1517
+ id,
1518
+ grp: "g",
1519
+ at,
1520
+ tags: ["t"],
1521
+ _v: 0
1522
+ }
1523
+ });
2174
1524
  const item = (id, values = {}) => ({
2175
1525
  id,
2176
1526
  grp: "g",
@@ -2747,6 +2097,79 @@ const setupDatabaseAdapterConformanceSuite = (options) => {
2747
2097
  const results = await Promise.all([adapter.write(skew(a, b)), adapter.write(skew(b, a))]);
2748
2098
  expect(results.filter((result) => result.ok).length).toBeLessThanOrEqual(1);
2749
2099
  });
2100
+ const expiringIds = async (adapter) => ({
2101
+ byGroup: ids(await adapter.query(conformanceExpiring, {
2102
+ index: "byGroup",
2103
+ eq: ["g"],
2104
+ order: "asc",
2105
+ limit: 500
2106
+ })),
2107
+ byTag: ids(await adapter.query(conformanceExpiring, {
2108
+ index: "byTag",
2109
+ eq: ["t"],
2110
+ order: "asc",
2111
+ limit: 500
2112
+ }))
2113
+ });
2114
+ it.skipIf(options.retention === "ttl")("prunes expired rows oldest first, up to the limit, with their index entries", async () => {
2115
+ const adapter = await setup();
2116
+ await ok(adapter, [
2117
+ expiring("a", 300),
2118
+ expiring("b", 100),
2119
+ expiring("c", 200),
2120
+ expiring("d", null),
2121
+ expiring("e", 5e3),
2122
+ expiring("f", 100)
2123
+ ]);
2124
+ expect(await adapter.prune(conformanceExpiring, 250, 2)).toBe(2);
2125
+ expect((await expiringIds(adapter)).byGroup).toEqual([
2126
+ "a",
2127
+ "c",
2128
+ "d",
2129
+ "e"
2130
+ ]);
2131
+ expect(await adapter.prune(conformanceExpiring, 250, 10)).toBe(1);
2132
+ expect(await adapter.prune(conformanceExpiring, 250, 10)).toBe(0);
2133
+ expect(await expiringIds(adapter)).toEqual({
2134
+ byGroup: [
2135
+ "a",
2136
+ "d",
2137
+ "e"
2138
+ ],
2139
+ byTag: [
2140
+ "a",
2141
+ "d",
2142
+ "e"
2143
+ ]
2144
+ });
2145
+ expect(await adapter.get(conformanceExpiring, [
2146
+ ["b"],
2147
+ ["c"],
2148
+ ["d"]
2149
+ ])).toEqual([
2150
+ null,
2151
+ null,
2152
+ expect.objectContaining({
2153
+ id: "d",
2154
+ at: null
2155
+ })
2156
+ ]);
2157
+ await ok(adapter, [insert(item("kept"))]);
2158
+ expect(await adapter.prune(conformanceExpiring, 1e4, 10)).toBe(2);
2159
+ expect(ids(await query(adapter, {
2160
+ index: "all",
2161
+ eq: []
2162
+ }))).toEqual(["kept"]);
2163
+ });
2164
+ it.skipIf(options.retention !== "ttl")("leaves expired rows to the backend's TTL and reads them until it deletes them", async () => {
2165
+ const adapter = await setup();
2166
+ expect(adapter.prune).toBeUndefined();
2167
+ await ok(adapter, [expiring("old", 0), expiring("never", null)]);
2168
+ expect(await expiringIds(adapter)).toEqual({
2169
+ byGroup: ["never", "old"],
2170
+ byTag: ["never", "old"]
2171
+ });
2172
+ });
2750
2173
  it.skipIf(options.maxOps === void 0)("rejects an over-limit write before sending it", async () => {
2751
2174
  const adapter = await setup();
2752
2175
  const ops = Array.from({ length: (options.maxOps ?? 0) + 1 }, (_, index) => insert(item(`over${index}`)));
@@ -2792,7 +2215,6 @@ const eventOf = (n, overrides = {}) => ({
2792
2215
  app_version: "1.0.0",
2793
2216
  channel: "production",
2794
2217
  metadata: {
2795
- username: null,
2796
2218
  cohort: "1",
2797
2219
  update_strategy: "appVersion",
2798
2220
  fingerprint_hash: null,
@@ -2802,6 +2224,31 @@ const eventOf = (n, overrides = {}) => ({
2802
2224
  ...overrides
2803
2225
  });
2804
2226
  /**
2227
+ * Update failures on day 3, after every other windowed read's range: installs
2228
+ * 3 and 5 fail to download release-b, and install 7's update check fails.
2229
+ */
2230
+ const failureOf = (n, install, check = false) => eventOf(200 + n, {
2231
+ type: "UPDATE_FAILED",
2232
+ install_id: `install-${install}`,
2233
+ from_release_id: "release-a",
2234
+ from_bundle_id: "bundle-a",
2235
+ to_release_id: check ? null : "release-b",
2236
+ to_bundle_id: check ? "bundle-a" : "bundle-b",
2237
+ metadata: {
2238
+ ...eventOf(0).metadata,
2239
+ failure: check ? {
2240
+ stage: "check",
2241
+ reason: "http",
2242
+ http_status: 500
2243
+ } : {
2244
+ stage: "download",
2245
+ reason: "http",
2246
+ http_status: 403
2247
+ }
2248
+ },
2249
+ received_at_ms: T0 + 3 * DAY + n * 10 * 6e4
2250
+ });
2251
+ /**
2805
2252
  * History event `n` of the day before T0, three an hour, on its own release
2806
2253
  * and bundles: every windowed read, which starts at T0 or later, skips it.
2807
2254
  */
@@ -2894,6 +2341,9 @@ const seed = async (database, server) => {
2894
2341
  received_at_ms: T0 + 2 * DAY
2895
2342
  }));
2896
2343
  for (let n = 1; n <= 72; n += 1) await api.insights.recordEvent(historyOf(n));
2344
+ await api.insights.recordEvent(failureOf(1, 3));
2345
+ await api.insights.recordEvent(failureOf(2, 5));
2346
+ await api.insights.recordEvent(failureOf(3, 7, true));
2897
2347
  const channel = (name) => core.findChannelByName(name).then((found) => found);
2898
2348
  const productionId = (await channel("production")).id;
2899
2349
  return {
@@ -3140,14 +2590,14 @@ const READ_BUDGETS = [
3140
2590
  read: ({ insights }) => insights.countLatestEvents({
3141
2591
  platform: "ios",
3142
2592
  channel: "production",
3143
- sinceMs: T0 + HOUR
2593
+ sinceMs: T0
3144
2594
  }),
3145
- adapter: reads(0, 0, 1, 20),
2595
+ adapter: reads(0, 0, 1, 15),
3146
2596
  engine: {
3147
2597
  calls: 1,
3148
- rows: 5
2598
+ rows: 2
3149
2599
  },
3150
- check: (count) => expect(count).toBe(20)
2600
+ check: (count) => expect(count).toBe(24)
3151
2601
  }),
3152
2602
  budget({
3153
2603
  api: "countLatestEvents by bundle: buckets in the window × shards, all used",
@@ -3161,10 +2611,10 @@ const READ_BUDGETS = [
3161
2611
  types: ["UPDATE_APPLIED"]
3162
2612
  }]
3163
2613
  }),
3164
- adapter: reads(0, 0, 1, 24),
2614
+ adapter: reads(0, 0, 1, 15),
3165
2615
  engine: {
3166
2616
  calls: 1,
3167
- rows: 6
2617
+ rows: 2
3168
2618
  },
3169
2619
  check: (count) => expect(count).toBe(24)
3170
2620
  }),
@@ -3212,16 +2662,79 @@ const READ_BUDGETS = [
3212
2662
  },
3213
2663
  intervalMs: DAY
3214
2664
  }),
3215
- adapter: reads(0, 0, 3, 39),
2665
+ adapter: reads(0, 0, 4, 30),
3216
2666
  engine: {
3217
- calls: 3,
3218
- rows: 8
2667
+ calls: 4,
2668
+ rows: 4
3219
2669
  },
3220
2670
  check: ({ versions }) => expect(versions).toEqual([{
3221
2671
  name: "1.0.0",
3222
2672
  installations: 24
3223
2673
  }])
3224
2674
  }),
2675
+ budget({
2676
+ api: "update failures of a release over a window: buckets × shards, all used",
2677
+ read: ({ insights }) => insights.getUpdateFailures({
2678
+ platform: "ios",
2679
+ channel: "production",
2680
+ releaseId: "release-b",
2681
+ timeRange: {
2682
+ start: T0 + 3 * DAY,
2683
+ end: T0 + 4 * DAY
2684
+ }
2685
+ }),
2686
+ adapter: reads(0, 0, 3, 5),
2687
+ engine: {
2688
+ calls: 3,
2689
+ rows: 3
2690
+ },
2691
+ check: (failures) => {
2692
+ expect(failures).toMatchObject({ failedUpdates: 2 });
2693
+ expect(failures.breakdown).toEqual([{
2694
+ stage: "download",
2695
+ reason: "http",
2696
+ events: 2,
2697
+ details: [expect.anything()]
2698
+ }]);
2699
+ }
2700
+ }),
2701
+ budget({
2702
+ api: "update failures of a channel over a window: buckets × shards, all used",
2703
+ read: ({ insights }) => insights.getUpdateFailures({
2704
+ platform: "ios",
2705
+ channel: "production",
2706
+ timeRange: {
2707
+ start: T0 + 3 * DAY,
2708
+ end: T0 + 4 * DAY
2709
+ }
2710
+ }),
2711
+ adapter: reads(0, 0, 5, 6),
2712
+ engine: {
2713
+ calls: 5,
2714
+ rows: 4
2715
+ },
2716
+ check: (failures) => expect(failures).toMatchObject({
2717
+ failedUpdates: 2,
2718
+ checks: { failures: 1 }
2719
+ })
2720
+ }),
2721
+ budget({
2722
+ api: "update failures of a release since its first: two kept rows × shards, all used",
2723
+ read: ({ insights }) => insights.getUpdateFailures({
2724
+ platform: "ios",
2725
+ channel: "production",
2726
+ releaseId: "release-b"
2727
+ }),
2728
+ adapter: reads(0, 0, 2, 10),
2729
+ engine: {
2730
+ calls: 2,
2731
+ rows: 2
2732
+ },
2733
+ check: (failures) => expect(failures).toMatchObject({
2734
+ failedUpdates: 2,
2735
+ failedInstallations: 2
2736
+ })
2737
+ }),
3225
2738
  budget({
3226
2739
  api: "catalog compile: the scope's enabled releases, all used",
3227
2740
  read: ({ core }) => core.deploy([{
@@ -3262,7 +2775,7 @@ const READ_BUDGETS = [
3262
2775
  install_id: "install-2",
3263
2776
  received_at_ms: T0 + 3 * HOUR
3264
2777
  })),
3265
- adapter: reads(5, 21, 0, 0),
2778
+ adapter: reads(4, 7, 0, 0),
3266
2779
  engine: {
3267
2780
  calls: 2,
3268
2781
  rows: 1
@@ -3285,7 +2798,7 @@ const setupReadBudgetTestSuite = (options) => {
3285
2798
  let writes = 0;
3286
2799
  beforeAll(async () => {
3287
2800
  created = await options.createAdapter({
3288
- tables: options.server.builtInSchema.tables,
2801
+ tables: options.server.toolingTargetOf(options.server.plugins).schema.tables,
3289
2802
  nativePageSize: NATIVE_PAGE_SIZE
3290
2803
  });
3291
2804
  const { adapter } = created;
@@ -3328,6 +2841,266 @@ const setupReadBudgetTestSuite = (options) => {
3328
2841
  });
3329
2842
  };
3330
2843
  //#endregion
2844
+ //#region src/runContentionHarness.ts
2845
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2846
+ /** Delays every adapter call by `ms`, like a round trip to a remote database. */
2847
+ const withAdapterLatency = (adapter, ms) => ({
2848
+ ...adapter,
2849
+ get: async (table, keys) => {
2850
+ await sleep(ms);
2851
+ return adapter.get(table, keys);
2852
+ },
2853
+ query: async (table, request) => {
2854
+ await sleep(ms);
2855
+ return adapter.query(table, request);
2856
+ },
2857
+ write: async (ops) => {
2858
+ await sleep(ms);
2859
+ return adapter.write(ops);
2860
+ }
2861
+ });
2862
+ /**
2863
+ * Starts `transactions` runs at a steady arrival rate through a fixed pool and
2864
+ * reports how they ended. Retry counts come from the engine's `onRetry`.
2865
+ */
2866
+ const runContentionHarness = async (options) => {
2867
+ const started = Date.now();
2868
+ const errors = {};
2869
+ const waiting = [];
2870
+ let free = options.concurrency;
2871
+ let committed = 0;
2872
+ const acquire = () => free > 0 ? (free -= 1, Promise.resolve()) : new Promise((resolve) => waiting.push(resolve));
2873
+ const release = () => {
2874
+ const next = waiting.shift();
2875
+ if (next) next();
2876
+ else free += 1;
2877
+ };
2878
+ await Promise.all(Array.from({ length: options.transactions }, async (_, index) => {
2879
+ await sleep(index * 1e3 / options.ratePerSecond);
2880
+ await acquire();
2881
+ try {
2882
+ await options.run(index);
2883
+ committed += 1;
2884
+ } catch (error) {
2885
+ const name = error instanceof Error ? error.name : "unknown";
2886
+ errors[name] = (errors[name] ?? 0) + 1;
2887
+ } finally {
2888
+ release();
2889
+ }
2890
+ }));
2891
+ return {
2892
+ transactions: options.transactions,
2893
+ committed,
2894
+ errors,
2895
+ durationMs: Date.now() - started
2896
+ };
2897
+ };
2898
+ //#endregion
2899
+ //#region src/setupAggregateBatchingTestSuite.ts
2900
+ const identity = (name) => ({ [name]: {
2901
+ type: "string",
2902
+ maxLength: 16
2903
+ } });
2904
+ /** A module shaped like `defineTable` and `defineAggregate` build them. */
2905
+ const module = {
2906
+ id: "batching",
2907
+ schema: {
2908
+ installs: {
2909
+ kind: "table",
2910
+ fields: {
2911
+ id: {
2912
+ type: "string",
2913
+ maxLength: 64
2914
+ },
2915
+ release: {
2916
+ type: "string",
2917
+ maxLength: 16
2918
+ }
2919
+ },
2920
+ key: ["id"],
2921
+ derived: {},
2922
+ indexes: {}
2923
+ },
2924
+ distribution: {
2925
+ kind: "aggregate",
2926
+ fields: identity("release"),
2927
+ key: ["release"],
2928
+ counters: [],
2929
+ gauges: ["installations"],
2930
+ distinct: [],
2931
+ shards: 8,
2932
+ batched: true,
2933
+ indexes: { byRelease: {
2934
+ eq: ["release"],
2935
+ sort: []
2936
+ } }
2937
+ },
2938
+ opens: {
2939
+ kind: "aggregate",
2940
+ fields: { day: { type: "integer" } },
2941
+ key: ["day"],
2942
+ counters: ["opens"],
2943
+ gauges: [],
2944
+ distinct: [],
2945
+ shards: 4,
2946
+ batched: true,
2947
+ indexes: { all: {
2948
+ eq: [],
2949
+ sort: ["day"]
2950
+ } }
2951
+ },
2952
+ users: {
2953
+ kind: "aggregate",
2954
+ fields: { day: { type: "integer" } },
2955
+ key: ["day"],
2956
+ counters: [],
2957
+ gauges: [],
2958
+ distinct: ["users"],
2959
+ shards: 4,
2960
+ batched: true,
2961
+ indexes: { all: {
2962
+ eq: [],
2963
+ sort: ["day"]
2964
+ } }
2965
+ }
2966
+ }
2967
+ };
2968
+ const RELEASES = [
2969
+ "A",
2970
+ "B",
2971
+ "C"
2972
+ ];
2973
+ /** Step `step`: 12 installations, each moving to the next release every round of 12. */
2974
+ const stepOf = (step) => ({
2975
+ id: `i${step % 12}`,
2976
+ release: RELEASES[(Math.floor(step / 12) + step) % 3],
2977
+ day: 1 + step % 2
2978
+ });
2979
+ /** An installation opens the app on `release`, moving its gauge when the release changed. */
2980
+ const open = (db, step) => {
2981
+ const { id, release, day } = stepOf(step);
2982
+ return db.transaction(async (tx) => {
2983
+ const head = await tx.findOne("installs", { id });
2984
+ if (head === null) tx.create("installs", {
2985
+ id,
2986
+ release
2987
+ });
2988
+ else if (head.release !== release) {
2989
+ tx.update("installs", head, { release });
2990
+ const from = { release: head.release };
2991
+ tx.aggregate("distribution", from, { installations: -1 }, { shardBy: id });
2992
+ }
2993
+ if (head?.release !== release) tx.aggregate("distribution", { release }, { installations: 1 }, { shardBy: id });
2994
+ tx.aggregate("opens", { day }, { opens: 1 }, { shardBy: id });
2995
+ const users = addDistinct(null, id);
2996
+ tx.aggregate("users", { day }, { users }, { shardBy: id });
2997
+ });
2998
+ };
2999
+ /** Every aggregate as reads see it, sketches as their estimates. */
3000
+ const snapshot = async (db) => {
3001
+ const read = (model, index, where = {}) => db.findAggregates(model, {
3002
+ index,
3003
+ where,
3004
+ limit: 100
3005
+ });
3006
+ const releases = await Promise.all(RELEASES.map((release) => read("distribution", "byRelease", { release })));
3007
+ const [opens, users] = await Promise.all([read("opens", "all"), read("users", "all")]);
3008
+ return {
3009
+ distribution: releases.flatMap(({ rows }) => rows),
3010
+ opens: opens.rows,
3011
+ users: users.rows.map((row) => ({
3012
+ day: row.day,
3013
+ users: countDistinct(row.users)
3014
+ }))
3015
+ };
3016
+ };
3017
+ /**
3018
+ * Aggregates declared `batched` on a key-value store: log rows the store
3019
+ * writes with each transaction and a compaction merges once, and a memory
3020
+ * buffer, read back exactly as transactional writes read.
3021
+ */
3022
+ const setupAggregateBatchingTestSuite = ({ name, engine, createAdapter, oversizedRows }) => {
3023
+ const schema = engine.resolveSchema([module, engine.aggregateBatchingModule]);
3024
+ const cleanups = [];
3025
+ const store = async () => {
3026
+ const { adapter, cleanup } = await createAdapter();
3027
+ if (cleanup) cleanups.push(cleanup);
3028
+ await adapter.migrations?.apply(schema.tables);
3029
+ return adapter;
3030
+ };
3031
+ const server = (adapter, batching) => {
3032
+ const created = engine.createDatabaseEngine({
3033
+ adapter,
3034
+ schema,
3035
+ ...batching === void 0 ? {} : { batching },
3036
+ retry: { attempts: 64 }
3037
+ });
3038
+ return {
3039
+ db: created.database(module),
3040
+ flush: created.flush
3041
+ };
3042
+ };
3043
+ /** The reference: transactional writes on a memory adapter. */
3044
+ const expected = async (steps) => {
3045
+ const memory = createMemoryAdapter();
3046
+ await memory.migrations?.apply(schema.tables);
3047
+ const { db } = server(memory);
3048
+ for (let step = 0; step < steps; step += 1) await open(db, step);
3049
+ return snapshot(db);
3050
+ };
3051
+ describe(`${name} aggregate batching`, () => {
3052
+ afterEach(async () => {
3053
+ for (const cleanup of cleanups.splice(0)) await cleanup();
3054
+ });
3055
+ for (const mode of ["log", "memory"]) it(`reads what transactional writes read, in ${mode} mode`, async () => {
3056
+ const { db } = server(await store(), { mode });
3057
+ for (let step = 0; step < 36; step += 1) await open(db, step);
3058
+ expect(await snapshot(db)).toEqual(await expected(36));
3059
+ }, 12e4);
3060
+ it("applies each log row once when two servers compact while 16 writers commit", async () => {
3061
+ const adapter = await store();
3062
+ const first = server(adapter, {
3063
+ mode: "log",
3064
+ windowMs: 0
3065
+ });
3066
+ const second = server(adapter, {
3067
+ mode: "log",
3068
+ windowMs: 0
3069
+ });
3070
+ const report = await runContentionHarness({
3071
+ transactions: 12,
3072
+ ratePerSecond: 1e3,
3073
+ concurrency: 16,
3074
+ run: async (install) => {
3075
+ for (let round = 0; round < 4; round += 1) {
3076
+ const db = (install + round) % 2 === 0 ? first.db : second.db;
3077
+ await open(db, round * 12 + install);
3078
+ }
3079
+ }
3080
+ });
3081
+ expect(report.errors).toEqual({});
3082
+ await Promise.all([first.flush(), second.flush()]);
3083
+ await first.flush();
3084
+ expect(await snapshot(second.db)).toEqual(await expected(48));
3085
+ }, 3e5);
3086
+ it("applies a log row larger than one write in parts, each change once", async () => {
3087
+ const { db, flush } = server(await store(), { mode: "log" });
3088
+ await db.transaction(async (tx) => {
3089
+ for (let day = 1; day <= oversizedRows; day += 1) tx.aggregate("opens", { day }, { opens: day }, { shardBy: "i" });
3090
+ });
3091
+ await flush();
3092
+ const { rows } = await db.findAggregates("opens", {
3093
+ index: "all",
3094
+ where: {},
3095
+ limit: 500
3096
+ });
3097
+ const days = Math.min(oversizedRows, 500);
3098
+ expect(rows).toHaveLength(days);
3099
+ expect(rows.every((row) => row.opens === row.day)).toBe(true);
3100
+ }, 3e5);
3101
+ });
3102
+ };
3103
+ //#endregion
3331
3104
  //#region src/sqlRowsExamined.ts
3332
3105
  /** Wraps executors so each read they run while counting is examined first. */
3333
3106
  const examineReads = (hooks) => {
@@ -3449,63 +3222,7 @@ const mysqlRowsExamined = (query) => {
3449
3222
  });
3450
3223
  };
3451
3224
  //#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);
3468
- }
3469
- });
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
- //#endregion
3507
3225
  //#region src/createPluginTestHarness.ts
3508
- const builtInPlugin = Symbol.for("@hot-updater/server/built-in-plugin");
3509
3226
  /**
3510
3227
  * Runs one plugin the way `createHotUpdater` does, on a memory adapter in
3511
3228
  * verify mode, so tests can call its API and measure what each call reads.
@@ -3515,7 +3232,7 @@ const createPluginTestHarness = async (plugin, options) => {
3515
3232
  const module = {
3516
3233
  id: plugin.id,
3517
3234
  schema: plugin.schema,
3518
- ...builtInPlugin in plugin ? {} : { namespace: plugin.id }
3235
+ ...plugin.namespace === false ? {} : { namespace: plugin.id }
3519
3236
  };
3520
3237
  const schema = options.engine.resolveSchema([module]);
3521
3238
  await adapter.migrations?.apply(schema.tables);
@@ -3615,4 +3332,4 @@ async function startHttpTestServer(handlers) {
3615
3332
  };
3616
3333
  }
3617
3334
  //#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 };
3335
+ export { RELEASE_CATALOG_DOWNLOAD_HASH, RELEASE_CATALOG_MANIFEST_URIS, RELEASE_CATALOG_STORAGE_FIXTURES, conformanceCounters, conformanceExpiring, conformanceItems, createAdminApiTestClient, createHandlerHttpTestClient, createHttpTestClient, createPluginTestHarness, createReleaseCatalogTestStorage, jsonRequest, mysqlRowsExamined, postgresRowsExamined, releaseCatalogArtifact, releaseCatalogDownloadUrl, runContentionHarness, setupAggregateBatchingTestSuite, setupBundleMethodsTestSuite, setupCoreAdminTestSuite, setupDatabaseAdapterConformanceSuite, setupDatabaseTestSuite, setupReadBudgetTestSuite, setupReleaseCatalogTestSuite, setupSemverSatisfiesTestSuite, startHttpTestServer, toDeployBundle, withAdapterLatency };