@iann29/rastro 0.1.0-alpha.1 → 0.1.0-alpha.10

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 (163) hide show
  1. package/README.md +521 -66
  2. package/agent/integration.md +661 -0
  3. package/agent/manifest.json +187 -0
  4. package/agent/manifest.schema.json +411 -0
  5. package/dist/client/federation.d.ts +236 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +196 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2467 -18
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +461 -32
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +16 -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 +302 -4
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +4 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/affiliates.d.ts.map +1 -1
  22. package/dist/component/affiliates.js +6 -2
  23. package/dist/component/affiliates.js.map +1 -1
  24. package/dist/component/cardinality.d.ts +12 -0
  25. package/dist/component/cardinality.d.ts.map +1 -0
  26. package/dist/component/cardinality.js +94 -0
  27. package/dist/component/cardinality.js.map +1 -0
  28. package/dist/component/constants.d.ts +25 -1
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +38 -1
  31. package/dist/component/constants.js.map +1 -1
  32. package/dist/component/convex.config.d.ts +6 -1
  33. package/dist/component/convex.config.js +9 -1
  34. package/dist/component/convex.config.js.map +1 -1
  35. package/dist/component/coverage.d.ts +19 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +30 -0
  38. package/dist/component/coverage.js.map +1 -0
  39. package/dist/component/diagnostics.d.ts +9 -0
  40. package/dist/component/diagnostics.d.ts.map +1 -0
  41. package/dist/component/diagnostics.js +47 -0
  42. package/dist/component/diagnostics.js.map +1 -0
  43. package/dist/component/errors.d.ts +1 -1
  44. package/dist/component/errors.d.ts.map +1 -1
  45. package/dist/component/errors.js.map +1 -1
  46. package/dist/component/eventStore.d.ts +21 -9
  47. package/dist/component/eventStore.d.ts.map +1 -1
  48. package/dist/component/eventStore.js +142 -152
  49. package/dist/component/eventStore.js.map +1 -1
  50. package/dist/component/funnels.d.ts.map +1 -1
  51. package/dist/component/funnels.js +5 -3
  52. package/dist/component/funnels.js.map +1 -1
  53. package/dist/component/geo.d.ts +71 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +611 -0
  56. package/dist/component/geo.js.map +1 -0
  57. package/dist/component/goals.d.ts.map +1 -1
  58. package/dist/component/goals.js +8 -5
  59. package/dist/component/goals.js.map +1 -1
  60. package/dist/component/guards.d.ts.map +1 -1
  61. package/dist/component/guards.js.map +1 -1
  62. package/dist/component/http.d.ts.map +1 -1
  63. package/dist/component/http.js +248 -49
  64. package/dist/component/http.js.map +1 -1
  65. package/dist/component/identity.d.ts +13 -0
  66. package/dist/component/identity.d.ts.map +1 -0
  67. package/dist/component/identity.js +58 -0
  68. package/dist/component/identity.js.map +1 -0
  69. package/dist/component/ingest.d.ts +3 -1
  70. package/dist/component/ingest.d.ts.map +1 -1
  71. package/dist/component/ingest.js +604 -55
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts.map +1 -1
  74. package/dist/component/live.js +7 -6
  75. package/dist/component/live.js.map +1 -1
  76. package/dist/component/reports.d.ts +259 -10
  77. package/dist/component/reports.d.ts.map +1 -1
  78. package/dist/component/reports.js +1110 -124
  79. package/dist/component/reports.js.map +1 -1
  80. package/dist/component/retention.d.ts +75 -1
  81. package/dist/component/retention.d.ts.map +1 -1
  82. package/dist/component/retention.js +537 -32
  83. package/dist/component/retention.js.map +1 -1
  84. package/dist/component/sanitize.d.ts +24 -1
  85. package/dist/component/sanitize.d.ts.map +1 -1
  86. package/dist/component/sanitize.js +96 -16
  87. package/dist/component/sanitize.js.map +1 -1
  88. package/dist/component/schema.d.ts +378 -62
  89. package/dist/component/schema.js +178 -20
  90. package/dist/component/schema.js.map +1 -1
  91. package/dist/component/sites.d.ts.map +1 -1
  92. package/dist/component/sites.js +11 -7
  93. package/dist/component/sites.js.map +1 -1
  94. package/dist/component/useragent.d.ts +9 -0
  95. package/dist/component/useragent.d.ts.map +1 -0
  96. package/dist/component/useragent.js +152 -0
  97. package/dist/component/useragent.js.map +1 -0
  98. package/dist/component/validators.d.ts +101 -65
  99. package/dist/component/validators.d.ts.map +1 -1
  100. package/dist/component/validators.js +19 -13
  101. package/dist/component/validators.js.map +1 -1
  102. package/dist/component/visitors.d.ts +19 -0
  103. package/dist/component/visitors.d.ts.map +1 -0
  104. package/dist/component/visitors.js +86 -0
  105. package/dist/component/visitors.js.map +1 -0
  106. package/dist/component/vitals.d.ts +41 -0
  107. package/dist/component/vitals.d.ts.map +1 -0
  108. package/dist/component/vitals.js +115 -0
  109. package/dist/component/vitals.js.map +1 -0
  110. package/dist/react/index.d.ts.map +1 -1
  111. package/dist/react/index.js.map +1 -1
  112. package/dist/tracker/generated.d.ts +8 -4
  113. package/dist/tracker/generated.d.ts.map +1 -1
  114. package/dist/tracker/generated.js +8 -4
  115. package/dist/tracker/generated.js.map +1 -1
  116. package/dist/tracker/tracker.d.ts +1 -1
  117. package/dist/tracker/tracker.d.ts.map +1 -1
  118. package/dist/tracker/tracker.js +57 -20
  119. package/dist/tracker/tracker.js.map +1 -1
  120. package/dist/tracker/vitals.d.ts +10 -0
  121. package/dist/tracker/vitals.d.ts.map +1 -0
  122. package/dist/tracker/vitals.js +140 -0
  123. package/dist/tracker/vitals.js.map +1 -0
  124. package/dist/tracker.min.js +1 -1
  125. package/dist/vitals.min.js +1 -0
  126. package/docs/benchmarks/2026-08-20-realistic.md +76 -76
  127. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  128. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  129. package/docs/federation-setup.md +397 -0
  130. package/docs/federation.md +262 -0
  131. package/docs/upgrading.md +259 -0
  132. package/llms.txt +71 -0
  133. package/package.json +55 -11
  134. package/scripts/benchmark-ingest.mjs +175 -73
  135. package/scripts/generate-federation-keys.mjs +20 -0
  136. package/src/component/_generated/api.ts +16 -0
  137. package/src/component/_generated/component.ts +366 -4
  138. package/src/component/_generated/server.ts +4 -0
  139. package/src/component/affiliates.ts +20 -5
  140. package/src/component/cardinality.ts +117 -0
  141. package/src/component/constants.ts +38 -1
  142. package/src/component/convex.config.ts +11 -1
  143. package/src/component/coverage.ts +36 -0
  144. package/src/component/diagnostics.ts +65 -0
  145. package/src/component/errors.ts +2 -1
  146. package/src/component/eventStore.ts +217 -193
  147. package/src/component/funnels.ts +19 -16
  148. package/src/component/geo.ts +781 -0
  149. package/src/component/goals.ts +29 -21
  150. package/src/component/guards.ts +3 -1
  151. package/src/component/http.ts +357 -57
  152. package/src/component/identity.ts +74 -0
  153. package/src/component/ingest.ts +1040 -135
  154. package/src/component/live.ts +13 -7
  155. package/src/component/reports.ts +1675 -183
  156. package/src/component/retention.ts +770 -96
  157. package/src/component/sanitize.ts +144 -29
  158. package/src/component/schema.ts +200 -21
  159. package/src/component/sites.ts +27 -12
  160. package/src/component/useragent.ts +171 -0
  161. package/src/component/validators.ts +29 -13
  162. package/src/component/visitors.ts +116 -0
  163. package/src/component/vitals.ts +146 -0
@@ -3,28 +3,48 @@ import {
3
3
  paginationResultValidator,
4
4
  } from "convex/server";
5
5
  import { v } from "convex/values";
6
- import type { Doc } from "./_generated/dataModel.js";
6
+ import type { Doc, Id } from "./_generated/dataModel.js";
7
+ import type { QueryCtx } from "./_generated/server.js";
7
8
  import { query } from "./_generated/server.js";
8
9
  import {
9
10
  AGGREGATE_SHARDS,
10
11
  DAY_MS,
12
+ FEATURE_REPORT_BYTE_RESERVE,
13
+ FEATURE_REPORT_DOCUMENT_RESERVE,
11
14
  HOUR_MS,
12
15
  HOURLY_AGGREGATE_SHARDS,
13
16
  MAX_AFFILIATES_PER_SITE,
14
17
  MAX_FUNNELS_PER_SITE,
18
+ MAX_FEATURE_ROLLUP_DAYS,
19
+ MAX_FEATURE_ROLLUP_ROWS,
15
20
  MAX_GOALS_PER_SITE,
16
21
  MAX_JOURNEY_EVENTS,
17
22
  MAX_LIVE_VISITORS,
18
23
  MAX_OVERVIEW_AGGREGATE_ROWS,
24
+ MAX_REPORT_BUCKETS,
19
25
  MAX_REPORT_RANGE_DAYS,
20
26
  MAX_REPORT_SITES,
27
+ MAX_SITE_MAP_ROUTES_PER_DAY,
28
+ MAX_SITE_MAP_ROWS,
29
+ MAX_VITALS_REPORT_ROWS,
30
+ REPORT_ROLLUP_SHARDS,
31
+ ROUTE_TRANSITION_SLOTS,
32
+ SITE_MAP_OTHER_ROUTE,
33
+ VITALS_REPORT_PAGE_LIMIT,
21
34
  } from "./constants.js";
35
+ import {
36
+ createVisitorSketch,
37
+ estimateVisitors,
38
+ unionVisitorSketches,
39
+ type VisitorSketch,
40
+ } from "./cardinality.js";
22
41
  import { fail } from "./errors.js";
23
42
  import {
24
43
  compareJourneyEvents,
25
44
  hydrateEventContexts,
26
45
  type JourneyCursor,
27
46
  loadSessionJourneyEvents,
47
+ loadVisitorJourneyEvents,
28
48
  type RawJourneyEvent,
29
49
  } from "./eventStore.js";
30
50
  import { isPlainRecord } from "./guards.js";
@@ -34,7 +54,17 @@ import {
34
54
  funnelFieldsValidator,
35
55
  goalFieldsValidator,
36
56
  sessionFieldsValidator,
57
+ vitalMetricValidator,
37
58
  } from "./validators.js";
59
+ import { resolveVisitorIdentities } from "./visitors.js";
60
+ import {
61
+ mergeVitalHistograms,
62
+ VITAL_ALL,
63
+ VITAL_METRICS,
64
+ vitalHistogramPercentile,
65
+ vitalRatingCounts,
66
+ type VitalMetric,
67
+ } from "./vitals.js";
38
68
 
