@hot-updater/test-utils 1.0.0-rc.15 → 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,710 +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("returns zero for successful empty scalar queries", async () => {
884
- const model = state.getDatabase();
885
- await expect(model.countLatestEvents({
886
- platform: "ios",
887
- channel: "production",
888
- sinceMs: 0
889
- })).resolves.toBe(0);
890
- await expect(model.countEvents({
891
- filter: {
892
- platform: "ios",
893
- channel: "production",
894
- type: "RECOVERED",
895
- fromBundleId: createBundleEventRowFixture("1", 0).to_bundle_id
896
- },
897
- sinceMs: 0,
898
- beforeReceivedAtMs: 100
899
- })).resolves.toBe(0);
900
- });
901
- it("maintains idempotent release, scope, usage, and latest-distribution summaries", async () => {
902
- const model = state.getDatabase();
903
- const releaseA = "00000000-0000-7000-8000-000000008001";
904
- const releaseB = "00000000-0000-7000-8000-000000008002";
905
- const download = {
906
- ...createBundleEventRowFixture("9801", 1e3),
907
- type: "UPDATE_DOWNLOADED",
908
- install_id: "summary-install-a",
909
- from_release_id: releaseA,
910
- to_release_id: releaseB
911
- };
912
- const appliedA = {
913
- ...download,
914
- id: createBundleEventRowFixture("9802", 2e3).id,
915
- type: "UPDATE_APPLIED",
916
- received_at_ms: 2e3
917
- };
918
- const appliedB = {
919
- ...appliedA,
920
- id: createBundleEventRowFixture("9803", 2100).id,
921
- install_id: "summary-install-b",
922
- received_at_ms: 2100
923
- };
924
- const recovered = {
925
- ...appliedA,
926
- id: createBundleEventRowFixture("9804", 3e3).id,
927
- type: "RECOVERED",
928
- from_release_id: releaseB,
929
- to_release_id: releaseA,
930
- received_at_ms: 3e3
931
- };
932
- for (const event of [
933
- download,
934
- appliedA,
935
- appliedB,
936
- appliedB,
937
- recovered
938
- ]) await record(model, event);
939
- const releases = [{
940
- releaseId: releaseA,
941
- platform: "ios",
942
- channel: "production"
943
- }, {
944
- releaseId: releaseB,
945
- platform: "ios",
946
- channel: "production"
947
- }];
948
- const lifetime = await model.getReleaseActivity({ releases });
949
- expect(lifetime.data.map(({ metrics }) => metrics)).toEqual([{
950
- downloads: 0,
951
- launches: 1,
952
- failedLaunches: 0
953
- }, {
954
- downloads: 1,
955
- launches: 2,
956
- failedLaunches: 1
957
- }]);
958
- const scope = await model.getReleaseActivity({
959
- scope: {
960
- platform: "ios",
961
- channel: "production"
962
- },
963
- timeRange: {
964
- start: 0,
965
- end: 36e5
966
- }
967
- });
968
- expect(scope.data[0]?.metrics).toMatchObject({
969
- downloads: 1,
970
- launches: 3,
971
- failedLaunches: 1,
972
- uniqueUsers: 2
973
- });
974
- const usage = await model.getAppUsage({
975
- channel: "production",
976
- platform: "all",
977
- timeRange: {
978
- start: 0,
979
- end: 36e5
980
- },
981
- intervalMs: 36e5
982
- });
983
- expect(usage.activeInstallations).toBe(2);
984
- expect(usage.points).toEqual([{
985
- startMs: 0,
986
- installations: 2
987
- }]);
988
- expect(usage.bundleDistribution).toEqual(expect.arrayContaining([{
989
- appVersion: "1.0.0",
990
- platform: "ios",
991
- releaseId: releaseA,
992
- installations: 1
993
- }, {
994
- appVersion: "1.0.0",
995
- platform: "ios",
996
- releaseId: releaseB,
997
- installations: 1
998
- }]));
999
- });
1000
- it("pages insights events newest first with a stable cursor", async () => {
1001
- const model = state.getDatabase();
1002
- const first = createBundleEventRowFixture("701", 100);
1003
- const second = createBundleEventRowFixture("702", 100);
1004
- const third = createBundleEventRowFixture("703", 200);
1005
- await model.recordEvent({ event: third });
1006
- await model.recordEvent({ event: second });
1007
- await model.recordEvent({ event: first });
1008
- await expectInsightsIndex(() => model.listEvents({
1009
- filter: { kind: "all" },
1010
- beforeReceivedAtMs: 201,
1011
- limit: 2
1012
- }), [third, second]);
1013
- await expectInsightsIndex(() => model.listEvents({
1014
- filter: { kind: "all" },
1015
- after: {
1016
- receivedAtMs: second.received_at_ms,
1017
- id: second.id
1018
- },
1019
- beforeReceivedAtMs: 201,
1020
- limit: 2
1021
- }), [first]);
1022
- await expectInsightsIndex(() => model.listEvents({
1023
- filter: { kind: "all" },
1024
- beforeReceivedAtMs: 200,
1025
- limit: 10
1026
- }), [second, first]);
1027
- });
1028
- it("filters installation movements before applying the page limit", async () => {
1029
- const model = state.getDatabase();
1030
- const unchanged = {
1031
- ...createBundleEventRowFixture("711", 300),
1032
- type: "UNCHANGED",
1033
- install_id: "install-target",
1034
- from_bundle_id: null,
1035
- metadata: {
1036
- ...createBundleEventRowFixture("711", 300).metadata,
1037
- update_strategy: null
1038
- }
1039
- };
1040
- const unrelated = createMovementEvent("712", 250, "UPDATE_APPLIED", "install-other");
1041
- const applied = createMovementEvent("713", 200, "UPDATE_APPLIED", "install-target");
1042
- const recovered = createMovementEvent("714", 100, "RECOVERED", "install-target");
1043
- for (const row of [
1044
- unchanged,
1045
- unrelated,
1046
- applied,
1047
- recovered
1048
- ]) await model.recordEvent({ event: row });
1049
- await expectInsightsIndex(() => model.listEvents({
1050
- filter: {
1051
- kind: "installationMovement",
1052
- installId: "install-target"
1053
- },
1054
- beforeReceivedAtMs: 301,
1055
- limit: 1
1056
- }), [applied]);
1057
- await expectInsightsIndex(() => model.listEvents({
1058
- filter: {
1059
- kind: "installationMovement",
1060
- installId: "install-target"
1061
- },
1062
- after: {
1063
- receivedAtMs: applied.received_at_ms,
1064
- id: applied.id
1065
- },
1066
- beforeReceivedAtMs: 301,
1067
- limit: 1
1068
- }), [recovered]);
1069
- });
1070
- });
1071
- };
1072
- //#endregion
1073
- //#region src/setupInsightsModelTestSuite.ts
1074
- /**
1075
- * The Insights report contract on one database: pass the Insights plugin's
1076
- * model over it, `createInsightsModel(createDatabasePluginApis(database,
1077
- * [insights()]).insights)`, as `createDatabase`.
1078
- */
1079
- const setupInsightsModelTestSuite = (lifecycle) => setupDatabaseTestRunner(lifecycle, registerInsightsModelTests);
1080
- //#endregion
1081
356
  //#region src/setupSemverSatisfiesTestSuite.ts
