@iann29/rastro 0.1.0-alpha.0 → 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 (164) hide show
  1. package/README.md +579 -57
  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 +18 -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 +34 -7
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +47 -7
  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 +106 -0
  47. package/dist/component/eventStore.d.ts.map +1 -0
  48. package/dist/component/eventStore.js +294 -0
  49. package/dist/component/eventStore.js.map +1 -0
  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 +25 -9
  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 +284 -53
  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 +1105 -243
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts +8 -0
  74. package/dist/component/live.d.ts.map +1 -1
  75. package/dist/component/live.js +74 -9
  76. package/dist/component/live.js.map +1 -1
  77. package/dist/component/reports.d.ts +273 -24
  78. package/dist/component/reports.d.ts.map +1 -1
  79. package/dist/component/reports.js +1320 -150
  80. package/dist/component/reports.js.map +1 -1
  81. package/dist/component/retention.d.ts +75 -1
  82. package/dist/component/retention.d.ts.map +1 -1
  83. package/dist/component/retention.js +541 -31
  84. package/dist/component/retention.js.map +1 -1
  85. package/dist/component/sanitize.d.ts +24 -1
  86. package/dist/component/sanitize.d.ts.map +1 -1
  87. package/dist/component/sanitize.js +107 -19
  88. package/dist/component/sanitize.js.map +1 -1
  89. package/dist/component/schema.d.ts +462 -55
  90. package/dist/component/schema.js +195 -15
  91. package/dist/component/schema.js.map +1 -1
  92. package/dist/component/sites.d.ts.map +1 -1
  93. package/dist/component/sites.js +38 -25
  94. package/dist/component/sites.js.map +1 -1
  95. package/dist/component/useragent.d.ts +9 -0
  96. package/dist/component/useragent.d.ts.map +1 -0
  97. package/dist/component/useragent.js +152 -0
  98. package/dist/component/useragent.js.map +1 -0
  99. package/dist/component/validators.d.ts +101 -16
  100. package/dist/component/validators.d.ts.map +1 -1
  101. package/dist/component/validators.js +19 -2
  102. package/dist/component/validators.js.map +1 -1
  103. package/dist/component/visitors.d.ts +19 -0
  104. package/dist/component/visitors.d.ts.map +1 -0
  105. package/dist/component/visitors.js +86 -0
  106. package/dist/component/visitors.js.map +1 -0
  107. package/dist/component/vitals.d.ts +41 -0
  108. package/dist/component/vitals.d.ts.map +1 -0
  109. package/dist/component/vitals.js +115 -0
  110. package/dist/component/vitals.js.map +1 -0
  111. package/dist/react/index.d.ts.map +1 -1
  112. package/dist/react/index.js.map +1 -1
  113. package/dist/tracker/generated.d.ts +8 -4
  114. package/dist/tracker/generated.d.ts.map +1 -1
  115. package/dist/tracker/generated.js +8 -4
  116. package/dist/tracker/generated.js.map +1 -1
  117. package/dist/tracker/tracker.d.ts +1 -1
  118. package/dist/tracker/tracker.d.ts.map +1 -1
  119. package/dist/tracker/tracker.js +81 -33
  120. package/dist/tracker/tracker.js.map +1 -1
  121. package/dist/tracker/vitals.d.ts +10 -0
  122. package/dist/tracker/vitals.d.ts.map +1 -0
  123. package/dist/tracker/vitals.js +140 -0
  124. package/dist/tracker/vitals.js.map +1 -0
  125. package/dist/tracker.min.js +1 -1
  126. package/dist/vitals.min.js +1 -0
  127. package/docs/benchmarks/2026-08-20-realistic.md +171 -0
  128. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  129. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  130. package/docs/federation-setup.md +397 -0
  131. package/docs/federation.md +262 -0
  132. package/docs/upgrading.md +259 -0
  133. package/llms.txt +71 -0
  134. package/package.json +61 -11
  135. package/scripts/benchmark-ingest.mjs +601 -0
  136. package/scripts/generate-federation-keys.mjs +20 -0
  137. package/src/component/_generated/api.ts +18 -0
  138. package/src/component/_generated/component.ts +366 -4
  139. package/src/component/_generated/server.ts +4 -0
  140. package/src/component/affiliates.ts +20 -5
  141. package/src/component/cardinality.ts +117 -0
  142. package/src/component/constants.ts +47 -7
  143. package/src/component/convex.config.ts +11 -1
  144. package/src/component/coverage.ts +36 -0
  145. package/src/component/diagnostics.ts +65 -0
  146. package/src/component/errors.ts +2 -1
  147. package/src/component/eventStore.ts +489 -0
  148. package/src/component/funnels.ts +19 -16
  149. package/src/component/geo.ts +781 -0
  150. package/src/component/goals.ts +45 -16
  151. package/src/component/guards.ts +3 -1
  152. package/src/component/http.ts +403 -60
  153. package/src/component/identity.ts +74 -0
  154. package/src/component/ingest.ts +1652 -291
  155. package/src/component/live.ts +90 -9
  156. package/src/component/reports.ts +1929 -199
  157. package/src/component/retention.ts +774 -97
  158. package/src/component/sanitize.ts +154 -32
  159. package/src/component/schema.ts +219 -15
  160. package/src/component/sites.ts +56 -28
  161. package/src/component/useragent.ts +171 -0
  162. package/src/component/validators.ts +29 -1
  163. package/src/component/visitors.ts +116 -0
  164. package/src/component/vitals.ts +146 -0
@@ -1,16 +1,20 @@
1
1
  import { paginationOptsValidator, paginationResultValidator, } from "convex/server";
2
2
  import { v } from "convex/values";
3
3
  import { query } from "./_generated/server.js";
4
- import { AGGREGATE_SHARDS, DAY_MS, HOUR_MS, MAX_AFFILIATES_PER_SITE, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, MAX_JOURNEY_EVENTS, MAX_LIVE_VISITORS, MAX_MANUAL_PAGINATION_OFFSET, MAX_OVERVIEW_AGGREGATE_ROWS, MAX_REPORT_RANGE_DAYS, MAX_REPORT_SITES, } from "./constants.js";
4
+ import { AGGREGATE_SHARDS, DAY_MS, FEATURE_REPORT_BYTE_RESERVE, FEATURE_REPORT_DOCUMENT_RESERVE, HOUR_MS, HOURLY_AGGREGATE_SHARDS, MAX_AFFILIATES_PER_SITE, MAX_FUNNELS_PER_SITE, MAX_FEATURE_ROLLUP_DAYS, MAX_FEATURE_ROLLUP_ROWS, MAX_GOALS_PER_SITE, MAX_JOURNEY_EVENTS, MAX_LIVE_VISITORS, MAX_OVERVIEW_AGGREGATE_ROWS, MAX_REPORT_BUCKETS, MAX_REPORT_RANGE_DAYS, MAX_REPORT_SITES, MAX_SITE_MAP_ROUTES_PER_DAY, MAX_SITE_MAP_ROWS, MAX_VITALS_REPORT_ROWS, REPORT_ROLLUP_SHARDS, ROUTE_TRANSITION_SLOTS, SITE_MAP_OTHER_ROUTE, VITALS_REPORT_PAGE_LIMIT, } from "./constants.js";
5
+ import { createVisitorSketch, estimateVisitors, unionVisitorSketches, } from "./cardinality.js";
5
6
  import { fail } from "./errors.js";
7
+ import { compareJourneyEvents, hydrateEventContexts, loadSessionJourneyEvents, loadVisitorJourneyEvents, } from "./eventStore.js";
6
8
  import { isPlainRecord } from "./guards.js";
7
- import { affiliateFieldsValidator, eventFieldsValidator, funnelFieldsValidator, goalFieldsValidator, sessionFieldsValidator, } from "./validators.js";
9
+ import { affiliateFieldsValidator, eventFieldsValidator, funnelFieldsValidator, goalFieldsValidator, sessionFieldsValidator, vitalMetricValidator, } from "./validators.js";
10
+ import { resolveVisitorIdentities } from "./visitors.js";
11
+ import { mergeVitalHistograms, VITAL_ALL, VITAL_METRICS, vitalHistogramPercentile, vitalRatingCounts, } from "./vitals.js";
8
12
  const sessionDocumentValidator = sessionFieldsValidator.extend({
9
13
  _id: v.id("sessions"),
10
14
  _creationTime: v.number(),
11
15
  });
12
16
  const eventDocumentValidator = eventFieldsValidator.extend({
13
- _id: v.id("events"),
17
+ _id: v.string(),
14
18
  _creationTime: v.number(),
15
19
  });
