@iann29/rastro 0.1.0-alpha.4 → 0.1.0-alpha.6

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 (135) hide show
  1. package/README.md +203 -56
  2. package/agent/integration.md +17 -15
  3. package/agent/manifest.json +6 -4
  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 +486 -31
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +209 -41
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +8 -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 +82 -8
  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 +6 -0
  24. package/dist/component/constants.d.ts.map +1 -1
  25. package/dist/component/constants.js +12 -0
  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 +3 -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 +71 -37
  46. package/dist/component/http.js.map +1 -1
  47. package/dist/component/identity.d.ts +13 -0
  48. package/dist/component/identity.d.ts.map +1 -0
  49. package/dist/component/identity.js +58 -0
  50. package/dist/component/identity.js.map +1 -0
  51. package/dist/component/ingest.d.ts +3 -1
  52. package/dist/component/ingest.d.ts.map +1 -1
  53. package/dist/component/ingest.js +168 -29
  54. package/dist/component/ingest.js.map +1 -1
  55. package/dist/component/live.d.ts.map +1 -1
  56. package/dist/component/live.js +1 -3
  57. package/dist/component/live.js.map +1 -1
  58. package/dist/component/reports.d.ts +70 -4
  59. package/dist/component/reports.d.ts.map +1 -1
  60. package/dist/component/reports.js +232 -59
  61. package/dist/component/reports.js.map +1 -1
  62. package/dist/component/retention.d.ts +4 -4
  63. package/dist/component/retention.d.ts.map +1 -1
  64. package/dist/component/retention.js +54 -22
  65. package/dist/component/retention.js.map +1 -1
  66. package/dist/component/sanitize.d.ts.map +1 -1
  67. package/dist/component/sanitize.js +42 -11
  68. package/dist/component/sanitize.js.map +1 -1
  69. package/dist/component/schema.d.ts +83 -10
  70. package/dist/component/schema.js +34 -1
  71. package/dist/component/schema.js.map +1 -1
  72. package/dist/component/sites.d.ts.map +1 -1
  73. package/dist/component/sites.js +6 -6
  74. package/dist/component/sites.js.map +1 -1
  75. package/dist/component/useragent.d.ts +9 -0
  76. package/dist/component/useragent.d.ts.map +1 -0
  77. package/dist/component/useragent.js +152 -0
  78. package/dist/component/useragent.js.map +1 -0
  79. package/dist/component/validators.d.ts +29 -18
  80. package/dist/component/validators.d.ts.map +1 -1
  81. package/dist/component/validators.js +8 -2
  82. package/dist/component/validators.js.map +1 -1
  83. package/dist/component/visitors.d.ts +19 -0
  84. package/dist/component/visitors.d.ts.map +1 -0
  85. package/dist/component/visitors.js +86 -0
  86. package/dist/component/visitors.js.map +1 -0
  87. package/dist/component/vitals.d.ts +41 -0
  88. package/dist/component/vitals.d.ts.map +1 -0
  89. package/dist/component/vitals.js +115 -0
  90. package/dist/component/vitals.js.map +1 -0
  91. package/dist/react/index.d.ts.map +1 -1
  92. package/dist/react/index.js.map +1 -1
  93. package/dist/tracker/generated.d.ts +8 -4
  94. package/dist/tracker/generated.d.ts.map +1 -1
  95. package/dist/tracker/generated.js +8 -4
  96. package/dist/tracker/generated.js.map +1 -1
  97. package/dist/tracker/tracker.d.ts.map +1 -1
  98. package/dist/tracker/tracker.js +23 -11
  99. package/dist/tracker/tracker.js.map +1 -1
  100. package/dist/tracker/vitals.d.ts +10 -0
  101. package/dist/tracker/vitals.d.ts.map +1 -0
  102. package/dist/tracker/vitals.js +137 -0
  103. package/dist/tracker/vitals.js.map +1 -0
  104. package/dist/tracker.min.js +1 -1
  105. package/dist/vitals.min.js +1 -0
  106. package/docs/benchmarks/2026-08-20-realistic.md +71 -71
  107. package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
  108. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  109. package/docs/federation.md +1 -1
  110. package/package.json +27 -9
  111. package/scripts/benchmark-ingest.mjs +101 -48
  112. package/src/component/_generated/api.ts +8 -0
  113. package/src/component/_generated/component.ts +87 -5
  114. package/src/component/affiliates.ts +20 -5
  115. package/src/component/cardinality.ts +8 -7
  116. package/src/component/constants.ts +12 -0
  117. package/src/component/diagnostics.ts +3 -2
  118. package/src/component/eventStore.ts +53 -46
  119. package/src/component/funnels.ts +19 -16
  120. package/src/component/geo.ts +16 -14
  121. package/src/component/goals.ts +29 -21
  122. package/src/component/guards.ts +3 -1
  123. package/src/component/http.ts +126 -78
  124. package/src/component/identity.ts +74 -0
  125. package/src/component/ingest.ts +349 -130
  126. package/src/component/live.ts +5 -4
  127. package/src/component/reports.ts +572 -221
  128. package/src/component/retention.ts +221 -98
  129. package/src/component/sanitize.ts +77 -27
  130. package/src/component/schema.ts +46 -5
  131. package/src/component/sites.ts +22 -11
  132. package/src/component/useragent.ts +171 -0
  133. package/src/component/validators.ts +19 -7
  134. package/src/component/visitors.ts +116 -0
  135. 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,7 +50,17 @@ import {
48
50
  funnelFieldsValidator,
49
51
  goalFieldsValidator,
50
52
  sessionFieldsValidator,
53
+ vitalMetricValidator,
51
54
  } from "./validators.js";
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";
52
64
 