1082
357
  /**
1083
358
  *
@@ -1288,6 +563,35 @@ const setupSemverSatisfiesTestSuite = ({ semverSatisfies }) => {
1288
563
  });
1289
564
  };
1290
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
1291
595
  //#region src/releaseCatalogHttpFixtures.ts
1292
596
  const releaseCatalogDownloadUrl = (uri) => `https://storage.example.com/${encodeURIComponent(uri)}`;
1293
597
  const targetId = createBundleRowFixture("302").id;
@@ -1682,10 +986,11 @@ const setupReleaseCatalogTestSuite = (options) => {
1682
986
  const otherPath = strategy === "APP_VERSION" ? `/release-catalogs/fingerprint/ios/${scope().channelKey}/fingerprint-a` : `/release-catalogs/app-version/ios/${scope().channelKey}/1.2.3`;
1683
987
  expect((await request(otherPath)).status).toBe(404);
1684
988
  });
1685
- it("returns an uncached 404 for an unknown scope", async () => {
989
+ it("caches an unknown scope's 404 like a catalog", async () => {
1686
990
  const response = await request(catalogPath());
1687
991
  expect(response.status).toBe(404);
1688
- 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");
1689
994
  await publish("121");
1690
995
  expect((await readCatalog()).releases).toHaveLength(1);
1691
996
  });
@@ -1997,9 +1302,8 @@ const setupReleaseCatalogTestSuite = (options) => {
1997
1302
  //#region src/setupDatabaseTestSuite.ts
1998
1303
  /**
1999
1304
  * A provider's database under Hot Updater's server: core's operations, the
2000
- * Release Catalog contract, bundles, and Insights, all through HTTP, on the
2001
- * tables the provider's tooling creates; with `createInsightsModel`, also the
2002
- * 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.
2003
1307
  */
