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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +521 -66
  2. package/agent/integration.md +661 -0
  3. package/agent/manifest.json +187 -0
  4. package/agent/manifest.schema.json +411 -0
  5. package/dist/client/federation.d.ts +236 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +196 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2467 -18
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +461 -32
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +16 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +302 -4
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +4 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/affiliates.d.ts.map +1 -1
  22. package/dist/component/affiliates.js +6 -2
  23. package/dist/component/affiliates.js.map +1 -1
  24. package/dist/component/cardinality.d.ts +12 -0
  25. package/dist/component/cardinality.d.ts.map +1 -0
  26. package/dist/component/cardinality.js +94 -0
  27. package/dist/component/cardinality.js.map +1 -0
  28. package/dist/component/constants.d.ts +25 -1
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +38 -1
  31. package/dist/component/constants.js.map +1 -1
  32. package/dist/component/convex.config.d.ts +6 -1
  33. package/dist/component/convex.config.js +9 -1
  34. package/dist/component/convex.config.js.map +1 -1
  35. package/dist/component/coverage.d.ts +19 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +30 -0
  38. package/dist/component/coverage.js.map +1 -0
  39. package/dist/component/diagnostics.d.ts +9 -0
  40. package/dist/component/diagnostics.d.ts.map +1 -0
  41. package/dist/component/diagnostics.js +47 -0
  42. package/dist/component/diagnostics.js.map +1 -0
  43. package/dist/component/errors.d.ts +1 -1
  44. package/dist/component/errors.d.ts.map +1 -1
  45. package/dist/component/errors.js.map +1 -1
  46. package/dist/component/eventStore.d.ts +21 -9
  47. package/dist/component/eventStore.d.ts.map +1 -1
  48. package/dist/component/eventStore.js +142 -152
  49. package/dist/component/eventStore.js.map +1 -1
  50. package/dist/component/funnels.d.ts.map +1 -1
  51. package/dist/component/funnels.js +5 -3
  52. package/dist/component/funnels.js.map +1 -1
  53. package/dist/component/geo.d.ts +71 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +611 -0
  56. package/dist/component/geo.js.map +1 -0
  57. package/dist/component/goals.d.ts.map +1 -1
  58. package/dist/component/goals.js +8 -5
  59. package/dist/component/goals.js.map +1 -1
  60. package/dist/component/guards.d.ts.map +1 -1
  61. package/dist/component/guards.js.map +1 -1
  62. package/dist/component/http.d.ts.map +1 -1
  63. package/dist/component/http.js +248 -49
  64. package/dist/component/http.js.map +1 -1
  65. package/dist/component/identity.d.ts +13 -0
  66. package/dist/component/identity.d.ts.map +1 -0
  67. package/dist/component/identity.js +58 -0
  68. package/dist/component/identity.js.map +1 -0
  69. package/dist/component/ingest.d.ts +3 -1
  70. package/dist/component/ingest.d.ts.map +1 -1
  71. package/dist/component/ingest.js +604 -55
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts.map +1 -1
  74. package/dist/component/live.js +7 -6
  75. package/dist/component/live.js.map +1 -1
  76. package/dist/component/reports.d.ts +259 -10
  77. package/dist/component/reports.d.ts.map +1 -1
  78. package/dist/component/reports.js +1110 -124
  79. package/dist/component/reports.js.map +1 -1
  80. package/dist/component/retention.d.ts +75 -1
  81. package/dist/component/retention.d.ts.map +1 -1
  82. package/dist/component/retention.js +537 -32
  83. package/dist/component/retention.js.map +1 -1
  84. package/dist/component/sanitize.d.ts +24 -1
  85. package/dist/component/sanitize.d.ts.map +1 -1
  86. package/dist/component/sanitize.js +96 -16
  87. package/dist/component/sanitize.js.map +1 -1
  88. package/dist/component/schema.d.ts +378 -62
  89. package/dist/component/schema.js +178 -20
  90. package/dist/component/schema.js.map +1 -1
  91. package/dist/component/sites.d.ts.map +1 -1
  92. package/dist/component/sites.js +11 -7
  93. package/dist/component/sites.js.map +1 -1
  94. package/dist/component/useragent.d.ts +9 -0
  95. package/dist/component/useragent.d.ts.map +1 -0
  96. package/dist/component/useragent.js +152 -0
  97. package/dist/component/useragent.js.map +1 -0
  98. package/dist/component/validators.d.ts +101 -65
  99. package/dist/component/validators.d.ts.map +1 -1
  100. package/dist/component/validators.js +19 -13
  101. package/dist/component/validators.js.map +1 -1
  102. package/dist/component/visitors.d.ts +19 -0
  103. package/dist/component/visitors.d.ts.map +1 -0
  104. package/dist/component/visitors.js +86 -0
  105. package/dist/component/visitors.js.map +1 -0
  106. package/dist/component/vitals.d.ts +41 -0
  107. package/dist/component/vitals.d.ts.map +1 -0
  108. package/dist/component/vitals.js +115 -0
  109. package/dist/component/vitals.js.map +1 -0
  110. package/dist/react/index.d.ts.map +1 -1
  111. package/dist/react/index.js.map +1 -1
  112. package/dist/tracker/generated.d.ts +8 -4
  113. package/dist/tracker/generated.d.ts.map +1 -1
  114. package/dist/tracker/generated.js +8 -4
  115. package/dist/tracker/generated.js.map +1 -1
  116. package/dist/tracker/tracker.d.ts +1 -1
  117. package/dist/tracker/tracker.d.ts.map +1 -1
  118. package/dist/tracker/tracker.js +57 -20
  119. package/dist/tracker/tracker.js.map +1 -1
  120. package/dist/tracker/vitals.d.ts +10 -0
  121. package/dist/tracker/vitals.d.ts.map +1 -0
  122. package/dist/tracker/vitals.js +140 -0
  123. package/dist/tracker/vitals.js.map +1 -0
  124. package/dist/tracker.min.js +1 -1
  125. package/dist/vitals.min.js +1 -0
  126. package/docs/benchmarks/2026-08-20-realistic.md +76 -76
  127. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  128. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  129. package/docs/federation-setup.md +397 -0
  130. package/docs/federation.md +262 -0
  131. package/docs/upgrading.md +259 -0
  132. package/llms.txt +71 -0
  133. package/package.json +55 -11
  134. package/scripts/benchmark-ingest.mjs +175 -73
  135. package/scripts/generate-federation-keys.mjs +20 -0
  136. package/src/component/_generated/api.ts +16 -0
  137. package/src/component/_generated/component.ts +366 -4
  138. package/src/component/_generated/server.ts +4 -0
  139. package/src/component/affiliates.ts +20 -5
  140. package/src/component/cardinality.ts +117 -0
  141. package/src/component/constants.ts +38 -1
  142. package/src/component/convex.config.ts +11 -1
  143. package/src/component/coverage.ts +36 -0
  144. package/src/component/diagnostics.ts +65 -0
  145. package/src/component/errors.ts +2 -1
  146. package/src/component/eventStore.ts +217 -193
  147. package/src/component/funnels.ts +19 -16
  148. package/src/component/geo.ts +781 -0
  149. package/src/component/goals.ts +29 -21
  150. package/src/component/guards.ts +3 -1
  151. package/src/component/http.ts +357 -57
  152. package/src/component/identity.ts +74 -0
  153. package/src/component/ingest.ts +1040 -135
  154. package/src/component/live.ts +13 -7
  155. package/src/component/reports.ts +1675 -183
  156. package/src/component/retention.ts +770 -96
  157. package/src/component/sanitize.ts +144 -29
  158. package/src/component/schema.ts +200 -21
  159. package/src/component/sites.ts +27 -12
  160. package/src/component/useragent.ts +171 -0
  161. package/src/component/validators.ts +29 -13
  162. package/src/component/visitors.ts +116 -0
  163. package/src/component/vitals.ts +146 -0