53
65
  const sessionDocumentValidator = sessionFieldsValidator.extend({
54
66
  _id: v.id("sessions"),
@@ -109,7 +121,9 @@ const metricTotalsValidator = v.object({
109
121
  conversions: v.number(),
110
122
  revenueCents: v.number(),
111
123
  });
112
- const timePointValidator = metricTotalsValidator.extend({ timestamp: v.number() });
124
+ const timePointValidator = metricTotalsValidator.extend({
125
+ timestamp: v.number(),
126
+ });
113
127
  const topItemValidator = v.object({
114
128
  value: v.string(),
115
129
  count: v.number(),
@@ -155,6 +169,7 @@ export const overview = query({
155
169
  totals: metricTotalsValidator,
156
170
  timeSeries: v.array(timePointValidator),
157
171
  topSources: v.array(topItemValidator),
172
+ topCampaigns: v.array(topItemValidator),
158
173
  topPages: v.array(topItemValidator),
159
174
  topCountries: v.array(topItemValidator),
160
175
  topDevices: v.array(topItemValidator),
@@ -174,6 +189,7 @@ export const overview = query({
174
189
  }),
175
190
  breakdowns: v.object({
176
191
  topSources: v.literal("eventVolume"),
192
+ topCampaigns: v.literal("eventVolume"),
177
193
  topPages: v.literal("eventVolume"),
178
194
  topCountries: v.literal("eventVolume"),
179
195
  topDevices: v.literal("eventVolume"),
@@ -187,7 +203,7 @@ export const overview = query({
187
203
  sessionReplay: v.literal("unsupported"),
188
204
  errorInsights: v.literal("unsupported"),
189
205
  outboundLinks: v.literal("eventVolume"),
190
- botDetection: v.literal("unsupported"),
206
+ botDetection: v.literal("userAgent"),
191
207
  }),
192
208
  }),
193
209
  }),
@@ -196,7 +212,8 @@ export const overview = query({
196
212
  const currencies = new Set<string>();
197
213
  for (const siteId of siteIds) {
198
214
  const site = await ctx.db.get("sites", siteId);
199
- if (!site) fail("NOT_FOUND", "site not found", { siteId: String(siteId) });
215
+ if (!site)
216
+ fail("NOT_FOUND", "site not found", { siteId: String(siteId) });
200
217
  currencies.add(site.currency ?? "USD");
201
218
  }
202
219
  if (currencies.size !== 1) {
@@ -205,7 +222,11 @@ export const overview = query({
205
222
  });
206
223
  }
207
224
  const currency = [...currencies][0];
208
- 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
+ );
209
230
  const interval = args.interval ?? (to - from <= DAY_MS ? "hour" : "day");
210
231
  if (interval === "hour" && to - from > DAY_MS) {
211
232
  fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
@@ -215,7 +236,8 @@ export const overview = query({
215
236
  const firstBucket = Math.floor(from / intervalMs) * intervalMs;
216
237
  const lastBucket = Math.floor(to / intervalMs) * intervalMs;
217
238
  const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
218
- const perSiteLimit = bucketCount *
239
+ const perSiteLimit =
240
+ bucketCount *
219
241
  (interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
220
242
  if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
221
243
  fail(
@@ -230,10 +252,14 @@ export const overview = query({
230
252
  let mergedVisitorSketch = visitorSketch;
231
253
  const pointVisitorSketches = new Map<number, VisitorSketch>();
232
254
  let visitorsComplete = true;
233
- const dimensions = new Map<string, { value: string; count: number; revenueCents: number }>();
234
- const overviewDataset = interval === "hour"
235
- ? "overviewHour" as const
236
- : "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);
237
263
 
238
264
  await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
239
265
 
@@ -288,7 +314,10 @@ export const overview = query({
288
314
  [...dimensions.entries()]
289
315
  .filter(([key]) => key.startsWith(`${type}:`))
290
316
  .map(([, value]) => value)
291
- .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
+ )
292
321
  .slice(0, 10);
293
322
  if (visitorsComplete) {
294
323
  totals.visitors = estimateVisitors(mergedVisitorSketch);
@@ -314,6 +343,7 @@ export const overview = query({
314
343
  .sort(([left], [right]) => left - right)
315
344
  .map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
316
345
  topSources: top("source"),
346
+ topCampaigns: top("campaign"),
317
347
  topPages: top("page"),
318
348
  topCountries: top("country"),
319
349
  topDevices: top("device"),
@@ -330,6 +360,7 @@ export const overview = query({
330
360
  },
331
361
  breakdowns: {
332
362
  topSources: "eventVolume" as const,
363
+ topCampaigns: "eventVolume" as const,
333
364
  topPages: "eventVolume" as const,
334
365
  topCountries: "eventVolume" as const,
335
366
  topDevices: "eventVolume" as const,
@@ -343,7 +374,7 @@ export const overview = query({
343
374
  sessionReplay: "unsupported" as const,
344
375
  errorInsights: "unsupported" as const,
345
376
  outboundLinks: "eventVolume" as const,
346
- botDetection: "unsupported" as const,
377
+ botDetection: "userAgent" as const,
347
378
  },
348
379
  },
349
380
  };
@@ -360,8 +391,15 @@ export const liveVisitors = query({
360
391
  handler: async (ctx, args) => {
361
392
  const siteIds = validateSiteIds(args.siteIds);
362
393
  const limit = args.limit ?? 100;
363
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIVE_VISITORS) {
364
- 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
+ );
365
403
  }
366
404
  if (!Number.isFinite(args.now) || args.now < 0) {
367
405
  fail("INVALID_ARGUMENT", "now must be a positive timestamp");
@@ -588,23 +626,37 @@ export const visitorJourney = query({
588
626
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
589
627
  await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
590
628
  const limit = args.limit ?? 200;
591
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
592
- 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
+ );
593
638
  }
594
639
  const rows: RawJourneyEvent[] = [];
595
640
  for (const siteId of siteIds) {
596
- const cutoff = rows.length >= limit
597
- ? rows[limit - 1]!.timestamp
598
- : args.to;
599
- rows.push(...await loadVisitorJourneyEvents(ctx, {
641
+ for (const visitorId of await resolveVisitorIdentities(
642
+ ctx,
600
643
  siteId,
601
- visitorId: args.visitorId,
602
- from: args.from,
603
- to: cutoff,
604
- resultLimit: limit,
605
- }));
606
- rows.sort(compareJourneyEvents);
607
- if (rows.length > limit) rows.length = limit;
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
+ );
657
+ rows.sort(compareJourneyEvents);
658
+ if (rows.length > limit) rows.length = limit;
659
+ }
608
660
  }
609
661
  return hydrateEventContexts(ctx, rows);
610
662
  },
@@ -632,7 +684,10 @@ export const goalsReport = query({
632
684
  .take(MAX_GOALS_PER_SITE + 1);
633
685
  failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
634
686
  const goals = goalRows;
635
- const totals = new Map<string, { completions: number; valueCents: number }>();
687
+ const totals = new Map<
688
+ string,
689
+ { completions: number; valueCents: number }
690
+ >();
636
691
  const useRollups = await dailyRollupsAvailable(
637
692
  ctx,
638
693
  args.siteId,
@@ -689,12 +744,18 @@ export const goalsReport = query({
689
744
  })),
690
745
  truncated: false,
691
746
  metadata: {
692
- basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
693
- coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
694
- datasets: [rollups ? "goalRollups" : "goalCompletions"],
695
- complete: true,
696
- generation: rollups ? 1 : null,
697
- }),
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
+ ),
698
759
  range: rangeSemantics(args.from, args.to),
699
760
  },
700
761
  };
@@ -705,7 +766,12 @@ const funnelReportItemValidator = funnelFieldsValidator.extend({
705
766
  _id: v.id("funnels"),
706
767
  _creationTime: v.number(),
707
768
  stepsReport: v.array(
708
- 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
+ }),
709
775
  ),
710
776
  truncated: v.boolean(),
711
777
  metadata: reportMetadataValidator,
@@ -795,7 +861,9 @@ export const funnelsReport = query({
795
861
  stepsReport,
796
862
  truncated: false,
797
863
  metadata: {
798
- basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
864
+ basis: rollups
865
+ ? ("dailyRollup" as const)
866
+ : ("boundedLegacy" as const),
799
867
  coverage: reportCoverage,
800
868
  range: rangeSemantics(args.from, args.to),
801
869
  },
@@ -835,7 +903,11 @@ export const affiliatesReport = query({
835
903
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
836
904
  .order("desc")
837
905
  .take(MAX_AFFILIATES_PER_SITE + 1);
838
- failIfIncomplete(affiliateRows, MAX_AFFILIATES_PER_SITE, "affiliate definitions");
906
+ failIfIncomplete(
907
+ affiliateRows,
908
+ MAX_AFFILIATES_PER_SITE,
909
+ "affiliate definitions",
910
+ );
839
911
  const affiliates = affiliateRows;
840
912
  const useRollups = await dailyRollupsAvailable(
841
913
  ctx,
@@ -853,16 +925,22 @@ export const affiliatesReport = query({
853
925
  args.to,
854
926
  )
855
927
  : null;
856
- const visits = new Map<string, {
857
- visits: number;
858
- visitors: Set<string>;
859
- sketch: VisitorSketch;
860
- }>();
861
- const totals = new Map<string, {
862
- conversions: number;
863
- revenueCents: number;
864
- commissionCents: number;
865
- }>();
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
+ >();
866
944
  if (rollups) {
867
945
  for (const row of rollups) {
868
946
  const key = String(row.affiliateId);
@@ -953,22 +1031,30 @@ export const affiliatesReport = query({
953
1031
  visits: traffic?.visits ?? 0,
954
1032
  uniqueVisitors: rollups
955
1033
  ? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
956
- : traffic?.visitors.size ?? 0,
1034
+ : (traffic?.visitors.size ?? 0),
957
1035
  };
958
1036
  }),
959
1037
  truncated: false,
960
1038
  metadata: {
961
- basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
962
- coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
963
- datasets: rollups
964
- ? ["affiliateRollups"]
965
- : ["affiliateStats", "sessions"],
966
- complete: true,
967
- generation: rollups ? 1 : null,
968
- }),
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
+ ),
969
1053
  range: rangeSemantics(args.from, args.to),
970
1054
  uniqueVisitors: {
971
- basis: rollups ? "estimatedUnique" as const : "exactLegacy" as const,
1055
+ basis: rollups
1056
+ ? ("estimatedUnique" as const)
1057
+ : ("exactLegacy" as const),
972
1058
  exact: !rollups,
973
1059
  },
974
1060
  },
@@ -976,6 +1062,153 @@ export const affiliatesReport = query({
976
1062
  },
977
1063
  });
978
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
+
979
1212
  const coverageDatasetValidator = v.union(
980
1213
  v.literal("overviewHour"),
981
1214
  v.literal("overviewDay"),
@@ -985,6 +1218,7 @@ const coverageDatasetValidator = v.union(
985
1218
  v.literal("goals"),
986
1219
  v.literal("funnels"),
987
1220
  v.literal("affiliates"),
1221
+ v.literal("vitals"),
988
1222
  );
989
1223
 
990
1224
  export const dataCoverage = query({
@@ -997,10 +1231,12 @@ export const dataCoverage = query({
997
1231
  siteId: v.id("sites"),
998
1232
  from: v.number(),
999
1233
  to: v.number(),
1000
- datasets: v.array(v.object({
1001
- dataset: coverageDatasetValidator,
1002
- coverage: resultCoverageValidator,
1003
- })),
1234
+ datasets: v.array(
1235
+ v.object({
1236
+ dataset: coverageDatasetValidator,
1237
+ coverage: resultCoverageValidator,
1238
+ }),
1239
+ ),
1004
1240
  }),
1005
1241
  handler: async (ctx, args) => {
1006
1242
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
@@ -1013,21 +1249,24 @@ export const dataCoverage = query({
1013
1249
  "goals",
1014
1250
  "funnels",
1015
1251
  "affiliates",
1252
+ "vitals",
1016
1253
  ] as const;
1017
1254
  return {
1018
1255
  siteId: args.siteId,
1019
1256
  from: args.from,
1020
1257
  to: args.to,
1021
- datasets: await Promise.all(datasets.map(async (dataset) => ({
1022
- dataset,
1023
- coverage: await datasetCoverageForRange(
1024
- ctx,
1025
- args.siteId,
1258
+ datasets: await Promise.all(
1259
+ datasets.map(async (dataset) => ({
1026
1260
  dataset,
1027
- args.from,
1028
- args.to,
1029
- ),
1030
- }))),
1261
+ coverage: await datasetCoverageForRange(
1262
+ ctx,
1263
+ args.siteId,
1264
+ dataset,
1265
+ args.from,
1266
+ args.to,
1267
+ ),
1268
+ })),
1269
+ ),
1031
1270
  };
1032
1271
  },
1033
1272
  });
@@ -1137,7 +1376,11 @@ async function loadAffiliateDailyRollups(
1137
1376
  .lte("bucketStart", to),
1138
1377
  )
1139
1378
  .take(perAffiliateLimit + 1);
1140
- failIfIncomplete(affiliateRows, perAffiliateLimit, "affiliate daily rollups");
1379
+ failIfIncomplete(
1380
+ affiliateRows,
1381
+ perAffiliateLimit,
1382
+ "affiliate daily rollups",
1383
+ );
1141
1384
  rows.push(...affiliateRows);
1142
1385
  await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
1143
1386
  }
@@ -1165,20 +1408,28 @@ async function requireFeatureReadHeadroom(ctx: QueryCtx, dataset: string) {
1165
1408
  metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
1166
1409
  metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE
1167
1410
  ) {
1168
- fail("REPORT_INCOMPLETE", `${dataset} exceeds its byte or document budget`, {
1169
- dataset,
1170
- bytesRead: metrics.bytesRead.used,
1171
- documentsRead: metrics.documentsRead.used,
1172
- });
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
+ );
1173
1420
  }
1174
1421
  }
1175
1422
 
1176
1423
  function failIfIncomplete<T>(rows: T[], maximum: number, dataset: string) {
1177
1424
  if (rows.length > maximum) {
1178
- fail("REPORT_INCOMPLETE", `${dataset} exceeds the complete report read budget`, {
1179
- maximumRows: maximum,
1180
- dataset,
1181
- });
1425
+ fail(
1426
+ "REPORT_INCOMPLETE",
1427
+ `${dataset} exceeds the complete report read budget`,
1428
+ {
1429
+ maximumRows: maximum,
1430
+ dataset,
1431
+ },
1432
+ );
1182
1433
  }
1183
1434
  }
1184
1435
 
@@ -1193,45 +1444,52 @@ async function coverageForRange(
1193
1444
  generation?: 1 | null;
1194
1445
  },
1195
1446
  ) {
1196
- const controls = await Promise.all(siteIds.map((siteId) =>
1197
- ctx.db
1198
- .query("analyticsCoverage")
1199
- .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1200
- .unique()
1201
- ));
1202
- const sourceRanges = await Promise.all(siteIds.map((siteId) =>
1203
- sourceSetRange(ctx, siteId, options.datasets)
1204
- ));
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
+ );
1205
1458
  const retainedBefore = controls.reduce<number | null>((latest, control) => {
1206
1459
  const before = control?.retained
1207
- .filter((item) => options.datasets.some((dataset) => dataset === item.dataset))
1460
+ .filter((item) =>
1461
+ options.datasets.some((dataset) => dataset === item.dataset),
1462
+ )
1208
1463
  .reduce<number | undefined>(
1209
1464
  (latest, item) => Math.max(latest ?? 0, item.before),
1210
1465
  undefined,
1211
1466
  );
1212
1467
  return before === undefined ? latest : Math.max(latest ?? 0, before);
1213
1468
  }, null);
1214
- const availableFrom = options.generation === 1
1215
- ? controls.length > 0 && controls.every(Boolean)
1216
- ? Math.max(...controls.map((control) => control!.rollupCompleteFrom))
1217
- : null
1218
- : sourceRanges.length > 0 && sourceRanges.every(Boolean)
1219
- ? 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))
1220
1480
  : null;
1221
- const availableThrough = sourceRanges.length > 0 && sourceRanges.every(Boolean)
1222
- ? Math.min(...sourceRanges.map((range) => range!.through))
1223
- : null;
1224
1481
  let state: "complete" | "partial" | "retained" | "unavailable";
1225
1482
  if (retainedBefore !== null && to < retainedBefore) {
1226
1483
  state = "unavailable";
1227
1484
  } else if (retainedBefore !== null && from < retainedBefore) {
1228
1485
  state = "partial";
1229
- } else if (options.complete || (
1230
- availableFrom !== null &&
1231
- availableThrough !== null &&
1232
- from >= availableFrom &&
1233
- to <= availableThrough
1234
- )) {
1486
+ } else if (
1487
+ options.complete ||
1488
+ (availableFrom !== null &&
1489
+ availableThrough !== null &&
1490
+ from >= availableFrom &&
1491
+ to <= availableThrough)
1492
+ ) {
1235
1493
  state = "complete";
1236
1494
  } else if (sourceRanges.some(Boolean)) {
1237
1495
  state = "retained";
@@ -1253,12 +1511,14 @@ async function assertSourceAvailable(
1253
1511
  sources: CoverageSource[],
1254
1512
  from: number,
1255
1513
  ) {
1256
- const rows = await Promise.all(siteIds.map((siteId) =>
1257
- ctx.db
1258
- .query("analyticsCoverage")
1259
- .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1260
- .unique()
1261
- ));
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
+ );
1262
1522
  const cutoff = rows.reduce<number | null>((latest, row) => {
1263
1523
  for (const retained of row?.retained ?? []) {
1264
1524
  if (sources.some((source) => source === retained.dataset)) {
@@ -1268,9 +1528,13 @@ async function assertSourceAvailable(
1268
1528
  return latest;
1269
1529
  }, null);
1270
1530
  if (cutoff !== null && from < cutoff) {
1271
- fail("REPORT_INCOMPLETE", "requested report range intersects retained data", {
1272
- retainedBefore: cutoff,
1273
- });
1531
+ fail(
1532
+ "REPORT_INCOMPLETE",
1533
+ "requested report range intersects retained data",
1534
+ {
1535
+ retainedBefore: cutoff,
1536
+ },
1537
+ );
1274
1538
  }
1275
1539
  }
1276
1540
 
@@ -1281,31 +1545,42 @@ async function datasetCoverageForRange(
1281
1545
  from: number,
1282
1546
  to: number,
1283
1547
  ) {
1284
- const sourceOptions: CoverageSource[][] = dataset === "goals"
1285
- ? [["goalRollups"], ["goalCompletions"]]
1286
- : dataset === "funnels"
1287
- ? [["funnelRollups"], ["funnelSteps"]]
1288
- : dataset === "affiliates"
1289
- ? [["affiliateRollups"], ["affiliateStats", "sessions"]]
1290
- : [[dataset]];
1291
- const options = (await Promise.all(sourceOptions.map(async (sources) => {
1292
- const rollupDataset = sources[0] as
1293
- | "goalRollups"
1294
- | "funnelRollups"
1295
- | "affiliateRollups";
1296
- const isRollup = rollupDataset.endsWith("Rollups");
1297
- const complete = isRollup
1298
- ? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
1299
- : false;
1300
- 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") {
1301
1551
  return coverageForRange(ctx, [siteId], from, to, {
1302
- datasets: sources,
1303
- complete,
1304
- generation: complete ? 1 : null,
1552
+ datasets: ["vitalRollups"],
1553
+ complete: false,
1305
1554
  });
1306
- }))).filter((option): option is NonNullable<typeof option> => option !== null);
1307
- return options.sort((left, right) =>
1308
- 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),
1309
1584
  )[0]!;
1310
1585
  }
1311
1586
 
@@ -1317,7 +1592,8 @@ type PublicCoverageDataset =
1317
1592
  | "conversions"
1318
1593
  | "goals"
1319
1594
  | "funnels"
1320
- | "affiliates";
1595
+ | "affiliates"
1596
+ | "vitals";
1321
1597
 
1322
1598
  type CoverageSource =
1323
1599
  | "overviewHour"
@@ -1330,7 +1606,8 @@ type CoverageSource =
1330
1606
  | "affiliateStats"
1331
1607
  | "goalRollups"
1332
1608
  | "funnelRollups"
1333
- | "affiliateRollups";
1609
+ | "affiliateRollups"
1610
+ | "vitalRollups";
1334
1611
 
1335
1612
  type SourceRange = { from: number; through: number };
1336
1613
 
@@ -1339,9 +1616,9 @@ async function sourceSetRange(
1339
1616
  siteId: Id<"sites">,
1340
1617
  sources: CoverageSource[],
1341
1618
  ): Promise<SourceRange | null> {
1342
- const ranges = (await Promise.all(
1343
- sources.map((source) => sourceRange(ctx, siteId, source)),
1344
- )).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);
1345
1622
  if (ranges.length === 0) return null;
1346
1623
  return {
1347
1624
  from: Math.min(...ranges.map((range) => range.from)),
@@ -1355,79 +1632,129 @@ async function sourceRange(
1355
1632
  source: CoverageSource,
1356
1633
  ): Promise<SourceRange | null> {
1357
1634
  if (source === "overviewHour" || source === "overviewDay") {
1358
- const granularity = source === "overviewHour" ? "hour" as const : "day" as const;
1635
+ const granularity =
1636
+ source === "overviewHour" ? ("hour" as const) : ("day" as const);
1359
1637
  const [first, last] = await Promise.all([
1360
- ctx.db.query("aggregateBuckets")
1638
+ ctx.db
1639
+ .query("aggregateBuckets")
1361
1640
  .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
1362
1641
  range.eq("siteId", siteId).eq("granularity", granularity),
1363
- ).order("asc").take(1),
1364
- ctx.db.query("aggregateBuckets")
1642
+ )
1643
+ .order("asc")
1644
+ .take(1),
1645
+ ctx.db
1646
+ .query("aggregateBuckets")
1365
1647
  .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
1366
1648
  range.eq("siteId", siteId).eq("granularity", granularity),
1367
- ).order("desc").take(1),
1649
+ )
1650
+ .order("desc")
1651
+ .take(1),
1368
1652
  ]);
1369
1653
  return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1370
1654
  }
1371
1655
  if (source === "events") {
1372
1656
  const [first, last] = await Promise.all([
1373
- ctx.db.query("eventBatches")
1374
- .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1375
- .order("asc").take(1),
1376
- ctx.db.query("eventBatches")
1377
- .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1378
- .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),
1379
1671
  ]);
1380
1672
  return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1381
1673
  }
1382
1674
  if (source === "sessions") {
1383
1675
  const [first, last] = await Promise.all([
1384
- ctx.db.query("sessions")
1385
- .withIndex("by_siteId_and_startedAt", (range) => range.eq("siteId", siteId))
1386
- .order("asc").take(1),
1387
- ctx.db.query("sessions")
1388
- .withIndex("by_siteId_and_lastSeenAt", (range) => range.eq("siteId", siteId))
1389
- .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),
1390
1690
  ]);
1391
1691
  return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
1392
1692
  }
1393
1693
  if (source === "conversions") {
1394
1694
  const [first, last] = await Promise.all([
1395
- ctx.db.query("conversions")
1396
- .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1397
- .order("asc").take(1),
1398
- ctx.db.query("conversions")
1399
- .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1400
- .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),
1401
1709
  ]);
1402
1710
  return bounds(first[0]?.timestamp, last[0]?.timestamp);
1403
1711
  }
1404
1712
  if (source === "goalCompletions") {
1405
1713
  const [first, last] = await Promise.all([
1406
- ctx.db.query("goalCompletions")
1407
- .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1408
- .order("asc").take(1),
1409
- ctx.db.query("goalCompletions")
1410
- .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1411
- .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),
1412
1728
  ]);
1413
1729
  return bounds(first[0]?.timestamp, last[0]?.timestamp);
1414
1730
  }
1415
- const table = source === "funnelSteps"
1416
- ? "funnelStepBuckets" as const
1417
- : source === "affiliateStats"
1418
- ? "affiliateDailyStats" as const
1419
- : source === "goalRollups"
1420
- ? "goalDailyRollups" as const
1421
- : source === "funnelRollups"
1422
- ? "funnelDailyRollups" as const
1423
- : "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);
1424
1743
  const [first, last] = await Promise.all([
1425
- ctx.db.query(table)
1426
- .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1427
- .order("asc").take(1),
1428
- ctx.db.query(table)
1429
- .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1430
- .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),
1431
1758
  ]);
1432
1759
  return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1433
1760
  }
@@ -1436,14 +1763,15 @@ function bounds(from: number | undefined, through: number | undefined) {
1436
1763
  return from === undefined || through === undefined ? null : { from, through };
1437
1764
  }
1438
1765
 
1439
- function coverageRank(state: "complete" | "partial" | "retained" | "unavailable") {
1766
+ function coverageRank(
1767
+ state: "complete" | "partial" | "retained" | "unavailable",
1768
+ ) {
1440
1769
  if (state === "complete") return 3;
1441
1770
  if (state === "retained") return 2;
1442
1771
  if (state === "partial") return 1;
1443
1772
  return 0;
1444
1773
  }
1445
1774
 
1446
-
1447
1775
  type MetricTotals = {
1448
1776
  events: number;
1449
1777
  pageviews: number;
@@ -1484,8 +1812,15 @@ function addMetrics(target: MetricTotals, source: MetricTotals) {
1484
1812
 
1485
1813
  function validateSiteIds<T>(siteIds: T[]): T[] {
1486
1814
  const unique = [...new Set(siteIds)];
1487
- if (unique.length < 1 || unique.length > MAX_REPORT_SITES || unique.length !== siteIds.length) {
1488
- 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
+ );
1489
1824
  }
1490
1825
  return unique;
1491
1826
  }
@@ -1660,22 +1995,23 @@ function keysetPaginationResult<T>(
1660
1995
  ) {
1661
1996
  const page = rows.slice(0, pagination.numItems);
1662
1997
  const isDone = rows.length <= pagination.numItems;
1663
- const next = page.length > 0
1664
- ? cursorFor(page[page.length - 1])
1665
- : pagination.cursor;
1998
+ const next =
1999
+ page.length > 0 ? cursorFor(page[page.length - 1]) : pagination.cursor;
1666
2000
  return {
1667
2001
  page,
1668
2002
  isDone,
1669
- continueCursor: btoa(JSON.stringify({
1670
- v: 2,
1671
- k: pagination.scope.kind,
1672
- s: pagination.scope.siteId,
1673
- n: pagination.scope.sessionId ?? null,
1674
- f: pagination.scope.from,
1675
- t: pagination.scope.to,
1676
- x: next?.timestamp ?? pagination.scope.to,
1677
- c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
1678
- })),
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
+ ),
1679
2015
  };
1680
2016
  }
1681
2017
 
@@ -1687,26 +2023,28 @@ function journeyPaginationResult(
1687
2023
  const isDone = rows.length <= pagination.numItems;
1688
2024
  const last = page.at(-1);
1689
2025
  const next = last
1690
- ? {
1691
- timestamp: last.timestamp,
1692
- creationTime: last._creationTime,
1693
- id: last._id,
2026
+ ? {
2027
+ timestamp: last.timestamp,
2028
+ creationTime: last._creationTime,
2029
+ id: last._id,
1694
2030
  }
1695
2031
  : pagination.cursor;
1696
2032
  return {
1697
2033
  page,
1698
2034
  isDone,
1699
- continueCursor: btoa(JSON.stringify({
1700
- v: 4,
1701
- k: pagination.scope.kind,
1702
- s: pagination.scope.siteId,
1703
- n: pagination.scope.sessionId,
1704
- f: pagination.scope.from,
1705
- t: pagination.scope.to,
1706
- x: next?.timestamp ?? pagination.scope.to,
1707
- c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
1708
- i: next?.id ?? "~",
1709
- })),
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
+ ),
1710
2048
  };
1711
2049
  }
1712
2050
 
@@ -1715,8 +2053,15 @@ function validatePagination(options: {
1715
2053
  maximumRowsRead?: number;
1716
2054
  maximumBytesRead?: number;
1717
2055
  }) {
1718
- if (!Number.isSafeInteger(options.numItems) || options.numItems < 1 || options.numItems > 100) {
1719
- 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
+ );
1720
2065
  }
1721
2066
  if (
1722
2067
  options.maximumRowsRead !== undefined &&
@@ -1724,7 +2069,10 @@ function validatePagination(options: {
1724
2069
  options.maximumRowsRead < 1 ||
1725
2070
  options.maximumRowsRead > 1_000)
1726
2071
  ) {
1727
- 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
+ );
1728
2076
  }
1729
2077
  if (
1730
2078
  options.maximumBytesRead !== undefined &&
@@ -1732,6 +2080,9 @@ function validatePagination(options: {
1732
2080
  options.maximumBytesRead < 1 ||
1733
2081
  options.maximumBytesRead > 8_000_000)
1734
2082
  ) {
1735
- 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
+ );
1736
2087
  }
1737
2088
  }