2004
1308
  const setupDatabaseTestSuite = (options) => {
2005
1309
  setupDatabaseTestRunner(options, ({ getDatabase }) => {
@@ -2021,10 +1325,11 @@ const setupDatabaseTestSuite = (options) => {
2021
1325
  setupCoreAdminTestSuite({ getClient });
2022
1326
  setupBundleMethodsTestSuite({ getClient });
2023
1327
  setupReleaseCatalogTestSuite({ getClient });
2024
- setupInsightsHttpTestSuite({ getClient });
2025
- const { createInsightsModel } = options;
2026
- if (createInsightsModel !== void 0) describe("Insights model", () => {
2027
- registerInsightsModelTests({ getDatabase: () => createInsightsModel(getDatabase()) });
1328
+ for (const suite of options.plugins ?? []) describe(suite.name, () => {
1329
+ suite.register({
1330
+ getClient,
1331
+ getDatabase
1332
+ });
2028
1333
  });
2029
1334
  });
2030
1335
  };
@@ -2151,7 +1456,71 @@ const conformanceCounters = {
2151
1456
  sort: ["shard"]
2152
1457
  }]
2153
1458
  };
2154
- 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
+ });
2155
1524
  const item = (id, values = {}) => ({
2156
1525
  id,
2157
1526
  grp: "g",
@@ -2728,6 +2097,79 @@ const setupDatabaseAdapterConformanceSuite = (options) => {
2728
2097
  const results = await Promise.all([adapter.write(skew(a, b)), adapter.write(skew(b, a))]);
2729
2098
  expect(results.filter((result) => result.ok).length).toBeLessThanOrEqual(1);
2730
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
+ });
2731
2173
  it.skipIf(options.maxOps === void 0)("rejects an over-limit write before sending it", async () => {
2732
2174
  const adapter = await setup();
2733
2175
  const ops = Array.from({ length: (options.maxOps ?? 0) + 1 }, (_, index) => insert(item(`over${index}`)));
@@ -2773,7 +2215,6 @@ const eventOf = (n, overrides = {}) => ({
2773
2215
  app_version: "1.0.0",
2774
2216
  channel: "production",
2775
2217
  metadata: {
2776
- username: null,
2777
2218
  cohort: "1",
2778
2219
  update_strategy: "appVersion",
2779
2220
  fingerprint_hash: null,
@@ -2783,6 +2224,31 @@ const eventOf = (n, overrides = {}) => ({
2783
2224
  ...overrides
2784
2225
  });
2785
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
+ /**
2786
2252
  * History event `n` of the day before T0, three an hour, on its own release
2787
2253
  * and bundles: every windowed read, which starts at T0 or later, skips it.
2788
2254
  */
@@ -2875,6 +2341,9 @@ const seed = async (database, server) => {
2875
2341
  received_at_ms: T0 + 2 * DAY
2876
2342
  }));
2877
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));
2878
2347
  const channel = (name) => core.findChannelByName(name).then((found) => found);
2879
2348
  const productionId = (await channel("production")).id;
2880
2349
  return {
@@ -2886,7 +2355,7 @@ const seed = async (database, server) => {
2886
2355
  nightlyId: (await channel("nightly")).id,
2887
2356
  /** The auto-patch base key of a new production iOS 1.0.0 bundle. */
2888
2357
  candidateKey: server.targetBaseCandidateKey({
2889
- channelId: productionId,
2358
+ channel: "production",
2890
2359
  platform: "ios",
2891
2360
  fingerprintHash: null,
2892
2361
  appVersion: "1.0.0"
@@ -3006,12 +2475,12 @@ const READ_BUDGETS = [
3006
2475
  returned: (releases) => releases.length
3007
2476
  }),
3008
2477
  budget({
3009
- api: "deploy base candidates: at most maxBaseBundles rows",
2478
+ api: "deploy base candidates: 1 catalog point read",
3010
2479
  read: ({ core, candidateKey }) => core.findBaseBundleIds(candidateKey, createBundleFixture("107").id, 2),
3011
- adapter: reads(0, 0, 1, 2),
2480
+ adapter: reads(1, 1, 0, 0),
3012
2481
  engine: {
3013
2482
  calls: 1,
3014
- rows: 2
2483
+ rows: 1
3015
2484
  },
3016
2485
  check: (ids) => expect(ids).toEqual(["106", "105"].map((suffix) => createBundleFixture(suffix).id))
3017
2486
  }),
@@ -3035,17 +2504,47 @@ const READ_BUDGETS = [
3035
2504
  returned: (channel) => channel === null ? 0 : 1
3036
2505
  }),
3037
2506
  budget({
3038
- api: "list events: limit rows, and one zero-row query per empty day",
2507
+ api: "list events of a dense day: limit rows from one query",
2508
+ read: ({ insights }) => insights.listEvents({
2509
+ filter: { kind: "all" },
2510
+ sinceMs: T0 - DAY,
2511
+ beforeReceivedAtMs: T0,
2512
+ limit: 5
2513
+ }),
2514
+ adapter: reads(0, 0, 1, 5),
2515
+ engine: {
2516
+ calls: 1,
2517
+ rows: 5
2518
+ },
2519
+ returned: (events) => events.length
2520
+ }),
2521
+ budget({
2522
+ api: "list events across a gap: limit rows, one empty day, and one outcome row",
3039
2523
  read: ({ insights }) => insights.listEvents({
3040
2524
  filter: bundleEvents,
3041
2525
  sinceMs: T0,
3042
2526
  beforeReceivedAtMs: T0 + 3 * DAY,
3043
2527
  limit: 5
3044
2528
  }),
3045
- adapter: reads(0, 0, 3, 5),
2529
+ adapter: reads(1, 7, 4, 6),
3046
2530
  engine: {
3047
- calls: 3,
3048
- rows: 5
2531
+ calls: 4,
2532
+ rows: 6
2533
+ },
2534
+ returned: (events) => events.length + 1
2535
+ }),
2536
+ budget({
2537
+ api: "list events over an empty range: its top day and one outcome read, both empty",
2538
+ read: ({ insights }) => insights.listEvents({
2539
+ filter: { kind: "all" },
2540
+ sinceMs: T0 - 91 * DAY,
2541
+ beforeReceivedAtMs: T0 - DAY,
2542
+ limit: 5
2543
+ }),
2544
+ adapter: reads(0, 0, 2, 0),
2545
+ engine: {
2546
+ calls: 2,
2547
+ rows: 0
3049
2548
  },
3050
2549
  returned: (events) => events.length
3051
2550
  }),
@@ -3091,14 +2590,14 @@ const READ_BUDGETS = [
3091
2590
  read: ({ insights }) => insights.countLatestEvents({
3092
2591
  platform: "ios",
3093
2592
  channel: "production",
3094
- sinceMs: T0 + HOUR
2593
+ sinceMs: T0
3095
2594
  }),
3096
- adapter: reads(0, 0, 1, 20),
2595
+ adapter: reads(0, 0, 1, 15),
3097
2596
  engine: {
3098
2597
  calls: 1,
3099
- rows: 5
2598
+ rows: 2
3100
2599
  },
3101
- check: (count) => expect(count).toBe(20)
2600
+ check: (count) => expect(count).toBe(24)
3102
2601
  }),
3103
2602
  budget({
3104
2603
  api: "countLatestEvents by bundle: buckets in the window × shards, all used",
@@ -3112,10 +2611,10 @@ const READ_BUDGETS = [
3112
2611
  types: ["UPDATE_APPLIED"]
3113
2612
  }]
3114
2613
  }),
3115
- adapter: reads(0, 0, 1, 24),
2614
+ adapter: reads(0, 0, 1, 15),
3116
2615
  engine: {
3117
2616
  calls: 1,
3118
- rows: 6
2617
+ rows: 2
3119
2618
  },
3120
2619
  check: (count) => expect(count).toBe(24)
3121
2620
  }),
