@iann29/rastro 0.1.0-alpha.5 → 0.1.0-alpha.7

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.
Files changed (125) hide show
  1. package/README.md +82 -29
  2. package/agent/integration.md +17 -15
  3. package/agent/manifest.json +7 -5
  4. package/agent/manifest.schema.json +8 -5
  5. package/dist/client/federation.d.ts +34 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +17 -3
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +418 -21
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +191 -41
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +2 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +63 -7
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/affiliates.d.ts.map +1 -1
  19. package/dist/component/affiliates.js +6 -2
  20. package/dist/component/affiliates.js.map +1 -1
  21. package/dist/component/cardinality.d.ts.map +1 -1
  22. package/dist/component/cardinality.js.map +1 -1
  23. package/dist/component/constants.d.ts +7 -1
  24. package/dist/component/constants.d.ts.map +1 -1
  25. package/dist/component/constants.js +13 -1
  26. package/dist/component/constants.js.map +1 -1
  27. package/dist/component/diagnostics.d.ts.map +1 -1
  28. package/dist/component/diagnostics.js.map +1 -1
  29. package/dist/component/eventStore.d.ts +2 -1
  30. package/dist/component/eventStore.d.ts.map +1 -1
  31. package/dist/component/eventStore.js +13 -10
  32. package/dist/component/eventStore.js.map +1 -1
  33. package/dist/component/funnels.d.ts.map +1 -1
  34. package/dist/component/funnels.js +5 -3
  35. package/dist/component/funnels.js.map +1 -1
  36. package/dist/component/geo.d.ts.map +1 -1
  37. package/dist/component/geo.js +3 -2
  38. package/dist/component/geo.js.map +1 -1
  39. package/dist/component/goals.d.ts.map +1 -1
  40. package/dist/component/goals.js +8 -5
  41. package/dist/component/goals.js.map +1 -1
  42. package/dist/component/guards.d.ts.map +1 -1
  43. package/dist/component/guards.js.map +1 -1
  44. package/dist/component/http.d.ts.map +1 -1
  45. package/dist/component/http.js +51 -17
  46. package/dist/component/http.js.map +1 -1
  47. package/dist/component/ingest.d.ts +2 -1
  48. package/dist/component/ingest.d.ts.map +1 -1
  49. package/dist/component/ingest.js +143 -23
  50. package/dist/component/ingest.js.map +1 -1
  51. package/dist/component/live.d.ts.map +1 -1
  52. package/dist/component/live.js +1 -3
  53. package/dist/component/live.js.map +1 -1
  54. package/dist/component/reports.d.ts +60 -3
  55. package/dist/component/reports.d.ts.map +1 -1
  56. package/dist/component/reports.js +215 -49
  57. package/dist/component/reports.js.map +1 -1
  58. package/dist/component/retention.d.ts +4 -4
  59. package/dist/component/retention.d.ts.map +1 -1
  60. package/dist/component/retention.js +54 -22
  61. package/dist/component/retention.js.map +1 -1
  62. package/dist/component/sanitize.d.ts +11 -0
  63. package/dist/component/sanitize.d.ts.map +1 -1
  64. package/dist/component/sanitize.js +58 -11
  65. package/dist/component/sanitize.js.map +1 -1
  66. package/dist/component/schema.d.ts +47 -5
  67. package/dist/component/schema.js +21 -1
  68. package/dist/component/schema.js.map +1 -1
  69. package/dist/component/sites.d.ts.map +1 -1
  70. package/dist/component/sites.js +6 -6
  71. package/dist/component/sites.js.map +1 -1
  72. package/dist/component/validators.d.ts +14 -9
  73. package/dist/component/validators.d.ts.map +1 -1
  74. package/dist/component/validators.js +5 -1
  75. package/dist/component/validators.js.map +1 -1
  76. package/dist/component/visitors.d.ts.map +1 -1
  77. package/dist/component/visitors.js.map +1 -1
  78. package/dist/component/vitals.d.ts +41 -0
  79. package/dist/component/vitals.d.ts.map +1 -0
  80. package/dist/component/vitals.js +115 -0
  81. package/dist/component/vitals.js.map +1 -0
  82. package/dist/react/index.d.ts.map +1 -1
  83. package/dist/react/index.js.map +1 -1
  84. package/dist/tracker/generated.d.ts +8 -4
  85. package/dist/tracker/generated.d.ts.map +1 -1
  86. package/dist/tracker/generated.js +8 -4
  87. package/dist/tracker/generated.js.map +1 -1
  88. package/dist/tracker/tracker.d.ts.map +1 -1
  89. package/dist/tracker/tracker.js +3 -1
  90. package/dist/tracker/tracker.js.map +1 -1
  91. package/dist/tracker/vitals.d.ts +10 -0
  92. package/dist/tracker/vitals.d.ts.map +1 -0
  93. package/dist/tracker/vitals.js +140 -0
  94. package/dist/tracker/vitals.js.map +1 -0
  95. package/dist/tracker.min.js +1 -1
  96. package/dist/vitals.min.js +1 -0
  97. package/docs/benchmarks/2026-08-20-realistic.md +71 -71
  98. package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
  99. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  100. package/docs/upgrading.md +46 -8
  101. package/llms.txt +5 -1
  102. package/package.json +17 -3
  103. package/scripts/benchmark-ingest.mjs +101 -48
  104. package/src/component/_generated/api.ts +2 -0
  105. package/src/component/_generated/component.ts +69 -4
  106. package/src/component/affiliates.ts +20 -5
  107. package/src/component/cardinality.ts +8 -7
  108. package/src/component/constants.ts +13 -1
  109. package/src/component/diagnostics.ts +3 -2
  110. package/src/component/eventStore.ts +53 -46
  111. package/src/component/funnels.ts +19 -16
  112. package/src/component/geo.ts +16 -14
  113. package/src/component/goals.ts +29 -21
  114. package/src/component/guards.ts +3 -1
  115. package/src/component/http.ts +93 -54
  116. package/src/component/ingest.ts +320 -127
  117. package/src/component/live.ts +5 -4
  118. package/src/component/reports.ts +563 -219
  119. package/src/component/retention.ts +221 -98
  120. package/src/component/sanitize.ts +99 -29
  121. package/src/component/schema.ts +31 -5
  122. package/src/component/sites.ts +22 -11
  123. package/src/component/validators.ts +16 -7
  124. package/src/component/visitors.ts +16 -5
  125. package/src/component/vitals.ts +146 -0