16
20
  const liveSessionDocumentValidator = v.object({
@@ -33,6 +37,8 @@ const liveSessionDocumentValidator = v.object({
33
37
  device: v.string(),
34
38
  pageviewCount: v.number(),
35
39
  eventCount: v.number(),
40
+ previousPath: v.optional(v.string()),
41
+ movedAt: v.optional(v.number()),
36
42
  });
37
43
  const conversionDocumentValidator = v.object({
38
44
  _id: v.id("conversions"),
@@ -64,12 +70,33 @@ const metricTotalsValidator = v.object({
64
70
  conversions: v.number(),
65
71
  revenueCents: v.number(),
66
72
  });
67
- const timePointValidator = metricTotalsValidator.extend({ timestamp: v.number() });
73
+ const timePointValidator = metricTotalsValidator.extend({
74
+ timestamp: v.number(),
75
+ });
68
76
  const topItemValidator = v.object({
69
77
  value: v.string(),
70
78
  count: v.number(),
71
79
  revenueCents: v.number(),
72
80
  });
81
+ const coverageStateValidator = v.union(v.literal("complete"), v.literal("partial"), v.literal("retained"), v.literal("unavailable"));
82
+ const resultCoverageValidator = v.object({
83
+ state: coverageStateValidator,
84
+ generation: v.union(v.number(), v.null()),
85
+ availableFrom: v.union(v.number(), v.null()),
86
+ availableThrough: v.union(v.number(), v.null()),
87
+ retainedBefore: v.union(v.number(), v.null()),
88
+ });
89
+ const rangeSemanticsValidator = v.object({
90
+ from: v.number(),
91
+ to: v.number(),
92
+ boundary: v.literal("inclusive"),
93
+ timezone: v.literal("UTC"),
94
+ });
95
+ const reportMetadataValidator = v.object({
96
+ basis: v.union(v.literal("dailyRollup"), v.literal("boundedLegacy")),
97
+ coverage: resultCoverageValidator,
98
+ range: rangeSemanticsValidator,
99
+ });
73
100
  export const overview = query({
74
101
  args: {
75
102
  siteIds: v.array(v.id("sites")),
@@ -85,12 +112,40 @@ export const overview = query({
85
112
  totals: metricTotalsValidator,
86
113
  timeSeries: v.array(timePointValidator),
87
114
  topSources: v.array(topItemValidator),
115
+ topCampaigns: v.array(topItemValidator),
88
116
  topPages: v.array(topItemValidator),
89
117
  topCountries: v.array(topItemValidator),
90
118
  topDevices: v.array(topItemValidator),
91
119
  topBrowsers: v.array(topItemValidator),
92
120
  topEvents: v.array(topItemValidator),
93
121
  topGoals: v.array(topItemValidator),
122
+ metadata: v.object({
123
+ visitors: v.object({
124
+ basis: v.union(v.literal("estimatedUnique"), v.literal("bucketOccurrences")),
125
+ exact: v.boolean(),
126
+ legacyIncomplete: v.boolean(),
127
+ sketchVersion: v.union(v.number(), v.null()),
128
+ precision: v.union(v.number(), v.null()),
129
+ }),
130
+ breakdowns: v.object({
131
+ topSources: v.literal("eventVolume"),
132
+ topCampaigns: v.literal("eventVolume"),
133
+ topPages: v.literal("eventVolume"),
134
+ topCountries: v.literal("eventVolume"),
135
+ topDevices: v.literal("eventVolume"),
136
+ topBrowsers: v.literal("eventVolume"),
137
+ topEvents: v.literal("eventVolume"),
138
+ topGoals: v.literal("goalCompletionVolume"),
139
+ }),
140
+ coverage: resultCoverageValidator,
141
+ range: rangeSemanticsValidator,
142
+ features: v.object({
143
+ sessionReplay: v.literal("unsupported"),
144
+ errorInsights: v.literal("unsupported"),
145
+ outboundLinks: v.literal("eventVolume"),
146
+ botDetection: v.literal("userAgent"),
147
+ }),
148
+ }),
94
149
  }),
95
150
  handler: async (ctx, args) => {
96
151
  const siteIds = validateSiteIds(args.siteIds);
@@ -108,21 +163,31 @@ export const overview = query({
108
163
  }
109
164
  const currency = [...currencies][0];
110
165
  const { from, to } = validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
111
- const interval = args.interval ?? (to - from <= 7 * DAY_MS ? "hour" : "day");
112
- if (interval === "hour" && to - from > 7 * DAY_MS) {
113
- fail("INVALID_ARGUMENT", "hourly reports are limited to seven days");
166
+ const interval = args.interval ?? (to - from <= DAY_MS ? "hour" : "day");
167
+ if (interval === "hour" && to - from > DAY_MS) {
168
+ fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
114
169
  }
115
170
  const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
171
+ requireAlignedRange(from, to, intervalMs, `${interval} overview`);
116
172
  const firstBucket = Math.floor(from / intervalMs) * intervalMs;
117
173
  const lastBucket = Math.floor(to / intervalMs) * intervalMs;
118
174
  const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
119
- const perSiteLimit = bucketCount * AGGREGATE_SHARDS;
175
+ const perSiteLimit = bucketCount *
176
+ (interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
120
177
  if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
121
178
  fail("LIMIT_EXCEEDED", "overview range is too wide for the requested number of sites", { maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS });
122
179
  }
123
180
  const totals = emptyTotals();
124
181
  const points = new Map();
182
+ const visitorSketch = createVisitorSketch();
183
+ let mergedVisitorSketch = visitorSketch;
184
+ const pointVisitorSketches = new Map();
185
+ let visitorsComplete = true;
125
186
  const dimensions = new Map();
187
+ const overviewDataset = interval === "hour"
188
+ ? "overviewHour"
189
+ : "overviewDay";
190
+ await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
126
191
  for (const siteId of siteIds) {
127
192
  const buckets = await ctx.db
128
193
  .query("aggregateBuckets")
@@ -131,12 +196,21 @@ export const overview = query({
131
196
  .eq("granularity", interval)
132
197
  .gte("bucketStart", firstBucket)
133
198
  .lte("bucketStart", lastBucket))
134
- .take(perSiteLimit);
199
+ .take(perSiteLimit + 1);
200
+ failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
135
201
  for (const bucket of buckets) {
136
202
  addMetrics(totals, bucket);
137
203
  const point = points.get(bucket.bucketStart) ?? emptyTotals();
138
204
  addMetrics(point, bucket);
139
205
  points.set(bucket.bucketStart, point);
206
+ if (bucket.visitorSketch && bucket.visitorSketchComplete === true) {
207
+ mergedVisitorSketch = unionVisitorSketches(mergedVisitorSketch, bucket.visitorSketch);
208
+ pointVisitorSketches.set(bucket.bucketStart, unionVisitorSketches(pointVisitorSketches.get(bucket.bucketStart) ??
209
+ createVisitorSketch(bucket.visitorSketch.precision), bucket.visitorSketch));
210
+ }
211
+ else if (bucket.events > 0) {
212
+ visitorsComplete = false;
213
+ }
140
214
  for (const dimension of bucket.dimensions) {
141
215
  const key = `${dimension.type}:${dimension.value}`;
142
216
  const current = dimensions.get(key) ?? {
@@ -155,6 +229,17 @@ export const overview = query({
155
229
  .map(([, value]) => value)
156
230
  .sort((left, right) => right.count - left.count || left.value.localeCompare(right.value))
157
231
  .slice(0, 10);
232
+ if (visitorsComplete) {
233
+ totals.visitors = estimateVisitors(mergedVisitorSketch);
234
+ for (const [timestamp, point] of points) {
235
+ point.visitors = estimateVisitors(pointVisitorSketches.get(timestamp) ?? createVisitorSketch());
236
+ }
237
+ }
238
+ const coverage = await coverageForRange(ctx, siteIds, from, to, {
239
+ datasets: [overviewDataset],
240
+ complete: visitorsComplete,
241
+ });
242
+ const visitorBasis = visitorsComplete ? "estimatedUnique" : "bucketOccurrences";
158
243
  return {
159
244
  from,
160
245
  to,
@@ -165,12 +250,40 @@ export const overview = query({
165
250
  .sort(([left], [right]) => left - right)
166
251
  .map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
167
252
  topSources: top("source"),
253
+ topCampaigns: top("campaign"),
168
254
  topPages: top("page"),
169
255
  topCountries: top("country"),
170
256
  topDevices: top("device"),
171
257
  topBrowsers: top("browser"),
172
258
  topEvents: top("event"),
173
259
  topGoals: top("goal"),
260
+ metadata: {
261
+ visitors: {
262
+ basis: visitorBasis,
263
+ exact: false,
264
+ legacyIncomplete: !visitorsComplete,
265
+ sketchVersion: visitorsComplete ? 1 : null,
266
+ precision: visitorsComplete ? 6 : null,
267
+ },
268
+ breakdowns: {
269
+ topSources: "eventVolume",
270
+ topCampaigns: "eventVolume",
271
+ topPages: "eventVolume",
272
+ topCountries: "eventVolume",
273
+ topDevices: "eventVolume",
274
+ topBrowsers: "eventVolume",
275
+ topEvents: "eventVolume",
276
+ topGoals: "goalCompletionVolume",
277
+ },
278
+ coverage,
279
+ range: rangeSemantics(from, to),
280
+ features: {
281
+ sessionReplay: "unsupported",
282
+ errorInsights: "unsupported",
283
+ outboundLinks: "eventVolume",
284
+ botDetection: "userAgent",
285
+ },
286
+ },
174
287
  };
175
288
  },
176
289
  });
@@ -184,7 +297,9 @@ export const liveVisitors = query({
184
297
  handler: async (ctx, args) => {
185
298
  const siteIds = validateSiteIds(args.siteIds);
186
299
  const limit = args.limit ?? 100;
187
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIVE_VISITORS) {
300
+ if (!Number.isSafeInteger(limit) ||
301
+ limit < 1 ||
302
+ limit > MAX_LIVE_VISITORS) {
188
303
  fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_LIVE_VISITORS}`);
189
304
  }
190
305
  if (!Number.isFinite(args.now) || args.now < 0) {
@@ -221,9 +336,241 @@ export const liveVisitors = query({
221
336
  device: row.device,
222
337
  pageviewCount: row.pageviewCount,
223
338
  eventCount: row.eventCount,
339
+ ...(row.previousPath ? { previousPath: row.previousPath } : {}),
340
+ ...(row.movedAt !== undefined ? { movedAt: row.movedAt } : {}),
224
341
  }));
225
342
  },
226
343
  });
344
+ const siteMapRouteValidator = v.object({
345
+ route: v.string(),
346
+ pageviews: v.number(),
347
+ visitors: v.number(),
348
+ entries: v.number(),
349
+ exits: v.number(),
350
+ bounces: v.number(),
351
+ transitionsIn: v.number(),
352
+ transitionsOut: v.number(),
353
+ dwellMs: v.number(),
354
+ dwellSamples: v.number(),
355
+ daily: v.array(v.object({ bucketStart: v.number(), pageviews: v.number() })),
356
+ });
357
+ const siteMapTransitionValidator = v.object({
358
+ from: v.string(),
359
+ to: v.string(),
360
+ count: v.number(),
361
+ });
362
+ const siteMapCoverageDataset = "siteMapRollups";
363
+ /**
364
+ * Every route the sites served in the range and the transitions between
365
+ * them, read from the daily route rollups newest day first. Exits and
366
+ * bounces are derived rather than counted: a session enters once, leaves
367
+ * once, and every later pageview is a transition, so per route
368
+ * `exits = entries + transitionsIn - transitionsOut` and
369
+ * `bounces = entries - continued` hold without a session-end hook. The
370
+ * report never falls back to raw telemetry; days it could not cover are
371
+ * declared through `coverage` and `window`.
372
+ */
373
+ export const siteMap = query({
374
+ args: {
375
+ siteIds: v.array(v.id("sites")),
376
+ from: v.number(),
377
+ to: v.number(),
378
+ },
379
+ returns: v.object({
380
+ from: v.number(),
381
+ to: v.number(),
382
+ window: v.union(v.object({ from: v.number(), to: v.number() }), v.null()),
383
+ routes: v.array(siteMapRouteValidator),
384
+ transitions: v.array(siteMapTransitionValidator),
385
+ truncated: v.object({ routes: v.boolean(), transitions: v.boolean() }),
386
+ metadata: reportMetadataValidator.extend({
387
+ visitors: v.object({
388
+ basis: v.literal("estimatedUnique"),
389
+ exact: v.boolean(),
390
+ }),
391
+ limits: v.object({
392
+ routesPerDay: v.number(),
393
+ transitionSlots: v.number(),
394
+ rows: v.number(),
395
+ }),
396
+ }),
397
+ }),
398
+ handler: async (ctx, args) => {
399
+ const siteIds = validateSiteIds(args.siteIds);
400
+ validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
401
+ requireAlignedRange(args.from, args.to, DAY_MS, "site map");
402
+ const controls = await Promise.all(siteIds.map((siteId) => ctx.db
403
+ .query("analyticsCoverage")
404
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
405
+ .unique()));
406
+ const since = controls.every((control) => control?.siteMapSince !== undefined)
407
+ ? Math.max(...controls.map((control) => control.siteMapSince))
408
+ : null;
409
+ const retainedBefore = controls.reduce((latest, control) => {
410
+ const before = control?.retained.find((item) => item.dataset === siteMapCoverageDataset)?.before;
411
+ return before === undefined ? latest : Math.max(latest ?? 0, before);
412
+ }, null);
413
+ const metadata = (state) => ({
414
+ basis: "dailyRollup",
415
+ coverage: {
416
+ state,
417
+ generation: 1,
418
+ availableFrom: since,
419
+ availableThrough: since === null ? null : args.to,
420
+ retainedBefore,
421
+ },
422
+ range: rangeSemantics(args.from, args.to),
423
+ visitors: { basis: "estimatedUnique", exact: false },
424
+ limits: {
425
+ routesPerDay: MAX_SITE_MAP_ROUTES_PER_DAY,
426
+ transitionSlots: ROUTE_TRANSITION_SLOTS,
427
+ rows: MAX_SITE_MAP_ROWS,
428
+ },
429
+ });
430
+ if (since === null ||
431
+ args.to < since ||
432
+ (retainedBefore !== null && args.to < retainedBefore)) {
433
+ return {
434
+ from: args.from,
435
+ to: args.to,
436
+ window: null,
437
+ routes: [],
438
+ transitions: [],
439
+ truncated: { routes: false, transitions: false },
440
+ metadata: metadata("unavailable"),
441
+ };
442
+ }
443
+ const readFrom = Math.max(args.from, Math.floor(since / DAY_MS) * DAY_MS, retainedBefore ?? 0);
444
+ let budget = MAX_SITE_MAP_ROWS;
445
+ let completeFrom = readFrom;
446
+ const rows = [];
447
+ for (const siteId of siteIds) {
448
+ const read = await readRouteRollups(ctx, siteId, readFrom, args.to, budget);
449
+ budget -= read.rows.length;
450
+ completeFrom = Math.max(completeFrom, read.completeFrom);
451
+ rows.push(...read.rows);
452
+ }
453
+ const windowFrom = Math.max(args.from, completeFrom, since, retainedBefore ?? 0);
454
+ const routes = new Map();
455
+ const totalsFor = (route) => {
456
+ let current = routes.get(route);
457
+ if (!current) {
458
+ current = {
459
+ pageviews: 0,
460
+ entries: 0,
461
+ continued: 0,
462
+ dwellMs: 0,
463
+ dwellSamples: 0,
464
+ transitionsIn: 0,
465
+ transitionsOut: 0,
466
+ sketch: createVisitorSketch(),
467
+ daily: new Map(),
468
+ };
469
+ routes.set(route, current);
470
+ }
471
+ return current;
472
+ };
473
+ const edges = new Map();
474
+ for (const row of rows) {
475
+ if (row.bucketStart < completeFrom)
476
+ continue;
477
+ const current = totalsFor(row.route);
478
+ current.pageviews += row.pageviews;
479
+ current.entries += row.entries;
480
+ current.continued += row.continued;
481
+ current.dwellMs += row.dwellMs;
482
+ current.dwellSamples += row.dwellSamples;
483
+ current.sketch = unionVisitorSketches(current.sketch, row.visitorSketch);
484
+ current.daily.set(row.bucketStart, (current.daily.get(row.bucketStart) ?? 0) + row.pageviews);
485
+ for (const target of row.outgoing) {
486
+ const key = `${row.route}\u0000${target.route}`;
487
+ const edge = edges.get(key) ?? {
488
+ from: row.route,
489
+ to: target.route,
490
+ count: 0,
491
+ };
492
+ edge.count += target.count;
493
+ edges.set(key, edge);
494
+ current.transitionsOut += target.count;
495
+ }
496
+ }
497
+ // A target without a row of its own in this window — folded past the
498
+ // daily cap, or already retained — counts under "(other)".
499
+ const transitions = new Map();
500
+ for (const edge of edges.values()) {
501
+ const to = routes.has(edge.to) ? edge.to : SITE_MAP_OTHER_ROUTE;
502
+ const key = `${edge.from}\u0000${to}`;
503
+ const merged = transitions.get(key) ?? { from: edge.from, to, count: 0 };
504
+ merged.count += edge.count;
505
+ transitions.set(key, merged);
506
+ totalsFor(to).transitionsIn += edge.count;
507
+ }
508
+ const routeRows = [...routes.entries()]
509
+ .map(([route, totals]) => ({
510
+ route,
511
+ pageviews: totals.pageviews,
512
+ visitors: estimateVisitors(totals.sketch),
513
+ entries: totals.entries,
514
+ exits: Math.max(0, totals.entries + totals.transitionsIn - totals.transitionsOut),
515
+ bounces: Math.max(0, totals.entries - totals.continued),
516
+ transitionsIn: totals.transitionsIn,
517
+ transitionsOut: totals.transitionsOut,
518
+ dwellMs: totals.dwellMs,
519
+ dwellSamples: totals.dwellSamples,
520
+ daily: [...totals.daily.entries()]
521
+ .sort(([left], [right]) => left - right)
522
+ .map(([bucketStart, pageviews]) => ({ bucketStart, pageviews })),
523
+ }))
524
+ .sort((left, right) => right.pageviews - left.pageviews ||
525
+ left.route.localeCompare(right.route));
526
+ const transitionRows = [...transitions.values()].sort((left, right) => right.count - left.count ||
527
+ left.from.localeCompare(right.from) ||
528
+ left.to.localeCompare(right.to));
529
+ return {
530
+ from: args.from,
531
+ to: args.to,
532
+ window: completeFrom > args.to ? null : { from: windowFrom, to: args.to },
533
+ routes: routeRows,
534
+ transitions: transitionRows,
535
+ truncated: {
536
+ routes: routes.has(SITE_MAP_OTHER_ROUTE),
537
+ transitions: transitionRows.some((transition) => transition.from === SITE_MAP_OTHER_ROUTE ||
538
+ transition.to === SITE_MAP_OTHER_ROUTE),
539
+ },
540
+ metadata: metadata(windowFrom > args.from ? "partial" : "complete"),
541
+ };
542
+ },
543
+ });
544
+ /**
545
+ * Reads a site's route rollups newest day first until the row budget or the
546
+ * transaction's read headroom runs out, then drops the day it could only
547
+ * read in part so every day it returns is complete.
548
+ */
549
+ async function readRouteRollups(ctx, siteId, from, to, budget) {
550
+ const rows = [];
551
+ let cutAt = null;
552
+ const query = ctx.db
553
+ .query("routeDailyRollups")
554
+ .withIndex("by_siteId_and_bucketStart", (range) => range
555
+ .eq("siteId", siteId)
556
+ .gte("bucketStart", from)
557
+ .lte("bucketStart", to))
558
+ .order("desc");
559
+ for await (const row of query) {
560
+ if (rows.length >= budget ||
561
+ (rows.length % 256 === 255 && !(await hasFeatureReadHeadroom(ctx)))) {
562
+ cutAt = row.bucketStart;
563
+ break;
564
+ }
565
+ rows.push(row);
566
+ }
567
+ if (cutAt === null)
568
+ return { rows, completeFrom: from };
569
+ while (rows.length > 0 && rows[rows.length - 1].bucketStart === cutAt) {
570
+ rows.pop();
571
+ }
572
+ return { rows, completeFrom: cutAt + DAY_MS };
573
+ }
227
574
  export const listSessions = query({
228
575
  args: {
229
576
  siteId: v.id("sites"),
@@ -234,16 +581,50 @@ export const listSessions = query({
234
581
  returns: paginationResultValidator(sessionDocumentValidator),
235
582
  handler: async (ctx, args) => {
236
583
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
237
- const pagination = parseManualPagination(args.paginationOpts);
238
- const rows = await ctx.db
239
- .query("sessions")
240
- .withIndex("by_siteId_and_lastSeenAt", (range) => range
241
- .eq("siteId", args.siteId)
242
- .gte("lastSeenAt", args.from)
243
- .lte("lastSeenAt", args.to))
244
- .order("desc")
245
- .take(pagination.offset + pagination.numItems + 1);
246
- return manualPaginationResult(rows, pagination);
584
+ await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
585
+ const pagination = parseKeysetPagination(args.paginationOpts, {
586
+ kind: "sessions",
587
+ siteId: String(args.siteId),
588
+ from: args.from,
589
+ to: args.to,
590
+ });
591
+ const rows = [];
592
+ const take = pagination.numItems + 1;
593
+ if (pagination.cursor) {
594
+ rows.push(...(await ctx.db
595
+ .query("sessions")
596
+ .withIndex("by_siteId_and_lastSeenAt", (range) => range
597
+ .eq("siteId", args.siteId)
598
+ .eq("lastSeenAt", pagination.cursor.timestamp)
599
+ .lt("_creationTime", pagination.cursor.creationTime))
600
+ .order("desc")
601
+ .take(take)));
602
+ if (rows.length < take) {
603
+ rows.push(...(await ctx.db
604
+ .query("sessions")
605
+ .withIndex("by_siteId_and_lastSeenAt", (range) => range
606
+ .eq("siteId", args.siteId)
607
+ .gte("lastSeenAt", args.from)
608
+ .lt("lastSeenAt", pagination.cursor.timestamp))
609
+ .order("desc")
610
+ .take(take - rows.length)));
611
+ }
612
+ }
613
+ else {
614
+ rows.push(...(await ctx.db
615
+ .query("sessions")
616
+ .withIndex("by_siteId_and_lastSeenAt", (range) => range
617
+ .eq("siteId", args.siteId)
618
+ .gte("lastSeenAt", args.from)
619
+ .lte("lastSeenAt", args.to))
620
+ .order("desc")
621
+ .take(take)));
622
+ }
623
+ const publicRows = rows.map(({ geoLookupAttemptedAt: _attemptedAt, lastPageviewAt: _lastPageviewAt, ...row }) => row);
624
+ return keysetPaginationResult(publicRows, pagination, (row) => ({
625
+ timestamp: row.lastSeenAt,
626
+ creationTime: row._creationTime,
627
+ }));
247
628
  },
248
629
  });
249
630
  export const sessionJourney = query({
@@ -257,17 +638,24 @@ export const sessionJourney = query({
257
638
  returns: paginationResultValidator(eventDocumentValidator),
258
639
  handler: async (ctx, args) => {
259
640
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
260
- const pagination = parseManualPagination(args.paginationOpts);
261
- const rows = await ctx.db
262
- .query("events")
263
- .withIndex("by_siteId_and_sessionId_and_timestamp", (range) => range
264
- .eq("siteId", args.siteId)
265
- .eq("sessionId", args.sessionId)
266
- .gte("timestamp", args.from)
267
- .lte("timestamp", args.to))
268
- .order("asc")
269
- .take(pagination.offset + pagination.numItems + 1);
270
- return manualPaginationResult(rows, pagination);
641
+ await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
642
+ const pagination = parseJourneyPagination(args.paginationOpts, {
643
+ kind: "events",
644
+ siteId: String(args.siteId),
645
+ sessionId: args.sessionId,
646
+ from: args.from,
647
+ to: args.to,
648
+ });
649
+ const rows = await loadSessionJourneyEvents(ctx, {
650
+ siteId: args.siteId,
651
+ sessionId: args.sessionId,
652
+ from: args.from,
653
+ to: args.to,
654
+ cursor: pagination.cursor,
655
+ resultLimit: pagination.numItems + 1,
656
+ });
657
+ const hydratedRows = await hydrateEventContexts(ctx, rows);
658
+ return journeyPaginationResult(hydratedRows, pagination);
271
659
  },
272
660
  });
273
661
  export const listConversions = query({
@@ -280,16 +668,49 @@ export const listConversions = query({
280
668
  returns: paginationResultValidator(conversionDocumentValidator),
281
669
  handler: async (ctx, args) => {
282
670
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
283
- const pagination = parseManualPagination(args.paginationOpts);
284
- const rows = await ctx.db
285
- .query("conversions")
286
- .withIndex("by_siteId_and_timestamp", (range) => range
287
- .eq("siteId", args.siteId)
288
- .gte("timestamp", args.from)
289
- .lte("timestamp", args.to))
290
- .order("desc")
291
- .take(pagination.offset + pagination.numItems + 1);
292
- return manualPaginationResult(rows, pagination);
671
+ await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
672
+ const pagination = parseKeysetPagination(args.paginationOpts, {
673
+ kind: "conversions",
674
+ siteId: String(args.siteId),
675
+ from: args.from,
676
+ to: args.to,
677
+ });
678
+ const rows = [];
679
+ const take = pagination.numItems + 1;
680
+ if (pagination.cursor) {
681
+ rows.push(...(await ctx.db
682
+ .query("conversions")
683
+ .withIndex("by_siteId_and_timestamp", (range) => range
684
+ .eq("siteId", args.siteId)
685
+ .eq("timestamp", pagination.cursor.timestamp)
686
+ .lt("_creationTime", pagination.cursor.creationTime))
687
+ .order("desc")
688
+ .take(take)));
689
+ if (rows.length < take) {
690
+ rows.push(...(await ctx.db
691
+ .query("conversions")
692
+ .withIndex("by_siteId_and_timestamp", (range) => range
693
+ .eq("siteId", args.siteId)
694
+ .gte("timestamp", args.from)
695
+ .lt("timestamp", pagination.cursor.timestamp))
696
+ .order("desc")
697
+ .take(take - rows.length)));
698
+ }
699
+ }
700
+ else {
701
+ rows.push(...(await ctx.db
702
+ .query("conversions")
703
+ .withIndex("by_siteId_and_timestamp", (range) => range
704
+ .eq("siteId", args.siteId)
705
+ .gte("timestamp", args.from)
706
+ .lte("timestamp", args.to))
707
+ .order("desc")
708
+ .take(take)));
709
+ }
710
+ return keysetPaginationResult(rows, pagination, (row) => ({
711
+ timestamp: row.timestamp,
712
+ creationTime: row._creationTime,
713
+ }));
293
714
  },
294
715
  });
295
716
  export const visitorJourney = query({
@@ -304,25 +725,30 @@ export const visitorJourney = query({
304
725
  handler: async (ctx, args) => {
305
726
  const siteIds = validateSiteIds(args.siteIds);
306
727
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
728
+ await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
307
729
  const limit = args.limit ?? 200;
308
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
730
+ if (!Number.isSafeInteger(limit) ||
731
+ limit < 1 ||
732
+ limit > MAX_JOURNEY_EVENTS) {
309
733
  fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
310
734
  }
311
735
  const rows = [];
312
736
  for (const siteId of siteIds) {
313
- rows.push(...(await ctx.db
314
- .query("events")
315
- .withIndex("by_siteId_and_visitorId_and_timestamp", (range) => range
316
- .eq("siteId", siteId)
317
- .eq("visitorId", args.visitorId)
318
- .gte("timestamp", args.from)
319
- .lte("timestamp", args.to))
320
- .order("asc")
321
- .take(limit)));
737
+ for (const visitorId of await resolveVisitorIdentities(ctx, siteId, args.visitorId)) {
738
+ const cutoff = rows.length >= limit ? rows[limit - 1].timestamp : args.to;
739
+ rows.push(...(await loadVisitorJourneyEvents(ctx, {
740
+ siteId,
741
+ visitorId,
742
+ from: args.from,
743
+ to: cutoff,
744
+ resultLimit: limit,
745
+ })));
746
+ rows.sort(compareJourneyEvents);
747
+ if (rows.length > limit)
748
+ rows.length = limit;
749
+ }
322
750
  }
323
- return rows
324
- .sort((left, right) => left.timestamp - right.timestamp || left.sequence - right.sequence)
325
- .slice(0, limit);
751
+ return hydrateEventContexts(ctx, rows);
326
752
  },
327
753
  });
328
754
  const goalReportItemValidator = goalFieldsValidator.extend({
@@ -336,69 +762,126 @@ export const goalsReport = query({
336
762
  returns: v.object({
337
763
  goals: v.array(goalReportItemValidator),
338
764
  truncated: v.boolean(),
765
+ metadata: reportMetadataValidator,
339
766
  }),
340
767
  handler: async (ctx, args) => {
341
768
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
342
- const goals = await ctx.db
769
+ const goalRows = await ctx.db
343
770
  .query("goals")
344
771
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
345
- .take(MAX_GOALS_PER_SITE);
346
- const completions = await ctx.db
347
- .query("goalCompletions")
348
- .withIndex("by_siteId_and_timestamp", (range) => range
349
- .eq("siteId", args.siteId)
350
- .gte("timestamp", args.from)
351
- .lte("timestamp", args.to))
352
- .take(5_001);
772
+ .take(MAX_GOALS_PER_SITE + 1);
773
+ failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
774
+ const goals = goalRows;
353
775
  const totals = new Map();
354
- for (const completion of completions.slice(0, 5_000)) {
355
- const key = String(completion.goalId);
356
- const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
357
- total.completions += 1;
358
- total.valueCents += completion.valueCents;
359
- totals.set(key, total);
776
+ const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "goalRollups");
777
+ const rollups = useRollups
778
+ ? await loadGoalDailyRollups(ctx, args.siteId, goals.map((goal) => goal._id), args.from, args.to)
779
+ : null;
780
+ if (rollups) {
781
+ for (const row of rollups) {
782
+ const key = String(row.goalId);
783
+ const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
784
+ total.completions += row.completions;
785
+ total.valueCents += row.valueCents;
786
+ totals.set(key, total);
787
+ }
788
+ }
789
+ else {
790
+ await assertSourceAvailable(ctx, [args.siteId], ["goalCompletions"], args.from);
791
+ const completions = await ctx.db
792
+ .query("goalCompletions")
793
+ .withIndex("by_siteId_and_timestamp", (range) => range
794
+ .eq("siteId", args.siteId)
795
+ .gte("timestamp", args.from)
796
+ .lte("timestamp", args.to))
797
+ .take(MAX_REPORT_BUCKETS + 1);
798
+ failIfIncomplete(completions, MAX_REPORT_BUCKETS, "goal completions");
799
+ for (const completion of completions) {
800
+ const key = String(completion.goalId);
801
+ const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
802
+ total.completions += 1;
803
+ total.valueCents += completion.valueCents;
804
+ totals.set(key, total);
805
+ }
360
806
  }
361
807
  return {
362
808
  goals: goals.map((goal) => ({
363
809
  ...goal,
364
810
  ...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
365
811
  })),
366
- truncated: completions.length > 5_000,
812
+ truncated: false,
813
+ metadata: {
814
+ basis: rollups ? "dailyRollup" : "boundedLegacy",
815
+ coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
816
+ datasets: [rollups ? "goalRollups" : "goalCompletions"],
817
+ complete: true,
818
+ generation: rollups ? 1 : null,
819
+ }),
820
+ range: rangeSemantics(args.from, args.to),
821
+ },
367
822
  };
368
823
  },
369
824
  });
370
825
  const funnelReportItemValidator = funnelFieldsValidator.extend({
371
826
  _id: v.id("funnels"),
372
827
  _creationTime: v.number(),
373
- stepsReport: v.array(v.object({ step: v.number(), name: v.string(), entrants: v.number(), completions: v.number() })),
828
+ stepsReport: v.array(v.object({
829
+ step: v.number(),
830
+ name: v.string(),
831
+ entrants: v.number(),
832
+ completions: v.number(),
833
+ })),
374
834
  truncated: v.boolean(),
835
+ metadata: reportMetadataValidator,
375
836
  });
376
837
  export const funnelsReport = query({
377
838
  args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
378
839
  returns: v.array(funnelReportItemValidator),
379
840
  handler: async (ctx, args) => {
380
841
  validateRange(args.from, args.to, 90);
381
- const funnels = await ctx.db
842
+ requireAlignedRange(args.from, args.to, DAY_MS, "funnel report");
843
+ const funnelRows = await ctx.db
382
844
  .query("funnels")
383
845
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
384
- .take(MAX_FUNNELS_PER_SITE);
846
+ .take(MAX_FUNNELS_PER_SITE + 1);
847
+ failIfIncomplete(funnelRows, MAX_FUNNELS_PER_SITE, "funnel definitions");
848
+ const funnels = funnelRows;
849
+ const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "funnelRollups");
850
+ const rollups = useRollups
851
+ ? await loadFunnelDailyRollups(ctx, args.siteId, funnels.map((funnel) => funnel._id), args.from, args.to)
852
+ : null;
853
+ if (!rollups) {
854
+ await assertSourceAvailable(ctx, [args.siteId], ["funnelSteps"], args.from);
855
+ }
856
+ const reportCoverage = await coverageForRange(ctx, [args.siteId], args.from, args.to, {
857
+ datasets: [rollups ? "funnelRollups" : "funnelSteps"],
858
+ complete: true,
859
+ generation: rollups ? 1 : null,
860
+ });
385
861
  const reports = [];
386
862
  for (const funnel of funnels) {
387
- const buckets = await ctx.db
388
- .query("funnelStepBuckets")
389
- .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
390
- .eq("siteId", args.siteId)
391
- .eq("funnelId", funnel._id)
392
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
393
- .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
394
- .take(601);
395
863
  const stepsReport = funnel.steps.map((step, index) => ({
396
864
  step: index,
397
865
  name: step.name,
398
866
  entrants: 0,
399
867
  completions: 0,
400
868
  }));
401
- for (const bucket of buckets.slice(0, 600)) {
869
+ const buckets = rollups
870
+ ? rollups
871
+ .filter((row) => row.funnelId === funnel._id)
872
+ .flatMap((row) => row.steps)
873
+ : await ctx.db
874
+ .query("funnelStepBuckets")
875
+ .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
876
+ .eq("siteId", args.siteId)
877
+ .eq("funnelId", funnel._id)
878
+ .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
879
+ .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
880
+ .take(MAX_REPORT_BUCKETS + 1);
881
+ if (!rollups) {
882
+ failIfIncomplete(buckets, MAX_REPORT_BUCKETS, "funnel step buckets");
883
+ }
884
+ for (const bucket of buckets) {
402
885
  const step = stepsReport[bucket.step];
403
886
  if (step) {
404
887
  step.entrants += bucket.entrants;
@@ -408,7 +891,14 @@ export const funnelsReport = query({
408
891
  reports.push({
409
892
  ...funnel,
410
893
  stepsReport,
411
- truncated: buckets.length > 600,
894
+ truncated: false,
895
+ metadata: {
896
+ basis: rollups
897
+ ? "dailyRollup"
898
+ : "boundedLegacy",
899
+ coverage: reportCoverage,
900
+ range: rangeSemantics(args.from, args.to),
901
+ },
412
902
  });
413
903
  }
414
904
  return reports;
@@ -428,54 +918,94 @@ export const affiliatesReport = query({
428
918
  returns: v.object({
429
919
  affiliates: v.array(affiliateReportItemValidator),
430
920
  truncated: v.boolean(),
921
+ metadata: reportMetadataValidator.extend({
922
+ uniqueVisitors: v.object({
923
+ basis: v.union(v.literal("estimatedUnique"), v.literal("exactLegacy")),
924
+ exact: v.boolean(),
925
+ }),
926
+ }),
431
927
  }),
432
928
  handler: async (ctx, args) => {
433
929
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
930
+ requireAlignedRange(args.from, args.to, DAY_MS, "affiliate report");
434
931
  const affiliateRows = await ctx.db
435
932
  .query("affiliates")
436
933
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
437
934
  .order("desc")
438
935
  .take(MAX_AFFILIATES_PER_SITE + 1);
439
- const affiliates = affiliateRows.slice(0, MAX_AFFILIATES_PER_SITE);
440
- const stats = await ctx.db
441
- .query("affiliateDailyStats")
442
- .withIndex("by_siteId_and_bucketStart", (range) => range
443
- .eq("siteId", args.siteId)
444
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
445
- .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
446
- .take(5_001);
447
- const sessions = await ctx.db
448
- .query("sessions")
449
- .withIndex("by_siteId_and_startedAt", (range) => range
450
- .eq("siteId", args.siteId)
451
- .gte("startedAt", args.from)
452
- .lte("startedAt", args.to))
453
- .take(5_001);
936
+ failIfIncomplete(affiliateRows, MAX_AFFILIATES_PER_SITE, "affiliate definitions");
937
+ const affiliates = affiliateRows;
938
+ const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "affiliateRollups");
939
+ const rollups = useRollups
940
+ ? await loadAffiliateDailyRollups(ctx, args.siteId, affiliates.map((affiliate) => affiliate._id), args.from, args.to)
941
+ : null;
454
942
  const visits = new Map();
455
- for (const session of sessions.slice(0, 5_000)) {
456
- if (!session.affiliateId)
457
- continue;
458
- const key = String(session.affiliateId);
459
- const total = visits.get(key) ?? {
460
- visits: 0,
461
- visitors: new Set(),
462
- };
463
- total.visits += 1;
464
- total.visitors.add(session.visitorId);
465
- visits.set(key, total);
466
- }
467
943
  const totals = new Map();
468
- for (const row of stats.slice(0, 5_000)) {
469
- const key = String(row.affiliateId);
470
- const total = totals.get(key) ?? {
471
- conversions: 0,
472
- revenueCents: 0,
473
- commissionCents: 0,
474
- };
475
- total.conversions += row.conversions;
476
- total.revenueCents += row.revenueCents;
477
- total.commissionCents += row.commissionCents;
478
- totals.set(key, total);
944
+ if (rollups) {
945
+ for (const row of rollups) {
946
+ const key = String(row.affiliateId);
947
+ const traffic = visits.get(key) ?? {
948
+ visits: 0,
949
+ visitors: new Set(),
950
+ sketch: createVisitorSketch(),
951
+ };
952
+ traffic.visits += row.visits;
953
+ traffic.sketch = unionVisitorSketches(traffic.sketch, row.visitorSketch);
954
+ visits.set(key, traffic);
955
+ const total = totals.get(key) ?? {
956
+ conversions: 0,
957
+ revenueCents: 0,
958
+ commissionCents: 0,
959
+ };
960
+ total.conversions += row.conversions;
961
+ total.revenueCents += row.revenueCents;
962
+ total.commissionCents += row.commissionCents;
963
+ totals.set(key, total);
964
+ }
965
+ }
966
+ else {
967
+ await assertSourceAvailable(ctx, [args.siteId], ["affiliateStats", "sessions"], args.from);
968
+ const stats = await ctx.db
969
+ .query("affiliateDailyStats")
970
+ .withIndex("by_siteId_and_bucketStart", (range) => range
971
+ .eq("siteId", args.siteId)
972
+ .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
973
+ .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
974
+ .take(MAX_REPORT_BUCKETS + 1);
975
+ const sessions = await ctx.db
976
+ .query("sessions")
977
+ .withIndex("by_siteId_and_startedAt", (range) => range
978
+ .eq("siteId", args.siteId)
979
+ .gte("startedAt", args.from)
980
+ .lte("startedAt", args.to))
981
+ .take(MAX_REPORT_BUCKETS + 1);
982
+ failIfIncomplete(stats, MAX_REPORT_BUCKETS, "affiliate stat buckets");
983
+ failIfIncomplete(sessions, MAX_REPORT_BUCKETS, "affiliate sessions");
984
+ for (const session of sessions) {
985
+ if (!session.affiliateId)
986
+ continue;
987
+ const key = String(session.affiliateId);
988
+ const total = visits.get(key) ?? {
989
+ visits: 0,
990
+ visitors: new Set(),
991
+ sketch: createVisitorSketch(),
992
+ };
993
+ total.visits += 1;
994
+ total.visitors.add(session.visitorId);
995
+ visits.set(key, total);
996
+ }
997
+ for (const row of stats) {
998
+ const key = String(row.affiliateId);
999
+ const total = totals.get(key) ?? {
1000
+ conversions: 0,
1001
+ revenueCents: 0,
1002
+ commissionCents: 0,
1003
+ };
1004
+ total.conversions += row.conversions;
1005
+ total.revenueCents += row.revenueCents;
1006
+ total.commissionCents += row.commissionCents;
1007
+ totals.set(key, total);
1008
+ }
479
1009
  }
480
1010
  return {
481
1011
  affiliates: affiliates.map((affiliate) => {
@@ -489,15 +1019,548 @@ export const affiliatesReport = query({
489
1019
  ...affiliate,
490
1020
  ...performance,
491
1021
  visits: traffic?.visits ?? 0,
492
- uniqueVisitors: traffic?.visitors.size ?? 0,
1022
+ uniqueVisitors: rollups
1023
+ ? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
1024
+ : (traffic?.visitors.size ?? 0),
493
1025
  };
494
1026
  }),
495
- truncated: affiliateRows.length > MAX_AFFILIATES_PER_SITE ||
496
- stats.length > 5_000 ||
497
- sessions.length > 5_000,
1027
+ truncated: false,
1028
+ metadata: {
1029
+ basis: rollups ? "dailyRollup" : "boundedLegacy",
1030
+ coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
1031
+ datasets: rollups
1032
+ ? ["affiliateRollups"]
1033
+ : ["affiliateStats", "sessions"],
1034
+ complete: true,
1035
+ generation: rollups ? 1 : null,
1036
+ }),
1037
+ range: rangeSemantics(args.from, args.to),
1038
+ uniqueVisitors: {
1039
+ basis: rollups
1040
+ ? "estimatedUnique"
1041
+ : "exactLegacy",
1042
+ exact: !rollups,
1043
+ },
1044
+ },
1045
+ };
1046
+ },
1047
+ });
1048
+ const vitalStatsValidator = v.object({
1049
+ count: v.number(),
1050
+ p75: v.number(),
1051
+ average: v.number(),
1052
+ good: v.number(),
1053
+ needsImprovement: v.number(),
1054
+ poor: v.number(),
1055
+ });
1056
+ export const vitalsReport = query({
1057
+ args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
1058
+ returns: v.object({
1059
+ metrics: v.array(vitalStatsValidator.extend({
1060
+ metric: vitalMetricValidator,
1061
+ devices: v.array(vitalStatsValidator.extend({ device: v.string() })),
1062
+ pages: v.array(vitalStatsValidator.extend({ page: v.string() })),
1063
+ timeSeries: v.array(v.object({
1064
+ timestamp: v.number(),
1065
+ count: v.number(),
1066
+ p75: v.number(),
1067
+ })),
1068
+ })),
1069
+ truncated: v.boolean(),
1070
+ metadata: reportMetadataValidator,
1071
+ }),
1072
+ handler: async (ctx, args) => {
1073
+ validateRange(args.from, args.to, MAX_FEATURE_ROLLUP_DAYS);
1074
+ requireAlignedRange(args.from, args.to, DAY_MS, "vitals report");
1075
+ await assertSourceAvailable(ctx, [args.siteId], ["vitalRollups"], args.from);
1076
+ const rows = await ctx.db
1077
+ .query("vitalRollups")
1078
+ .withIndex("by_siteId_and_bucketStart", (range) => range
1079
+ .eq("siteId", args.siteId)
1080
+ .gte("bucketStart", args.from)
1081
+ .lte("bucketStart", args.to))
1082
+ .take(MAX_VITALS_REPORT_ROWS + 1);
1083
+ failIfIncomplete(rows, MAX_VITALS_REPORT_ROWS, "vital rollups");
1084
+ const fold = (groups, metric, key, entry) => {
1085
+ const group = groups.get(metric) ?? new Map();
1086
+ const current = group.get(key) ?? { count: 0, sum: 0, histogram: [] };
1087
+ current.count += entry.count;
1088
+ current.sum += entry.sum;
1089
+ current.histogram = mergeVitalHistograms(current.histogram, entry.histogram);
1090
+ group.set(key, current);
1091
+ groups.set(metric, group);
1092
+ };
1093
+ const byDevice = new Map();
1094
+ const byPage = new Map();
1095
+ const byDay = new Map();
1096
+ for (const row of rows) {
1097
+ for (const entry of row.metrics) {
1098
+ if (row.page === VITAL_ALL) {
1099
+ // A device row carries the complete signal once, so overall totals
1100
+ // and the time series derive from this family alone.
1101
+ fold(byDevice, entry.metric, row.device, entry);
1102
+ fold(byDay, entry.metric, row.bucketStart, entry);
1103
+ }
1104
+ else {
1105
+ fold(byPage, entry.metric, row.page, entry);
1106
+ }
1107
+ }
1108
+ }
1109
+ const stats = (metric, accumulated) => ({
1110
+ count: accumulated.count,
1111
+ p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
1112
+ average: accumulated.count > 0
1113
+ ? Math.round(accumulated.sum / accumulated.count)
1114
+ : 0,
1115
+ ...vitalRatingCounts(accumulated.histogram, metric),
1116
+ });
1117
+ const metrics = VITAL_METRICS.map((metric) => {
1118
+ const overall = { count: 0, sum: 0, histogram: [] };
1119
+ for (const accumulated of byDevice.get(metric)?.values() ?? []) {
1120
+ overall.count += accumulated.count;
1121
+ overall.sum += accumulated.sum;
1122
+ overall.histogram = mergeVitalHistograms(overall.histogram, accumulated.histogram);
1123
+ }
1124
+ return {
1125
+ metric,
1126
+ ...stats(metric, overall),
1127
+ devices: [...(byDevice.get(metric) ?? [])]
1128
+ .map(([device, accumulated]) => ({
1129
+ device,
1130
+ ...stats(metric, accumulated),
1131
+ }))
1132
+ .sort((left, right) => right.count - left.count),
1133
+ pages: [...(byPage.get(metric) ?? [])]
1134
+ .map(([page, accumulated]) => ({
1135
+ page,
1136
+ ...stats(metric, accumulated),
1137
+ }))
1138
+ .sort((left, right) => right.count - left.count || left.page.localeCompare(right.page))
1139
+ .slice(0, VITALS_REPORT_PAGE_LIMIT),
1140
+ timeSeries: [...(byDay.get(metric) ?? [])]
1141
+ .sort(([left], [right]) => left - right)
1142
+ .map(([timestamp, accumulated]) => ({
1143
+ timestamp,
1144
+ count: accumulated.count,
1145
+ p75: vitalHistogramPercentile(accumulated.histogram, 0.75),
1146
+ })),
1147
+ };
1148
+ });
1149
+ return {
1150
+ metrics,
1151
+ truncated: false,
1152
+ metadata: {
1153
+ basis: "dailyRollup",
1154
+ coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, { datasets: ["vitalRollups"], complete: true }),
1155
+ range: rangeSemantics(args.from, args.to),
1156
+ },
1157
+ };
1158
+ },
1159
+ });
1160
+ const coverageDatasetValidator = v.union(v.literal("overviewHour"), v.literal("overviewDay"), v.literal("events"), v.literal("sessions"), v.literal("conversions"), v.literal("goals"), v.literal("funnels"), v.literal("affiliates"), v.literal("vitals"), v.literal("siteMap"));
1161
+ export const dataCoverage = query({
1162
+ args: {
1163
+ siteId: v.id("sites"),
1164
+ from: v.number(),
1165
+ to: v.number(),
1166
+ },
1167
+ returns: v.object({
1168
+ siteId: v.id("sites"),
1169
+ from: v.number(),
1170
+ to: v.number(),
1171
+ datasets: v.array(v.object({
1172
+ dataset: coverageDatasetValidator,
1173
+ coverage: resultCoverageValidator,
1174
+ })),
1175
+ }),
1176
+ handler: async (ctx, args) => {
1177
+ validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
1178
+ const datasets = [
1179
+ "overviewHour",
1180
+ "overviewDay",
1181
+ "events",
1182
+ "sessions",
1183
+ "conversions",
1184
+ "goals",
1185
+ "funnels",
1186
+ "affiliates",
1187
+ "vitals",
1188
+ "siteMap",
1189
+ ];
1190
+ return {
1191
+ siteId: args.siteId,
1192
+ from: args.from,
1193
+ to: args.to,
1194
+ datasets: await Promise.all(datasets.map(async (dataset) => ({
1195
+ dataset,
1196
+ coverage: await datasetCoverageForRange(ctx, args.siteId, dataset, args.from, args.to),
1197
+ }))),
498
1198
  };
499
1199
  },
500
1200
  });
1201
+ async function dailyRollupsAvailable(ctx, siteId, from, to, dataset) {
1202
+ if (!isAlignedRange(from, to, DAY_MS))
1203
+ return false;
1204
+ const coverage = await ctx.db
1205
+ .query("analyticsCoverage")
1206
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1207
+ .unique();
1208
+ if (!coverage || coverage.rollupCompleteFrom > from) {
1209
+ return false;
1210
+ }
1211
+ const retainedBefore = coverage.retained.find((item) => item.dataset === dataset)?.before;
1212
+ return retainedBefore === undefined || from >= retainedBefore;
1213
+ }
1214
+ async function loadGoalDailyRollups(ctx, siteId, goalIds, from, to) {
1215
+ const days = Math.floor((to - from) / DAY_MS) + 1;
1216
+ requireFeatureRollupBudget(goalIds.length, days, "goal daily rollups");
1217
+ const rows = [];
1218
+ for (const goalId of goalIds) {
1219
+ const perGoalLimit = days * REPORT_ROLLUP_SHARDS;
1220
+ const goalRows = await ctx.db
1221
+ .query("goalDailyRollups")
1222
+ .withIndex("by_siteId_and_goalId_and_bucketStart", (range) => range
1223
+ .eq("siteId", siteId)
1224
+ .eq("goalId", goalId)
1225
+ .gte("bucketStart", from)
1226
+ .lte("bucketStart", to))
1227
+ .take(perGoalLimit + 1);
1228
+ failIfIncomplete(goalRows, perGoalLimit, "goal daily rollups");
1229
+ rows.push(...goalRows);
1230
+ await requireFeatureReadHeadroom(ctx, "goal daily rollups");
1231
+ }
1232
+ return rows;
1233
+ }
1234
+ async function loadFunnelDailyRollups(ctx, siteId, funnelIds, from, to) {
1235
+ const days = Math.floor((to - from) / DAY_MS) + 1;
1236
+ requireFeatureRollupBudget(funnelIds.length, days, "funnel daily rollups");
1237
+ const rows = [];
1238
+ for (const funnelId of funnelIds) {
1239
+ const perFunnelLimit = days * REPORT_ROLLUP_SHARDS;
1240
+ const funnelRows = await ctx.db
1241
+ .query("funnelDailyRollups")
1242
+ .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
1243
+ .eq("siteId", siteId)
1244
+ .eq("funnelId", funnelId)
1245
+ .gte("bucketStart", from)
1246
+ .lte("bucketStart", to))
1247
+ .take(perFunnelLimit + 1);
1248
+ failIfIncomplete(funnelRows, perFunnelLimit, "funnel daily rollups");
1249
+ rows.push(...funnelRows);
1250
+ await requireFeatureReadHeadroom(ctx, "funnel daily rollups");
1251
+ }
1252
+ return rows;
1253
+ }
1254
+ async function loadAffiliateDailyRollups(ctx, siteId, affiliateIds, from, to) {
1255
+ const days = Math.floor((to - from) / DAY_MS) + 1;
1256
+ requireFeatureRollupBudget(affiliateIds.length, days, "affiliate daily rollups");
1257
+ const rows = [];
1258
+ for (const affiliateId of affiliateIds) {
1259
+ const perAffiliateLimit = days * REPORT_ROLLUP_SHARDS;
1260
+ const affiliateRows = await ctx.db
1261
+ .query("affiliateDailyRollups")
1262
+ .withIndex("by_siteId_and_affiliateId_and_bucketStart", (range) => range
1263
+ .eq("siteId", siteId)
1264
+ .eq("affiliateId", affiliateId)
1265
+ .gte("bucketStart", from)
1266
+ .lte("bucketStart", to))
1267
+ .take(perAffiliateLimit + 1);
1268
+ failIfIncomplete(affiliateRows, perAffiliateLimit, "affiliate daily rollups");
1269
+ rows.push(...affiliateRows);
1270
+ await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
1271
+ }
1272
+ return rows;
1273
+ }
1274
+ function requireFeatureRollupBudget(entities, days, dataset) {
1275
+ const maximumRows = entities * days * REPORT_ROLLUP_SHARDS;
1276
+ if (days > MAX_FEATURE_ROLLUP_DAYS || maximumRows > MAX_FEATURE_ROLLUP_ROWS) {
1277
+ fail("REPORT_INCOMPLETE", `${dataset} exceeds its complete read budget`, {
1278
+ dataset,
1279
+ maximumDays: MAX_FEATURE_ROLLUP_DAYS,
1280
+ maximumRows: MAX_FEATURE_ROLLUP_ROWS,
1281
+ });
1282
+ }
1283
+ }
1284
+ async function hasFeatureReadHeadroom(ctx) {
1285
+ const metrics = await ctx.meta.getTransactionMetrics();
1286
+ return (metrics.bytesRead.remaining >= FEATURE_REPORT_BYTE_RESERVE &&
1287
+ metrics.documentsRead.remaining >= FEATURE_REPORT_DOCUMENT_RESERVE);
1288
+ }
1289
+ async function requireFeatureReadHeadroom(ctx, dataset) {
1290
+ const metrics = await ctx.meta.getTransactionMetrics();
1291
+ if (metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
1292
+ metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE) {
1293
+ fail("REPORT_INCOMPLETE", `${dataset} exceeds its byte or document budget`, {
1294
+ dataset,
1295
+ bytesRead: metrics.bytesRead.used,
1296
+ documentsRead: metrics.documentsRead.used,
1297
+ });
1298
+ }
1299
+ }
1300
+ function failIfIncomplete(rows, maximum, dataset) {
1301
+ if (rows.length > maximum) {
1302
+ fail("REPORT_INCOMPLETE", `${dataset} exceeds the complete report read budget`, {
1303
+ maximumRows: maximum,
1304
+ dataset,
1305
+ });
1306
+ }
1307
+ }
1308
+ async function coverageForRange(ctx, siteIds, from, to, options) {
1309
+ const controls = await Promise.all(siteIds.map((siteId) => ctx.db
1310
+ .query("analyticsCoverage")
1311
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1312
+ .unique()));
1313
+ const sourceRanges = await Promise.all(siteIds.map((siteId) => sourceSetRange(ctx, siteId, options.datasets)));
1314
+ const retainedBefore = controls.reduce((latest, control) => {
1315
+ const before = control?.retained
1316
+ .filter((item) => options.datasets.some((dataset) => dataset === item.dataset))
1317
+ .reduce((latest, item) => Math.max(latest ?? 0, item.before), undefined);
1318
+ return before === undefined ? latest : Math.max(latest ?? 0, before);
1319
+ }, null);
1320
+ const availableFrom = options.generation === 1
1321
+ ? controls.length > 0 && controls.every(Boolean)
1322
+ ? Math.max(...controls.map((control) => control.rollupCompleteFrom))
1323
+ : null
1324
+ : sourceRanges.length > 0 && sourceRanges.every(Boolean)
1325
+ ? Math.max(...sourceRanges.map((range) => range.from))
1326
+ : null;
1327
+ const availableThrough = sourceRanges.length > 0 && sourceRanges.every(Boolean)
1328
+ ? Math.min(...sourceRanges.map((range) => range.through))
1329
+ : null;
1330
+ let state;
1331
+ if (retainedBefore !== null && to < retainedBefore) {
1332
+ state = "unavailable";
1333
+ }
1334
+ else if (retainedBefore !== null && from < retainedBefore) {
1335
+ state = "partial";
1336
+ }
1337
+ else if (options.complete ||
1338
+ (availableFrom !== null &&
1339
+ availableThrough !== null &&
1340
+ from >= availableFrom &&
1341
+ to <= availableThrough)) {
1342
+ state = "complete";
1343
+ }
1344
+ else if (sourceRanges.some(Boolean)) {
1345
+ state = "retained";
1346
+ }
1347
+ else {
1348
+ state = "unavailable";
1349
+ }
1350
+ return {
1351
+ state,
1352
+ generation: options.generation ?? null,
1353
+ availableFrom,
1354
+ availableThrough,
1355
+ retainedBefore,
1356
+ };
1357
+ }
1358
+ async function assertSourceAvailable(ctx, siteIds, sources, from) {
1359
+ const rows = await Promise.all(siteIds.map((siteId) => ctx.db
1360
+ .query("analyticsCoverage")
1361
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1362
+ .unique()));
1363
+ const cutoff = rows.reduce((latest, row) => {
1364
+ for (const retained of row?.retained ?? []) {
1365
+ if (sources.some((source) => source === retained.dataset)) {
1366
+ latest = Math.max(latest ?? 0, retained.before);
1367
+ }
1368
+ }
1369
+ return latest;
1370
+ }, null);
1371
+ if (cutoff !== null && from < cutoff) {
1372
+ fail("REPORT_INCOMPLETE", "requested report range intersects retained data", {
1373
+ retainedBefore: cutoff,
1374
+ });
1375
+ }
1376
+ }
1377
+ async function datasetCoverageForRange(ctx, siteId, dataset, from, to) {
1378
+ if (dataset === "siteMap") {
1379
+ return siteMapDatasetCoverage(ctx, siteId, from, to);
1380
+ }
1381
+ // Vitals have no legacy fallback source; the rollup either covers the range
1382
+ // or the coverage states say how much of it exists.
1383
+ if (dataset === "vitals") {
1384
+ return coverageForRange(ctx, [siteId], from, to, {
1385
+ datasets: ["vitalRollups"],
1386
+ complete: false,
1387
+ });
1388
+ }
1389
+ const sourceOptions = dataset === "goals"
1390
+ ? [["goalRollups"], ["goalCompletions"]]
1391
+ : dataset === "funnels"
1392
+ ? [["funnelRollups"], ["funnelSteps"]]
1393
+ : dataset === "affiliates"
1394
+ ? [["affiliateRollups"], ["affiliateStats", "sessions"]]
1395
+ : [[dataset]];
1396
+ const options = (await Promise.all(sourceOptions.map(async (sources) => {
1397
+ const rollupDataset = sources[0];
1398
+ const isRollup = rollupDataset.endsWith("Rollups");
1399
+ const complete = isRollup
1400
+ ? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
1401
+ : false;
1402
+ if (isRollup && !complete)
1403
+ return null;
1404
+ return coverageForRange(ctx, [siteId], from, to, {
1405
+ datasets: sources,
1406
+ complete,
1407
+ generation: complete ? 1 : null,
1408
+ });
1409
+ }))).filter((option) => option !== null);
1410
+ return options.sort((left, right) => coverageRank(right.state) - coverageRank(left.state))[0];
1411
+ }
1412
+ /**
1413
+ * The site map's coverage without reading its rows: it starts at the site's
1414
+ * `siteMapSince` and ends where retention cut it. The report itself may still
1415
+ * narrow the window when the range exceeds its row budget.
1416
+ */
1417
+ async function siteMapDatasetCoverage(ctx, siteId, from, to) {
1418
+ const control = await ctx.db
1419
+ .query("analyticsCoverage")
1420
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1421
+ .unique();
1422
+ const since = control?.siteMapSince ?? null;
1423
+ const retainedBefore = control?.retained.find((item) => item.dataset === siteMapCoverageDataset)
1424
+ ?.before ?? null;
1425
+ const unavailable = since === null ||
1426
+ to < since ||
1427
+ (retainedBefore !== null && to < retainedBefore);
1428
+ const state = unavailable
1429
+ ? "unavailable"
1430
+ : from < Math.max(since, retainedBefore ?? 0)
1431
+ ? "partial"
1432
+ : "complete";
1433
+ return {
1434
+ state,
1435
+ generation: 1,
1436
+ availableFrom: since,
1437
+ availableThrough: since === null ? null : to,
1438
+ retainedBefore,
1439
+ };
1440
+ }
1441
+ async function sourceSetRange(ctx, siteId, sources) {
1442
+ const ranges = (await Promise.all(sources.map((source) => sourceRange(ctx, siteId, source)))).filter((range) => range !== null);
1443
+ if (ranges.length === 0)
1444
+ return null;
1445
+ return {
1446
+ from: Math.min(...ranges.map((range) => range.from)),
1447
+ through: Math.max(...ranges.map((range) => range.through)),
1448
+ };
1449
+ }
1450
+ async function sourceRange(ctx, siteId, source) {
1451
+ if (source === "overviewHour" || source === "overviewDay") {
1452
+ const granularity = source === "overviewHour" ? "hour" : "day";
1453
+ const [first, last] = await Promise.all([
1454
+ ctx.db
1455
+ .query("aggregateBuckets")
1456
+ .withIndex("by_siteId_and_granularity_and_bucketStart", (range) => range.eq("siteId", siteId).eq("granularity", granularity))
1457
+ .order("asc")
1458
+ .take(1),
1459
+ ctx.db
1460
+ .query("aggregateBuckets")
1461
+ .withIndex("by_siteId_and_granularity_and_bucketStart", (range) => range.eq("siteId", siteId).eq("granularity", granularity))
1462
+ .order("desc")
1463
+ .take(1),
1464
+ ]);
1465
+ return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1466
+ }
1467
+ if (source === "events") {
1468
+ const [first, last] = await Promise.all([
1469
+ ctx.db
1470
+ .query("eventBatches")
1471
+ .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1472
+ .order("asc")
1473
+ .take(1),
1474
+ ctx.db
1475
+ .query("eventBatches")
1476
+ .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1477
+ .order("desc")
1478
+ .take(1),
1479
+ ]);
1480
+ return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1481
+ }
1482
+ if (source === "sessions") {
1483
+ const [first, last] = await Promise.all([
1484
+ ctx.db
1485
+ .query("sessions")
1486
+ .withIndex("by_siteId_and_startedAt", (range) => range.eq("siteId", siteId))
1487
+ .order("asc")
1488
+ .take(1),
1489
+ ctx.db
1490
+ .query("sessions")
1491
+ .withIndex("by_siteId_and_lastSeenAt", (range) => range.eq("siteId", siteId))
1492
+ .order("desc")
1493
+ .take(1),
1494
+ ]);
1495
+ return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
1496
+ }
1497
+ if (source === "conversions") {
1498
+ const [first, last] = await Promise.all([
1499
+ ctx.db
1500
+ .query("conversions")
1501
+ .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1502
+ .order("asc")
1503
+ .take(1),
1504
+ ctx.db
1505
+ .query("conversions")
1506
+ .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1507
+ .order("desc")
1508
+ .take(1),
1509
+ ]);
1510
+ return bounds(first[0]?.timestamp, last[0]?.timestamp);
1511
+ }
1512
+ if (source === "goalCompletions") {
1513
+ const [first, last] = await Promise.all([
1514
+ ctx.db
1515
+ .query("goalCompletions")
1516
+ .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1517
+ .order("asc")
1518
+ .take(1),
1519
+ ctx.db
1520
+ .query("goalCompletions")
1521
+ .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1522
+ .order("desc")
1523
+ .take(1),
1524
+ ]);
1525
+ return bounds(first[0]?.timestamp, last[0]?.timestamp);
1526
+ }
1527
+ const table = source === "funnelSteps"
1528
+ ? "funnelStepBuckets"
1529
+ : source === "affiliateStats"
1530
+ ? "affiliateDailyStats"
1531
+ : source === "goalRollups"
1532
+ ? "goalDailyRollups"
1533
+ : source === "funnelRollups"
1534
+ ? "funnelDailyRollups"
1535
+ : source === "vitalRollups"
1536
+ ? "vitalRollups"
1537
+ : "affiliateDailyRollups";
1538
+ const [first, last] = await Promise.all([
1539
+ ctx.db
1540
+ .query(table)
1541
+ .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1542
+ .order("asc")
1543
+ .take(1),
1544
+ ctx.db
1545
+ .query(table)
1546
+ .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1547
+ .order("desc")
1548
+ .take(1),
1549
+ ]);
1550
+ return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1551
+ }
1552
+ function bounds(from, through) {
1553
+ return from === undefined || through === undefined ? null : { from, through };
1554
+ }
1555
+ function coverageRank(state) {
1556
+ if (state === "complete")
1557
+ return 3;
1558
+ if (state === "retained")
1559
+ return 2;
1560
+ if (state === "partial")
1561
+ return 1;
1562
+ return 0;
1563
+ }
501
1564
  function emptyTotals() {
502
1565
  return {
503
1566
  events: 0,
@@ -524,25 +1587,43 @@ function addMetrics(target, source) {
524
1587
  }
525
1588
  function validateSiteIds(siteIds) {
526
1589
  const unique = [...new Set(siteIds)];
527
- if (unique.length < 1 || unique.length > MAX_REPORT_SITES || unique.length !== siteIds.length) {
1590
+ if (unique.length < 1 ||
1591
+ unique.length > MAX_REPORT_SITES ||
1592
+ unique.length !== siteIds.length) {
528
1593
  fail("INVALID_ARGUMENT", `siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`);
529
1594
  }
530
1595
  return unique;
531
1596
  }
532
1597
  function validateRange(from, to, maxDays) {
533
- if (!Number.isFinite(from) ||
534
- !Number.isFinite(to) ||
1598
+ if (!Number.isSafeInteger(from) ||
1599
+ !Number.isSafeInteger(to) ||
535
1600
  from < 0 ||
536
- to <= from ||
537
- to - from > maxDays * DAY_MS) {
538
- fail("INVALID_ARGUMENT", `range must be positive, ordered, and at most ${maxDays} days`);
1601
+ to < from ||
1602
+ to - from >= maxDays * DAY_MS) {
1603
+ fail("INVALID_ARGUMENT", `range must use inclusive integer milliseconds and span at most ${maxDays} UTC days`);
539
1604
  }
540
- return { from: Math.trunc(from), to: Math.trunc(to) };
1605
+ return { from, to };
1606
+ }
1607
+ function isAlignedRange(from, to, intervalMs) {
1608
+ return from % intervalMs === 0 && (to + 1) % intervalMs === 0;
541
1609
  }
542
- function parseManualPagination(options) {
1610
+ function requireAlignedRange(from, to, intervalMs, report) {
1611
+ if (!isAlignedRange(from, to, intervalMs)) {
1612
+ fail("REPORT_INCOMPLETE", `${report} requires complete inclusive UTC bucket boundaries`, { from, to, intervalMs, timezone: "UTC" });
1613
+ }
1614
+ }
1615
+ function rangeSemantics(from, to) {
1616
+ return {
1617
+ from,
1618
+ to,
1619
+ boundary: "inclusive",
1620
+ timezone: "UTC",
1621
+ };
1622
+ }
1623
+ function parseKeysetPagination(options, scope) {
543
1624
  validatePagination(options);
544
1625
  if (options.cursor === null) {
545
- return { offset: 0, numItems: options.numItems };
1626
+ return { numItems: options.numItems, scope, cursor: null };
546
1627
  }
547
1628
  let decoded;
548
1629
  try {
@@ -552,31 +1633,120 @@ function parseManualPagination(options) {
552
1633
  fail("INVALID_ARGUMENT", "pagination cursor is malformed");
553
1634
  }
554
1635
  if (!isPlainRecord(decoded) ||
555
- decoded.v !== 1 ||
556
- typeof decoded.o !== "number" ||
557
- !Number.isSafeInteger(decoded.o) ||
558
- decoded.o < 0 ||
559
- decoded.o > MAX_MANUAL_PAGINATION_OFFSET) {
560
- fail("INVALID_ARGUMENT", "pagination cursor is invalid or exceeds the offset limit");
561
- }
562
- return { offset: decoded.o, numItems: options.numItems };
1636
+ decoded.v !== 2 ||
1637
+ decoded.k !== scope.kind ||
1638
+ decoded.s !== scope.siteId ||
1639
+ decoded.n !== (scope.sessionId ?? null) ||
1640
+ decoded.f !== scope.from ||
1641
+ decoded.t !== scope.to ||
1642
+ typeof decoded.x !== "number" ||
1643
+ !Number.isFinite(decoded.x) ||
1644
+ decoded.x < scope.from ||
1645
+ decoded.x > scope.to ||
1646
+ typeof decoded.c !== "number" ||
1647
+ !Number.isFinite(decoded.c) ||
1648
+ decoded.c < 0) {
1649
+ fail("INVALID_ARGUMENT", "pagination cursor is invalid for this query");
1650
+ }
1651
+ return {
1652
+ numItems: options.numItems,
1653
+ scope,
1654
+ cursor: {
1655
+ timestamp: decoded.x,
1656
+ creationTime: decoded.c,
1657
+ },
1658
+ };
563
1659
  }
564
- function manualPaginationResult(rows, pagination) {
565
- const page = rows.slice(pagination.offset, pagination.offset + pagination.numItems);
566
- const nextOffset = pagination.offset + page.length;
567
- const isDone = rows.length <= pagination.offset + pagination.numItems ||
568
- nextOffset >= MAX_MANUAL_PAGINATION_OFFSET;
1660
+ function parseJourneyPagination(options, scope) {
1661
+ validatePagination(options);
1662
+ if (options.cursor === null) {
1663
+ return { numItems: options.numItems, scope, cursor: null };
1664
+ }
1665
+ let decoded;
1666
+ try {
1667
+ decoded = JSON.parse(atob(options.cursor));
1668
+ }
1669
+ catch {
1670
+ fail("INVALID_ARGUMENT", "pagination cursor is malformed");
1671
+ }
1672
+ if (!isPlainRecord(decoded) ||
1673
+ decoded.v !== 4 ||
1674
+ decoded.k !== scope.kind ||
1675
+ decoded.s !== scope.siteId ||
1676
+ decoded.n !== scope.sessionId ||
1677
+ decoded.f !== scope.from ||
1678
+ decoded.t !== scope.to ||
1679
+ typeof decoded.x !== "number" ||
1680
+ !Number.isFinite(decoded.x) ||
1681
+ decoded.x < scope.from ||
1682
+ decoded.x > scope.to ||
1683
+ typeof decoded.c !== "number" ||
1684
+ !Number.isFinite(decoded.c) ||
1685
+ decoded.c < 0 ||
1686
+ typeof decoded.i !== "string" ||
1687
+ decoded.i.length === 0) {
1688
+ fail("INVALID_ARGUMENT", "pagination cursor is invalid for this query");
1689
+ }
1690
+ return {
1691
+ numItems: options.numItems,
1692
+ scope,
1693
+ cursor: {
1694
+ timestamp: decoded.x,
1695
+ creationTime: decoded.c,
1696
+ id: decoded.i,
1697
+ },
1698
+ };
1699
+ }
1700
+ function keysetPaginationResult(rows, pagination, cursorFor) {
1701
+ const page = rows.slice(0, pagination.numItems);
1702
+ const isDone = rows.length <= pagination.numItems;
1703
+ const next = page.length > 0 ? cursorFor(page[page.length - 1]) : pagination.cursor;
1704
+ return {
1705
+ page,
1706
+ isDone,
1707
+ continueCursor: btoa(JSON.stringify({
1708
+ v: 2,
1709
+ k: pagination.scope.kind,
1710
+ s: pagination.scope.siteId,
1711
+ n: pagination.scope.sessionId ?? null,
1712
+ f: pagination.scope.from,
1713
+ t: pagination.scope.to,
1714
+ x: next?.timestamp ?? pagination.scope.to,
1715
+ c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
1716
+ })),
1717
+ };
1718
+ }
1719
+ function journeyPaginationResult(rows, pagination) {
1720
+ const page = rows.slice(0, pagination.numItems);
1721
+ const isDone = rows.length <= pagination.numItems;
1722
+ const last = page.at(-1);
1723
+ const next = last
1724
+ ? {
1725
+ timestamp: last.timestamp,
1726
+ creationTime: last._creationTime,
1727
+ id: last._id,
1728
+ }
1729
+ : pagination.cursor;
569
1730
  return {
570
1731
  page,
571
1732
  isDone,
572
1733
  continueCursor: btoa(JSON.stringify({
573
- v: 1,
574
- o: Math.min(nextOffset, MAX_MANUAL_PAGINATION_OFFSET),
1734
+ v: 4,
1735
+ k: pagination.scope.kind,
1736
+ s: pagination.scope.siteId,
1737
+ n: pagination.scope.sessionId,
1738
+ f: pagination.scope.from,
1739
+ t: pagination.scope.to,
1740
+ x: next?.timestamp ?? pagination.scope.to,
1741
+ c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
1742
+ i: next?.id ?? "~",
575
1743
  })),
576
1744
  };
577
1745
  }
578
1746
  function validatePagination(options) {
579
- if (!Number.isSafeInteger(options.numItems) || options.numItems < 1 || options.numItems > 100) {
1747
+ if (!Number.isSafeInteger(options.numItems) ||
1748
+ options.numItems < 1 ||
1749
+ options.numItems > 100) {
580
1750
  fail("INVALID_ARGUMENT", "pagination numItems must be an integer between 1 and 100");
581
1751
  }
582
1752
  if (options.maximumRowsRead !== undefined &&