@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
@@ -3,22 +3,50 @@ import {
3
3
  paginationResultValidator,
4
4
  } from "convex/server";
5
5
  import { v } from "convex/values";
6
+ import type { Doc, Id } from "./_generated/dataModel.js";
7
+ import type { QueryCtx } from "./_generated/server.js";
6
8
  import { query } from "./_generated/server.js";
7
9
  import {
8
10
  AGGREGATE_SHARDS,
9
11
  DAY_MS,
12
+ FEATURE_REPORT_BYTE_RESERVE,
13
+ FEATURE_REPORT_DOCUMENT_RESERVE,
10
14
  HOUR_MS,
15
+ HOURLY_AGGREGATE_SHARDS,
11
16
  MAX_AFFILIATES_PER_SITE,
12
17
  MAX_FUNNELS_PER_SITE,
18
+ MAX_FEATURE_ROLLUP_DAYS,
19
+ MAX_FEATURE_ROLLUP_ROWS,
13
20
  MAX_GOALS_PER_SITE,
14
21
  MAX_JOURNEY_EVENTS,
15
22
  MAX_LIVE_VISITORS,
16
- MAX_MANUAL_PAGINATION_OFFSET,
17
23
  MAX_OVERVIEW_AGGREGATE_ROWS,
24
+ MAX_REPORT_BUCKETS,
18
25
  MAX_REPORT_RANGE_DAYS,
19
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,
20
34
  } from "./constants.js";
35
+ import {
36
+ createVisitorSketch,
37
+ estimateVisitors,
38
+ unionVisitorSketches,
39
+ type VisitorSketch,
40
+ } from "./cardinality.js";
21
41
  import { fail } from "./errors.js";
42
+ import {
43
+ compareJourneyEvents,
44
+ hydrateEventContexts,
45
+ type JourneyCursor,
46
+ loadSessionJourneyEvents,
47
+ loadVisitorJourneyEvents,
48
+ type RawJourneyEvent,
49
+ } from "./eventStore.js";
22
50
  import { isPlainRecord } from "./guards.js";