39
69
  const sessionDocumentValidator = sessionFieldsValidator.extend({
40
70
  _id: v.id("sessions"),
@@ -64,6 +94,8 @@ const liveSessionDocumentValidator = v.object({
64
94
  device: v.string(),
65
95
  pageviewCount: v.number(),
66
96
  eventCount: v.number(),
97
+ previousPath: v.optional(v.string()),
98
+ movedAt: v.optional(v.number()),
67
99
  });
68
100
  const conversionDocumentValidator = v.object({
69
101
  _id: v.id("conversions"),
@@ -95,12 +127,38 @@ const metricTotalsValidator = v.object({
95
127
  conversions: v.number(),
96
128
  revenueCents: v.number(),
97
129
  });
98
- const timePointValidator = metricTotalsValidator.extend({ timestamp: v.number() });
130
+ const timePointValidator = metricTotalsValidator.extend({
131
+ timestamp: v.number(),
132
+ });
99
133
  const topItemValidator = v.object({
100
134
  value: v.string(),
101
135
  count: v.number(),
102
136
  revenueCents: v.number(),
103
137
  });
138
+ const coverageStateValidator = v.union(
139
+ v.literal("complete"),
140
+ v.literal("partial"),
141
+ v.literal("retained"),
142
+ v.literal("unavailable"),
143
+ );
144
+ const resultCoverageValidator = v.object({
145
+ state: coverageStateValidator,
146
+ generation: v.union(v.number(), v.null()),
147
+ availableFrom: v.union(v.number(), v.null()),
148
+ availableThrough: v.union(v.number(), v.null()),
149
+ retainedBefore: v.union(v.number(), v.null()),
150
+ });
151
+ const rangeSemanticsValidator = v.object({
152
+ from: v.number(),
153
+ to: v.number(),
154
+ boundary: v.literal("inclusive"),
155
+ timezone: v.literal("UTC"),
156
+ });
157
+ const reportMetadataValidator = v.object({
158
+ basis: v.union(v.literal("dailyRollup"), v.literal("boundedLegacy")),
159
+ coverage: resultCoverageValidator,
160
+ range: rangeSemanticsValidator,
161
+ });
104
162
 
105
163
  export const overview = query({
106
164
  args: {
@@ -117,19 +175,51 @@ export const overview = query({
117
175
  totals: metricTotalsValidator,
118
176
  timeSeries: v.array(timePointValidator),
119
177
  topSources: v.array(topItemValidator),
178
+ topCampaigns: v.array(topItemValidator),
120
179
  topPages: v.array(topItemValidator),
121
180
  topCountries: v.array(topItemValidator),
122
181
  topDevices: v.array(topItemValidator),
123
182
  topBrowsers: v.array(topItemValidator),
124
183
  topEvents: v.array(topItemValidator),
125
184
  topGoals: v.array(topItemValidator),
185
+ metadata: v.object({
186
+ visitors: v.object({
187
+ basis: v.union(
188
+ v.literal("estimatedUnique"),
189
+ v.literal("bucketOccurrences"),
190
+ ),
191
+ exact: v.boolean(),
192
+ legacyIncomplete: v.boolean(),
193
+ sketchVersion: v.union(v.number(), v.null()),
194
+ precision: v.union(v.number(), v.null()),
195
+ }),
196
+ breakdowns: v.object({
197
+ topSources: v.literal("eventVolume"),
198
+ topCampaigns: v.literal("eventVolume"),
199
+ topPages: v.literal("eventVolume"),
200
+ topCountries: v.literal("eventVolume"),
201
+ topDevices: v.literal("eventVolume"),
202
+ topBrowsers: v.literal("eventVolume"),
203
+ topEvents: v.literal("eventVolume"),
204
+ topGoals: v.literal("goalCompletionVolume"),
205
+ }),
206
+ coverage: resultCoverageValidator,
207
+ range: rangeSemanticsValidator,
208
+ features: v.object({
209
+ sessionReplay: v.literal("unsupported"),
210
+ errorInsights: v.literal("unsupported"),
211
+ outboundLinks: v.literal("eventVolume"),
212
+ botDetection: v.literal("userAgent"),
213
+ }),
214
+ }),
126
215
  }),
127
216
  handler: async (ctx, args) => {
128
217
  const siteIds = validateSiteIds(args.siteIds);
129
218
  const currencies = new Set<string>();
130
219
  for (const siteId of siteIds) {
131
220
  const site = await ctx.db.get("sites", siteId);
132
- if (!site) fail("NOT_FOUND", "site not found", { siteId: String(siteId) });
221
+ if (!site)
222
+ fail("NOT_FOUND", "site not found", { siteId: String(siteId) });
133
223
  currencies.add(site.currency ?? "USD");
134
224
  }
135
225
  if (currencies.size !== 1) {
@@ -138,16 +228,22 @@ export const overview = query({
138
228
  });
139
229
  }
140
230
  const currency = [...currencies][0];
141
- const { from, to } = validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
231
+ const { from, to } = validateRange(
232
+ args.from,
233
+ args.to,
234
+ MAX_REPORT_RANGE_DAYS,
235
+ );
142
236
  const interval = args.interval ?? (to - from <= DAY_MS ? "hour" : "day");
143
237
  if (interval === "hour" && to - from > DAY_MS) {
144
238
  fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
145
239
  }
146
240
  const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
241
+ requireAlignedRange(from, to, intervalMs, `${interval} overview`);
147
242
  const firstBucket = Math.floor(from / intervalMs) * intervalMs;
148
243
  const lastBucket = Math.floor(to / intervalMs) * intervalMs;
149
244
  const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
150
- const perSiteLimit = bucketCount *
245
+ const perSiteLimit =
246
+ bucketCount *
151
247
  (interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
152
248
  if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
153
249
  fail(
@@ -158,7 +254,20 @@ export const overview = query({
158
254
  }
159
255
  const totals = emptyTotals();
160
256
  const points = new Map<number, MetricTotals>();
161
- const dimensions = new Map<string, { value: string; count: number; revenueCents: number }>();
257
+ const visitorSketch = createVisitorSketch();
258
+ let mergedVisitorSketch = visitorSketch;
259
+ const pointVisitorSketches = new Map<number, VisitorSketch>();
260
+ let visitorsComplete = true;
261
+ const dimensions = new Map<
262
+ string,
263
+ { value: string; count: number; revenueCents: number }
264
+ >();
265
+ const overviewDataset =
266
+ interval === "hour"
267
+ ? ("overviewHour" as const)
268
+ : ("overviewDay" as const);
269
+
270
+ await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
162
271
 
163
272
  for (const siteId of siteIds) {
164
273
  const buckets = await ctx.db
@@ -170,12 +279,29 @@ export const overview = query({
170
279
  .gte("bucketStart", firstBucket)
171
280
  .lte("bucketStart", lastBucket),
172
281
  )
173
- .take(perSiteLimit);
282
+ .take(perSiteLimit + 1);
283
+ failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
174
284
  for (const bucket of buckets) {
175
285
  addMetrics(totals, bucket);
176
286
  const point = points.get(bucket.bucketStart) ?? emptyTotals();
177
287
  addMetrics(point, bucket);
178
288
  points.set(bucket.bucketStart, point);
289
+ if (bucket.visitorSketch && bucket.visitorSketchComplete === true) {
290
+ mergedVisitorSketch = unionVisitorSketches(
291
+ mergedVisitorSketch,
292
+ bucket.visitorSketch,
293
+ );
294
+ pointVisitorSketches.set(
295
+ bucket.bucketStart,
296
+ unionVisitorSketches(
297
+ pointVisitorSketches.get(bucket.bucketStart) ??
298
+ createVisitorSketch(bucket.visitorSketch.precision),
299
+ bucket.visitorSketch,
300
+ ),
301
+ );
302
+ } else if (bucket.events > 0) {
303
+ visitorsComplete = false;
304
+ }
179
305
  for (const dimension of bucket.dimensions) {
180
306
  const key = `${dimension.type}:${dimension.value}`;
181
307
  const current = dimensions.get(key) ?? {
@@ -194,8 +320,25 @@ export const overview = query({
194
320
  [...dimensions.entries()]
195
321
  .filter(([key]) => key.startsWith(`${type}:`))
196
322
  .map(([, value]) => value)
197
- .sort((left, right) => right.count - left.count || left.value.localeCompare(right.value))
323
+ .sort(
324
+ (left, right) =>
325
+ right.count - left.count || left.value.localeCompare(right.value),
326
+ )
198
327
  .slice(0, 10);
328
+ if (visitorsComplete) {
329
+ totals.visitors = estimateVisitors(mergedVisitorSketch);
330
+ for (const [timestamp, point] of points) {
331
+ point.visitors = estimateVisitors(
332
+ pointVisitorSketches.get(timestamp) ?? createVisitorSketch(),
333
+ );
334
+ }
335
+ }
336
+ const coverage = await coverageForRange(ctx, siteIds, from, to, {
337
+ datasets: [overviewDataset],
338
+ complete: visitorsComplete,
339
+ });
340
+ const visitorBasis: "estimatedUnique" | "bucketOccurrences" =
341
+ visitorsComplete ? "estimatedUnique" : "bucketOccurrences";
199
342
  return {
200
343
  from,
201
344
  to,
@@ -206,12 +349,40 @@ export const overview = query({
206
349
  .sort(([left], [right]) => left - right)
207
350
  .map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
208
351
  topSources: top("source"),
352
+ topCampaigns: top("campaign"),
209
353
  topPages: top("page"),
210
354
  topCountries: top("country"),
211
355
  topDevices: top("device"),
212
356
  topBrowsers: top("browser"),
213
357
  topEvents: top("event"),
214
358
  topGoals: top("goal"),
359
+ metadata: {
360
+ visitors: {
361
+ basis: visitorBasis,
362
+ exact: false,
363
+ legacyIncomplete: !visitorsComplete,
364
+ sketchVersion: visitorsComplete ? 1 : null,
365
+ precision: visitorsComplete ? 6 : null,
366
+ },
367
+ breakdowns: {
368
+ topSources: "eventVolume" as const,
369
+ topCampaigns: "eventVolume" as const,
370
+ topPages: "eventVolume" as const,
371
+ topCountries: "eventVolume" as const,
372
+ topDevices: "eventVolume" as const,
373
+ topBrowsers: "eventVolume" as const,
374
+ topEvents: "eventVolume" as const,
375
+ topGoals: "goalCompletionVolume" as const,
376
+ },
377
+ coverage,
378
+ range: rangeSemantics(from, to),
379
+ features: {
380
+ sessionReplay: "unsupported" as const,
381
+ errorInsights: "unsupported" as const,
382
+ outboundLinks: "eventVolume" as const,
383
+ botDetection: "userAgent" as const,
384
+ },
385
+ },
215
386
  };
216
387
  },
217
388
  });
@@ -226,8 +397,15 @@ export const liveVisitors = query({
226
397
  handler: async (ctx, args) => {
227
398
  const siteIds = validateSiteIds(args.siteIds);
228
399
  const limit = args.limit ?? 100;
229
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIVE_VISITORS) {
230
- fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_LIVE_VISITORS}`);
400
+ if (
401
+ !Number.isSafeInteger(limit) ||
402
+ limit < 1 ||
403
+ limit > MAX_LIVE_VISITORS
404
+ ) {
405
+ fail(
406
+ "INVALID_ARGUMENT",
407
+ `limit must be an integer between 1 and ${MAX_LIVE_VISITORS}`,
408
+ );
231
409
  }
232
410
  if (!Number.isFinite(args.now) || args.now < 0) {
233
411
  fail("INVALID_ARGUMENT", "now must be a positive timestamp");
@@ -267,10 +445,311 @@ export const liveVisitors = query({
267
445
  device: row.device,
268
446
  pageviewCount: row.pageviewCount,
269
447
  eventCount: row.eventCount,
448
+ ...(row.previousPath ? { previousPath: row.previousPath } : {}),
449
+ ...(row.movedAt !== undefined ? { movedAt: row.movedAt } : {}),
270
450
  }));
271
451
  },
272
452
  });
273
453
 
454
+ const siteMapRouteValidator = v.object({
455
+ route: v.string(),
456
+ pageviews: v.number(),
457
+ visitors: v.number(),
458
+ entries: v.number(),
459
+ exits: v.number(),
460
+ bounces: v.number(),
461
+ transitionsIn: v.number(),
462
+ transitionsOut: v.number(),
463
+ dwellMs: v.number(),
464
+ dwellSamples: v.number(),
465
+ daily: v.array(v.object({ bucketStart: v.number(), pageviews: v.number() })),
466
+ });
467
+ const siteMapTransitionValidator = v.object({
468
+ from: v.string(),
469
+ to: v.string(),
470
+ count: v.number(),
471
+ });
472
+ const siteMapCoverageDataset = "siteMapRollups";
473
+
474
+ /**
475
+ * Every route the sites served in the range and the transitions between
476
+ * them, read from the daily route rollups newest day first. Exits and
477
+ * bounces are derived rather than counted: a session enters once, leaves
478
+ * once, and every later pageview is a transition, so per route
479
+ * `exits = entries + transitionsIn - transitionsOut` and
480
+ * `bounces = entries - continued` hold without a session-end hook. The
481
+ * report never falls back to raw telemetry; days it could not cover are
482
+ * declared through `coverage` and `window`.
483
+ */
484
+ export const siteMap = query({
485
+ args: {
486
+ siteIds: v.array(v.id("sites")),
487
+ from: v.number(),
488
+ to: v.number(),
489
+ },
490
+ returns: v.object({
491
+ from: v.number(),
492
+ to: v.number(),
493
+ window: v.union(v.object({ from: v.number(), to: v.number() }), v.null()),
494
+ routes: v.array(siteMapRouteValidator),
495
+ transitions: v.array(siteMapTransitionValidator),
496
+ truncated: v.object({ routes: v.boolean(), transitions: v.boolean() }),
497
+ metadata: reportMetadataValidator.extend({
498
+ visitors: v.object({
499
+ basis: v.literal("estimatedUnique"),
500
+ exact: v.boolean(),
501
+ }),
502
+ limits: v.object({
503
+ routesPerDay: v.number(),
504
+ transitionSlots: v.number(),
505
+ rows: v.number(),
506
+ }),
507
+ }),
508
+ }),
509
+ handler: async (ctx, args) => {
510
+ const siteIds = validateSiteIds(args.siteIds);
511
+ validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
512
+ requireAlignedRange(args.from, args.to, DAY_MS, "site map");
513
+ const controls = await Promise.all(
514
+ siteIds.map((siteId) =>
515
+ ctx.db
516
+ .query("analyticsCoverage")
517
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
518
+ .unique(),
519
+ ),
520
+ );
521
+ const since = controls.every(
522
+ (control) => control?.siteMapSince !== undefined,
523
+ )
524
+ ? Math.max(...controls.map((control) => control!.siteMapSince!))
525
+ : null;
526
+ const retainedBefore = controls.reduce<number | null>((latest, control) => {
527
+ const before = control?.retained.find(
528
+ (item) => item.dataset === siteMapCoverageDataset,
529
+ )?.before;
530
+ return before === undefined ? latest : Math.max(latest ?? 0, before);
531
+ }, null);
532
+ const metadata = (state: "complete" | "partial" | "unavailable") => ({
533
+ basis: "dailyRollup" as const,
534
+ coverage: {
535
+ state,
536
+ generation: 1 as const,
537
+ availableFrom: since,
538
+ availableThrough: since === null ? null : args.to,
539
+ retainedBefore,
540
+ },
541
+ range: rangeSemantics(args.from, args.to),
542
+ visitors: { basis: "estimatedUnique" as const, exact: false },
543
+ limits: {
544
+ routesPerDay: MAX_SITE_MAP_ROUTES_PER_DAY,
545
+ transitionSlots: ROUTE_TRANSITION_SLOTS,
546
+ rows: MAX_SITE_MAP_ROWS,
547
+ },
548
+ });
549
+ if (
550
+ since === null ||
551
+ args.to < since ||
552
+ (retainedBefore !== null && args.to < retainedBefore)
553
+ ) {
554
+ return {
555
+ from: args.from,
556
+ to: args.to,
557
+ window: null,
558
+ routes: [],
559
+ transitions: [],
560
+ truncated: { routes: false, transitions: false },
561
+ metadata: metadata("unavailable"),
562
+ };
563
+ }
564
+ const readFrom = Math.max(
565
+ args.from,
566
+ Math.floor(since / DAY_MS) * DAY_MS,
567
+ retainedBefore ?? 0,
568
+ );
569
+ let budget = MAX_SITE_MAP_ROWS;
570
+ let completeFrom = readFrom;
571
+ const rows: Doc<"routeDailyRollups">[] = [];
572
+ for (const siteId of siteIds) {
573
+ const read = await readRouteRollups(
574
+ ctx,
575
+ siteId,
576
+ readFrom,
577
+ args.to,
578
+ budget,
579
+ );
580
+ budget -= read.rows.length;
581
+ completeFrom = Math.max(completeFrom, read.completeFrom);
582
+ rows.push(...read.rows);
583
+ }
584
+ const windowFrom = Math.max(
585
+ args.from,
586
+ completeFrom,
587
+ since,
588
+ retainedBefore ?? 0,
589
+ );
590
+
591
+ type RouteTotals = {
592
+ pageviews: number;
593
+ entries: number;
594
+ continued: number;
595
+ dwellMs: number;
596
+ dwellSamples: number;
597
+ transitionsIn: number;
598
+ transitionsOut: number;
599
+ sketch: VisitorSketch;
600
+ daily: Map<number, number>;
601
+ };
602
+ const routes = new Map<string, RouteTotals>();
603
+ const totalsFor = (route: string) => {
604
+ let current = routes.get(route);
605
+ if (!current) {
606
+ current = {
607
+ pageviews: 0,
608
+ entries: 0,
609
+ continued: 0,
610
+ dwellMs: 0,
611
+ dwellSamples: 0,
612
+ transitionsIn: 0,
613
+ transitionsOut: 0,
614
+ sketch: createVisitorSketch(),
615
+ daily: new Map<number, number>(),
616
+ };
617
+ routes.set(route, current);
618
+ }
619
+ return current;
620
+ };
621
+ const edges = new Map<
622
+ string,
623
+ { from: string; to: string; count: number }
624
+ >();
625
+ for (const row of rows) {
626
+ if (row.bucketStart < completeFrom) continue;
627
+ const current = totalsFor(row.route);
628
+ current.pageviews += row.pageviews;
629
+ current.entries += row.entries;
630
+ current.continued += row.continued;
631
+ current.dwellMs += row.dwellMs;
632
+ current.dwellSamples += row.dwellSamples;
633
+ current.sketch = unionVisitorSketches(current.sketch, row.visitorSketch);
634
+ current.daily.set(
635
+ row.bucketStart,
636
+ (current.daily.get(row.bucketStart) ?? 0) + row.pageviews,
637
+ );
638
+ for (const target of row.outgoing) {
639
+ const key = `${row.route}\u0000${target.route}`;
640
+ const edge = edges.get(key) ?? {
641
+ from: row.route,
642
+ to: target.route,
643
+ count: 0,
644
+ };
645
+ edge.count += target.count;
646
+ edges.set(key, edge);
647
+ current.transitionsOut += target.count;
648
+ }
649
+ }
650
+ // A target without a row of its own in this window — folded past the
651
+ // daily cap, or already retained — counts under "(other)".
652
+ const transitions = new Map<
653
+ string,
654
+ { from: string; to: string; count: number }
655
+ >();
656
+ for (const edge of edges.values()) {
657
+ const to = routes.has(edge.to) ? edge.to : SITE_MAP_OTHER_ROUTE;
658
+ const key = `${edge.from}\u0000${to}`;
659
+ const merged = transitions.get(key) ?? { from: edge.from, to, count: 0 };
660
+ merged.count += edge.count;
661
+ transitions.set(key, merged);
662
+ totalsFor(to).transitionsIn += edge.count;
663
+ }
664
+ const routeRows = [...routes.entries()]
665
+ .map(([route, totals]) => ({
666
+ route,
667
+ pageviews: totals.pageviews,
668
+ visitors: estimateVisitors(totals.sketch),
669
+ entries: totals.entries,
670
+ exits: Math.max(
671
+ 0,
672
+ totals.entries + totals.transitionsIn - totals.transitionsOut,
673
+ ),
674
+ bounces: Math.max(0, totals.entries - totals.continued),
675
+ transitionsIn: totals.transitionsIn,
676
+ transitionsOut: totals.transitionsOut,
677
+ dwellMs: totals.dwellMs,
678
+ dwellSamples: totals.dwellSamples,
679
+ daily: [...totals.daily.entries()]
680
+ .sort(([left], [right]) => left - right)
681
+ .map(([bucketStart, pageviews]) => ({ bucketStart, pageviews })),
682
+ }))
683
+ .sort(
684
+ (left, right) =>
685
+ right.pageviews - left.pageviews ||
686
+ left.route.localeCompare(right.route),
687
+ );
688
+ const transitionRows = [...transitions.values()].sort(
689
+ (left, right) =>
690
+ right.count - left.count ||
691
+ left.from.localeCompare(right.from) ||
692
+ left.to.localeCompare(right.to),
693
+ );
694
+ return {
695
+ from: args.from,
696
+ to: args.to,
697
+ window: completeFrom > args.to ? null : { from: windowFrom, to: args.to },
698
+ routes: routeRows,
699
+ transitions: transitionRows,
700
+ truncated: {
701
+ routes: routes.has(SITE_MAP_OTHER_ROUTE),
702
+ transitions: transitionRows.some(
703
+ (transition) =>
704
+ transition.from === SITE_MAP_OTHER_ROUTE ||
705
+ transition.to === SITE_MAP_OTHER_ROUTE,
706
+ ),
707
+ },
708
+ metadata: metadata(windowFrom > args.from ? "partial" : "complete"),
709
+ };
710
+ },
711
+ });
712
+
713
+ /**
714
+ * Reads a site's route rollups newest day first until the row budget or the
715
+ * transaction's read headroom runs out, then drops the day it could only
716
+ * read in part so every day it returns is complete.
717
+ */
718
+ async function readRouteRollups(
719
+ ctx: QueryCtx,
720
+ siteId: Id<"sites">,
721
+ from: number,
722
+ to: number,
723
+ budget: number,
724
+ ) {
725
+ const rows: Doc<"routeDailyRollups">[] = [];
726
+ let cutAt: number | null = null;
727
+ const query = ctx.db
728
+ .query("routeDailyRollups")
729
+ .withIndex("by_siteId_and_bucketStart", (range) =>
730
+ range
731
+ .eq("siteId", siteId)
732
+ .gte("bucketStart", from)
733
+ .lte("bucketStart", to),
734
+ )
735
+ .order("desc");
736
+ for await (const row of query) {
737
+ if (
738
+ rows.length >= budget ||
739
+ (rows.length % 256 === 255 && !(await hasFeatureReadHeadroom(ctx)))
740
+ ) {
741
+ cutAt = row.bucketStart;
742
+ break;
743
+ }
744
+ rows.push(row);
745
+ }
746
+ if (cutAt === null) return { rows, completeFrom: from };
747
+ while (rows.length > 0 && rows[rows.length - 1]!.bucketStart === cutAt) {
748
+ rows.pop();
749
+ }
750
+ return { rows, completeFrom: cutAt + DAY_MS };
751
+ }
752
+
274
753
  export const listSessions = query({
275
754
  args: {
276
755
  siteId: v.id("sites"),
@@ -281,6 +760,7 @@ export const listSessions = query({
281
760
  returns: paginationResultValidator(sessionDocumentValidator),
282
761
  handler: async (ctx, args) => {
283
762
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
763
+ await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
284
764
  const pagination = parseKeysetPagination(args.paginationOpts, {
285
765
  kind: "sessions",
286
766
  siteId: String(args.siteId),
@@ -330,7 +810,14 @@ export const listSessions = query({
330
810
  .take(take)),
331
811
  );
332
812
  }
333
- return keysetPaginationResult(rows, pagination, (row) => ({
813
+ const publicRows = rows.map(
814
+ ({
815
+ geoLookupAttemptedAt: _attemptedAt,
816
+ lastPageviewAt: _lastPageviewAt,
817
+ ...row
818
+ }) => row,
819
+ );
820
+ return keysetPaginationResult(publicRows, pagination, (row) => ({
334
821
  timestamp: row.lastSeenAt,
335
822
  creationTime: row._creationTime,
336
823
  }));
@@ -348,6 +835,7 @@ export const sessionJourney = query({
348
835
  returns: paginationResultValidator(eventDocumentValidator),
349
836
  handler: async (ctx, args) => {
350
837
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
838
+ await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
351
839
  const pagination = parseJourneyPagination(args.paginationOpts, {
352
840
  kind: "events",
353
841
  siteId: String(args.siteId),
@@ -378,6 +866,7 @@ export const listConversions = query({
378
866
  returns: paginationResultValidator(conversionDocumentValidator),
379
867
  handler: async (ctx, args) => {
380
868
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
869
+ await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
381
870
  const pagination = parseKeysetPagination(args.paginationOpts, {
382
871
  kind: "conversions",
383
872
  siteId: String(args.siteId),
@@ -446,54 +935,39 @@ export const visitorJourney = query({
446
935
  handler: async (ctx, args) => {
447
936
  const siteIds = validateSiteIds(args.siteIds);
448
937
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
938
+ await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
449
939
  const limit = args.limit ?? 200;
450
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
451
- fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
940
+ if (
941
+ !Number.isSafeInteger(limit) ||
942
+ limit < 1 ||
943
+ limit > MAX_JOURNEY_EVENTS
944
+ ) {
945
+ fail(
946
+ "INVALID_ARGUMENT",
947
+ `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`,
948
+ );
452
949
  }
453
- const sessionGroups = await Promise.all(
454
- siteIds.map((siteId) =>
455
- ctx.db
456
- .query("sessions")
457
- .withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) =>
458
- range
459
- .eq("siteId", siteId)
460
- .eq("visitorId", args.visitorId)
461
- .gte("lastSeenAt", args.from),
462
- )
463
- .order("asc")
464
- .take(MAX_JOURNEY_EVENTS + 1),
465
- ),
466
- );
467
- const sessions = sessionGroups
468
- .flat()
469
- .filter((session) => session.startedAt <= args.to)
470
- .sort((left, right) => left.startedAt - right.startedAt)
471
- .slice(0, MAX_JOURNEY_EVENTS);
472
950
  const rows: RawJourneyEvent[] = [];
473
- let overlappingSessionsRead = 0;
474
- for (const session of sessions) {
475
- const resultIsFull = rows.length >= limit;
476
- const cutoff = resultIsFull
477
- ? rows[limit - 1]!.timestamp
478
- : args.to;
479
- if (session.startedAt > cutoff) break;
480
- if (resultIsFull && ++overlappingSessionsRead > 8) {
481
- fail(
482
- "LIMIT_EXCEEDED",
483
- "visitor journey has too many overlapping sessions",
484
- { maximumOverlappingSessions: 8 },
951
+ for (const siteId of siteIds) {
952
+ for (const visitorId of await resolveVisitorIdentities(
953
+ ctx,
954
+ siteId,
955
+ args.visitorId,
956
+ )) {
957
+ const cutoff =
958
+ rows.length >= limit ? rows[limit - 1]!.timestamp : args.to;
959
+ rows.push(
960
+ ...(await loadVisitorJourneyEvents(ctx, {
961
+ siteId,
962
+ visitorId,
963
+ from: args.from,
964
+ to: cutoff,
965
+ resultLimit: limit,
966
+ })),
485
967
  );
968
+ rows.sort(compareJourneyEvents);
969
+ if (rows.length > limit) rows.length = limit;
486
970
  }
487
- rows.push(...await loadSessionJourneyEvents(ctx, {
488
- siteId: session.siteId,
489
- sessionId: session.sessionId,
490
- from: args.from,
491
- to: Math.min(args.to, cutoff),
492
- cursor: null,
493
- resultLimit: resultIsFull ? limit : limit - rows.length,
494
- }));
495
- rows.sort(compareJourneyEvents);
496
- if (rows.length > limit) rows.length = limit;
497
971
  }
498
972
  return hydrateEventContexts(ctx, rows);
499
973
  },
@@ -511,36 +985,90 @@ export const goalsReport = query({
511
985
  returns: v.object({
512
986
  goals: v.array(goalReportItemValidator),
513
987
  truncated: v.boolean(),
988
+ metadata: reportMetadataValidator,
514
989
  }),
515
990
  handler: async (ctx, args) => {
516
991
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
517
- const goals = await ctx.db
992
+ const goalRows = await ctx.db
518
993
  .query("goals")
519
994
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
520
- .take(MAX_GOALS_PER_SITE);
521
- const completions = await ctx.db
522
- .query("goalCompletions")
523
- .withIndex("by_siteId_and_timestamp", (range) =>
524
- range
525
- .eq("siteId", args.siteId)
526
- .gte("timestamp", args.from)
527
- .lte("timestamp", args.to),
528
- )
529
- .take(5_001);
530
- const totals = new Map<string, { completions: number; valueCents: number }>();
531
- for (const completion of completions.slice(0, 5_000)) {
532
- const key = String(completion.goalId);
533
- const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
534
- total.completions += 1;
535
- total.valueCents += completion.valueCents;
536
- totals.set(key, total);
995
+ .take(MAX_GOALS_PER_SITE + 1);
996
+ failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
997
+ const goals = goalRows;
998
+ const totals = new Map<
999
+ string,
1000
+ { completions: number; valueCents: number }
1001
+ >();
1002
+ const useRollups = await dailyRollupsAvailable(
1003
+ ctx,
1004
+ args.siteId,
1005
+ args.from,
1006
+ args.to,
1007
+ "goalRollups",
1008
+ );
1009
+ const rollups = useRollups
1010
+ ? await loadGoalDailyRollups(
1011
+ ctx,
1012
+ args.siteId,
1013
+ goals.map((goal) => goal._id),
1014
+ args.from,
1015
+ args.to,
1016
+ )
1017
+ : null;
1018
+ if (rollups) {
1019
+ for (const row of rollups) {
1020
+ const key = String(row.goalId);
1021
+ const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
1022
+ total.completions += row.completions;
1023
+ total.valueCents += row.valueCents;
1024
+ totals.set(key, total);
1025
+ }
1026
+ } else {
1027
+ await assertSourceAvailable(
1028
+ ctx,
1029
+ [args.siteId],
1030
+ ["goalCompletions"],
1031
+ args.from,
1032
+ );
1033
+ const completions = await ctx.db
1034
+ .query("goalCompletions")
1035
+ .withIndex("by_siteId_and_timestamp", (range) =>
1036
+ range
1037
+ .eq("siteId", args.siteId)
1038
+ .gte("timestamp", args.from)
1039
+ .lte("timestamp", args.to),
1040
+ )
1041
+ .take(MAX_REPORT_BUCKETS + 1);
1042
+ failIfIncomplete(completions, MAX_REPORT_BUCKETS, "goal completions");
1043
+ for (const completion of completions) {
1044
+ const key = String(completion.goalId);
1045
+ const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
1046
+ total.completions += 1;
1047
+ total.valueCents += completion.valueCents;
1048
+ totals.set(key, total);
1049
+ }
537
1050
  }
538
1051
  return {
539
1052
  goals: goals.map((goal) => ({
540
1053
  ...goal,
541
1054
  ...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
542
1055
  })),
543
- truncated: completions.length > 5_000,
1056
+ truncated: false,
1057
+ metadata: {
1058
+ basis: rollups ? ("dailyRollup" as const) : ("boundedLegacy" as const),
1059
+ coverage: await coverageForRange(
1060
+ ctx,
1061
+ [args.siteId],
1062
+ args.from,
1063
+ args.to,
1064
+ {
1065
+ datasets: [rollups ? "goalRollups" : "goalCompletions"],
1066
+ complete: true,
1067
+ generation: rollups ? 1 : null,
1068
+ },
1069
+ ),
1070
+ range: rangeSemantics(args.from, args.to),
1071
+ },
544
1072
  };
545
1073
  },
546
1074
  });
@@ -549,9 +1077,15 @@ const funnelReportItemValidator = funnelFieldsValidator.extend({
549
1077
  _id: v.id("funnels"),
550
1078
  _creationTime: v.number(),
551
1079
  stepsReport: v.array(
552
- v.object({ step: v.number(), name: v.string(), entrants: v.number(), completions: v.number() }),
1080
+ v.object({
1081
+ step: v.number(),
1082
+ name: v.string(),
1083
+ entrants: v.number(),
1084
+ completions: v.number(),
1085
+ }),
553
1086
  ),
554
1087
  truncated: v.boolean(),
1088
+ metadata: reportMetadataValidator,
555
1089
  });
556
1090
 
557
1091
  export const funnelsReport = query({
@@ -559,29 +1093,74 @@ export const funnelsReport = query({
559
1093
  returns: v.array(funnelReportItemValidator),
560
1094
  handler: async (ctx, args) => {
561
1095
  validateRange(args.from, args.to, 90);
562
- const funnels = await ctx.db
1096
+ requireAlignedRange(args.from, args.to, DAY_MS, "funnel report");
1097
+ const funnelRows = await ctx.db
563
1098
  .query("funnels")
564
1099
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
565
- .take(MAX_FUNNELS_PER_SITE);
1100
+ .take(MAX_FUNNELS_PER_SITE + 1);
1101
+ failIfIncomplete(funnelRows, MAX_FUNNELS_PER_SITE, "funnel definitions");
1102
+ const funnels = funnelRows;
1103
+ const useRollups = await dailyRollupsAvailable(
1104
+ ctx,
1105
+ args.siteId,
1106
+ args.from,
1107
+ args.to,
1108
+ "funnelRollups",
1109
+ );
1110
+ const rollups = useRollups
1111
+ ? await loadFunnelDailyRollups(
1112
+ ctx,
1113
+ args.siteId,
1114
+ funnels.map((funnel) => funnel._id),
1115
+ args.from,
1116
+ args.to,
1117
+ )
1118
+ : null;
1119
+ if (!rollups) {
1120
+ await assertSourceAvailable(
1121
+ ctx,
1122
+ [args.siteId],
1123
+ ["funnelSteps"],
1124
+ args.from,
1125
+ );
1126
+ }
1127
+ const reportCoverage = await coverageForRange(
1128
+ ctx,
1129
+ [args.siteId],
1130
+ args.from,
1131
+ args.to,
1132
+ {
1133
+ datasets: [rollups ? "funnelRollups" : "funnelSteps"],
1134
+ complete: true,
1135
+ generation: rollups ? 1 : null,
1136
+ },
1137
+ );
566
1138
  const reports = [];
567
1139
  for (const funnel of funnels) {
568
- const buckets = await ctx.db
569
- .query("funnelStepBuckets")
570
- .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
571
- range
572
- .eq("siteId", args.siteId)
573
- .eq("funnelId", funnel._id)
574
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
575
- .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
576
- )
577
- .take(601);
578
1140
  const stepsReport = funnel.steps.map((step, index) => ({
579
1141
  step: index,
580
1142
  name: step.name,
581
1143
  entrants: 0,
582
1144
  completions: 0,
583
1145
  }));
584
- for (const bucket of buckets.slice(0, 600)) {
1146
+ const buckets = rollups
1147
+ ? rollups
1148
+ .filter((row) => row.funnelId === funnel._id)
1149
+ .flatMap((row) => row.steps)
1150
+ : await ctx.db
1151
+ .query("funnelStepBuckets")
1152
+ .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
1153
+ range
1154
+ .eq("siteId", args.siteId)
1155
+ .eq("funnelId", funnel._id)
1156
+ .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
1157
+ .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
1158
+ )
1159
+ .take(MAX_REPORT_BUCKETS + 1);
1160
+ if (!rollups) {
1161
+ failIfIncomplete(buckets, MAX_REPORT_BUCKETS, "funnel step buckets");
1162
+ }
1163
+ for (const bucket of buckets) {
585
1164
  const step = stepsReport[bucket.step];
586
1165
  if (step) {
587
1166
  step.entrants += bucket.entrants;
@@ -591,7 +1170,14 @@ export const funnelsReport = query({
591
1170
  reports.push({
592
1171
  ...funnel,
593
1172
  stepsReport,
594
- truncated: buckets.length > 600,
1173
+ truncated: false,
1174
+ metadata: {
1175
+ basis: rollups
1176
+ ? ("dailyRollup" as const)
1177
+ : ("boundedLegacy" as const),
1178
+ coverage: reportCoverage,
1179
+ range: rangeSemantics(args.from, args.to),
1180
+ },
595
1181
  });
596
1182
  }
597
1183
  return reports;
@@ -613,64 +1199,134 @@ export const affiliatesReport = query({
613
1199
  returns: v.object({
614
1200
  affiliates: v.array(affiliateReportItemValidator),
615
1201
  truncated: v.boolean(),
1202
+ metadata: reportMetadataValidator.extend({
1203
+ uniqueVisitors: v.object({
1204
+ basis: v.union(v.literal("estimatedUnique"), v.literal("exactLegacy")),
1205
+ exact: v.boolean(),
1206
+ }),
1207
+ }),
616
1208
  }),
617
1209
  handler: async (ctx, args) => {
618
1210
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
1211
+ requireAlignedRange(args.from, args.to, DAY_MS, "affiliate report");
619
1212
  const affiliateRows = await ctx.db
620
1213
  .query("affiliates")
621
1214
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
622
1215
  .order("desc")
623
1216
  .take(MAX_AFFILIATES_PER_SITE + 1);
624
- const affiliates = affiliateRows.slice(0, MAX_AFFILIATES_PER_SITE);
625
- const stats = await ctx.db
626
- .query("affiliateDailyStats")
627
- .withIndex("by_siteId_and_bucketStart", (range) =>
628
- range
629
- .eq("siteId", args.siteId)
630
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
631
- .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
632
- )
633
- .take(5_001);
634
- const sessions = await ctx.db
635
- .query("sessions")
636
- .withIndex("by_siteId_and_startedAt", (range) =>
637
- range
638
- .eq("siteId", args.siteId)
639
- .gte("startedAt", args.from)
640
- .lte("startedAt", args.to),
641
- )
642
- .take(5_001);
643
- const visits = new Map<string, {
644
- visits: number;
645
- visitors: Set<string>;
646
- }>();
647
- for (const session of sessions.slice(0, 5_000)) {
648
- if (!session.affiliateId) continue;
649
- const key = String(session.affiliateId);
650
- const total = visits.get(key) ?? {
651
- visits: 0,
652
- visitors: new Set<string>(),
653
- };
654
- total.visits += 1;
655
- total.visitors.add(session.visitorId);
656
- visits.set(key, total);
657
- }
658
- const totals = new Map<string, {
659
- conversions: number;
660
- revenueCents: number;
661
- commissionCents: number;
662
- }>();
663
- for (const row of stats.slice(0, 5_000)) {
664
- const key = String(row.affiliateId);
665
- const total = totals.get(key) ?? {
666
- conversions: 0,
667
- revenueCents: 0,
668
- commissionCents: 0,
669
- };
670
- total.conversions += row.conversions;
671
- total.revenueCents += row.revenueCents;
672
- total.commissionCents += row.commissionCents;
673
- totals.set(key, total);
1217
+ failIfIncomplete(
1218
+ affiliateRows,
1219
+ MAX_AFFILIATES_PER_SITE,
1220
+ "affiliate definitions",
1221
+ );
1222
+ const affiliates = affiliateRows;
1223
+ const useRollups = await dailyRollupsAvailable(
1224
+ ctx,
1225
+ args.siteId,
1226
+ args.from,
1227
+ args.to,
1228
+ "affiliateRollups",
1229
+ );
1230
+ const rollups = useRollups
1231
+ ? await loadAffiliateDailyRollups(
1232
+ ctx,
1233
+ args.siteId,
1234
+ affiliates.map((affiliate) => affiliate._id),
1235
+ args.from,
1236
+ args.to,
1237
+ )
1238
+ : null;
1239
+ const visits = new Map<
1240
+ string,
1241
+ {
1242
+ visits: number;
1243
+ visitors: Set<string>;
1244
+ sketch: VisitorSketch;
1245
+ }
1246
+ >();
1247
+ const totals = new Map<
1248
+ string,
1249
+ {
1250
+ conversions: number;
1251
+ revenueCents: number;
1252
+ commissionCents: number;
1253
+ }
1254
+ >();
1255
+ if (rollups) {
1256
+ for (const row of rollups) {
1257
+ const key = String(row.affiliateId);
1258
+ const traffic = visits.get(key) ?? {
1259
+ visits: 0,
1260
+ visitors: new Set<string>(),
1261
+ sketch: createVisitorSketch(),
1262
+ };
1263
+ traffic.visits += row.visits;
1264
+ traffic.sketch = unionVisitorSketches(
1265
+ traffic.sketch,
1266
+ row.visitorSketch,
1267
+ );
1268
+ visits.set(key, traffic);
1269
+ const total = totals.get(key) ?? {
1270
+ conversions: 0,
1271
+ revenueCents: 0,
1272
+ commissionCents: 0,
1273
+ };
1274
+ total.conversions += row.conversions;
1275
+ total.revenueCents += row.revenueCents;
1276
+ total.commissionCents += row.commissionCents;
1277
+ totals.set(key, total);
1278
+ }
1279
+ } else {
1280
+ await assertSourceAvailable(
1281
+ ctx,
1282
+ [args.siteId],
1283
+ ["affiliateStats", "sessions"],
1284
+ args.from,
1285
+ );
1286
+ const stats = await ctx.db
1287
+ .query("affiliateDailyStats")
1288
+ .withIndex("by_siteId_and_bucketStart", (range) =>
1289
+ range
1290
+ .eq("siteId", args.siteId)
1291
+ .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
1292
+ .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
1293
+ )
1294
+ .take(MAX_REPORT_BUCKETS + 1);
1295
+ const sessions = await ctx.db
1296
+ .query("sessions")
1297
+ .withIndex("by_siteId_and_startedAt", (range) =>
1298
+ range
1299
+ .eq("siteId", args.siteId)
1300
+ .gte("startedAt", args.from)
1301
+ .lte("startedAt", args.to),
1302
+ )
1303
+ .take(MAX_REPORT_BUCKETS + 1);
1304
+ failIfIncomplete(stats, MAX_REPORT_BUCKETS, "affiliate stat buckets");
1305
+ failIfIncomplete(sessions, MAX_REPORT_BUCKETS, "affiliate sessions");
1306
+ for (const session of sessions) {
1307
+ if (!session.affiliateId) continue;
1308
+ const key = String(session.affiliateId);
1309
+ const total = visits.get(key) ?? {
1310
+ visits: 0,
1311
+ visitors: new Set<string>(),
1312
+ sketch: createVisitorSketch(),
1313
+ };
1314
+ total.visits += 1;
1315
+ total.visitors.add(session.visitorId);
1316
+ visits.set(key, total);
1317
+ }
1318
+ for (const row of stats) {
1319
+ const key = String(row.affiliateId);
1320
+ const total = totals.get(key) ?? {
1321
+ conversions: 0,
1322
+ revenueCents: 0,
1323
+ commissionCents: 0,
1324
+ };
1325
+ total.conversions += row.conversions;
1326
+ total.revenueCents += row.revenueCents;
1327
+ total.commissionCents += row.commissionCents;
1328
+ totals.set(key, total);
1329
+ }
674
1330
  }
675
1331
  return {
676
1332
  affiliates: affiliates.map((affiliate) => {
@@ -684,17 +1340,799 @@ export const affiliatesReport = query({
684
1340
  ...affiliate,
685
1341
  ...performance,
686
1342
  visits: traffic?.visits ?? 0,
687
- uniqueVisitors: traffic?.visitors.size ?? 0,
1343
+ uniqueVisitors: rollups
1344
+ ? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
1345
+ : (traffic?.visitors.size ?? 0),
688
1346
  };
689
1347
  }),
690
- truncated:
691
- affiliateRows.length > MAX_AFFILIATES_PER_SITE ||
692
- stats.length > 5_000 ||
693
- sessions.length > 5_000,
1348
+ truncated: false,
1349
+ metadata: {
1350
+ basis: rollups ? ("dailyRollup" as const) : ("boundedLegacy" as const),
1351
+ coverage: await coverageForRange(
1352
+ ctx,
1353
+ [args.siteId],
1354
+ args.from,
1355
+ args.to,
1356
+ {
1357
+ datasets: rollups
1358
+ ? ["affiliateRollups"]
1359
+ : ["affiliateStats", "sessions"],
1360
+ complete: true,
1361
+ generation: rollups ? 1 : null,
1362
+ },
1363
+ ),
1364
+ range: rangeSemantics(args.from, args.to),
1365
+ uniqueVisitors: {
1366
+ basis: rollups
1367
+ ? ("estimatedUnique" as const)
1368
+ : ("exactLegacy" as const),
1369
+ exact: !rollups,
1370
+ },
1371
+ },
1372
+ };
1373
+ },
1374
+ });
1375
+
1376
+ const vitalStatsValidator = v.object({
1377
+ count: v.number(),
1378
+ p75: v.number(),
1379
+ average: v.number(),
1380
+ good: v.number(),
1381
+ needsImprovement: v.number(),
1382
+ poor: v.number(),
1383
+ });
1384
+
1385
+ export const vitalsReport = query({
1386
+ args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
1387
+ returns: v.object({
1388
+ metrics: v.array(
1389
+ vitalStatsValidator.extend({
1390
+ metric: vitalMetricValidator,
1391
+ devices: v.array(vitalStatsValidator.extend({ device: v.string() })),
1392
+ pages: v.array(vitalStatsValidator.extend({ page: v.string() })),
1393
+ timeSeries: v.array(
1394
+ v.object({
1395
+ timestamp: v.number(),
1396
+ count: v.number(),
1397
+ p75: v.number(),
1398
+ }),
1399
+ ),
1400
+ }),
1401
+ ),
1402
+ truncated: v.boolean(),
1403
+ metadata: reportMetadataValidator,
1404
+ }),
1405
+ handler: async (ctx, args) => {
1406
+ validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
1407
+ requireAlignedRange(args.from, args.to, DAY_MS, "vitals report");
1408
+ await assertSourceAvailable(
1409
+ ctx,
1410
+ [args.siteId],
1411
+ ["vitalRollups"],
1412
+ args.from,
1413
+ );
1414
+ const rows = await ctx.db
1415
+ .query("vitalRollups")
1416
+ .withIndex("by_siteId_and_bucketStart", (range) =>
1417
+ range
1418
+ .eq("siteId", args.siteId)
1419
+ .gte("bucketStart", args.from)
1420
+ .lte("bucketStart", args.to),
1421
+ )
1422
+ .take(MAX_VITALS_REPORT_ROWS + 1);
1423
+ failIfIncomplete(rows, MAX_VITALS_REPORT_ROWS, "vital rollups");
1424
+
1425
+ type Accumulated = { count: number; sum: number; histogram: number[] };
1426
+ const fold = <Key>(
1427
+ groups: Map<VitalMetric, Map<Key, Accumulated>>,
1428
+ metric: VitalMetric,
1429
+ key: Key,
1430
+ entry: { count: number; sum: number; histogram: number[] },
1431
+ ) => {
1432
+ const group = groups.get(metric) ?? new Map<Key, Accumulated>();
1433
+ const current = group.get(key) ?? { count: 0, sum: 0, histogram: [] };
1434
+ current.count += entry.count;
1435
+ current.sum += entry.sum;
1436
+ current.histogram = mergeVitalHistograms(
1437
+ current.histogram,
1438
+ entry.histogram,
1439
+ );
1440
+ group.set(key, current);
1441
+ groups.set(metric, group);
1442
+ };
1443
+ const byDevice = new Map<VitalMetric, Map<string, Accumulated>>();
1444
+ const byPage = new Map<VitalMetric, Map<string, Accumulated>>();
1445
+ const byDay = new Map<VitalMetric, Map<number, Accumulated>>();
1446
+ for (const row of rows) {
1447
+ for (const entry of row.metrics) {
1448
+ if (row.page === VITAL_ALL) {
1449
+ // A device row carries the complete signal once, so overall totals
1450
+ // and the time series derive from this family alone.
1451
+ fold(byDevice, entry.metric, row.device, entry);
1452
+ fold(byDay, entry.metric, row.bucketStart, entry);
1453
+ } else {
1454
+ fold(byPage, entry.metric, row.page, entry);
1455
+ }
1456
+ }
1457
+ }
1458
+ const stats = (metric: VitalMetric, accumulated: Accumulated) => ({
1459
+ count: accumulated.count,
1460
+ p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
1461
+ average:
1462
+ accumulated.count > 0
1463
+ ? Math.round(accumulated.sum / accumulated.count)
1464
+ : 0,
1465
+ ...vitalRatingCounts(accumulated.histogram, metric),
1466
+ });
1467
+ const metrics = VITAL_METRICS.map((metric) => {
1468
+ const overall: Accumulated = { count: 0, sum: 0, histogram: [] };
1469
+ for (const accumulated of byDevice.get(metric)?.values() ?? []) {
1470
+ overall.count += accumulated.count;
1471
+ overall.sum += accumulated.sum;
1472
+ overall.histogram = mergeVitalHistograms(
1473
+ overall.histogram,
1474
+ accumulated.histogram,
1475
+ );
1476
+ }
1477
+ return {
1478
+ metric,
1479
+ ...stats(metric, overall),
1480
+ devices: [...(byDevice.get(metric) ?? [])]
1481
+ .map(([device, accumulated]) => ({
1482
+ device,
1483
+ ...stats(metric, accumulated),
1484
+ }))
1485
+ .sort((left, right) => right.count - left.count),
1486
+ pages: [...(byPage.get(metric) ?? [])]
1487
+ .map(([page, accumulated]) => ({
1488
+ page,
1489
+ ...stats(metric, accumulated),
1490
+ }))
1491
+ .sort(
1492
+ (left, right) =>
1493
+ right.count - left.count || left.page.localeCompare(right.page),
1494
+ )
1495
+ .slice(0, VITALS_REPORT_PAGE_LIMIT),
1496
+ timeSeries: [...(byDay.get(metric) ?? [])]
1497
+ .sort(([left], [right]) => left - right)
1498
+ .map(([timestamp, accumulated]) => ({
1499
+ timestamp,
1500
+ count: accumulated.count,
1501
+ p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
1502
+ })),
1503
+ };
1504
+ });
1505
+ return {
1506
+ metrics,
1507
+ truncated: false,
1508
+ metadata: {
1509
+ basis: "dailyRollup" as const,
1510
+ coverage: await coverageForRange(
1511
+ ctx,
1512
+ [args.siteId],
1513
+ args.from,
1514
+ args.to,
1515
+ { datasets: ["vitalRollups"], complete: true },
1516
+ ),
1517
+ range: rangeSemantics(args.from, args.to),
1518
+ },
1519
+ };
1520
+ },
1521
+ });
1522
+
1523
+ const coverageDatasetValidator = v.union(
1524
+ v.literal("overviewHour"),
1525
+ v.literal("overviewDay"),
1526
+ v.literal("events"),
1527
+ v.literal("sessions"),
1528
+ v.literal("conversions"),
1529
+ v.literal("goals"),
1530
+ v.literal("funnels"),
1531
+ v.literal("affiliates"),
1532
+ v.literal("vitals"),
1533
+ v.literal("siteMap"),
1534
+ );
1535
+
1536
+ export const dataCoverage = query({
1537
+ args: {
1538
+ siteId: v.id("sites"),
1539
+ from: v.number(),
1540
+ to: v.number(),
1541
+ },
1542
+ returns: v.object({
1543
+ siteId: v.id("sites"),
1544
+ from: v.number(),
1545
+ to: v.number(),
1546
+ datasets: v.array(
1547
+ v.object({
1548
+ dataset: coverageDatasetValidator,
1549
+ coverage: resultCoverageValidator,
1550
+ }),
1551
+ ),
1552
+ }),
1553
+ handler: async (ctx, args) => {
1554
+ validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
1555
+ const datasets = [
1556
+ "overviewHour",
1557
+ "overviewDay",
1558
+ "events",
1559
+ "sessions",
1560
+ "conversions",
1561
+ "goals",
1562
+ "funnels",
1563
+ "affiliates",
1564
+ "vitals",
1565
+ "siteMap",
1566
+ ] as const;
1567
+ return {
1568
+ siteId: args.siteId,
1569
+ from: args.from,
1570
+ to: args.to,
1571
+ datasets: await Promise.all(
1572
+ datasets.map(async (dataset) => ({
1573
+ dataset,
1574
+ coverage: await datasetCoverageForRange(
1575
+ ctx,
1576
+ args.siteId,
1577
+ dataset,
1578
+ args.from,
1579
+ args.to,
1580
+ ),
1581
+ })),
1582
+ ),
694
1583
  };
695
1584
  },
696
1585
  });
697
1586
 
1587
+ async function dailyRollupsAvailable(
1588
+ ctx: QueryCtx,
1589
+ siteId: Id<"sites">,
1590
+ from: number,
1591
+ to: number,
1592
+ dataset: "goalRollups" | "funnelRollups" | "affiliateRollups",
1593
+ ) {
1594
+ if (!isAlignedRange(from, to, DAY_MS)) return false;
1595
+ const coverage = await ctx.db
1596
+ .query("analyticsCoverage")
1597
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1598
+ .unique();
1599
+ if (!coverage || coverage.rollupCompleteFrom > from) {
1600
+ return false;
1601
+ }
1602
+ const retainedBefore = coverage.retained.find(
1603
+ (item) => item.dataset === dataset,
1604
+ )?.before;
1605
+ return retainedBefore === undefined || from >= retainedBefore;
1606
+ }
1607
+
1608
+ async function loadGoalDailyRollups(
1609
+ ctx: QueryCtx,
1610
+ siteId: Id<"sites">,
1611
+ goalIds: Id<"goals">[],
1612
+ from: number,
1613
+ to: number,
1614
+ ) {
1615
+ const days = Math.floor((to - from) / DAY_MS) + 1;
1616
+ requireFeatureRollupBudget(goalIds.length, days, "goal daily rollups");
1617
+ const rows: Doc<"goalDailyRollups">[] = [];
1618
+ for (const goalId of goalIds) {
1619
+ const perGoalLimit = days * REPORT_ROLLUP_SHARDS;
1620
+ const goalRows = await ctx.db
1621
+ .query("goalDailyRollups")
1622
+ .withIndex("by_siteId_and_goalId_and_bucketStart", (range) =>
1623
+ range
1624
+ .eq("siteId", siteId)
1625
+ .eq("goalId", goalId)
1626
+ .gte("bucketStart", from)
1627
+ .lte("bucketStart", to),
1628
+ )
1629
+ .take(perGoalLimit + 1);
1630
+ failIfIncomplete(goalRows, perGoalLimit, "goal daily rollups");
1631
+ rows.push(...goalRows);
1632
+ await requireFeatureReadHeadroom(ctx, "goal daily rollups");
1633
+ }
1634
+ return rows;
1635
+ }
1636
+
1637
+ async function loadFunnelDailyRollups(
1638
+ ctx: QueryCtx,
1639
+ siteId: Id<"sites">,
1640
+ funnelIds: Id<"funnels">[],
1641
+ from: number,
1642
+ to: number,
1643
+ ) {
1644
+ const days = Math.floor((to - from) / DAY_MS) + 1;
1645
+ requireFeatureRollupBudget(funnelIds.length, days, "funnel daily rollups");
1646
+ const rows: Doc<"funnelDailyRollups">[] = [];
1647
+ for (const funnelId of funnelIds) {
1648
+ const perFunnelLimit = days * REPORT_ROLLUP_SHARDS;
1649
+ const funnelRows = await ctx.db
1650
+ .query("funnelDailyRollups")
1651
+ .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
1652
+ range
1653
+ .eq("siteId", siteId)
1654
+ .eq("funnelId", funnelId)
1655
+ .gte("bucketStart", from)
1656
+ .lte("bucketStart", to),
1657
+ )
1658
+ .take(perFunnelLimit + 1);
1659
+ failIfIncomplete(funnelRows, perFunnelLimit, "funnel daily rollups");
1660
+ rows.push(...funnelRows);
1661
+ await requireFeatureReadHeadroom(ctx, "funnel daily rollups");
1662
+ }
1663
+ return rows;
1664
+ }
1665
+
1666
+ async function loadAffiliateDailyRollups(
1667
+ ctx: QueryCtx,
1668
+ siteId: Id<"sites">,
1669
+ affiliateIds: Id<"affiliates">[],
1670
+ from: number,
1671
+ to: number,
1672
+ ) {
1673
+ const days = Math.floor((to - from) / DAY_MS) + 1;
1674
+ requireFeatureRollupBudget(
1675
+ affiliateIds.length,
1676
+ days,
1677
+ "affiliate daily rollups",
1678
+ );
1679
+ const rows: Doc<"affiliateDailyRollups">[] = [];
1680
+ for (const affiliateId of affiliateIds) {
1681
+ const perAffiliateLimit = days * REPORT_ROLLUP_SHARDS;
1682
+ const affiliateRows = await ctx.db
1683
+ .query("affiliateDailyRollups")
1684
+ .withIndex("by_siteId_and_affiliateId_and_bucketStart", (range) =>
1685
+ range
1686
+ .eq("siteId", siteId)
1687
+ .eq("affiliateId", affiliateId)
1688
+ .gte("bucketStart", from)
1689
+ .lte("bucketStart", to),
1690
+ )
1691
+ .take(perAffiliateLimit + 1);
1692
+ failIfIncomplete(
1693
+ affiliateRows,
1694
+ perAffiliateLimit,
1695
+ "affiliate daily rollups",
1696
+ );
1697
+ rows.push(...affiliateRows);
1698
+ await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
1699
+ }
1700
+ return rows;
1701
+ }
1702
+
1703
+ function requireFeatureRollupBudget(
1704
+ entities: number,
1705
+ days: number,
1706
+ dataset: string,
1707
+ ) {
1708
+ const maximumRows = entities * days * REPORT_ROLLUP_SHARDS;
1709
+ if (days > MAX_FEATURE_ROLLUP_DAYS || maximumRows > MAX_FEATURE_ROLLUP_ROWS) {
1710
+ fail("REPORT_INCOMPLETE", `${dataset} exceeds its complete read budget`, {
1711
+ dataset,
1712
+ maximumDays: MAX_FEATURE_ROLLUP_DAYS,
1713
+ maximumRows: MAX_FEATURE_ROLLUP_ROWS,
1714
+ });
1715
+ }
1716
+ }
1717
+
1718
+ async function hasFeatureReadHeadroom(ctx: QueryCtx) {
1719
+ const metrics = await ctx.meta.getTransactionMetrics();
1720
+ return (
1721
+ metrics.bytesRead.remaining >= FEATURE_REPORT_BYTE_RESERVE &&
1722
+ metrics.documentsRead.remaining >= FEATURE_REPORT_DOCUMENT_RESERVE
1723
+ );
1724
+ }
1725
+
1726
+ async function requireFeatureReadHeadroom(ctx: QueryCtx, dataset: string) {
1727
+ const metrics = await ctx.meta.getTransactionMetrics();
1728
+ if (
1729
+ metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
1730
+ metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE
1731
+ ) {
1732
+ fail(
1733
+ "REPORT_INCOMPLETE",
1734
+ `${dataset} exceeds its byte or document budget`,
1735
+ {
1736
+ dataset,
1737
+ bytesRead: metrics.bytesRead.used,
1738
+ documentsRead: metrics.documentsRead.used,
1739
+ },
1740
+ );
1741
+ }
1742
+ }
1743
+
1744
+ function failIfIncomplete<T>(rows: T[], maximum: number, dataset: string) {
1745
+ if (rows.length > maximum) {
1746
+ fail(
1747
+ "REPORT_INCOMPLETE",
1748
+ `${dataset} exceeds the complete report read budget`,
1749
+ {
1750
+ maximumRows: maximum,
1751
+ dataset,
1752
+ },
1753
+ );
1754
+ }
1755
+ }
1756
+
1757
+ async function coverageForRange(
1758
+ ctx: QueryCtx,
1759
+ siteIds: Id<"sites">[],
1760
+ from: number,
1761
+ to: number,
1762
+ options: {
1763
+ datasets: CoverageSource[];
1764
+ complete?: boolean;
1765
+ generation?: 1 | null;
1766
+ },
1767
+ ) {
1768
+ const controls = await Promise.all(
1769
+ siteIds.map((siteId) =>
1770
+ ctx.db
1771
+ .query("analyticsCoverage")
1772
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1773
+ .unique(),
1774
+ ),
1775
+ );
1776
+ const sourceRanges = await Promise.all(
1777
+ siteIds.map((siteId) => sourceSetRange(ctx, siteId, options.datasets)),
1778
+ );
1779
+ const retainedBefore = controls.reduce<number | null>((latest, control) => {
1780
+ const before = control?.retained
1781
+ .filter((item) =>
1782
+ options.datasets.some((dataset) => dataset === item.dataset),
1783
+ )
1784
+ .reduce<number | undefined>(
1785
+ (latest, item) => Math.max(latest ?? 0, item.before),
1786
+ undefined,
1787
+ );
1788
+ return before === undefined ? latest : Math.max(latest ?? 0, before);
1789
+ }, null);
1790
+ const availableFrom =
1791
+ options.generation === 1
1792
+ ? controls.length > 0 && controls.every(Boolean)
1793
+ ? Math.max(...controls.map((control) => control!.rollupCompleteFrom))
1794
+ : null
1795
+ : sourceRanges.length > 0 && sourceRanges.every(Boolean)
1796
+ ? Math.max(...sourceRanges.map((range) => range!.from))
1797
+ : null;
1798
+ const availableThrough =
1799
+ sourceRanges.length > 0 && sourceRanges.every(Boolean)
1800
+ ? Math.min(...sourceRanges.map((range) => range!.through))
1801
+ : null;
1802
+ let state: "complete" | "partial" | "retained" | "unavailable";
1803
+ if (retainedBefore !== null && to < retainedBefore) {
1804
+ state = "unavailable";
1805
+ } else if (retainedBefore !== null && from < retainedBefore) {
1806
+ state = "partial";
1807
+ } else if (
1808
+ options.complete ||
1809
+ (availableFrom !== null &&
1810
+ availableThrough !== null &&
1811
+ from >= availableFrom &&
1812
+ to <= availableThrough)
1813
+ ) {
1814
+ state = "complete";
1815
+ } else if (sourceRanges.some(Boolean)) {
1816
+ state = "retained";
1817
+ } else {
1818
+ state = "unavailable";
1819
+ }
1820
+ return {
1821
+ state,
1822
+ generation: options.generation ?? null,
1823
+ availableFrom,
1824
+ availableThrough,
1825
+ retainedBefore,
1826
+ };
1827
+ }
1828
+
1829
+ async function assertSourceAvailable(
1830
+ ctx: QueryCtx,
1831
+ siteIds: Id<"sites">[],
1832
+ sources: CoverageSource[],
1833
+ from: number,
1834
+ ) {
1835
+ const rows = await Promise.all(
1836
+ siteIds.map((siteId) =>
1837
+ ctx.db
1838
+ .query("analyticsCoverage")
1839
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1840
+ .unique(),
1841
+ ),
1842
+ );
1843
+ const cutoff = rows.reduce<number | null>((latest, row) => {
1844
+ for (const retained of row?.retained ?? []) {
1845
+ if (sources.some((source) => source === retained.dataset)) {
1846
+ latest = Math.max(latest ?? 0, retained.before);
1847
+ }
1848
+ }
1849
+ return latest;
1850
+ }, null);
1851
+ if (cutoff !== null && from < cutoff) {
1852
+ fail(
1853
+ "REPORT_INCOMPLETE",
1854
+ "requested report range intersects retained data",
1855
+ {
1856
+ retainedBefore: cutoff,
1857
+ },
1858
+ );
1859
+ }
1860
+ }
1861
+
1862
+ async function datasetCoverageForRange(
1863
+ ctx: QueryCtx,
1864
+ siteId: Id<"sites">,
1865
+ dataset: PublicCoverageDataset,
1866
+ from: number,
1867
+ to: number,
1868
+ ) {
1869
+ if (dataset === "siteMap") {
1870
+ return siteMapDatasetCoverage(ctx, siteId, from, to);
1871
+ }
1872
+ // Vitals have no legacy fallback source; the rollup either covers the range
1873
+ // or the coverage states say how much of it exists.
1874
+ if (dataset === "vitals") {
1875
+ return coverageForRange(ctx, [siteId], from, to, {
1876
+ datasets: ["vitalRollups"],
1877
+ complete: false,
1878
+ });
1879
+ }
1880
+ const sourceOptions: CoverageSource[][] =
1881
+ dataset === "goals"
1882
+ ? [["goalRollups"], ["goalCompletions"]]
1883
+ : dataset === "funnels"
1884
+ ? [["funnelRollups"], ["funnelSteps"]]
1885
+ : dataset === "affiliates"
1886
+ ? [["affiliateRollups"], ["affiliateStats", "sessions"]]
1887
+ : [[dataset]];
1888
+ const options = (
1889
+ await Promise.all(
1890
+ sourceOptions.map(async (sources) => {
1891
+ const rollupDataset = sources[0] as
1892
+ "goalRollups" | "funnelRollups" | "affiliateRollups";
1893
+ const isRollup = rollupDataset.endsWith("Rollups");
1894
+ const complete = isRollup
1895
+ ? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
1896
+ : false;
1897
+ if (isRollup && !complete) return null;
1898
+ return coverageForRange(ctx, [siteId], from, to, {
1899
+ datasets: sources,
1900
+ complete,
1901
+ generation: complete ? 1 : null,
1902
+ });
1903
+ }),
1904
+ )
1905
+ ).filter((option): option is NonNullable<typeof option> => option !== null);
1906
+ return options.sort(
1907
+ (left, right) => coverageRank(right.state) - coverageRank(left.state),
1908
+ )[0]!;
1909
+ }
1910
+
1911
+ /**
1912
+ * The site map's coverage without reading its rows: it starts at the site's
1913
+ * `siteMapSince` and ends where retention cut it. The report itself may still
1914
+ * narrow the window when the range exceeds its row budget.
1915
+ */
1916
+ async function siteMapDatasetCoverage(
1917
+ ctx: QueryCtx,
1918
+ siteId: Id<"sites">,
1919
+ from: number,
1920
+ to: number,
1921
+ ) {
1922
+ const control = await ctx.db
1923
+ .query("analyticsCoverage")
1924
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1925
+ .unique();
1926
+ const since = control?.siteMapSince ?? null;
1927
+ const retainedBefore =
1928
+ control?.retained.find((item) => item.dataset === siteMapCoverageDataset)
1929
+ ?.before ?? null;
1930
+ const unavailable =
1931
+ since === null ||
1932
+ to < since ||
1933
+ (retainedBefore !== null && to < retainedBefore);
1934
+ const state = unavailable
1935
+ ? ("unavailable" as const)
1936
+ : from < Math.max(since, retainedBefore ?? 0)
1937
+ ? ("partial" as const)
1938
+ : ("complete" as const);
1939
+ return {
1940
+ state,
1941
+ generation: 1 as const,
1942
+ availableFrom: since,
1943
+ availableThrough: since === null ? null : to,
1944
+ retainedBefore,
1945
+ };
1946
+ }
1947
+
1948
+ type PublicCoverageDataset =
1949
+ | "overviewHour"
1950
+ | "overviewDay"
1951
+ | "events"
1952
+ | "sessions"
1953
+ | "conversions"
1954
+ | "goals"
1955
+ | "funnels"
1956
+ | "affiliates"
1957
+ | "vitals"
1958
+ | "siteMap";
1959
+
1960
+ type CoverageSource =
1961
+ | "overviewHour"
1962
+ | "overviewDay"
1963
+ | "events"
1964
+ | "sessions"
1965
+ | "conversions"
1966
+ | "goalCompletions"
1967
+ | "funnelSteps"
1968
+ | "affiliateStats"
1969
+ | "goalRollups"
1970
+ | "funnelRollups"
1971
+ | "affiliateRollups"
1972
+ | "vitalRollups";
1973
+
1974
+ type SourceRange = { from: number; through: number };
1975
+
1976
+ async function sourceSetRange(
1977
+ ctx: QueryCtx,
1978
+ siteId: Id<"sites">,
1979
+ sources: CoverageSource[],
1980
+ ): Promise<SourceRange | null> {
1981
+ const ranges = (
1982
+ await Promise.all(sources.map((source) => sourceRange(ctx, siteId, source)))
1983
+ ).filter((range): range is SourceRange => range !== null);
1984
+ if (ranges.length === 0) return null;
1985
+ return {
1986
+ from: Math.min(...ranges.map((range) => range.from)),
1987
+ through: Math.max(...ranges.map((range) => range.through)),
1988
+ };
1989
+ }
1990
+
1991
+ async function sourceRange(
1992
+ ctx: QueryCtx,
1993
+ siteId: Id<"sites">,
1994
+ source: CoverageSource,
1995
+ ): Promise<SourceRange | null> {
1996
+ if (source === "overviewHour" || source === "overviewDay") {
1997
+ const granularity =
1998
+ source === "overviewHour" ? ("hour" as const) : ("day" as const);
1999
+ const [first, last] = await Promise.all([
2000
+ ctx.db
2001
+ .query("aggregateBuckets")
2002
+ .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
2003
+ range.eq("siteId", siteId).eq("granularity", granularity),
2004
+ )
2005
+ .order("asc")
2006
+ .take(1),
2007
+ ctx.db
2008
+ .query("aggregateBuckets")
2009
+ .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
2010
+ range.eq("siteId", siteId).eq("granularity", granularity),
2011
+ )
2012
+ .order("desc")
2013
+ .take(1),
2014
+ ]);
2015
+ return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
2016
+ }
2017
+ if (source === "events") {
2018
+ const [first, last] = await Promise.all([
2019
+ ctx.db
2020
+ .query("eventBatches")
2021
+ .withIndex("by_siteId_and_bucketStart", (range) =>
2022
+ range.eq("siteId", siteId),
2023
+ )
2024
+ .order("asc")
2025
+ .take(1),
2026
+ ctx.db
2027
+ .query("eventBatches")
2028
+ .withIndex("by_siteId_and_bucketStart", (range) =>
2029
+ range.eq("siteId", siteId),
2030
+ )
2031
+ .order("desc")
2032
+ .take(1),
2033
+ ]);
2034
+ return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
2035
+ }
2036
+ if (source === "sessions") {
2037
+ const [first, last] = await Promise.all([
2038
+ ctx.db
2039
+ .query("sessions")
2040
+ .withIndex("by_siteId_and_startedAt", (range) =>
2041
+ range.eq("siteId", siteId),
2042
+ )
2043
+ .order("asc")
2044
+ .take(1),
2045
+ ctx.db
2046
+ .query("sessions")
2047
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
2048
+ range.eq("siteId", siteId),
2049
+ )
2050
+ .order("desc")
2051
+ .take(1),
2052
+ ]);
2053
+ return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
2054
+ }
2055
+ if (source === "conversions") {
2056
+ const [first, last] = await Promise.all([
2057
+ ctx.db
2058
+ .query("conversions")
2059
+ .withIndex("by_siteId_and_timestamp", (range) =>
2060
+ range.eq("siteId", siteId),
2061
+ )
2062
+ .order("asc")
2063
+ .take(1),
2064
+ ctx.db
2065
+ .query("conversions")
2066
+ .withIndex("by_siteId_and_timestamp", (range) =>
2067
+ range.eq("siteId", siteId),
2068
+ )
2069
+ .order("desc")
2070
+ .take(1),
2071
+ ]);
2072
+ return bounds(first[0]?.timestamp, last[0]?.timestamp);
2073
+ }
2074
+ if (source === "goalCompletions") {
2075
+ const [first, last] = await Promise.all([
2076
+ ctx.db
2077
+ .query("goalCompletions")
2078
+ .withIndex("by_siteId_and_timestamp", (range) =>
2079
+ range.eq("siteId", siteId),
2080
+ )
2081
+ .order("asc")
2082
+ .take(1),
2083
+ ctx.db
2084
+ .query("goalCompletions")
2085
+ .withIndex("by_siteId_and_timestamp", (range) =>
2086
+ range.eq("siteId", siteId),
2087
+ )
2088
+ .order("desc")
2089
+ .take(1),
2090
+ ]);
2091
+ return bounds(first[0]?.timestamp, last[0]?.timestamp);
2092
+ }
2093
+ const table =
2094
+ source === "funnelSteps"
2095
+ ? ("funnelStepBuckets" as const)
2096
+ : source === "affiliateStats"
2097
+ ? ("affiliateDailyStats" as const)
2098
+ : source === "goalRollups"
2099
+ ? ("goalDailyRollups" as const)
2100
+ : source === "funnelRollups"
2101
+ ? ("funnelDailyRollups" as const)
2102
+ : source === "vitalRollups"
2103
+ ? ("vitalRollups" as const)
2104
+ : ("affiliateDailyRollups" as const);
2105
+ const [first, last] = await Promise.all([
2106
+ ctx.db
2107
+ .query(table)
2108
+ .withIndex("by_siteId_and_bucketStart", (range) =>
2109
+ range.eq("siteId", siteId),
2110
+ )
2111
+ .order("asc")
2112
+ .take(1),
2113
+ ctx.db
2114
+ .query(table)
2115
+ .withIndex("by_siteId_and_bucketStart", (range) =>
2116
+ range.eq("siteId", siteId),
2117
+ )
2118
+ .order("desc")
2119
+ .take(1),
2120
+ ]);
2121
+ return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
2122
+ }
2123
+
2124
+ function bounds(from: number | undefined, through: number | undefined) {
2125
+ return from === undefined || through === undefined ? null : { from, through };
2126
+ }
2127
+
2128
+ function coverageRank(
2129
+ state: "complete" | "partial" | "retained" | "unavailable",
2130
+ ) {
2131
+ if (state === "complete") return 3;
2132
+ if (state === "retained") return 2;
2133
+ if (state === "partial") return 1;
2134
+ return 0;
2135
+ }
698
2136
 
699
2137
  type MetricTotals = {
700
2138
  events: number;
@@ -736,23 +2174,61 @@ function addMetrics(target: MetricTotals, source: MetricTotals) {
736
2174
 
737
2175
  function validateSiteIds<T>(siteIds: T[]): T[] {
738
2176
  const unique = [...new Set(siteIds)];
739
- if (unique.length < 1 || unique.length > MAX_REPORT_SITES || unique.length !== siteIds.length) {
740
- fail("INVALID_ARGUMENT", `siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`);
2177
+ if (
2178
+ unique.length < 1 ||
2179
+ unique.length > MAX_REPORT_SITES ||
2180
+ unique.length !== siteIds.length
2181
+ ) {
2182
+ fail(
2183
+ "INVALID_ARGUMENT",
2184
+ `siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`,
2185
+ );
741
2186
  }
742
2187
  return unique;
743
2188
  }
744
2189
 
745
2190
  function validateRange(from: number, to: number, maxDays: number) {
746
2191
  if (
747
- !Number.isFinite(from) ||
748
- !Number.isFinite(to) ||
2192
+ !Number.isSafeInteger(from) ||
2193
+ !Number.isSafeInteger(to) ||
749
2194
  from < 0 ||
750
- to <= from ||
751
- to - from > maxDays * DAY_MS
2195
+ to < from ||
2196
+ to - from >= maxDays * DAY_MS
752
2197
  ) {
753
- fail("INVALID_ARGUMENT", `range must be positive, ordered, and at most ${maxDays} days`);
2198
+ fail(
2199
+ "INVALID_ARGUMENT",
2200
+ `range must use inclusive integer milliseconds and span at most ${maxDays} UTC days`,
2201
+ );
754
2202
  }
755
- return { from: Math.trunc(from), to: Math.trunc(to) };
2203
+ return { from, to };
2204
+ }
2205
+
2206
+ function isAlignedRange(from: number, to: number, intervalMs: number) {
2207
+ return from % intervalMs === 0 && (to + 1) % intervalMs === 0;
2208
+ }
2209
+
2210
+ function requireAlignedRange(
2211
+ from: number,
2212
+ to: number,
2213
+ intervalMs: number,
2214
+ report: string,
2215
+ ) {
2216
+ if (!isAlignedRange(from, to, intervalMs)) {
2217
+ fail(
2218
+ "REPORT_INCOMPLETE",
2219
+ `${report} requires complete inclusive UTC bucket boundaries`,
2220
+ { from, to, intervalMs, timezone: "UTC" },
2221
+ );
2222
+ }
2223
+ }
2224
+
2225
+ function rangeSemantics(from: number, to: number) {
2226
+ return {
2227
+ from,
2228
+ to,
2229
+ boundary: "inclusive" as const,
2230
+ timezone: "UTC" as const,
2231
+ };
756
2232
  }
757
2233
 
758
2234
  type KeysetPaginationInput = {
@@ -881,22 +2357,23 @@ function keysetPaginationResult<T>(
881
2357
  ) {
882
2358
  const page = rows.slice(0, pagination.numItems);
883
2359
  const isDone = rows.length <= pagination.numItems;
884
- const next = page.length > 0
885
- ? cursorFor(page[page.length - 1])
886
- : pagination.cursor;
2360
+ const next =
2361
+ page.length > 0 ? cursorFor(page[page.length - 1]) : pagination.cursor;
887
2362
  return {
888
2363
  page,
889
2364
  isDone,
890
- continueCursor: btoa(JSON.stringify({
891
- v: 2,
892
- k: pagination.scope.kind,
893
- s: pagination.scope.siteId,
894
- n: pagination.scope.sessionId ?? null,
895
- f: pagination.scope.from,
896
- t: pagination.scope.to,
897
- x: next?.timestamp ?? pagination.scope.to,
898
- c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
899
- })),
2365
+ continueCursor: btoa(
2366
+ JSON.stringify({
2367
+ v: 2,
2368
+ k: pagination.scope.kind,
2369
+ s: pagination.scope.siteId,
2370
+ n: pagination.scope.sessionId ?? null,
2371
+ f: pagination.scope.from,
2372
+ t: pagination.scope.to,
2373
+ x: next?.timestamp ?? pagination.scope.to,
2374
+ c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
2375
+ }),
2376
+ ),
900
2377
  };
901
2378
  }
902
2379
 
@@ -908,26 +2385,28 @@ function journeyPaginationResult(
908
2385
  const isDone = rows.length <= pagination.numItems;
909
2386
  const last = page.at(-1);
910
2387
  const next = last
911
- ? {
912
- timestamp: last.timestamp,
913
- creationTime: last._creationTime,
914
- id: last._id,
2388
+ ? {
2389
+ timestamp: last.timestamp,
2390
+ creationTime: last._creationTime,
2391
+ id: last._id,
915
2392
  }
916
2393
  : pagination.cursor;
917
2394
  return {
918
2395
  page,
919
2396
  isDone,
920
- continueCursor: btoa(JSON.stringify({
921
- v: 4,
922
- k: pagination.scope.kind,
923
- s: pagination.scope.siteId,
924
- n: pagination.scope.sessionId,
925
- f: pagination.scope.from,
926
- t: pagination.scope.to,
927
- x: next?.timestamp ?? pagination.scope.to,
928
- c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
929
- i: next?.id ?? "~",
930
- })),
2397
+ continueCursor: btoa(
2398
+ JSON.stringify({
2399
+ v: 4,
2400
+ k: pagination.scope.kind,
2401
+ s: pagination.scope.siteId,
2402
+ n: pagination.scope.sessionId,
2403
+ f: pagination.scope.from,
2404
+ t: pagination.scope.to,
2405
+ x: next?.timestamp ?? pagination.scope.to,
2406
+ c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
2407
+ i: next?.id ?? "~",
2408
+ }),
2409
+ ),
931
2410
  };
932
2411
  }
933
2412
 
@@ -936,8 +2415,15 @@ function validatePagination(options: {
936
2415
  maximumRowsRead?: number;
937
2416
  maximumBytesRead?: number;
938
2417
  }) {
939
- if (!Number.isSafeInteger(options.numItems) || options.numItems < 1 || options.numItems > 100) {
940
- fail("INVALID_ARGUMENT", "pagination numItems must be an integer between 1 and 100");
2418
+ if (
2419
+ !Number.isSafeInteger(options.numItems) ||
2420
+ options.numItems < 1 ||
2421
+ options.numItems > 100
2422
+ ) {
2423
+ fail(
2424
+ "INVALID_ARGUMENT",
2425
+ "pagination numItems must be an integer between 1 and 100",
2426
+ );
941
2427
  }
942
2428
  if (
943
2429
  options.maximumRowsRead !== undefined &&
@@ -945,7 +2431,10 @@ function validatePagination(options: {
945
2431
  options.maximumRowsRead < 1 ||
946
2432
  options.maximumRowsRead > 1_000)
947
2433
  ) {
948
- fail("INVALID_ARGUMENT", "maximumRowsRead must be an integer between 1 and 1000");
2434
+ fail(
2435
+ "INVALID_ARGUMENT",
2436
+ "maximumRowsRead must be an integer between 1 and 1000",
2437
+ );
949
2438
  }
950
2439
  if (
951
2440
  options.maximumBytesRead !== undefined &&
@@ -953,6 +2442,9 @@ function validatePagination(options: {
953
2442
  options.maximumBytesRead < 1 ||
954
2443
  options.maximumBytesRead > 8_000_000)
955
2444
  ) {
956
- fail("INVALID_ARGUMENT", "maximumBytesRead must be an integer between 1 and 8000000");
2445
+ fail(
2446
+ "INVALID_ARGUMENT",
2447
+ "maximumBytesRead must be an integer between 1 and 8000000",
2448
+ );
957
2449
  }
958
2450
  }