@@ -24,7 +24,9 @@ import {
24
24
  MAX_REPORT_BUCKETS,
25
25
  MAX_REPORT_RANGE_DAYS,
26
26
  MAX_REPORT_SITES,
27
+ MAX_VITALS_REPORT_ROWS,
27
28
  REPORT_ROLLUP_SHARDS,
29
+ VITALS_REPORT_PAGE_LIMIT,
28
30
  } from "./constants.js";
29
31
  import {
30
32
  createVisitorSketch,
@@ -48,8 +50,17 @@ import {
48
50
  funnelFieldsValidator,
49
51
  goalFieldsValidator,
50
52
  sessionFieldsValidator,
53
+ vitalMetricValidator,
51
54
  } from "./validators.js";
52
55
  import { resolveVisitorIdentities } from "./visitors.js";
56
+ import {
57
+ mergeVitalHistograms,
58
+ VITAL_ALL,
59
+ VITAL_METRICS,
60
+ vitalHistogramPercentile,
61
+ vitalRatingCounts,
62
+ type VitalMetric,
63
+ } from "./vitals.js";
53
64
 
54
65
  const sessionDocumentValidator = sessionFieldsValidator.extend({
55
66
  _id: v.id("sessions"),
@@ -110,7 +121,9 @@ const metricTotalsValidator = v.object({
110
121
  conversions: v.number(),
111
122
  revenueCents: v.number(),
112
123
  });
113
- const timePointValidator = metricTotalsValidator.extend({ timestamp: v.number() });
124
+ const timePointValidator = metricTotalsValidator.extend({
125
+ timestamp: v.number(),
126
+ });
114
127
  const topItemValidator = v.object({
115
128
  value: v.string(),
116
129
  count: v.number(),
@@ -199,7 +212,8 @@ export const overview = query({
199
212
  const currencies = new Set<string>();
200
213
  for (const siteId of siteIds) {
201
214
  const site = await ctx.db.get("sites", siteId);
202
- if (!site) fail("NOT_FOUND", "site not found", { siteId: String(siteId) });
215
+ if (!site)
216
+ fail("NOT_FOUND", "site not found", { siteId: String(siteId) });
203
217
  currencies.add(site.currency ?? "USD");
204
218
  }
205
219
  if (currencies.size !== 1) {
@@ -208,7 +222,11 @@ export const overview = query({
208
222
  });
209
223
  }
210
224
  const currency = [...currencies][0];
211
- const { from, to } = validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
225
+ const { from, to } = validateRange(
226
+ args.from,
227
+ args.to,
228
+ MAX_REPORT_RANGE_DAYS,
229
+ );
212
230
  const interval = args.interval ?? (to - from <= DAY_MS ? "hour" : "day");
213
231
  if (interval === "hour" && to - from > DAY_MS) {
214
232
  fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
@@ -218,7 +236,8 @@ export const overview = query({
218
236
  const firstBucket = Math.floor(from / intervalMs) * intervalMs;
219
237
  const lastBucket = Math.floor(to / intervalMs) * intervalMs;
220
238
  const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
221
- const perSiteLimit = bucketCount *
239
+ const perSiteLimit =
240
+ bucketCount *
222
241
  (interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
223
242
  if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
224
243
  fail(
@@ -233,10 +252,14 @@ export const overview = query({
233
252
  let mergedVisitorSketch = visitorSketch;
234
253
  const pointVisitorSketches = new Map<number, VisitorSketch>();
235
254
  let visitorsComplete = true;
236
- const dimensions = new Map<string, { value: string; count: number; revenueCents: number }>();
237
- const overviewDataset = interval === "hour"
238
- ? "overviewHour" as const
239
- : "overviewDay" as const;
255
+ const dimensions = new Map<
256
+ string,
257
+ { value: string; count: number; revenueCents: number }
258
+ >();
259
+ const overviewDataset =
260
+ interval === "hour"
261
+ ? ("overviewHour" as const)
262
+ : ("overviewDay" as const);
240
263
 
241
264
  await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
242
265
 
@@ -291,7 +314,10 @@ export const overview = query({
291
314
  [...dimensions.entries()]
292
315
  .filter(([key]) => key.startsWith(`${type}:`))
293
316
  .map(([, value]) => value)
294
- .sort((left, right) => right.count - left.count || left.value.localeCompare(right.value))
317
+ .sort(
318
+ (left, right) =>
319
+ right.count - left.count || left.value.localeCompare(right.value),
320
+ )
295
321
  .slice(0, 10);
296
322
  if (visitorsComplete) {
297
323
  totals.visitors = estimateVisitors(mergedVisitorSketch);
@@ -365,8 +391,15 @@ export const liveVisitors = query({
365
391
  handler: async (ctx, args) => {
366
392
  const siteIds = validateSiteIds(args.siteIds);
367
393
  const limit = args.limit ?? 100;
368
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIVE_VISITORS) {
369
- fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_LIVE_VISITORS}`);
394
+ if (
395
+ !Number.isSafeInteger(limit) ||
396
+ limit < 1 ||
397
+ limit > MAX_LIVE_VISITORS
398
+ ) {
399
+ fail(
400
+ "INVALID_ARGUMENT",
401
+ `limit must be an integer between 1 and ${MAX_LIVE_VISITORS}`,
402
+ );
370
403
  }
371
404
  if (!Number.isFinite(args.now) || args.now < 0) {
372
405
  fail("INVALID_ARGUMENT", "now must be a positive timestamp");
@@ -593,22 +626,34 @@ export const visitorJourney = query({
593
626
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
594
627
  await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
595
628
  const limit = args.limit ?? 200;
596
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
597
- fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
629
+ if (
630
+ !Number.isSafeInteger(limit) ||
631
+ limit < 1 ||
632
+ limit > MAX_JOURNEY_EVENTS
633
+ ) {
634
+ fail(
635
+ "INVALID_ARGUMENT",
636
+ `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`,
637
+ );
598
638
  }
599
639
  const rows: RawJourneyEvent[] = [];
600
640
  for (const siteId of siteIds) {
601
- for (const visitorId of await resolveVisitorIdentities(ctx, siteId, args.visitorId)) {
602
- const cutoff = rows.length >= limit
603
- ? rows[limit - 1]!.timestamp
604
- : args.to;
605
- rows.push(...await loadVisitorJourneyEvents(ctx, {
606
- siteId,
607
- visitorId,
608
- from: args.from,
609
- to: cutoff,
610
- resultLimit: limit,
611
- }));
641
+ for (const visitorId of await resolveVisitorIdentities(
642
+ ctx,
643
+ siteId,
644
+ args.visitorId,
645
+ )) {
646
+ const cutoff =
647
+ rows.length >= limit ? rows[limit - 1]!.timestamp : args.to;
648
+ rows.push(
649
+ ...(await loadVisitorJourneyEvents(ctx, {
650
+ siteId,
651
+ visitorId,
652
+ from: args.from,
653
+ to: cutoff,
654
+ resultLimit: limit,
655
+ })),
656
+ );
612
657
  rows.sort(compareJourneyEvents);
613
658
  if (rows.length > limit) rows.length = limit;
614
659
  }
@@ -639,7 +684,10 @@ export const goalsReport = query({
639
684
  .take(MAX_GOALS_PER_SITE + 1);
640
685
  failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
641
686
  const goals = goalRows;
642
- const totals = new Map<string, { completions: number; valueCents: number }>();
687
+ const totals = new Map<
688
+ string,
689
+ { completions: number; valueCents: number }
690
+ >();
643
691
  const useRollups = await dailyRollupsAvailable(
644
692
  ctx,
645
693
  args.siteId,
@@ -696,12 +744,18 @@ export const goalsReport = query({
696
744
  })),
697
745
  truncated: false,
698
746
  metadata: {
699
- basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
700
- coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
701
- datasets: [rollups ? "goalRollups" : "goalCompletions"],
702
- complete: true,
703
- generation: rollups ? 1 : null,
704
- }),
747
+ basis: rollups ? ("dailyRollup" as const) : ("boundedLegacy" as const),
748
+ coverage: await coverageForRange(
749
+ ctx,
750
+ [args.siteId],
751
+ args.from,
752
+ args.to,
753
+ {
754
+ datasets: [rollups ? "goalRollups" : "goalCompletions"],
755
+ complete: true,
756
+ generation: rollups ? 1 : null,
757
+ },
758
+ ),
705
759
  range: rangeSemantics(args.from, args.to),
706
760
  },
707
761
  };
@@ -712,7 +766,12 @@ const funnelReportItemValidator = funnelFieldsValidator.extend({
712
766
  _id: v.id("funnels"),
713
767
  _creationTime: v.number(),
714
768
  stepsReport: v.array(
715
- v.object({ step: v.number(), name: v.string(), entrants: v.number(), completions: v.number() }),
769
+ v.object({
770
+ step: v.number(),
771
+ name: v.string(),
772
+ entrants: v.number(),
773
+ completions: v.number(),
774
+ }),
716
775
  ),
717
776
  truncated: v.boolean(),
718
777
  metadata: reportMetadataValidator,
@@ -802,7 +861,9 @@ export const funnelsReport = query({
802
861
  stepsReport,
803
862
  truncated: false,
804
863
  metadata: {
805
- basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
864
+ basis: rollups
865
+ ? ("dailyRollup" as const)
866
+ : ("boundedLegacy" as const),
806
867
  coverage: reportCoverage,
807
868
  range: rangeSemantics(args.from, args.to),
808
869
  },
@@ -842,7 +903,11 @@ export const affiliatesReport = query({
842
903
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
843
904
  .order("desc")
844
905
  .take(MAX_AFFILIATES_PER_SITE + 1);
845
- failIfIncomplete(affiliateRows, MAX_AFFILIATES_PER_SITE, "affiliate definitions");
906
+ failIfIncomplete(
907
+ affiliateRows,
908
+ MAX_AFFILIATES_PER_SITE,
909
+ "affiliate definitions",
910
+ );
846
911
  const affiliates = affiliateRows;
847
912
  const useRollups = await dailyRollupsAvailable(
848
913
  ctx,
@@ -860,16 +925,22 @@ export const affiliatesReport = query({
860
925
  args.to,
861
926
  )
862
927
  : null;
863
- const visits = new Map<string, {
864
- visits: number;
865
- visitors: Set<string>;
866
- sketch: VisitorSketch;
867
- }>();
868
- const totals = new Map<string, {
869
- conversions: number;
870
- revenueCents: number;
871
- commissionCents: number;
872
- }>();
928
+ const visits = new Map<
929
+ string,
930
+ {
931
+ visits: number;
932
+ visitors: Set<string>;
933
+ sketch: VisitorSketch;
934
+ }
935
+ >();
936
+ const totals = new Map<
937
+ string,
938
+ {
939
+ conversions: number;
940
+ revenueCents: number;
941
+ commissionCents: number;
942
+ }
943
+ >();
873
944
  if (rollups) {
874
945
  for (const row of rollups) {
875
946
  const key = String(row.affiliateId);
@@ -960,22 +1031,30 @@ export const affiliatesReport = query({
960
1031
  visits: traffic?.visits ?? 0,
961
1032
  uniqueVisitors: rollups
962
1033
  ? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
963
- : traffic?.visitors.size ?? 0,
1034
+ : (traffic?.visitors.size ?? 0),
964
1035
  };
965
1036
  }),
966
1037
  truncated: false,
967
1038
  metadata: {
968
- basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
969
- coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
970
- datasets: rollups
971
- ? ["affiliateRollups"]
972
- : ["affiliateStats", "sessions"],
973
- complete: true,
974
- generation: rollups ? 1 : null,
975
- }),
1039
+ basis: rollups ? ("dailyRollup" as const) : ("boundedLegacy" as const),
1040
+ coverage: await coverageForRange(
1041
+ ctx,
1042
+ [args.siteId],
1043
+ args.from,
1044
+ args.to,
1045
+ {
1046
+ datasets: rollups
1047
+ ? ["affiliateRollups"]
1048
+ : ["affiliateStats", "sessions"],
1049
+ complete: true,
1050
+ generation: rollups ? 1 : null,
1051
+ },
1052
+ ),
976
1053
  range: rangeSemantics(args.from, args.to),
977
1054
  uniqueVisitors: {
978
- basis: rollups ? "estimatedUnique" as const : "exactLegacy" as const,
1055
+ basis: rollups
1056
+ ? ("estimatedUnique" as const)
1057
+ : ("exactLegacy" as const),
979
1058
  exact: !rollups,
980
1059
  },
981
1060
  },
@@ -983,6 +1062,153 @@ export const affiliatesReport = query({
983
1062
  },
984
1063
  });
985
1064
 
1065
+ const vitalStatsValidator = v.object({
1066
+ count: v.number(),
1067
+ p75: v.number(),
1068
+ average: v.number(),
1069
+ good: v.number(),
1070
+ needsImprovement: v.number(),
1071
+ poor: v.number(),
1072
+ });
1073
+
1074
+ export const vitalsReport = query({
1075
+ args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
1076
+ returns: v.object({
1077
+ metrics: v.array(
1078
+ vitalStatsValidator.extend({
1079
+ metric: vitalMetricValidator,
1080
+ devices: v.array(vitalStatsValidator.extend({ device: v.string() })),
1081
+ pages: v.array(vitalStatsValidator.extend({ page: v.string() })),
1082
+ timeSeries: v.array(
1083
+ v.object({
1084
+ timestamp: v.number(),
1085
+ count: v.number(),
1086
+ p75: v.number(),
1087
+ }),
1088
+ ),
1089
+ }),
1090
+ ),
1091
+ truncated: v.boolean(),
1092
+ metadata: reportMetadataValidator,
1093
+ }),
1094
+ handler: async (ctx, args) => {
1095
+ validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
1096
+ requireAlignedRange(args.from, args.to, DAY_MS, "vitals report");
1097
+ await assertSourceAvailable(
1098
+ ctx,
1099
+ [args.siteId],
1100
+ ["vitalRollups"],
1101
+ args.from,
1102
+ );
1103
+ const rows = await ctx.db
1104
+ .query("vitalRollups")
1105
+ .withIndex("by_siteId_and_bucketStart", (range) =>
1106
+ range
1107
+ .eq("siteId", args.siteId)
1108
+ .gte("bucketStart", args.from)
1109
+ .lte("bucketStart", args.to),
1110
+ )
1111
+ .take(MAX_VITALS_REPORT_ROWS + 1);
1112
+ failIfIncomplete(rows, MAX_VITALS_REPORT_ROWS, "vital rollups");
1113
+
1114
+ type Accumulated = { count: number; sum: number; histogram: number[] };
1115
+ const fold = <Key>(
1116
+ groups: Map<VitalMetric, Map<Key, Accumulated>>,
1117
+ metric: VitalMetric,
1118
+ key: Key,
1119
+ entry: { count: number; sum: number; histogram: number[] },
1120
+ ) => {
1121
+ const group = groups.get(metric) ?? new Map<Key, Accumulated>();
1122
+ const current = group.get(key) ?? { count: 0, sum: 0, histogram: [] };
1123
+ current.count += entry.count;
1124
+ current.sum += entry.sum;
1125
+ current.histogram = mergeVitalHistograms(
1126
+ current.histogram,
1127
+ entry.histogram,
1128
+ );
1129
+ group.set(key, current);
1130
+ groups.set(metric, group);
1131
+ };
1132
+ const byDevice = new Map<VitalMetric, Map<string, Accumulated>>();
1133
+ const byPage = new Map<VitalMetric, Map<string, Accumulated>>();
1134
+ const byDay = new Map<VitalMetric, Map<number, Accumulated>>();
1135
+ for (const row of rows) {
1136
+ for (const entry of row.metrics) {
1137
+ if (row.page === VITAL_ALL) {
1138
+ // A device row carries the complete signal once, so overall totals
1139
+ // and the time series derive from this family alone.
1140
+ fold(byDevice, entry.metric, row.device, entry);
1141
+ fold(byDay, entry.metric, row.bucketStart, entry);
1142
+ } else {
1143
+ fold(byPage, entry.metric, row.page, entry);
1144
+ }
1145
+ }
1146
+ }
1147
+ const stats = (metric: VitalMetric, accumulated: Accumulated) => ({
1148
+ count: accumulated.count,
1149
+ p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
1150
+ average:
1151
+ accumulated.count > 0
1152
+ ? Math.round(accumulated.sum / accumulated.count)
1153
+ : 0,
1154
+ ...vitalRatingCounts(accumulated.histogram, metric),
1155
+ });
1156
+ const metrics = VITAL_METRICS.map((metric) => {
1157
+ const overall: Accumulated = { count: 0, sum: 0, histogram: [] };
1158
+ for (const accumulated of byDevice.get(metric)?.values() ?? []) {
1159
+ overall.count += accumulated.count;
1160
+ overall.sum += accumulated.sum;
1161
+ overall.histogram = mergeVitalHistograms(
1162
+ overall.histogram,
1163
+ accumulated.histogram,
1164
+ );
1165
+ }
1166
+ return {
1167
+ metric,
1168
+ ...stats(metric, overall),
1169
+ devices: [...(byDevice.get(metric) ?? [])]
1170
+ .map(([device, accumulated]) => ({
1171
+ device,
1172
+ ...stats(metric, accumulated),
1173
+ }))
1174
+ .sort((left, right) => right.count - left.count),
1175
+ pages: [...(byPage.get(metric) ?? [])]
1176
+ .map(([page, accumulated]) => ({
1177
+ page,
1178
+ ...stats(metric, accumulated),
1179
+ }))
1180
+ .sort(
1181
+ (left, right) =>
1182
+ right.count - left.count || left.page.localeCompare(right.page),
1183
+ )
1184
+ .slice(0, VITALS_REPORT_PAGE_LIMIT),
1185
+ timeSeries: [...(byDay.get(metric) ?? [])]
1186
+ .sort(([left], [right]) => left - right)
1187
+ .map(([timestamp, accumulated]) => ({
1188
+ timestamp,
1189
+ count: accumulated.count,
1190
+ p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
1191
+ })),
1192
+ };
1193
+ });
1194
+ return {
1195
+ metrics,
1196
+ truncated: false,
1197
+ metadata: {
1198
+ basis: "dailyRollup" as const,
1199
+ coverage: await coverageForRange(
1200
+ ctx,
1201
+ [args.siteId],
1202
+ args.from,
1203
+ args.to,
1204
+ { datasets: ["vitalRollups"], complete: true },
1205
+ ),
1206
+ range: rangeSemantics(args.from, args.to),
1207
+ },
1208
+ };
1209
+ },
1210
+ });
1211
+
986
1212
  const coverageDatasetValidator = v.union(
987
1213
  v.literal("overviewHour"),
988
1214
  v.literal("overviewDay"),
@@ -992,6 +1218,7 @@ const coverageDatasetValidator = v.union(
992
1218
  v.literal("goals"),
993
1219
  v.literal("funnels"),
994
1220
  v.literal("affiliates"),
1221
+ v.literal("vitals"),
995
1222
  );
996
1223
 
997
1224
  export const dataCoverage = query({
@@ -1004,10 +1231,12 @@ export const dataCoverage = query({
1004
1231
  siteId: v.id("sites"),
1005
1232
  from: v.number(),
1006
1233
  to: v.number(),
1007
- datasets: v.array(v.object({
1008
- dataset: coverageDatasetValidator,
1009
- coverage: resultCoverageValidator,
1010
- })),
1234
+ datasets: v.array(
1235
+ v.object({
1236
+ dataset: coverageDatasetValidator,
1237
+ coverage: resultCoverageValidator,
1238
+ }),
1239
+ ),
1011
1240
  }),
1012
1241
  handler: async (ctx, args) => {
1013
1242
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
@@ -1020,21 +1249,24 @@ export const dataCoverage = query({
1020
1249
  "goals",
1021
1250
  "funnels",
1022
1251
  "affiliates",
1252
+ "vitals",
1023
1253
  ] as const;
1024
1254
  return {
1025
1255
  siteId: args.siteId,
1026
1256
  from: args.from,
1027
1257
  to: args.to,
1028
- datasets: await Promise.all(datasets.map(async (dataset) => ({
1029
- dataset,
1030
- coverage: await datasetCoverageForRange(
1031
- ctx,
1032
- args.siteId,
1258
+ datasets: await Promise.all(
1259
+ datasets.map(async (dataset) => ({
1033
1260
  dataset,
1034
- args.from,
1035
- args.to,
1036
- ),
1037
- }))),
1261
+ coverage: await datasetCoverageForRange(
1262
+ ctx,
1263
+ args.siteId,
1264
+ dataset,
1265
+ args.from,
1266
+ args.to,
1267
+ ),
1268
+ })),
1269
+ ),
1038
1270
  };
1039
1271
  },
1040
1272
  });
@@ -1144,7 +1376,11 @@ async function loadAffiliateDailyRollups(
1144
1376
  .lte("bucketStart", to),
1145
1377
  )
1146
1378
  .take(perAffiliateLimit + 1);
1147
- failIfIncomplete(affiliateRows, perAffiliateLimit, "affiliate daily rollups");
1379
+ failIfIncomplete(
1380
+ affiliateRows,
1381
+ perAffiliateLimit,
1382
+ "affiliate daily rollups",
1383
+ );
1148
1384
  rows.push(...affiliateRows);
1149
1385
  await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
1150
1386
  }
@@ -1172,20 +1408,28 @@ async function requireFeatureReadHeadroom(ctx: QueryCtx, dataset: string) {
1172
1408
  metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
1173
1409
  metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE
1174
1410
  ) {
1175
- fail("REPORT_INCOMPLETE", `${dataset} exceeds its byte or document budget`, {
1176
- dataset,
1177
- bytesRead: metrics.bytesRead.used,
1178
- documentsRead: metrics.documentsRead.used,
1179
- });
1411
+ fail(
1412
+ "REPORT_INCOMPLETE",
1413
+ `${dataset} exceeds its byte or document budget`,
1414
+ {
1415
+ dataset,
1416
+ bytesRead: metrics.bytesRead.used,
1417
+ documentsRead: metrics.documentsRead.used,
1418
+ },
1419
+ );
1180
1420
  }
1181
1421
  }
1182
1422
 
1183
1423
  function failIfIncomplete<T>(rows: T[], maximum: number, dataset: string) {
1184
1424
  if (rows.length > maximum) {
1185
- fail("REPORT_INCOMPLETE", `${dataset} exceeds the complete report read budget`, {
1186
- maximumRows: maximum,
1187
- dataset,
1188
- });
1425
+ fail(
1426
+ "REPORT_INCOMPLETE",
1427
+ `${dataset} exceeds the complete report read budget`,
1428
+ {
1429
+ maximumRows: maximum,
1430
+ dataset,
1431
+ },
1432
+ );
1189
1433
  }
1190
1434
  }
1191
1435
 
@@ -1200,45 +1444,52 @@ async function coverageForRange(
1200
1444
  generation?: 1 | null;
1201
1445
  },
1202
1446
  ) {
1203
- const controls = await Promise.all(siteIds.map((siteId) =>
1204
- ctx.db
1205
- .query("analyticsCoverage")
1206
- .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1207
- .unique()
1208
- ));
1209
- const sourceRanges = await Promise.all(siteIds.map((siteId) =>
1210
- sourceSetRange(ctx, siteId, options.datasets)
1211
- ));
1447
+ const controls = await Promise.all(
1448
+ siteIds.map((siteId) =>
1449
+ ctx.db
1450
+ .query("analyticsCoverage")
1451
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1452
+ .unique(),
1453
+ ),
1454
+ );
1455
+ const sourceRanges = await Promise.all(
1456
+ siteIds.map((siteId) => sourceSetRange(ctx, siteId, options.datasets)),
1457
+ );
1212
1458
  const retainedBefore = controls.reduce<number | null>((latest, control) => {
1213
1459
  const before = control?.retained
1214
- .filter((item) => options.datasets.some((dataset) => dataset === item.dataset))
1460
+ .filter((item) =>
1461
+ options.datasets.some((dataset) => dataset === item.dataset),
1462
+ )
1215
1463
  .reduce<number | undefined>(
1216
1464
  (latest, item) => Math.max(latest ?? 0, item.before),
1217
1465
  undefined,
1218
1466
  );
1219
1467
  return before === undefined ? latest : Math.max(latest ?? 0, before);
1220
1468
  }, null);
1221
- const availableFrom = options.generation === 1
1222
- ? controls.length > 0 && controls.every(Boolean)
1223
- ? Math.max(...controls.map((control) => control!.rollupCompleteFrom))
1224
- : null
1225
- : sourceRanges.length > 0 && sourceRanges.every(Boolean)
1226
- ? Math.max(...sourceRanges.map((range) => range!.from))
1469
+ const availableFrom =
1470
+ options.generation === 1
1471
+ ? controls.length > 0 && controls.every(Boolean)
1472
+ ? Math.max(...controls.map((control) => control!.rollupCompleteFrom))
1473
+ : null
1474
+ : sourceRanges.length > 0 && sourceRanges.every(Boolean)
1475
+ ? Math.max(...sourceRanges.map((range) => range!.from))
1476
+ : null;
1477
+ const availableThrough =
1478
+ sourceRanges.length > 0 && sourceRanges.every(Boolean)
1479
+ ? Math.min(...sourceRanges.map((range) => range!.through))
1227
1480
  : null;
1228
- const availableThrough = sourceRanges.length > 0 && sourceRanges.every(Boolean)
1229
- ? Math.min(...sourceRanges.map((range) => range!.through))
1230
- : null;
1231
1481
  let state: "complete" | "partial" | "retained" | "unavailable";
1232
1482
  if (retainedBefore !== null && to < retainedBefore) {
1233
1483
  state = "unavailable";
1234
1484
  } else if (retainedBefore !== null && from < retainedBefore) {
1235
1485
  state = "partial";
1236
- } else if (options.complete || (
1237
- availableFrom !== null &&
1238
- availableThrough !== null &&
1239
- from >= availableFrom &&
1240
- to <= availableThrough
1241
- )) {
1486
+ } else if (
1487
+ options.complete ||
1488
+ (availableFrom !== null &&
1489
+ availableThrough !== null &&
1490
+ from >= availableFrom &&
1491
+ to <= availableThrough)
1492
+ ) {
1242
1493
  state = "complete";
1243
1494
  } else if (sourceRanges.some(Boolean)) {
1244
1495
  state = "retained";
@@ -1260,12 +1511,14 @@ async function assertSourceAvailable(
1260
1511
  sources: CoverageSource[],
1261
1512
  from: number,
1262
1513
  ) {
1263
- const rows = await Promise.all(siteIds.map((siteId) =>
1264
- ctx.db
1265
- .query("analyticsCoverage")
1266
- .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1267
- .unique()
1268
- ));
1514
+ const rows = await Promise.all(
1515
+ siteIds.map((siteId) =>
1516
+ ctx.db
1517
+ .query("analyticsCoverage")
1518
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1519
+ .unique(),
1520
+ ),
1521
+ );
1269
1522
  const cutoff = rows.reduce<number | null>((latest, row) => {
1270
1523
  for (const retained of row?.retained ?? []) {
1271
1524
  if (sources.some((source) => source === retained.dataset)) {
@@ -1275,9 +1528,13 @@ async function assertSourceAvailable(
1275
1528
  return latest;
1276
1529
  }, null);
1277
1530
  if (cutoff !== null && from < cutoff) {
1278
- fail("REPORT_INCOMPLETE", "requested report range intersects retained data", {
1279
- retainedBefore: cutoff,
1280
- });
1531
+ fail(
1532
+ "REPORT_INCOMPLETE",
1533
+ "requested report range intersects retained data",
1534
+ {
1535
+ retainedBefore: cutoff,
1536
+ },
1537
+ );
1281
1538
  }
1282
1539
  }
1283
1540
 
@@ -1288,31 +1545,42 @@ async function datasetCoverageForRange(
1288
1545
  from: number,
1289
1546
  to: number,
1290
1547
  ) {
1291
- const sourceOptions: CoverageSource[][] = dataset === "goals"
1292
- ? [["goalRollups"], ["goalCompletions"]]
1293
- : dataset === "funnels"
1294
- ? [["funnelRollups"], ["funnelSteps"]]
1295
- : dataset === "affiliates"
1296
- ? [["affiliateRollups"], ["affiliateStats", "sessions"]]
1297
- : [[dataset]];
1298
- const options = (await Promise.all(sourceOptions.map(async (sources) => {
1299
- const rollupDataset = sources[0] as
1300
- | "goalRollups"
1301
- | "funnelRollups"
1302
- | "affiliateRollups";
1303
- const isRollup = rollupDataset.endsWith("Rollups");
1304
- const complete = isRollup
1305
- ? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
1306
- : false;
1307
- if (isRollup && !complete) return null;
1548
+ // Vitals have no legacy fallback source; the rollup either covers the range
1549
+ // or the coverage states say how much of it exists.
1550
+ if (dataset === "vitals") {
1308
1551
  return coverageForRange(ctx, [siteId], from, to, {
1309
- datasets: sources,
1310
- complete,
1311
- generation: complete ? 1 : null,
1552
+ datasets: ["vitalRollups"],
1553
+ complete: false,
1312
1554
  });
1313
- }))).filter((option): option is NonNullable<typeof option> => option !== null);
1314
- return options.sort((left, right) =>
1315
- coverageRank(right.state) - coverageRank(left.state)
1555
+ }
1556
+ const sourceOptions: CoverageSource[][] =
1557
+ dataset === "goals"
1558
+ ? [["goalRollups"], ["goalCompletions"]]
1559
+ : dataset === "funnels"
1560
+ ? [["funnelRollups"], ["funnelSteps"]]
1561
+ : dataset === "affiliates"
1562
+ ? [["affiliateRollups"], ["affiliateStats", "sessions"]]
1563
+ : [[dataset]];
1564
+ const options = (
1565
+ await Promise.all(
1566
+ sourceOptions.map(async (sources) => {
1567
+ const rollupDataset = sources[0] as
1568
+ "goalRollups" | "funnelRollups" | "affiliateRollups";
1569
+ const isRollup = rollupDataset.endsWith("Rollups");
1570
+ const complete = isRollup
1571
+ ? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
1572
+ : false;
1573
+ if (isRollup && !complete) return null;
1574
+ return coverageForRange(ctx, [siteId], from, to, {
1575
+ datasets: sources,
1576
+ complete,
1577
+ generation: complete ? 1 : null,
1578
+ });
1579
+ }),
1580
+ )
1581
+ ).filter((option): option is NonNullable<typeof option> => option !== null);
1582
+ return options.sort(
1583
+ (left, right) => coverageRank(right.state) - coverageRank(left.state),
1316
1584
  )[0]!;
1317
1585
  }
1318
1586
 
@@ -1324,7 +1592,8 @@ type PublicCoverageDataset =
1324
1592
  | "conversions"
1325
1593
  | "goals"
1326
1594
  | "funnels"
1327
- | "affiliates";
1595
+ | "affiliates"
1596
+ | "vitals";
1328
1597
 
1329
1598
  type CoverageSource =
1330
1599
  | "overviewHour"
@@ -1337,7 +1606,8 @@ type CoverageSource =
1337
1606
  | "affiliateStats"
1338
1607
  | "goalRollups"
1339
1608
  | "funnelRollups"
1340
- | "affiliateRollups";
1609
+ | "affiliateRollups"
1610
+ | "vitalRollups";
1341
1611
 
1342
1612
  type SourceRange = { from: number; through: number };
1343
1613
 
@@ -1346,9 +1616,9 @@ async function sourceSetRange(
1346
1616
  siteId: Id<"sites">,
1347
1617
  sources: CoverageSource[],
1348
1618
  ): Promise<SourceRange | null> {
1349
- const ranges = (await Promise.all(
1350
- sources.map((source) => sourceRange(ctx, siteId, source)),
1351
- )).filter((range): range is SourceRange => range !== null);
1619
+ const ranges = (
1620
+ await Promise.all(sources.map((source) => sourceRange(ctx, siteId, source)))
1621
+ ).filter((range): range is SourceRange => range !== null);
1352
1622
  if (ranges.length === 0) return null;
1353
1623
  return {
1354
1624
  from: Math.min(...ranges.map((range) => range.from)),
@@ -1362,79 +1632,129 @@ async function sourceRange(
1362
1632
  source: CoverageSource,
1363
1633
  ): Promise<SourceRange | null> {
1364
1634
  if (source === "overviewHour" || source === "overviewDay") {
1365
- const granularity = source === "overviewHour" ? "hour" as const : "day" as const;
1635
+ const granularity =
1636
+ source === "overviewHour" ? ("hour" as const) : ("day" as const);
1366
1637
  const [first, last] = await Promise.all([
1367
- ctx.db.query("aggregateBuckets")
1638
+ ctx.db
1639
+ .query("aggregateBuckets")
1368
1640
  .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
1369
1641
  range.eq("siteId", siteId).eq("granularity", granularity),
1370
- ).order("asc").take(1),
1371
- ctx.db.query("aggregateBuckets")
1642
+ )
1643
+ .order("asc")
1644
+ .take(1),
1645
+ ctx.db
1646
+ .query("aggregateBuckets")
1372
1647
  .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
1373
1648
  range.eq("siteId", siteId).eq("granularity", granularity),
1374
- ).order("desc").take(1),
1649
+ )
1650
+ .order("desc")
1651
+ .take(1),
1375
1652
  ]);
1376
1653
  return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1377
1654
  }
1378
1655
  if (source === "events") {
1379
1656
  const [first, last] = await Promise.all([
1380
- ctx.db.query("eventBatches")
1381
- .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1382
- .order("asc").take(1),
1383
- ctx.db.query("eventBatches")
1384
- .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1385
- .order("desc").take(1),
1657
+ ctx.db
1658
+ .query("eventBatches")
1659
+ .withIndex("by_siteId_and_bucketStart", (range) =>
1660
+ range.eq("siteId", siteId),
1661
+ )
1662
+ .order("asc")
1663
+ .take(1),
1664
+ ctx.db
1665
+ .query("eventBatches")
1666
+ .withIndex("by_siteId_and_bucketStart", (range) =>
1667
+ range.eq("siteId", siteId),
1668
+ )
1669
+ .order("desc")
1670
+ .take(1),
1386
1671
  ]);
1387
1672
  return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1388
1673
  }
1389
1674
  if (source === "sessions") {
1390
1675
  const [first, last] = await Promise.all([
1391
- ctx.db.query("sessions")
1392
- .withIndex("by_siteId_and_startedAt", (range) => range.eq("siteId", siteId))
1393
- .order("asc").take(1),
1394
- ctx.db.query("sessions")
1395
- .withIndex("by_siteId_and_lastSeenAt", (range) => range.eq("siteId", siteId))
1396
- .order("desc").take(1),
1676
+ ctx.db
1677
+ .query("sessions")
1678
+ .withIndex("by_siteId_and_startedAt", (range) =>
1679
+ range.eq("siteId", siteId),
1680
+ )
1681
+ .order("asc")
1682
+ .take(1),
1683
+ ctx.db
1684
+ .query("sessions")
1685
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
1686
+ range.eq("siteId", siteId),
1687
+ )
1688
+ .order("desc")
1689
+ .take(1),
1397
1690
  ]);
1398
1691
  return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
1399
1692
  }
1400
1693
  if (source === "conversions") {
1401
1694
  const [first, last] = await Promise.all([
1402
- ctx.db.query("conversions")
1403
- .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1404
- .order("asc").take(1),
1405
- ctx.db.query("conversions")
1406
- .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1407
- .order("desc").take(1),
1695
+ ctx.db
1696
+ .query("conversions")
1697
+ .withIndex("by_siteId_and_timestamp", (range) =>
1698
+ range.eq("siteId", siteId),
1699
+ )
1700
+ .order("asc")
1701
+ .take(1),
1702
+ ctx.db
1703
+ .query("conversions")
1704
+ .withIndex("by_siteId_and_timestamp", (range) =>
1705
+ range.eq("siteId", siteId),
1706
+ )
1707
+ .order("desc")
1708
+ .take(1),
1408
1709
  ]);
1409
1710
  return bounds(first[0]?.timestamp, last[0]?.timestamp);
1410
1711
  }
1411
1712
  if (source === "goalCompletions") {
1412
1713
  const [first, last] = await Promise.all([
1413
- ctx.db.query("goalCompletions")
1414
- .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1415
- .order("asc").take(1),
1416
- ctx.db.query("goalCompletions")
1417
- .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1418
- .order("desc").take(1),
1714
+ ctx.db
1715
+ .query("goalCompletions")
1716
+ .withIndex("by_siteId_and_timestamp", (range) =>
1717
+ range.eq("siteId", siteId),
1718
+ )
1719
+ .order("asc")
1720
+ .take(1),
1721
+ ctx.db
1722
+ .query("goalCompletions")
1723
+ .withIndex("by_siteId_and_timestamp", (range) =>
1724
+ range.eq("siteId", siteId),
1725
+ )
1726
+ .order("desc")
1727
+ .take(1),
1419
1728
  ]);
1420
1729
  return bounds(first[0]?.timestamp, last[0]?.timestamp);
1421
1730
  }
1422
- const table = source === "funnelSteps"
1423
- ? "funnelStepBuckets" as const
1424
- : source === "affiliateStats"
1425
- ? "affiliateDailyStats" as const
1426
- : source === "goalRollups"
1427
- ? "goalDailyRollups" as const
1428
- : source === "funnelRollups"
1429
- ? "funnelDailyRollups" as const
1430
- : "affiliateDailyRollups" as const;
1731
+ const table =
1732
+ source === "funnelSteps"
1733
+ ? ("funnelStepBuckets" as const)
1734
+ : source === "affiliateStats"
1735
+ ? ("affiliateDailyStats" as const)
1736
+ : source === "goalRollups"
1737
+ ? ("goalDailyRollups" as const)
1738
+ : source === "funnelRollups"
1739
+ ? ("funnelDailyRollups" as const)
1740
+ : source === "vitalRollups"
1741
+ ? ("vitalRollups" as const)
1742
+ : ("affiliateDailyRollups" as const);
1431
1743
  const [first, last] = await Promise.all([
1432
- ctx.db.query(table)
1433
- .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1434
- .order("asc").take(1),
1435
- ctx.db.query(table)
1436
- .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1437
- .order("desc").take(1),
1744
+ ctx.db
1745
+ .query(table)
1746
+ .withIndex("by_siteId_and_bucketStart", (range) =>
1747
+ range.eq("siteId", siteId),
1748
+ )
1749
+ .order("asc")
1750
+ .take(1),
1751
+ ctx.db
1752
+ .query(table)
1753
+ .withIndex("by_siteId_and_bucketStart", (range) =>
1754
+ range.eq("siteId", siteId),
1755
+ )
1756
+ .order("desc")
1757
+ .take(1),
1438
1758
  ]);
1439
1759
  return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1440
1760
  }
@@ -1443,14 +1763,15 @@ function bounds(from: number | undefined, through: number | undefined) {
1443
1763
  return from === undefined || through === undefined ? null : { from, through };
1444
1764
  }
1445
1765
 
1446
- function coverageRank(state: "complete" | "partial" | "retained" | "unavailable") {
1766
+ function coverageRank(
1767
+ state: "complete" | "partial" | "retained" | "unavailable",
1768
+ ) {
1447
1769
  if (state === "complete") return 3;
1448
1770
  if (state === "retained") return 2;
1449
1771
  if (state === "partial") return 1;
1450
1772
  return 0;
1451
1773
  }
1452
1774
 
1453
-
1454
1775
  type MetricTotals = {
1455
1776
  events: number;
1456
1777
  pageviews: number;
@@ -1491,8 +1812,15 @@ function addMetrics(target: MetricTotals, source: MetricTotals) {
1491
1812
 
1492
1813
  function validateSiteIds<T>(siteIds: T[]): T[] {
1493
1814
  const unique = [...new Set(siteIds)];
1494
- if (unique.length < 1 || unique.length > MAX_REPORT_SITES || unique.length !== siteIds.length) {
1495
- fail("INVALID_ARGUMENT", `siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`);
1815
+ if (
1816
+ unique.length < 1 ||
1817
+ unique.length > MAX_REPORT_SITES ||
1818
+ unique.length !== siteIds.length
1819
+ ) {
1820
+ fail(
1821
+ "INVALID_ARGUMENT",
1822
+ `siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`,
1823
+ );
1496
1824
  }
1497
1825
  return unique;
1498
1826
  }
@@ -1667,22 +1995,23 @@ function keysetPaginationResult<T>(
1667
1995
  ) {
1668
1996
  const page = rows.slice(0, pagination.numItems);
1669
1997
  const isDone = rows.length <= pagination.numItems;
1670
- const next = page.length > 0
1671
- ? cursorFor(page[page.length - 1])
1672
- : pagination.cursor;
1998
+ const next =
1999
+ page.length > 0 ? cursorFor(page[page.length - 1]) : pagination.cursor;
1673
2000
  return {
1674
2001
  page,
1675
2002
  isDone,
1676
- continueCursor: btoa(JSON.stringify({
1677
- v: 2,
1678
- k: pagination.scope.kind,
1679
- s: pagination.scope.siteId,
1680
- n: pagination.scope.sessionId ?? null,
1681
- f: pagination.scope.from,
1682
- t: pagination.scope.to,
1683
- x: next?.timestamp ?? pagination.scope.to,
1684
- c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
1685
- })),
2003
+ continueCursor: btoa(
2004
+ JSON.stringify({
2005
+ v: 2,
2006
+ k: pagination.scope.kind,
2007
+ s: pagination.scope.siteId,
2008
+ n: pagination.scope.sessionId ?? null,
2009
+ f: pagination.scope.from,
2010
+ t: pagination.scope.to,
2011
+ x: next?.timestamp ?? pagination.scope.to,
2012
+ c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
2013
+ }),
2014
+ ),
1686
2015
  };
1687
2016
  }
1688
2017
 
@@ -1694,26 +2023,28 @@ function journeyPaginationResult(
1694
2023
  const isDone = rows.length <= pagination.numItems;
1695
2024
  const last = page.at(-1);
1696
2025
  const next = last
1697
- ? {
1698
- timestamp: last.timestamp,
1699
- creationTime: last._creationTime,
1700
- id: last._id,
2026
+ ? {
2027
+ timestamp: last.timestamp,
2028
+ creationTime: last._creationTime,
2029
+ id: last._id,
1701
2030
  }
1702
2031
  : pagination.cursor;
1703
2032
  return {
1704
2033
  page,
1705
2034
  isDone,
1706
- continueCursor: btoa(JSON.stringify({
1707
- v: 4,
1708
- k: pagination.scope.kind,
1709
- s: pagination.scope.siteId,
1710
- n: pagination.scope.sessionId,
1711
- f: pagination.scope.from,
1712
- t: pagination.scope.to,
1713
- x: next?.timestamp ?? pagination.scope.to,
1714
- c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
1715
- i: next?.id ?? "~",
1716
- })),
2035
+ continueCursor: btoa(
2036
+ JSON.stringify({
2037
+ v: 4,
2038
+ k: pagination.scope.kind,
2039
+ s: pagination.scope.siteId,
2040
+ n: pagination.scope.sessionId,
2041
+ f: pagination.scope.from,
2042
+ t: pagination.scope.to,
2043
+ x: next?.timestamp ?? pagination.scope.to,
2044
+ c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
2045
+ i: next?.id ?? "~",
2046
+ }),
2047
+ ),
1717
2048
  };
1718
2049
  }
1719
2050
 
@@ -1722,8 +2053,15 @@ function validatePagination(options: {
1722
2053
  maximumRowsRead?: number;
1723
2054
  maximumBytesRead?: number;
1724
2055
  }) {
1725
- if (!Number.isSafeInteger(options.numItems) || options.numItems < 1 || options.numItems > 100) {
1726
- fail("INVALID_ARGUMENT", "pagination numItems must be an integer between 1 and 100");
2056
+ if (
2057
+ !Number.isSafeInteger(options.numItems) ||
2058
+ options.numItems < 1 ||
2059
+ options.numItems > 100
2060
+ ) {
2061
+ fail(
2062
+ "INVALID_ARGUMENT",
2063
+ "pagination numItems must be an integer between 1 and 100",
2064
+ );
1727
2065
  }
1728
2066
  if (
1729
2067
  options.maximumRowsRead !== undefined &&
@@ -1731,7 +2069,10 @@ function validatePagination(options: {
1731
2069
  options.maximumRowsRead < 1 ||
1732
2070
  options.maximumRowsRead > 1_000)
1733
2071
  ) {
1734
- fail("INVALID_ARGUMENT", "maximumRowsRead must be an integer between 1 and 1000");
2072
+ fail(
2073
+ "INVALID_ARGUMENT",
2074
+ "maximumRowsRead must be an integer between 1 and 1000",
2075
+ );
1735
2076
  }
1736
2077
  if (
1737
2078
  options.maximumBytesRead !== undefined &&
@@ -1739,6 +2080,9 @@ function validatePagination(options: {
1739
2080
  options.maximumBytesRead < 1 ||
1740
2081
  options.maximumBytesRead > 8_000_000)
1741
2082
  ) {
1742
- fail("INVALID_ARGUMENT", "maximumBytesRead must be an integer between 1 and 8000000");
2083
+ fail(
2084
+ "INVALID_ARGUMENT",
2085
+ "maximumBytesRead must be an integer between 1 and 8000000",
2086
+ );
1743
2087
  }
1744
2088
  }