23
51
  import {
24
52
  affiliateFieldsValidator,
@@ -26,14 +54,24 @@ import {
26
54
  funnelFieldsValidator,
27
55
  goalFieldsValidator,
28
56
  sessionFieldsValidator,
57
+ vitalMetricValidator,
29
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";
30
68
 
31
69
  const sessionDocumentValidator = sessionFieldsValidator.extend({
32
70
  _id: v.id("sessions"),
33
71
  _creationTime: v.number(),
34
72
  });
35
73
  const eventDocumentValidator = eventFieldsValidator.extend({
36
- _id: v.id("events"),
74
+ _id: v.string(),
37
75
  _creationTime: v.number(),
38
76
  });
39
77
  const liveSessionDocumentValidator = v.object({
@@ -56,6 +94,8 @@ const liveSessionDocumentValidator = v.object({
56
94
  device: v.string(),
57
95
  pageviewCount: v.number(),
58
96
  eventCount: v.number(),
97
+ previousPath: v.optional(v.string()),
98
+ movedAt: v.optional(v.number()),
59
99
  });
60
100
  const conversionDocumentValidator = v.object({
61
101
  _id: v.id("conversions"),
@@ -87,12 +127,38 @@ const metricTotalsValidator = v.object({
87
127
  conversions: v.number(),
88
128
  revenueCents: v.number(),
89
129
  });
90
- const timePointValidator = metricTotalsValidator.extend({ timestamp: v.number() });
130
+ const timePointValidator = metricTotalsValidator.extend({
131
+ timestamp: v.number(),
132
+ });
91
133
  const topItemValidator = v.object({
92
134
  value: v.string(),
93
135
  count: v.number(),
94
136
  revenueCents: v.number(),
95
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
+ });
96
162
 
97
163
  export const overview = query({
98
164
  args: {
@@ -109,19 +175,51 @@ export const overview = query({
109
175
  totals: metricTotalsValidator,
110
176
  timeSeries: v.array(timePointValidator),
111
177
  topSources: v.array(topItemValidator),
178
+ topCampaigns: v.array(topItemValidator),
112
179
  topPages: v.array(topItemValidator),
113
180
  topCountries: v.array(topItemValidator),
114
181
  topDevices: v.array(topItemValidator),
115
182
  topBrowsers: v.array(topItemValidator),
116
183
  topEvents: v.array(topItemValidator),
117
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
+ }),
118
215
  }),
119
216
  handler: async (ctx, args) => {
120
217
  const siteIds = validateSiteIds(args.siteIds);
121
218
  const currencies = new Set<string>();
122
219
  for (const siteId of siteIds) {
123
220
  const site = await ctx.db.get("sites", siteId);
124
- if (!site) fail("NOT_FOUND", "site not found", { siteId: String(siteId) });
221
+ if (!site)
222
+ fail("NOT_FOUND", "site not found", { siteId: String(siteId) });
125
223
  currencies.add(site.currency ?? "USD");
126
224
  }
127
225
  if (currencies.size !== 1) {
@@ -130,16 +228,23 @@ export const overview = query({
130
228
  });
131
229
  }
132
230
  const currency = [...currencies][0];
133
- const { from, to } = validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
134
- const interval = args.interval ?? (to - from <= 7 * DAY_MS ? "hour" : "day");
135
- if (interval === "hour" && to - from > 7 * DAY_MS) {
136
- fail("INVALID_ARGUMENT", "hourly reports are limited to seven days");
231
+ const { from, to } = validateRange(
232
+ args.from,
233
+ args.to,
234
+ MAX_REPORT_RANGE_DAYS,
235
+ );
236
+ const interval = args.interval ?? (to - from <= DAY_MS ? "hour" : "day");
237
+ if (interval === "hour" && to - from > DAY_MS) {
238
+ fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
137
239
  }
138
240
  const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
241
+ requireAlignedRange(from, to, intervalMs, `${interval} overview`);
139
242
  const firstBucket = Math.floor(from / intervalMs) * intervalMs;
140
243
  const lastBucket = Math.floor(to / intervalMs) * intervalMs;
141
244
  const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
142
- const perSiteLimit = bucketCount * AGGREGATE_SHARDS;
245
+ const perSiteLimit =
246
+ bucketCount *
247
+ (interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
143
248
  if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
144
249
  fail(
145
250
  "LIMIT_EXCEEDED",
@@ -149,7 +254,20 @@ export const overview = query({
149
254
  }
150
255
  const totals = emptyTotals();
151
256
  const points = new Map<number, MetricTotals>();
152
- 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);
153
271
 
154
272
  for (const siteId of siteIds) {
155
273
  const buckets = await ctx.db
@@ -161,12 +279,29 @@ export const overview = query({
161
279
  .gte("bucketStart", firstBucket)
162
280
  .lte("bucketStart", lastBucket),
163
281
  )
164
- .take(perSiteLimit);
282
+ .take(perSiteLimit + 1);
283
+ failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
165
284
  for (const bucket of buckets) {
166
285
  addMetrics(totals, bucket);
167
286
  const point = points.get(bucket.bucketStart) ?? emptyTotals();
168
287
  addMetrics(point, bucket);
169
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
+ }
170
305
  for (const dimension of bucket.dimensions) {
171
306
  const key = `${dimension.type}:${dimension.value}`;
172
307
  const current = dimensions.get(key) ?? {
@@ -185,8 +320,25 @@ export const overview = query({
185
320
  [...dimensions.entries()]
186
321
  .filter(([key]) => key.startsWith(`${type}:`))
187
322
  .map(([, value]) => value)
188
- .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
+ )
189
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";
190
342
  return {
191
343
  from,
192
344
  to,
@@ -197,12 +349,40 @@ export const overview = query({
197
349
  .sort(([left], [right]) => left - right)
198
350
  .map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
199
351
  topSources: top("source"),
352
+ topCampaigns: top("campaign"),
200
353
  topPages: top("page"),
201
354
  topCountries: top("country"),
202
355
  topDevices: top("device"),
203
356
  topBrowsers: top("browser"),
204
357
  topEvents: top("event"),
205
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
+ },
206
386
  };
207
387
  },
208
388
  });
@@ -217,8 +397,15 @@ export const liveVisitors = query({
217
397
  handler: async (ctx, args) => {
218
398
  const siteIds = validateSiteIds(args.siteIds);
219
399
  const limit = args.limit ?? 100;
220
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIVE_VISITORS) {
221
- 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
+ );
222
409
  }
223
410
  if (!Number.isFinite(args.now) || args.now < 0) {
224
411
  fail("INVALID_ARGUMENT", "now must be a positive timestamp");
@@ -258,10 +445,311 @@ export const liveVisitors = query({
258
445
  device: row.device,
259
446
  pageviewCount: row.pageviewCount,
260
447
  eventCount: row.eventCount,
448
+ ...(row.previousPath ? { previousPath: row.previousPath } : {}),
449
+ ...(row.movedAt !== undefined ? { movedAt: row.movedAt } : {}),
261
450
  }));
262
451
  },
263
452
  });
264
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
+
265
753
  export const listSessions = query({
266
754
  args: {
267
755
  siteId: v.id("sites"),
@@ -272,18 +760,67 @@ export const listSessions = query({
272
760
  returns: paginationResultValidator(sessionDocumentValidator),
273
761
  handler: async (ctx, args) => {
274
762
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
275
- const pagination = parseManualPagination(args.paginationOpts);
276
- const rows = await ctx.db
277
- .query("sessions")
278
- .withIndex("by_siteId_and_lastSeenAt", (range) =>
279
- range
280
- .eq("siteId", args.siteId)
281
- .gte("lastSeenAt", args.from)
282
- .lte("lastSeenAt", args.to),
283
- )
284
- .order("desc")
285
- .take(pagination.offset + pagination.numItems + 1);
286
- return manualPaginationResult(rows, pagination);
763
+ await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
764
+ const pagination = parseKeysetPagination(args.paginationOpts, {
765
+ kind: "sessions",
766
+ siteId: String(args.siteId),
767
+ from: args.from,
768
+ to: args.to,
769
+ });
770
+ const rows: Doc<"sessions">[] = [];
771
+ const take = pagination.numItems + 1;
772
+ if (pagination.cursor) {
773
+ rows.push(
774
+ ...(await ctx.db
775
+ .query("sessions")
776
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
777
+ range
778
+ .eq("siteId", args.siteId)
779
+ .eq("lastSeenAt", pagination.cursor!.timestamp)
780
+ .lt("_creationTime", pagination.cursor!.creationTime),
781
+ )
782
+ .order("desc")
783
+ .take(take)),
784
+ );
785
+ if (rows.length < take) {
786
+ rows.push(
787
+ ...(await ctx.db
788
+ .query("sessions")
789
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
790
+ range
791
+ .eq("siteId", args.siteId)
792
+ .gte("lastSeenAt", args.from)
793
+ .lt("lastSeenAt", pagination.cursor!.timestamp),
794
+ )
795
+ .order("desc")
796
+ .take(take - rows.length)),
797
+ );
798
+ }
799
+ } else {
800
+ rows.push(
801
+ ...(await ctx.db
802
+ .query("sessions")
803
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
804
+ range
805
+ .eq("siteId", args.siteId)
806
+ .gte("lastSeenAt", args.from)
807
+ .lte("lastSeenAt", args.to),
808
+ )
809
+ .order("desc")
810
+ .take(take)),
811
+ );
812
+ }
813
+ const publicRows = rows.map(
814
+ ({
815
+ geoLookupAttemptedAt: _attemptedAt,
816
+ lastPageviewAt: _lastPageviewAt,
817
+ ...row
818
+ }) => row,
819
+ );
820
+ return keysetPaginationResult(publicRows, pagination, (row) => ({
821
+ timestamp: row.lastSeenAt,
822
+ creationTime: row._creationTime,
823
+ }));
287
824
  },
288
825
  });
289
826
 
@@ -298,19 +835,24 @@ export const sessionJourney = query({
298
835
  returns: paginationResultValidator(eventDocumentValidator),
299
836
  handler: async (ctx, args) => {
300
837
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
301
- const pagination = parseManualPagination(args.paginationOpts);
302
- const rows = await ctx.db
303
- .query("events")
304
- .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
305
- range
306
- .eq("siteId", args.siteId)
307
- .eq("sessionId", args.sessionId)
308
- .gte("timestamp", args.from)
309
- .lte("timestamp", args.to),
310
- )
311
- .order("asc")
312
- .take(pagination.offset + pagination.numItems + 1);
313
- return manualPaginationResult(rows, pagination);
838
+ await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
839
+ const pagination = parseJourneyPagination(args.paginationOpts, {
840
+ kind: "events",
841
+ siteId: String(args.siteId),
842
+ sessionId: args.sessionId,
843
+ from: args.from,
844
+ to: args.to,
845
+ });
846
+ const rows = await loadSessionJourneyEvents(ctx, {
847
+ siteId: args.siteId,
848
+ sessionId: args.sessionId,
849
+ from: args.from,
850
+ to: args.to,
851
+ cursor: pagination.cursor,
852
+ resultLimit: pagination.numItems + 1,
853
+ });
854
+ const hydratedRows = await hydrateEventContexts(ctx, rows);
855
+ return journeyPaginationResult(hydratedRows, pagination);
314
856
  },
315
857
  });
316
858
 
@@ -324,18 +866,60 @@ export const listConversions = query({
324
866
  returns: paginationResultValidator(conversionDocumentValidator),
325
867
  handler: async (ctx, args) => {
326
868
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
327
- const pagination = parseManualPagination(args.paginationOpts);
328
- const rows = await ctx.db
329
- .query("conversions")
330
- .withIndex("by_siteId_and_timestamp", (range) =>
331
- range
332
- .eq("siteId", args.siteId)
333
- .gte("timestamp", args.from)
334
- .lte("timestamp", args.to),
335
- )
336
- .order("desc")
337
- .take(pagination.offset + pagination.numItems + 1);
338
- return manualPaginationResult(rows, pagination);
869
+ await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
870
+ const pagination = parseKeysetPagination(args.paginationOpts, {
871
+ kind: "conversions",
872
+ siteId: String(args.siteId),
873
+ from: args.from,
874
+ to: args.to,
875
+ });
876
+ const rows: Doc<"conversions">[] = [];
877
+ const take = pagination.numItems + 1;
878
+ if (pagination.cursor) {
879
+ rows.push(
880
+ ...(await ctx.db
881
+ .query("conversions")
882
+ .withIndex("by_siteId_and_timestamp", (range) =>
883
+ range
884
+ .eq("siteId", args.siteId)
885
+ .eq("timestamp", pagination.cursor!.timestamp)
886
+ .lt("_creationTime", pagination.cursor!.creationTime),
887
+ )
888
+ .order("desc")
889
+ .take(take)),
890
+ );
891
+ if (rows.length < take) {
892
+ rows.push(
893
+ ...(await ctx.db
894
+ .query("conversions")
895
+ .withIndex("by_siteId_and_timestamp", (range) =>
896
+ range
897
+ .eq("siteId", args.siteId)
898
+ .gte("timestamp", args.from)
899
+ .lt("timestamp", pagination.cursor!.timestamp),
900
+ )
901
+ .order("desc")
902
+ .take(take - rows.length)),
903
+ );
904
+ }
905
+ } else {
906
+ rows.push(
907
+ ...(await ctx.db
908
+ .query("conversions")
909
+ .withIndex("by_siteId_and_timestamp", (range) =>
910
+ range
911
+ .eq("siteId", args.siteId)
912
+ .gte("timestamp", args.from)
913
+ .lte("timestamp", args.to),
914
+ )
915
+ .order("desc")
916
+ .take(take)),
917
+ );
918
+ }
919
+ return keysetPaginationResult(rows, pagination, (row) => ({
920
+ timestamp: row.timestamp,
921
+ creationTime: row._creationTime,
922
+ }));
339
923
  },
340
924
  });
341
925
 
@@ -351,29 +935,41 @@ export const visitorJourney = query({
351
935
  handler: async (ctx, args) => {
352
936
  const siteIds = validateSiteIds(args.siteIds);
353
937
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
938
+ await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
354
939
  const limit = args.limit ?? 200;
355
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
356
- 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
+ );
357
949
  }
358
- const rows = [];
950
+ const rows: RawJourneyEvent[] = [];
359
951
  for (const siteId of siteIds) {
360
- rows.push(
361
- ...(await ctx.db
362
- .query("events")
363
- .withIndex("by_siteId_and_visitorId_and_timestamp", (range) =>
364
- range
365
- .eq("siteId", siteId)
366
- .eq("visitorId", args.visitorId)
367
- .gte("timestamp", args.from)
368
- .lte("timestamp", args.to),
369
- )
370
- .order("asc")
371
- .take(limit)),
372
- );
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
+ })),
967
+ );
968
+ rows.sort(compareJourneyEvents);
969
+ if (rows.length > limit) rows.length = limit;
970
+ }
373
971
  }
374
- return rows
375
- .sort((left, right) => left.timestamp - right.timestamp || left.sequence - right.sequence)
376
- .slice(0, limit);
972
+ return hydrateEventContexts(ctx, rows);
377
973
  },
378
974
  });
379
975
 
@@ -389,36 +985,90 @@ export const goalsReport = query({
389
985
  returns: v.object({
390
986
  goals: v.array(goalReportItemValidator),
391
987
  truncated: v.boolean(),
988
+ metadata: reportMetadataValidator,
392
989
  }),
393
990
  handler: async (ctx, args) => {
394
991
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
395
- const goals = await ctx.db
992
+ const goalRows = await ctx.db
396
993
  .query("goals")
397
994
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
398
- .take(MAX_GOALS_PER_SITE);
399
- const completions = await ctx.db
400
- .query("goalCompletions")
401
- .withIndex("by_siteId_and_timestamp", (range) =>
402
- range
403
- .eq("siteId", args.siteId)
404
- .gte("timestamp", args.from)
405
- .lte("timestamp", args.to),
406
- )
407
- .take(5_001);
408
- const totals = new Map<string, { completions: number; valueCents: number }>();
409
- for (const completion of completions.slice(0, 5_000)) {
410
- const key = String(completion.goalId);
411
- const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
412
- total.completions += 1;
413
- total.valueCents += completion.valueCents;
414
- 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
+ }
415
1050
  }
416
1051
  return {
417
1052
  goals: goals.map((goal) => ({
418
1053
  ...goal,
419
1054
  ...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
420
1055
  })),
421
- 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
+ },
422
1072
  };
423
1073
  },
424
1074
  });
@@ -427,9 +1077,15 @@ const funnelReportItemValidator = funnelFieldsValidator.extend({
427
1077
  _id: v.id("funnels"),
428
1078
  _creationTime: v.number(),
429
1079
  stepsReport: v.array(
430
- 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
+ }),
431
1086
  ),
432
1087
  truncated: v.boolean(),
1088
+ metadata: reportMetadataValidator,
433
1089
  });