@@ -3163,23 +2662,86 @@ const READ_BUDGETS = [
3163
2662
  },
3164
2663
  intervalMs: DAY
3165
2664
  }),
3166
- adapter: reads(0, 0, 3, 39),
2665
+ adapter: reads(0, 0, 4, 30),
3167
2666
  engine: {
3168
- calls: 3,
3169
- rows: 8
2667
+ calls: 4,
2668
+ rows: 4
3170
2669
  },
3171
2670
  check: ({ versions }) => expect(versions).toEqual([{
3172
2671
  name: "1.0.0",
3173
2672
  installations: 24
3174
2673
  }])
3175
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
+ }),
3176
2738
  budget({
3177
2739
  api: "catalog compile: the scope's enabled releases, all used",
3178
2740
  read: ({ core }) => core.deploy([{
3179
2741
  bundle: bundleOf("107"),
3180
2742
  release: production
3181
2743
  }]),
3182
- adapter: (copies) => reads(copies ? 6 : 4, copies ? 9 : 4, 3, 6),
2744
+ adapter: (copies) => reads(copies ? 5 : 3, copies ? 8 : 3, 3, 6),
3183
2745
  engine: {
3184
2746
  calls: 6,
3185
2747
  rows: 9
@@ -3213,7 +2775,7 @@ const READ_BUDGETS = [
3213
2775
  install_id: "install-2",
3214
2776
  received_at_ms: T0 + 3 * HOUR
3215
2777
  })),
3216
- adapter: reads(5, 19, 0, 0),
2778
+ adapter: reads(4, 7, 0, 0),
3217
2779
  engine: {
3218
2780
  calls: 2,
3219
2781
  rows: 1
@@ -3236,7 +2798,7 @@ const setupReadBudgetTestSuite = (options) => {
3236
2798
  let writes = 0;
3237
2799
  beforeAll(async () => {
3238
2800
  created = await options.createAdapter({
3239
- tables: options.server.builtInSchema.tables,
2801
+ tables: options.server.toolingTargetOf(options.server.plugins).schema.tables,
3240
2802
  nativePageSize: NATIVE_PAGE_SIZE
3241
2803
  });
3242
2804
  const { adapter } = created;
@@ -3279,6 +2841,266 @@ const setupReadBudgetTestSuite = (options) => {
3279
2841
  });
3280
2842
  };
3281
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
3282
3104
  //#region src/sqlRowsExamined.ts
3283
3105
  /** Wraps executors so each read they run while counting is examined first. */
3284
3106
  const examineReads = (hooks) => {
@@ -3400,63 +3222,7 @@ const mysqlRowsExamined = (query) => {
3400
3222
  });
3401
3223
  };
3402
3224
  //#endregion
3403
- //#region src/runContentionHarness.ts
3404
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3405
- /** Delays every adapter call by `ms`, like a round trip to a remote database. */
3406
- const withAdapterLatency = (adapter, ms) => ({
3407
- ...adapter,
3408
- get: async (table, keys) => {
3409
- await sleep(ms);
3410
- return adapter.get(table, keys);
3411
- },
3412
- query: async (table, request) => {
3413
- await sleep(ms);
3414
- return adapter.query(table, request);
3415
- },
3416
- write: async (ops) => {
3417
- await sleep(ms);
3418
- return adapter.write(ops);
3419
- }
3420
- });
3421
- /**
3422
- * Starts `transactions` runs at a steady arrival rate through a fixed pool and
3423
- * reports how they ended. Retry counts come from the engine's `onRetry`.
3424
- */
3425
- const runContentionHarness = async (options) => {
3426
- const started = Date.now();
3427
- const errors = {};
3428
- const waiting = [];
3429
- let free = options.concurrency;
3430
- let committed = 0;
3431
- const acquire = () => free > 0 ? (free -= 1, Promise.resolve()) : new Promise((resolve) => waiting.push(resolve));
3432
- const release = () => {
3433
- const next = waiting.shift();
3434
- if (next) next();
3435
- else free += 1;
3436
- };
3437
- await Promise.all(Array.from({ length: options.transactions }, async (_, index) => {
3438
- await sleep(index * 1e3 / options.ratePerSecond);
3439
- await acquire();
3440
- try {
3441
- await options.run(index);
3442
- committed += 1;
3443
- } catch (error) {
3444
- const name = error instanceof Error ? error.name : "unknown";
3445
- errors[name] = (errors[name] ?? 0) + 1;
3446
- } finally {
3447
- release();
3448
- }
3449
- }));
3450
- return {
3451
- transactions: options.transactions,
3452
- committed,
3453
- errors,
3454
- durationMs: Date.now() - started
3455
- };
3456
- };
3457
- //#endregion
3458
3225
  //#region src/createPluginTestHarness.ts
3459
- const builtInPlugin = Symbol.for("@hot-updater/server/built-in-plugin");
3460
3226
  /**
3461
3227
  * Runs one plugin the way `createHotUpdater` does, on a memory adapter in
3462
3228
  * verify mode, so tests can call its API and measure what each call reads.
@@ -3466,7 +3232,7 @@ const createPluginTestHarness = async (plugin, options) => {
3466
3232
  const module = {
3467
3233
  id: plugin.id,
3468
3234
  schema: plugin.schema,
3469
- ...builtInPlugin in plugin ? {} : { namespace: plugin.id }
3235
+ ...plugin.namespace === false ? {} : { namespace: plugin.id }
3470
3236
  };
3471
3237
  const schema = options.engine.resolveSchema([module]);
3472
3238
  await adapter.migrations?.apply(schema.tables);
@@ -3566,4 +3332,4 @@ async function startHttpTestServer(handlers) {
3566
3332
  };
3567
3333
  }
3568
3334
  //#endregion
3569
- 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 };