@@ -1,11 +1,14 @@
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, HOURLY_AGGREGATE_SHARDS, MAX_AFFILIATES_PER_SITE, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, MAX_JOURNEY_EVENTS, MAX_LIVE_VISITORS, 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";
6
- import { compareJourneyEvents, hydrateEventContexts, loadSessionJourneyEvents, } from "./eventStore.js";
7
+ import { compareJourneyEvents, hydrateEventContexts, loadSessionJourneyEvents, loadVisitorJourneyEvents, } from "./eventStore.js";
7
8
  import { isPlainRecord } from "./guards.js";
8
- 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";
9
12
  const sessionDocumentValidator = sessionFieldsValidator.extend({
10
13
  _id: v.id("sessions"),
11
14
  _creationTime: v.number(),
@@ -34,6 +37,8 @@ const liveSessionDocumentValidator = v.object({
34
37
  device: v.string(),
35
38
  pageviewCount: v.number(),
36
39
  eventCount: v.number(),
40
+ previousPath: v.optional(v.string()),
41
+ movedAt: v.optional(v.number()),
37
42
  });
38
43
  const conversionDocumentValidator = v.object({
39
44
  _id: v.id("conversions"),
@@ -65,12 +70,33 @@ const metricTotalsValidator = v.object({
65
70
  conversions: v.number(),
66
71
  revenueCents: v.number(),
67
72
  });
68
- const timePointValidator = metricTotalsValidator.extend({ timestamp: v.number() });
73
+ const timePointValidator = metricTotalsValidator.extend({
74
+ timestamp: v.number(),
75
+ });
69
76
  const topItemValidator = v.object({
70
77
  value: v.string(),
71
78
  count: v.number(),
72
79
  revenueCents: v.number(),
73
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
+ });
74
100
  export const overview = query({
75
101
  args: {
76
102
  siteIds: v.array(v.id("sites")),
@@ -86,12 +112,40 @@ export const overview = query({
86
112
  totals: metricTotalsValidator,
87
113
  timeSeries: v.array(timePointValidator),
88
114
  topSources: v.array(topItemValidator),
115
+ topCampaigns: v.array(topItemValidator),
89
116
  topPages: v.array(topItemValidator),
90
117
  topCountries: v.array(topItemValidator),
91
118
  topDevices: v.array(topItemValidator),
92
119
  topBrowsers: v.array(topItemValidator),
93
120
  topEvents: v.array(topItemValidator),
94
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
+ }),
95
149
  }),
96
150
  handler: async (ctx, args) => {
97
151
  const siteIds = validateSiteIds(args.siteIds);
@@ -114,6 +168,7 @@ export const overview = query({
114
168
  fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
115
169
  }
116
170
  const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
171
+ requireAlignedRange(from, to, intervalMs, `${interval} overview`);
117
172
  const firstBucket = Math.floor(from / intervalMs) * intervalMs;
118
173
  const lastBucket = Math.floor(to / intervalMs) * intervalMs;
119
174
  const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
@@ -124,7 +179,15 @@ export const overview = query({
124
179
  }
125
180
  const totals = emptyTotals();
126
181
  const points = new Map();
182
+ const visitorSketch = createVisitorSketch();
183
+ let mergedVisitorSketch = visitorSketch;
184
+ const pointVisitorSketches = new Map();
185
+ let visitorsComplete = true;
127
186
  const dimensions = new Map();
187
+ const overviewDataset = interval === "hour"
188
+ ? "overviewHour"
189
+ : "overviewDay";
190
+ await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
128
191
  for (const siteId of siteIds) {
129
192
  const buckets = await ctx.db
130
193
  .query("aggregateBuckets")
@@ -133,12 +196,21 @@ export const overview = query({
133
196
  .eq("granularity", interval)
134
197
  .gte("bucketStart", firstBucket)
135
198
  .lte("bucketStart", lastBucket))
136
- .take(perSiteLimit);
199
+ .take(perSiteLimit + 1);
200
+ failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
137
201
  for (const bucket of buckets) {
138
202
  addMetrics(totals, bucket);
139
203
  const point = points.get(bucket.bucketStart) ?? emptyTotals();
140
204
  addMetrics(point, bucket);
141
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
+ }
142
214
  for (const dimension of bucket.dimensions) {
143
215
  const key = `${dimension.type}:${dimension.value}`;
144
216
  const current = dimensions.get(key) ?? {
@@ -157,6 +229,17 @@ export const overview = query({
157
229
  .map(([, value]) => value)
158
230
  .sort((left, right) => right.count - left.count || left.value.localeCompare(right.value))
159
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";
160
243
  return {
161
244
  from,
162
245
  to,
@@ -167,12 +250,40 @@ export const overview = query({
167
250
  .sort(([left], [right]) => left - right)
168
251
  .map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
169
252
  topSources: top("source"),
253
+ topCampaigns: top("campaign"),
170
254
  topPages: top("page"),
171
255
  topCountries: top("country"),
172
256
  topDevices: top("device"),
173
257
  topBrowsers: top("browser"),
174
258
  topEvents: top("event"),
175
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
+ },
176
287
  };
177
288
  },
178
289
  });
@@ -186,7 +297,9 @@ export const liveVisitors = query({
186
297
  handler: async (ctx, args) => {
187
298
  const siteIds = validateSiteIds(args.siteIds);
188
299
  const limit = args.limit ?? 100;
189
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIVE_VISITORS) {
300
+ if (!Number.isSafeInteger(limit) ||
301
+ limit < 1 ||
302
+ limit > MAX_LIVE_VISITORS) {
190
303
  fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_LIVE_VISITORS}`);
191
304
  }
192
305
  if (!Number.isFinite(args.now) || args.now < 0) {
@@ -223,9 +336,241 @@ export const liveVisitors = query({
223
336
  device: row.device,
224
337
  pageviewCount: row.pageviewCount,
225
338
  eventCount: row.eventCount,
339
+ ...(row.previousPath ? { previousPath: row.previousPath } : {}),
340
+ ...(row.movedAt !== undefined ? { movedAt: row.movedAt } : {}),
226
341
  }));
227
342
  },
228
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
+ }
229
574
  export const listSessions = query({
230
575
  args: {
231
576
  siteId: v.id("sites"),
@@ -236,6 +581,7 @@ export const listSessions = query({
236
581
  returns: paginationResultValidator(sessionDocumentValidator),
237
582
  handler: async (ctx, args) => {
238
583
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
584
+ await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
239
585
  const pagination = parseKeysetPagination(args.paginationOpts, {
240
586
  kind: "sessions",
241
587
  siteId: String(args.siteId),
@@ -274,7 +620,8 @@ export const listSessions = query({
274
620
  .order("desc")
275
621
  .take(take)));
276
622
  }
277
- return keysetPaginationResult(rows, pagination, (row) => ({
623
+ const publicRows = rows.map(({ geoLookupAttemptedAt: _attemptedAt, lastPageviewAt: _lastPageviewAt, ...row }) => row);
624
+ return keysetPaginationResult(publicRows, pagination, (row) => ({
278
625
  timestamp: row.lastSeenAt,
279
626
  creationTime: row._creationTime,
280
627
  }));
@@ -291,6 +638,7 @@ export const sessionJourney = query({
291
638
  returns: paginationResultValidator(eventDocumentValidator),
292
639
  handler: async (ctx, args) => {
293
640
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
641
+ await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
294
642
  const pagination = parseJourneyPagination(args.paginationOpts, {
295
643
  kind: "events",
296
644
  siteId: String(args.siteId),
@@ -320,6 +668,7 @@ export const listConversions = query({
320
668
  returns: paginationResultValidator(conversionDocumentValidator),
321
669
  handler: async (ctx, args) => {
322
670
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
671
+ await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
323
672
  const pagination = parseKeysetPagination(args.paginationOpts, {
324
673
  kind: "conversions",
325
674
  siteId: String(args.siteId),
@@ -376,46 +725,28 @@ export const visitorJourney = query({
376
725
  handler: async (ctx, args) => {
377
726
  const siteIds = validateSiteIds(args.siteIds);
378
727
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
728
+ await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
379
729
  const limit = args.limit ?? 200;
380
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
730
+ if (!Number.isSafeInteger(limit) ||
731
+ limit < 1 ||
732
+ limit > MAX_JOURNEY_EVENTS) {
381
733
  fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
382
734
  }
383
- const sessionGroups = await Promise.all(siteIds.map((siteId) => ctx.db
384
- .query("sessions")
385
- .withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) => range
386
- .eq("siteId", siteId)
387
- .eq("visitorId", args.visitorId)
388
- .gte("lastSeenAt", args.from))
389
- .order("asc")
390
- .take(MAX_JOURNEY_EVENTS + 1)));
391
- const sessions = sessionGroups
392
- .flat()
393
- .filter((session) => session.startedAt <= args.to)
394
- .sort((left, right) => left.startedAt - right.startedAt)
395
- .slice(0, MAX_JOURNEY_EVENTS);
396
735
  const rows = [];
397
- let overlappingSessionsRead = 0;
398
- for (const session of sessions) {
399
- const resultIsFull = rows.length >= limit;
400
- const cutoff = resultIsFull
401
- ? rows[limit - 1].timestamp
402
- : args.to;
403
- if (session.startedAt > cutoff)
404
- break;
405
- if (resultIsFull && ++overlappingSessionsRead > 8) {
406
- fail("LIMIT_EXCEEDED", "visitor journey has too many overlapping sessions", { maximumOverlappingSessions: 8 });
736
+ for (const siteId of siteIds) {
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;
407
749
  }
408
- rows.push(...await loadSessionJourneyEvents(ctx, {
409
- siteId: session.siteId,
410
- sessionId: session.sessionId,
411
- from: args.from,
412
- to: Math.min(args.to, cutoff),
413
- cursor: null,
414
- resultLimit: resultIsFull ? limit : limit - rows.length,
415
- }));
416
- rows.sort(compareJourneyEvents);
417
- if (rows.length > limit)
418
- rows.length = limit;
419
750
  }
420
751
  return hydrateEventContexts(ctx, rows);
421
752
  },
@@ -431,69 +762,126 @@ export const goalsReport = query({
431
762
  returns: v.object({
432
763
  goals: v.array(goalReportItemValidator),
433
764
  truncated: v.boolean(),
765
+ metadata: reportMetadataValidator,
434
766
  }),
435
767
  handler: async (ctx, args) => {
436
768
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
437
- const goals = await ctx.db
769
+ const goalRows = await ctx.db
438
770
  .query("goals")
439
771
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
440
- .take(MAX_GOALS_PER_SITE);
441
- const completions = await ctx.db
442
- .query("goalCompletions")
443
- .withIndex("by_siteId_and_timestamp", (range) => range
444
- .eq("siteId", args.siteId)
445
- .gte("timestamp", args.from)
446
- .lte("timestamp", args.to))
447
- .take(5_001);
772
+ .take(MAX_GOALS_PER_SITE + 1);
773
+ failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
774
+ const goals = goalRows;
448
775
  const totals = new Map();
449
- for (const completion of completions.slice(0, 5_000)) {
450
- const key = String(completion.goalId);
451
- const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
452
- total.completions += 1;
453
- total.valueCents += completion.valueCents;
454
- 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
+ }
455
806
  }
456
807
  return {
457
808
  goals: goals.map((goal) => ({
458
809
  ...goal,
459
810
  ...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
460
811
  })),
461
- 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
+ },
462
822
  };
463
823
  },
464
824
  });
465
825
  const funnelReportItemValidator = funnelFieldsValidator.extend({
466
826
  _id: v.id("funnels"),
467
827
  _creationTime: v.number(),
468
- 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
+ })),
469
834
  truncated: v.boolean(),
835
+ metadata: reportMetadataValidator,
470
836
  });
471
837
  export const funnelsReport = query({
472
838
  args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
473
839
  returns: v.array(funnelReportItemValidator),
474
840
  handler: async (ctx, args) => {
475
841
  validateRange(args.from, args.to, 90);
476
- const funnels = await ctx.db
842
+ requireAlignedRange(args.from, args.to, DAY_MS, "funnel report");
843
+ const funnelRows = await ctx.db
477
844
  .query("funnels")
478
845
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
479
- .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
+ });
480
861
  const reports = [];
481
862
  for (const funnel of funnels) {
482
- const buckets = await ctx.db
483
- .query("funnelStepBuckets")
484
- .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
485
- .eq("siteId", args.siteId)
486
- .eq("funnelId", funnel._id)
487
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
488
- .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
489
- .take(601);
490
863
  const stepsReport = funnel.steps.map((step, index) => ({
491
864
  step: index,
492
865
  name: step.name,
493
866
  entrants: 0,
494
867
  completions: 0,
495
868
  }));
496
- 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) {
497
885
  const step = stepsReport[bucket.step];
498
886
  if (step) {
499
887
  step.entrants += bucket.entrants;
@@ -503,7 +891,14 @@ export const funnelsReport = query({
503
891
  reports.push({
504
892
  ...funnel,
505
893
  stepsReport,
506
- 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
+ },
507
902
  });
508
903
  }
509
904
  return reports;
@@ -523,54 +918,94 @@ export const affiliatesReport = query({
523
918
  returns: v.object({
524
919
  affiliates: v.array(affiliateReportItemValidator),
525
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
+ }),
526
927
  }),
527
928
  handler: async (ctx, args) => {
528
929
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
930
+ requireAlignedRange(args.from, args.to, DAY_MS, "affiliate report");
529
931
  const affiliateRows = await ctx.db
530
932
  .query("affiliates")
531
933
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
532
934
  .order("desc")
533
935
  .take(MAX_AFFILIATES_PER_SITE + 1);
534
- const affiliates = affiliateRows.slice(0, MAX_AFFILIATES_PER_SITE);
535
- const stats = await ctx.db
536
- .query("affiliateDailyStats")
537
- .withIndex("by_siteId_and_bucketStart", (range) => range
538
- .eq("siteId", args.siteId)
539
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
540
- .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
541
- .take(5_001);
542
- const sessions = await ctx.db
543
- .query("sessions")
544
- .withIndex("by_siteId_and_startedAt", (range) => range
545
- .eq("siteId", args.siteId)
546
- .gte("startedAt", args.from)
547
- .lte("startedAt", args.to))
548
- .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;
549
942
  const visits = new Map();
550
- for (const session of sessions.slice(0, 5_000)) {
551
- if (!session.affiliateId)
552
- continue;
553
- const key = String(session.affiliateId);
554
- const total = visits.get(key) ?? {
555
- visits: 0,
556
- visitors: new Set(),
557
- };
558
- total.visits += 1;
559
- total.visitors.add(session.visitorId);
560
- visits.set(key, total);
561
- }
562
943
  const totals = new Map();
563
- for (const row of stats.slice(0, 5_000)) {
564
- const key = String(row.affiliateId);
565
- const total = totals.get(key) ?? {
566
- conversions: 0,
567
- revenueCents: 0,
568
- commissionCents: 0,
569
- };
570
- total.conversions += row.conversions;
571
- total.revenueCents += row.revenueCents;
572
- total.commissionCents += row.commissionCents;
573
- 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
+ }
574
1009
  }
575
1010
  return {
576
1011
  affiliates: affiliates.map((affiliate) => {
@@ -584,15 +1019,548 @@ export const affiliatesReport = query({
584
1019
  ...affiliate,
585
1020
  ...performance,
586
1021
  visits: traffic?.visits ?? 0,
587
- uniqueVisitors: traffic?.visitors.size ?? 0,
1022
+ uniqueVisitors: rollups
1023
+ ? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
1024
+ : (traffic?.visitors.size ?? 0),
588
1025
  };
589
1026
  }),
590
- truncated: affiliateRows.length > MAX_AFFILIATES_PER_SITE ||
591
- stats.length > 5_000 ||
592
- 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
+ },
593
1045
  };
594
1046
  },
595
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
+ }))),
1198
+ };
1199
+ },
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
+ }
596
1564
  function emptyTotals() {
597
1565
  return {
598
1566
  events: 0,
@@ -619,20 +1587,38 @@ function addMetrics(target, source) {
619
1587
  }
620
1588
  function validateSiteIds(siteIds) {
621
1589
  const unique = [...new Set(siteIds)];
622
- 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) {
623
1593
  fail("INVALID_ARGUMENT", `siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`);
624
1594
  }
625
1595
  return unique;
626
1596
  }
627
1597
  function validateRange(from, to, maxDays) {
628
- if (!Number.isFinite(from) ||
629
- !Number.isFinite(to) ||
1598
+ if (!Number.isSafeInteger(from) ||
1599
+ !Number.isSafeInteger(to) ||
630
1600
  from < 0 ||
631
- to <= from ||
632
- to - from > maxDays * DAY_MS) {
633
- 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`);
634
1604
  }
635
- 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;
1609
+ }
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
+ };
636
1622
  }
637
1623
  function parseKeysetPagination(options, scope) {
638
1624
  validatePagination(options);
@@ -714,9 +1700,7 @@ function parseJourneyPagination(options, scope) {
714
1700
  function keysetPaginationResult(rows, pagination, cursorFor) {
715
1701
  const page = rows.slice(0, pagination.numItems);
716
1702
  const isDone = rows.length <= pagination.numItems;
717
- const next = page.length > 0
718
- ? cursorFor(page[page.length - 1])
719
- : pagination.cursor;
1703
+ const next = page.length > 0 ? cursorFor(page[page.length - 1]) : pagination.cursor;
720
1704
  return {
721
1705
  page,
722
1706
  isDone,
@@ -760,7 +1744,9 @@ function journeyPaginationResult(rows, pagination) {
760
1744
  };
761
1745
  }
762
1746
  function validatePagination(options) {
763
- 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) {
764
1750
  fail("INVALID_ARGUMENT", "pagination numItems must be an integer between 1 and 100");
765
1751
  }
766
1752
  if (options.maximumRowsRead !== undefined &&