434
1090
 
435
1091
  export const funnelsReport = query({
@@ -437,29 +1093,74 @@ export const funnelsReport = query({
437
1093
  returns: v.array(funnelReportItemValidator),
438
1094
  handler: async (ctx, args) => {
439
1095
  validateRange(args.from, args.to, 90);
440
- const funnels = await ctx.db
1096
+ requireAlignedRange(args.from, args.to, DAY_MS, "funnel report");
1097
+ const funnelRows = await ctx.db
441
1098
  .query("funnels")
442
1099
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
443
- .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
+ );
444
1138
  const reports = [];
445
1139
  for (const funnel of funnels) {
446
- const buckets = await ctx.db
447
- .query("funnelStepBuckets")
448
- .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
449
- range
450
- .eq("siteId", args.siteId)
451
- .eq("funnelId", funnel._id)
452
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
453
- .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
454
- )
455
- .take(601);
456
1140
  const stepsReport = funnel.steps.map((step, index) => ({
457
1141
  step: index,
458
1142
  name: step.name,
459
1143
  entrants: 0,
460
1144
  completions: 0,
461
1145
  }));
462
- 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) {
463
1164
  const step = stepsReport[bucket.step];
464
1165
  if (step) {
465
1166
  step.entrants += bucket.entrants;
@@ -469,7 +1170,14 @@ export const funnelsReport = query({
469
1170
  reports.push({
470
1171
  ...funnel,
471
1172
  stepsReport,
472
- 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
+ },
473
1181
  });
474
1182
  }
475
1183
  return reports;
@@ -491,64 +1199,134 @@ export const affiliatesReport = query({
491
1199
  returns: v.object({
492
1200
  affiliates: v.array(affiliateReportItemValidator),
493
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
+ }),
494
1208
  }),
495
1209
  handler: async (ctx, args) => {
496
1210
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
1211
+ requireAlignedRange(args.from, args.to, DAY_MS, "affiliate report");
497
1212
  const affiliateRows = await ctx.db
498
1213
  .query("affiliates")
499
1214
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
500
1215
  .order("desc")
501
1216
  .take(MAX_AFFILIATES_PER_SITE + 1);
502
- const affiliates = affiliateRows.slice(0, MAX_AFFILIATES_PER_SITE);
503
- const stats = await ctx.db
504
- .query("affiliateDailyStats")
505
- .withIndex("by_siteId_and_bucketStart", (range) =>
506
- range
507
- .eq("siteId", args.siteId)
508
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
509
- .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
510
- )
511
- .take(5_001);
512
- const sessions = await ctx.db
513
- .query("sessions")
514
- .withIndex("by_siteId_and_startedAt", (range) =>
515
- range
516
- .eq("siteId", args.siteId)
517
- .gte("startedAt", args.from)
518
- .lte("startedAt", args.to),
519
- )
520
- .take(5_001);
521
- const visits = new Map<string, {
522
- visits: number;
523
- visitors: Set<string>;
524
- }>();
525
- for (const session of sessions.slice(0, 5_000)) {
526
- if (!session.affiliateId) continue;
527
- const key = String(session.affiliateId);
528
- const total = visits.get(key) ?? {
529
- visits: 0,
530
- visitors: new Set<string>(),
531
- };
532
- total.visits += 1;
533
- total.visitors.add(session.visitorId);
534
- visits.set(key, total);
535
- }
536
- const totals = new Map<string, {
537
- conversions: number;
538
- revenueCents: number;
539
- commissionCents: number;
540
- }>();
541
- for (const row of stats.slice(0, 5_000)) {
542
- const key = String(row.affiliateId);
543
- const total = totals.get(key) ?? {
544
- conversions: 0,
545
- revenueCents: 0,
546
- commissionCents: 0,
547
- };
548
- total.conversions += row.conversions;
549
- total.revenueCents += row.revenueCents;
550
- total.commissionCents += row.commissionCents;
551
- 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
+ }
552
1330
  }
553
1331
  return {
554
1332
  affiliates: affiliates.map((affiliate) => {
@@ -562,17 +1340,799 @@ export const affiliatesReport = query({
562
1340
  ...affiliate,
563
1341
  ...performance,
564
1342
  visits: traffic?.visits ?? 0,
565
- uniqueVisitors: traffic?.visitors.size ?? 0,
1343
+ uniqueVisitors: rollups
1344
+ ? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
1345
+ : (traffic?.visitors.size ?? 0),
566
1346
  };
567
1347
  }),
568
- truncated:
569
- affiliateRows.length > MAX_AFFILIATES_PER_SITE ||
570
- stats.length > 5_000 ||
571
- 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
+ ),
572
1583
  };
573
1584
  },
574
1585
  });
575
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
+ }
576
2136
 
577
2137
  type MetricTotals = {
578
2138
  events: number;
@@ -614,43 +2174,144 @@ function addMetrics(target: MetricTotals, source: MetricTotals) {
614
2174
 
615
2175
  function validateSiteIds<T>(siteIds: T[]): T[] {
616
2176
  const unique = [...new Set(siteIds)];
617
- if (unique.length < 1 || unique.length > MAX_REPORT_SITES || unique.length !== siteIds.length) {
618
- 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
+ );
619
2186
  }
620
2187
  return unique;
621
2188
  }
622
2189
 
623
2190
  function validateRange(from: number, to: number, maxDays: number) {
624
2191
  if (
625
- !Number.isFinite(from) ||
626
- !Number.isFinite(to) ||
2192
+ !Number.isSafeInteger(from) ||
2193
+ !Number.isSafeInteger(to) ||
627
2194
  from < 0 ||
628
- to <= from ||
629
- to - from > maxDays * DAY_MS
2195
+ to < from ||
2196
+ to - from >= maxDays * DAY_MS
630
2197
  ) {
631
- 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
+ );
2202
+ }
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
+ );
632
2222
  }
633
- return { from: Math.trunc(from), to: Math.trunc(to) };
634
2223
  }
635
2224
 
636
- type ManualPaginationInput = {
2225
+ function rangeSemantics(from: number, to: number) {
2226
+ return {
2227
+ from,
2228
+ to,
2229
+ boundary: "inclusive" as const,
2230
+ timezone: "UTC" as const,
2231
+ };
2232
+ }
2233
+
2234
+ type KeysetPaginationInput = {
637
2235
  cursor: string | null;
638
2236
  numItems: number;
639
2237
  maximumRowsRead?: number;
640
2238
  maximumBytesRead?: number;
641
2239
  };
642
2240
 
643
- type ManualPagination = {
644
- offset: number;
2241
+ type PaginationScope = {
2242
+ kind: "sessions" | "events" | "conversions";
2243
+ siteId: string;
2244
+ sessionId?: string;
2245
+ from: number;
2246
+ to: number;
2247
+ };
2248
+
2249
+ type KeysetCursor = {
2250
+ timestamp: number;
2251
+ creationTime: number;
2252
+ };
2253
+
2254
+ type KeysetPagination = {
2255
+ numItems: number;
2256
+ scope: PaginationScope;
2257
+ cursor: KeysetCursor | null;
2258
+ };
2259
+
2260
+ type JourneyPagination = {
645
2261
  numItems: number;
2262
+ scope: PaginationScope & { kind: "events"; sessionId: string };
2263
+ cursor: JourneyCursor | null;
646
2264
  };
647
2265
 
648
- function parseManualPagination(
649
- options: ManualPaginationInput,
650
- ): ManualPagination {
2266
+ function parseKeysetPagination(
2267
+ options: KeysetPaginationInput,
2268
+ scope: PaginationScope,
2269
+ ): KeysetPagination {
2270
+ validatePagination(options);
2271
+ if (options.cursor === null) {
2272
+ return { numItems: options.numItems, scope, cursor: null };
2273
+ }
2274
+ let decoded: unknown;
2275
+ try {
2276
+ decoded = JSON.parse(atob(options.cursor));
2277
+ } catch {
2278
+ fail("INVALID_ARGUMENT", "pagination cursor is malformed");
2279
+ }
2280
+ if (
2281
+ !isPlainRecord(decoded) ||
2282
+ decoded.v !== 2 ||
2283
+ decoded.k !== scope.kind ||
2284
+ decoded.s !== scope.siteId ||
2285
+ decoded.n !== (scope.sessionId ?? null) ||
2286
+ decoded.f !== scope.from ||
2287
+ decoded.t !== scope.to ||
2288
+ typeof decoded.x !== "number" ||
2289
+ !Number.isFinite(decoded.x) ||
2290
+ decoded.x < scope.from ||
2291
+ decoded.x > scope.to ||
2292
+ typeof decoded.c !== "number" ||
2293
+ !Number.isFinite(decoded.c) ||
2294
+ decoded.c < 0
2295
+ ) {
2296
+ fail("INVALID_ARGUMENT", "pagination cursor is invalid for this query");
2297
+ }
2298
+ return {
2299
+ numItems: options.numItems,
2300
+ scope,
2301
+ cursor: {
2302
+ timestamp: decoded.x,
2303
+ creationTime: decoded.c,
2304
+ },
2305
+ };
2306
+ }
2307
+
2308
+ function parseJourneyPagination(
2309
+ options: KeysetPaginationInput,
2310
+ scope: PaginationScope & { kind: "events"; sessionId: string },
2311
+ ): JourneyPagination {
651
2312
  validatePagination(options);
652
2313
  if (options.cursor === null) {
653
- return { offset: 0, numItems: options.numItems };
2314
+ return { numItems: options.numItems, scope, cursor: null };
654
2315
  }
655
2316
  let decoded: unknown;
656
2317
  try {
@@ -660,36 +2321,92 @@ function parseManualPagination(
660
2321
  }
661
2322
  if (
662
2323
  !isPlainRecord(decoded) ||
663
- decoded.v !== 1 ||
664
- typeof decoded.o !== "number" ||
665
- !Number.isSafeInteger(decoded.o) ||
666
- decoded.o < 0 ||
667
- decoded.o > MAX_MANUAL_PAGINATION_OFFSET
2324
+ decoded.v !== 4 ||
2325
+ decoded.k !== scope.kind ||
2326
+ decoded.s !== scope.siteId ||
2327
+ decoded.n !== scope.sessionId ||
2328
+ decoded.f !== scope.from ||
2329
+ decoded.t !== scope.to ||
2330
+ typeof decoded.x !== "number" ||
2331
+ !Number.isFinite(decoded.x) ||
2332
+ decoded.x < scope.from ||
2333
+ decoded.x > scope.to ||
2334
+ typeof decoded.c !== "number" ||
2335
+ !Number.isFinite(decoded.c) ||
2336
+ decoded.c < 0 ||
2337
+ typeof decoded.i !== "string" ||
2338
+ decoded.i.length === 0
668
2339
  ) {
669
- fail("INVALID_ARGUMENT", "pagination cursor is invalid or exceeds the offset limit");
2340
+ fail("INVALID_ARGUMENT", "pagination cursor is invalid for this query");
670
2341
  }
671
- return { offset: decoded.o, numItems: options.numItems };
2342
+ return {
2343
+ numItems: options.numItems,
2344
+ scope,
2345
+ cursor: {
2346
+ timestamp: decoded.x,
2347
+ creationTime: decoded.c,
2348
+ id: decoded.i,
2349
+ },
2350
+ };
672
2351
  }
673
2352
 
674
- function manualPaginationResult<T>(
2353
+ function keysetPaginationResult<T>(
675
2354
  rows: T[],
676
- pagination: ManualPagination,
2355
+ pagination: KeysetPagination,
2356
+ cursorFor: (row: T) => KeysetCursor,
677
2357
  ) {
678
- const page = rows.slice(
679
- pagination.offset,
680
- pagination.offset + pagination.numItems,
681
- );
682
- const nextOffset = pagination.offset + page.length;
683
- const isDone =
684
- rows.length <= pagination.offset + pagination.numItems ||
685
- nextOffset >= MAX_MANUAL_PAGINATION_OFFSET;
2358
+ const page = rows.slice(0, pagination.numItems);
2359
+ const isDone = rows.length <= pagination.numItems;
2360
+ const next =
2361
+ page.length > 0 ? cursorFor(page[page.length - 1]) : pagination.cursor;
686
2362
  return {
687
2363
  page,
688
2364
  isDone,
689
- continueCursor: btoa(JSON.stringify({
690
- v: 1,
691
- o: Math.min(nextOffset, MAX_MANUAL_PAGINATION_OFFSET),
692
- })),
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
+ ),
2377
+ };
2378
+ }
2379
+
2380
+ function journeyPaginationResult(
2381
+ rows: Awaited<ReturnType<typeof hydrateEventContexts>>,
2382
+ pagination: JourneyPagination,
2383
+ ) {
2384
+ const page = rows.slice(0, pagination.numItems);
2385
+ const isDone = rows.length <= pagination.numItems;
2386
+ const last = page.at(-1);
2387
+ const next = last
2388
+ ? {
2389
+ timestamp: last.timestamp,
2390
+ creationTime: last._creationTime,
2391
+ id: last._id,
2392
+ }
2393
+ : pagination.cursor;
2394
+ return {
2395
+ page,
2396
+ isDone,
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
+ ),
693
2410
  };
694
2411
  }
695
2412
 
@@ -698,8 +2415,15 @@ function validatePagination(options: {
698
2415
  maximumRowsRead?: number;
699
2416
  maximumBytesRead?: number;
700
2417
  }) {
701
- if (!Number.isSafeInteger(options.numItems) || options.numItems < 1 || options.numItems > 100) {
702
- 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
+ );
703
2427
  }
704
2428
  if (
705
2429
  options.maximumRowsRead !== undefined &&
@@ -707,7 +2431,10 @@ function validatePagination(options: {
707
2431
  options.maximumRowsRead < 1 ||
708
2432
  options.maximumRowsRead > 1_000)
709
2433
  ) {
710
- 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
+ );
711
2438
  }
712
2439
  if (
713
2440
  options.maximumBytesRead !== undefined &&
@@ -715,6 +2442,9 @@ function validatePagination(options: {
715
2442
  options.maximumBytesRead < 1 ||
716
2443
  options.maximumBytesRead > 8_000_000)
717
2444
  ) {
718
- 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
+ );
719
2449
  }
720
2450
  }