@iann29/rastro 0.1.0-alpha.3 → 0.1.0-alpha.5

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 (111) hide show
  1. package/README.md +374 -62
  2. package/agent/integration.md +651 -0
  3. package/agent/manifest.json +183 -0
  4. package/agent/manifest.schema.json +405 -0
  5. package/dist/client/federation.d.ts +205 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +179 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +1658 -8
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +228 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +10 -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 +169 -1
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/cardinality.d.ts +12 -0
  19. package/dist/component/cardinality.d.ts.map +1 -0
  20. package/dist/component/cardinality.js +94 -0
  21. package/dist/component/cardinality.js.map +1 -0
  22. package/dist/component/constants.d.ts +11 -0
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +11 -0
  25. package/dist/component/constants.js.map +1 -1
  26. package/dist/component/coverage.d.ts +18 -0
  27. package/dist/component/coverage.d.ts.map +1 -0
  28. package/dist/component/coverage.js +19 -0
  29. package/dist/component/coverage.js.map +1 -0
  30. package/dist/component/errors.d.ts +1 -1
  31. package/dist/component/errors.d.ts.map +1 -1
  32. package/dist/component/errors.js.map +1 -1
  33. package/dist/component/eventStore.d.ts +1 -0
  34. package/dist/component/eventStore.d.ts.map +1 -1
  35. package/dist/component/http.d.ts.map +1 -1
  36. package/dist/component/http.js +85 -36
  37. package/dist/component/http.js.map +1 -1
  38. package/dist/component/identity.d.ts +13 -0
  39. package/dist/component/identity.d.ts.map +1 -0
  40. package/dist/component/identity.js +58 -0
  41. package/dist/component/identity.js.map +1 -0
  42. package/dist/component/ingest.d.ts +1 -0
  43. package/dist/component/ingest.d.ts.map +1 -1
  44. package/dist/component/ingest.js +258 -19
  45. package/dist/component/ingest.js.map +1 -1
  46. package/dist/component/live.d.ts.map +1 -1
  47. package/dist/component/live.js +6 -3
  48. package/dist/component/live.js.map +1 -1
  49. package/dist/component/reports.d.ts +120 -2
  50. package/dist/component/reports.d.ts.map +1 -1
  51. package/dist/component/reports.js +659 -91
  52. package/dist/component/reports.js.map +1 -1
  53. package/dist/component/retention.d.ts +75 -1
  54. package/dist/component/retention.d.ts.map +1 -1
  55. package/dist/component/retention.js +517 -54
  56. package/dist/component/retention.js.map +1 -1
  57. package/dist/component/sanitize.d.ts +4 -1
  58. package/dist/component/sanitize.d.ts.map +1 -1
  59. package/dist/component/sanitize.js +13 -5
  60. package/dist/component/sanitize.js.map +1 -1
  61. package/dist/component/schema.d.ts +224 -7
  62. package/dist/component/schema.js +113 -1
  63. package/dist/component/schema.js.map +1 -1
  64. package/dist/component/sites.d.ts.map +1 -1
  65. package/dist/component/sites.js +5 -1
  66. package/dist/component/sites.js.map +1 -1
  67. package/dist/component/useragent.d.ts +9 -0
  68. package/dist/component/useragent.d.ts.map +1 -0
  69. package/dist/component/useragent.js +152 -0
  70. package/dist/component/useragent.js.map +1 -0
  71. package/dist/component/validators.d.ts +42 -11
  72. package/dist/component/validators.d.ts.map +1 -1
  73. package/dist/component/validators.js +10 -1
  74. package/dist/component/validators.js.map +1 -1
  75. package/dist/component/visitors.d.ts +19 -0
  76. package/dist/component/visitors.d.ts.map +1 -0
  77. package/dist/component/visitors.js +86 -0
  78. package/dist/component/visitors.js.map +1 -0
  79. package/dist/tracker/generated.d.ts +4 -4
  80. package/dist/tracker/generated.d.ts.map +1 -1
  81. package/dist/tracker/generated.js +4 -4
  82. package/dist/tracker/generated.js.map +1 -1
  83. package/dist/tracker/tracker.js +23 -11
  84. package/dist/tracker/tracker.js.map +1 -1
  85. package/dist/tracker.min.js +1 -1
  86. package/docs/benchmarks/2026-08-20-realistic.md +4 -4
  87. package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
  88. package/docs/federation-setup.md +395 -0
  89. package/docs/federation.md +258 -0
  90. package/docs/upgrading.md +130 -0
  91. package/llms.txt +65 -0
  92. package/package.json +41 -11
  93. package/scripts/generate-federation-keys.mjs +20 -0
  94. package/src/component/_generated/api.ts +10 -0
  95. package/src/component/_generated/component.ts +228 -1
  96. package/src/component/cardinality.ts +116 -0
  97. package/src/component/constants.ts +11 -0
  98. package/src/component/coverage.ts +25 -0
  99. package/src/component/errors.ts +2 -1
  100. package/src/component/http.ts +129 -38
  101. package/src/component/identity.ts +74 -0
  102. package/src/component/ingest.ts +414 -13
  103. package/src/component/live.ts +8 -3
  104. package/src/component/reports.ts +910 -98
  105. package/src/component/retention.ts +624 -88
  106. package/src/component/sanitize.ts +19 -5
  107. package/src/component/schema.ts +125 -0
  108. package/src/component/sites.ts +5 -1
  109. package/src/component/useragent.ts +171 -0
  110. package/src/component/validators.ts +11 -0
  111. package/src/component/visitors.ts +105 -0
@@ -3,22 +3,35 @@ import {
3
3
  paginationResultValidator,
4
4
  } from "convex/server";
5
5
  import { v } from "convex/values";
6
- import type { Doc } from "./_generated/dataModel.js";
6
+ import type { Doc, Id } from "./_generated/dataModel.js";
7
+ import type { QueryCtx } from "./_generated/server.js";
7
8
  import { query } from "./_generated/server.js";
8
9
  import {
9
10
  AGGREGATE_SHARDS,
10
11
  DAY_MS,
12
+ FEATURE_REPORT_BYTE_RESERVE,
13
+ FEATURE_REPORT_DOCUMENT_RESERVE,
11
14
  HOUR_MS,
12
15
  HOURLY_AGGREGATE_SHARDS,
13
16
  MAX_AFFILIATES_PER_SITE,
14
17
  MAX_FUNNELS_PER_SITE,
18
+ MAX_FEATURE_ROLLUP_DAYS,
19
+ MAX_FEATURE_ROLLUP_ROWS,
15
20
  MAX_GOALS_PER_SITE,
16
21
  MAX_JOURNEY_EVENTS,
17
22
  MAX_LIVE_VISITORS,
18
23
  MAX_OVERVIEW_AGGREGATE_ROWS,
24
+ MAX_REPORT_BUCKETS,
19
25
  MAX_REPORT_RANGE_DAYS,
20
26
  MAX_REPORT_SITES,
27
+ REPORT_ROLLUP_SHARDS,
21
28
  } from "./constants.js";
29
+ import {
30
+ createVisitorSketch,
31
+ estimateVisitors,
32
+ unionVisitorSketches,
33
+ type VisitorSketch,
34
+ } from "./cardinality.js";
22
35
  import { fail } from "./errors.js";
23
36
  import {
24
37
  compareJourneyEvents,
@@ -36,6 +49,7 @@ import {
36
49
  goalFieldsValidator,
37
50
  sessionFieldsValidator,
38
51
  } from "./validators.js";
52
+ import { resolveVisitorIdentities } from "./visitors.js";
39
53
 
40
54
  const sessionDocumentValidator = sessionFieldsValidator.extend({
41
55
  _id: v.id("sessions"),
@@ -102,6 +116,30 @@ const topItemValidator = v.object({
102
116
  count: v.number(),
103
117
  revenueCents: v.number(),
104
118
  });
119
+ const coverageStateValidator = v.union(
120
+ v.literal("complete"),
121
+ v.literal("partial"),
122
+ v.literal("retained"),
123
+ v.literal("unavailable"),
124
+ );
125
+ const resultCoverageValidator = v.object({
126
+ state: coverageStateValidator,
127
+ generation: v.union(v.number(), v.null()),
128
+ availableFrom: v.union(v.number(), v.null()),
129
+ availableThrough: v.union(v.number(), v.null()),
130
+ retainedBefore: v.union(v.number(), v.null()),
131
+ });
132
+ const rangeSemanticsValidator = v.object({
133
+ from: v.number(),
134
+ to: v.number(),
135
+ boundary: v.literal("inclusive"),
136
+ timezone: v.literal("UTC"),
137
+ });
138
+ const reportMetadataValidator = v.object({
139
+ basis: v.union(v.literal("dailyRollup"), v.literal("boundedLegacy")),
140
+ coverage: resultCoverageValidator,
141
+ range: rangeSemanticsValidator,
142
+ });
105
143
 
106
144
  export const overview = query({
107
145
  args: {
@@ -118,12 +156,43 @@ export const overview = query({
118
156
  totals: metricTotalsValidator,
119
157
  timeSeries: v.array(timePointValidator),
120
158
  topSources: v.array(topItemValidator),
159
+ topCampaigns: v.array(topItemValidator),
121
160
  topPages: v.array(topItemValidator),
122
161
  topCountries: v.array(topItemValidator),
123
162
  topDevices: v.array(topItemValidator),
124
163
  topBrowsers: v.array(topItemValidator),
125
164
  topEvents: v.array(topItemValidator),
126
165
  topGoals: v.array(topItemValidator),
166
+ metadata: v.object({
167
+ visitors: v.object({
168
+ basis: v.union(
169
+ v.literal("estimatedUnique"),
170
+ v.literal("bucketOccurrences"),
171
+ ),
172
+ exact: v.boolean(),
173
+ legacyIncomplete: v.boolean(),
174
+ sketchVersion: v.union(v.number(), v.null()),
175
+ precision: v.union(v.number(), v.null()),
176
+ }),
177
+ breakdowns: v.object({
178
+ topSources: v.literal("eventVolume"),
179
+ topCampaigns: v.literal("eventVolume"),
180
+ topPages: v.literal("eventVolume"),
181
+ topCountries: v.literal("eventVolume"),
182
+ topDevices: v.literal("eventVolume"),
183
+ topBrowsers: v.literal("eventVolume"),
184
+ topEvents: v.literal("eventVolume"),
185
+ topGoals: v.literal("goalCompletionVolume"),
186
+ }),
187
+ coverage: resultCoverageValidator,
188
+ range: rangeSemanticsValidator,
189
+ features: v.object({
190
+ sessionReplay: v.literal("unsupported"),
191
+ errorInsights: v.literal("unsupported"),
192
+ outboundLinks: v.literal("eventVolume"),
193
+ botDetection: v.literal("userAgent"),
194
+ }),
195
+ }),
127
196
  }),
128
197
  handler: async (ctx, args) => {
129
198
  const siteIds = validateSiteIds(args.siteIds);
@@ -145,6 +214,7 @@ export const overview = query({
145
214
  fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
146
215
  }
147
216
  const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
217
+ requireAlignedRange(from, to, intervalMs, `${interval} overview`);
148
218
  const firstBucket = Math.floor(from / intervalMs) * intervalMs;
149
219
  const lastBucket = Math.floor(to / intervalMs) * intervalMs;
150
220
  const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
@@ -159,7 +229,16 @@ export const overview = query({
159
229
  }
160
230
  const totals = emptyTotals();
161
231
  const points = new Map<number, MetricTotals>();
232
+ const visitorSketch = createVisitorSketch();
233
+ let mergedVisitorSketch = visitorSketch;
234
+ const pointVisitorSketches = new Map<number, VisitorSketch>();
235
+ let visitorsComplete = true;
162
236
  const dimensions = new Map<string, { value: string; count: number; revenueCents: number }>();
237
+ const overviewDataset = interval === "hour"
238
+ ? "overviewHour" as const
239
+ : "overviewDay" as const;
240
+
241
+ await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
163
242
 
164
243
  for (const siteId of siteIds) {
165
244
  const buckets = await ctx.db
@@ -171,12 +250,29 @@ export const overview = query({
171
250
  .gte("bucketStart", firstBucket)
172
251
  .lte("bucketStart", lastBucket),
173
252
  )
174
- .take(perSiteLimit);
253
+ .take(perSiteLimit + 1);
254
+ failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
175
255
  for (const bucket of buckets) {
176
256
  addMetrics(totals, bucket);
177
257
  const point = points.get(bucket.bucketStart) ?? emptyTotals();
178
258
  addMetrics(point, bucket);
179
259
  points.set(bucket.bucketStart, point);
260
+ if (bucket.visitorSketch && bucket.visitorSketchComplete === true) {
261
+ mergedVisitorSketch = unionVisitorSketches(
262
+ mergedVisitorSketch,
263
+ bucket.visitorSketch,
264
+ );
265
+ pointVisitorSketches.set(
266
+ bucket.bucketStart,
267
+ unionVisitorSketches(
268
+ pointVisitorSketches.get(bucket.bucketStart) ??
269
+ createVisitorSketch(bucket.visitorSketch.precision),
270
+ bucket.visitorSketch,
271
+ ),
272
+ );
273
+ } else if (bucket.events > 0) {
274
+ visitorsComplete = false;
275
+ }
180
276
  for (const dimension of bucket.dimensions) {
181
277
  const key = `${dimension.type}:${dimension.value}`;
182
278
  const current = dimensions.get(key) ?? {
@@ -197,6 +293,20 @@ export const overview = query({
197
293
  .map(([, value]) => value)
198
294
  .sort((left, right) => right.count - left.count || left.value.localeCompare(right.value))
199
295
  .slice(0, 10);
296
+ if (visitorsComplete) {
297
+ totals.visitors = estimateVisitors(mergedVisitorSketch);
298
+ for (const [timestamp, point] of points) {
299
+ point.visitors = estimateVisitors(
300
+ pointVisitorSketches.get(timestamp) ?? createVisitorSketch(),
301
+ );
302
+ }
303
+ }
304
+ const coverage = await coverageForRange(ctx, siteIds, from, to, {
305
+ datasets: [overviewDataset],
306
+ complete: visitorsComplete,
307
+ });
308
+ const visitorBasis: "estimatedUnique" | "bucketOccurrences" =
309
+ visitorsComplete ? "estimatedUnique" : "bucketOccurrences";
200
310
  return {
201
311
  from,
202
312
  to,
@@ -207,12 +317,40 @@ export const overview = query({
207
317
  .sort(([left], [right]) => left - right)
208
318
  .map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
209
319
  topSources: top("source"),
320
+ topCampaigns: top("campaign"),
210
321
  topPages: top("page"),
211
322
  topCountries: top("country"),
212
323
  topDevices: top("device"),
213
324
  topBrowsers: top("browser"),
214
325
  topEvents: top("event"),
215
326
  topGoals: top("goal"),
327
+ metadata: {
328
+ visitors: {
329
+ basis: visitorBasis,
330
+ exact: false,
331
+ legacyIncomplete: !visitorsComplete,
332
+ sketchVersion: visitorsComplete ? 1 : null,
333
+ precision: visitorsComplete ? 6 : null,
334
+ },
335
+ breakdowns: {
336
+ topSources: "eventVolume" as const,
337
+ topCampaigns: "eventVolume" as const,
338
+ topPages: "eventVolume" as const,
339
+ topCountries: "eventVolume" as const,
340
+ topDevices: "eventVolume" as const,
341
+ topBrowsers: "eventVolume" as const,
342
+ topEvents: "eventVolume" as const,
343
+ topGoals: "goalCompletionVolume" as const,
344
+ },
345
+ coverage,
346
+ range: rangeSemantics(from, to),
347
+ features: {
348
+ sessionReplay: "unsupported" as const,
349
+ errorInsights: "unsupported" as const,
350
+ outboundLinks: "eventVolume" as const,
351
+ botDetection: "userAgent" as const,
352
+ },
353
+ },
216
354
  };
217
355
  },
218
356
  });
@@ -282,6 +420,7 @@ export const listSessions = query({
282
420
  returns: paginationResultValidator(sessionDocumentValidator),
283
421
  handler: async (ctx, args) => {
284
422
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
423
+ await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
285
424
  const pagination = parseKeysetPagination(args.paginationOpts, {
286
425
  kind: "sessions",
287
426
  siteId: String(args.siteId),
@@ -352,6 +491,7 @@ export const sessionJourney = query({
352
491
  returns: paginationResultValidator(eventDocumentValidator),
353
492
  handler: async (ctx, args) => {
354
493
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
494
+ await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
355
495
  const pagination = parseJourneyPagination(args.paginationOpts, {
356
496
  kind: "events",
357
497
  siteId: String(args.siteId),
@@ -382,6 +522,7 @@ export const listConversions = query({
382
522
  returns: paginationResultValidator(conversionDocumentValidator),
383
523
  handler: async (ctx, args) => {
384
524
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
525
+ await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
385
526
  const pagination = parseKeysetPagination(args.paginationOpts, {
386
527
  kind: "conversions",
387
528
  siteId: String(args.siteId),
@@ -450,24 +591,27 @@ export const visitorJourney = query({
450
591
  handler: async (ctx, args) => {
451
592
  const siteIds = validateSiteIds(args.siteIds);
452
593
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
594
+ await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
453
595
  const limit = args.limit ?? 200;
454
596
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
455
597
  fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
456
598
  }
457
599
  const rows: RawJourneyEvent[] = [];
458
600
  for (const siteId of siteIds) {
459
- const cutoff = rows.length >= limit
460
- ? rows[limit - 1]!.timestamp
461
- : args.to;
462
- rows.push(...await loadVisitorJourneyEvents(ctx, {
463
- siteId,
464
- visitorId: args.visitorId,
465
- from: args.from,
466
- to: cutoff,
467
- resultLimit: limit,
468
- }));
469
- rows.sort(compareJourneyEvents);
470
- if (rows.length > limit) rows.length = limit;
601
+ for (const visitorId of await resolveVisitorIdentities(ctx, siteId, args.visitorId)) {
602
+ const cutoff = rows.length >= limit
603
+ ? rows[limit - 1]!.timestamp
604
+ : args.to;
605
+ rows.push(...await loadVisitorJourneyEvents(ctx, {
606
+ siteId,
607
+ visitorId,
608
+ from: args.from,
609
+ to: cutoff,
610
+ resultLimit: limit,
611
+ }));
612
+ rows.sort(compareJourneyEvents);
613
+ if (rows.length > limit) rows.length = limit;
614
+ }
471
615
  }
472
616
  return hydrateEventContexts(ctx, rows);
473
617
  },
@@ -485,36 +629,81 @@ export const goalsReport = query({
485
629
  returns: v.object({
486
630
  goals: v.array(goalReportItemValidator),
487
631
  truncated: v.boolean(),
632
+ metadata: reportMetadataValidator,
488
633
  }),
489
634
  handler: async (ctx, args) => {
490
635
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
491
- const goals = await ctx.db
636
+ const goalRows = await ctx.db
492
637
  .query("goals")
493
638
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
494
- .take(MAX_GOALS_PER_SITE);
495
- const completions = await ctx.db
496
- .query("goalCompletions")
497
- .withIndex("by_siteId_and_timestamp", (range) =>
498
- range
499
- .eq("siteId", args.siteId)
500
- .gte("timestamp", args.from)
501
- .lte("timestamp", args.to),
502
- )
503
- .take(5_001);
639
+ .take(MAX_GOALS_PER_SITE + 1);
640
+ failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
641
+ const goals = goalRows;
504
642
  const totals = new Map<string, { completions: number; valueCents: number }>();
505
- for (const completion of completions.slice(0, 5_000)) {
506
- const key = String(completion.goalId);
507
- const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
508
- total.completions += 1;
509
- total.valueCents += completion.valueCents;
510
- totals.set(key, total);
643
+ const useRollups = await dailyRollupsAvailable(
644
+ ctx,
645
+ args.siteId,
646
+ args.from,
647
+ args.to,
648
+ "goalRollups",
649
+ );
650
+ const rollups = useRollups
651
+ ? await loadGoalDailyRollups(
652
+ ctx,
653
+ args.siteId,
654
+ goals.map((goal) => goal._id),
655
+ args.from,
656
+ args.to,
657
+ )
658
+ : null;
659
+ if (rollups) {
660
+ for (const row of rollups) {
661
+ const key = String(row.goalId);
662
+ const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
663
+ total.completions += row.completions;
664
+ total.valueCents += row.valueCents;
665
+ totals.set(key, total);
666
+ }
667
+ } else {
668
+ await assertSourceAvailable(
669
+ ctx,
670
+ [args.siteId],
671
+ ["goalCompletions"],
672
+ args.from,
673
+ );
674
+ const completions = await ctx.db
675
+ .query("goalCompletions")
676
+ .withIndex("by_siteId_and_timestamp", (range) =>
677
+ range
678
+ .eq("siteId", args.siteId)
679
+ .gte("timestamp", args.from)
680
+ .lte("timestamp", args.to),
681
+ )
682
+ .take(MAX_REPORT_BUCKETS + 1);
683
+ failIfIncomplete(completions, MAX_REPORT_BUCKETS, "goal completions");
684
+ for (const completion of completions) {
685
+ const key = String(completion.goalId);
686
+ const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
687
+ total.completions += 1;
688
+ total.valueCents += completion.valueCents;
689
+ totals.set(key, total);
690
+ }
511
691
  }
512
692
  return {
513
693
  goals: goals.map((goal) => ({
514
694
  ...goal,
515
695
  ...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
516
696
  })),
517
- truncated: completions.length > 5_000,
697
+ truncated: false,
698
+ metadata: {
699
+ basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
700
+ coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
701
+ datasets: [rollups ? "goalRollups" : "goalCompletions"],
702
+ complete: true,
703
+ generation: rollups ? 1 : null,
704
+ }),
705
+ range: rangeSemantics(args.from, args.to),
706
+ },
518
707
  };
519
708
  },
520
709
  });
@@ -526,6 +715,7 @@ const funnelReportItemValidator = funnelFieldsValidator.extend({
526
715
  v.object({ step: v.number(), name: v.string(), entrants: v.number(), completions: v.number() }),
527
716
  ),
528
717
  truncated: v.boolean(),
718
+ metadata: reportMetadataValidator,
529
719
  });
530
720
 
531
721
  export const funnelsReport = query({
@@ -533,29 +723,74 @@ export const funnelsReport = query({
533
723
  returns: v.array(funnelReportItemValidator),
534
724
  handler: async (ctx, args) => {
535
725
  validateRange(args.from, args.to, 90);
536
- const funnels = await ctx.db
726
+ requireAlignedRange(args.from, args.to, DAY_MS, "funnel report");
727
+ const funnelRows = await ctx.db
537
728
  .query("funnels")
538
729
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
539
- .take(MAX_FUNNELS_PER_SITE);
730
+ .take(MAX_FUNNELS_PER_SITE + 1);
731
+ failIfIncomplete(funnelRows, MAX_FUNNELS_PER_SITE, "funnel definitions");
732
+ const funnels = funnelRows;
733
+ const useRollups = await dailyRollupsAvailable(
734
+ ctx,
735
+ args.siteId,
736
+ args.from,
737
+ args.to,
738
+ "funnelRollups",
739
+ );
740
+ const rollups = useRollups
741
+ ? await loadFunnelDailyRollups(
742
+ ctx,
743
+ args.siteId,
744
+ funnels.map((funnel) => funnel._id),
745
+ args.from,
746
+ args.to,
747
+ )
748
+ : null;
749
+ if (!rollups) {
750
+ await assertSourceAvailable(
751
+ ctx,
752
+ [args.siteId],
753
+ ["funnelSteps"],
754
+ args.from,
755
+ );
756
+ }
757
+ const reportCoverage = await coverageForRange(
758
+ ctx,
759
+ [args.siteId],
760
+ args.from,
761
+ args.to,
762
+ {
763
+ datasets: [rollups ? "funnelRollups" : "funnelSteps"],
764
+ complete: true,
765
+ generation: rollups ? 1 : null,
766
+ },
767
+ );
540
768
  const reports = [];
541
769
  for (const funnel of funnels) {
542
- const buckets = await ctx.db
543
- .query("funnelStepBuckets")
544
- .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
545
- range
546
- .eq("siteId", args.siteId)
547
- .eq("funnelId", funnel._id)
548
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
549
- .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
550
- )
551
- .take(601);
552
770
  const stepsReport = funnel.steps.map((step, index) => ({
553
771
  step: index,
554
772
  name: step.name,
555
773
  entrants: 0,
556
774
  completions: 0,
557
775
  }));
558
- for (const bucket of buckets.slice(0, 600)) {
776
+ const buckets = rollups
777
+ ? rollups
778
+ .filter((row) => row.funnelId === funnel._id)
779
+ .flatMap((row) => row.steps)
780
+ : await ctx.db
781
+ .query("funnelStepBuckets")
782
+ .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
783
+ range
784
+ .eq("siteId", args.siteId)
785
+ .eq("funnelId", funnel._id)
786
+ .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
787
+ .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
788
+ )
789
+ .take(MAX_REPORT_BUCKETS + 1);
790
+ if (!rollups) {
791
+ failIfIncomplete(buckets, MAX_REPORT_BUCKETS, "funnel step buckets");
792
+ }
793
+ for (const bucket of buckets) {
559
794
  const step = stepsReport[bucket.step];
560
795
  if (step) {
561
796
  step.entrants += bucket.entrants;
@@ -565,7 +800,12 @@ export const funnelsReport = query({
565
800
  reports.push({
566
801
  ...funnel,
567
802
  stepsReport,
568
- truncated: buckets.length > 600,
803
+ truncated: false,
804
+ metadata: {
805
+ basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
806
+ coverage: reportCoverage,
807
+ range: rangeSemantics(args.from, args.to),
808
+ },
569
809
  });
570
810
  }
571
811
  return reports;
@@ -587,64 +827,124 @@ export const affiliatesReport = query({
587
827
  returns: v.object({
588
828
  affiliates: v.array(affiliateReportItemValidator),
589
829
  truncated: v.boolean(),
830
+ metadata: reportMetadataValidator.extend({
831
+ uniqueVisitors: v.object({
832
+ basis: v.union(v.literal("estimatedUnique"), v.literal("exactLegacy")),
833
+ exact: v.boolean(),
834
+ }),
835
+ }),
590
836
  }),
591
837
  handler: async (ctx, args) => {
592
838
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
839
+ requireAlignedRange(args.from, args.to, DAY_MS, "affiliate report");
593
840
  const affiliateRows = await ctx.db
594
841
  .query("affiliates")
595
842
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
596
843
  .order("desc")
597
844
  .take(MAX_AFFILIATES_PER_SITE + 1);
598
- const affiliates = affiliateRows.slice(0, MAX_AFFILIATES_PER_SITE);
599
- const stats = await ctx.db
600
- .query("affiliateDailyStats")
601
- .withIndex("by_siteId_and_bucketStart", (range) =>
602
- range
603
- .eq("siteId", args.siteId)
604
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
605
- .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
606
- )
607
- .take(5_001);
608
- const sessions = await ctx.db
609
- .query("sessions")
610
- .withIndex("by_siteId_and_startedAt", (range) =>
611
- range
612
- .eq("siteId", args.siteId)
613
- .gte("startedAt", args.from)
614
- .lte("startedAt", args.to),
615
- )
616
- .take(5_001);
845
+ failIfIncomplete(affiliateRows, MAX_AFFILIATES_PER_SITE, "affiliate definitions");
846
+ const affiliates = affiliateRows;
847
+ const useRollups = await dailyRollupsAvailable(
848
+ ctx,
849
+ args.siteId,
850
+ args.from,
851
+ args.to,
852
+ "affiliateRollups",
853
+ );
854
+ const rollups = useRollups
855
+ ? await loadAffiliateDailyRollups(
856
+ ctx,
857
+ args.siteId,
858
+ affiliates.map((affiliate) => affiliate._id),
859
+ args.from,
860
+ args.to,
861
+ )
862
+ : null;
617
863
  const visits = new Map<string, {
618
864
  visits: number;
619
865
  visitors: Set<string>;
866
+ sketch: VisitorSketch;
620
867
  }>();
621
- for (const session of sessions.slice(0, 5_000)) {
622
- if (!session.affiliateId) continue;
623
- const key = String(session.affiliateId);
624
- const total = visits.get(key) ?? {
625
- visits: 0,
626
- visitors: new Set<string>(),
627
- };
628
- total.visits += 1;
629
- total.visitors.add(session.visitorId);
630
- visits.set(key, total);
631
- }
632
868
  const totals = new Map<string, {
633
869
  conversions: number;
634
870
  revenueCents: number;
635
871
  commissionCents: number;
636
872
  }>();
637
- for (const row of stats.slice(0, 5_000)) {
638
- const key = String(row.affiliateId);
639
- const total = totals.get(key) ?? {
640
- conversions: 0,
641
- revenueCents: 0,
642
- commissionCents: 0,
643
- };
644
- total.conversions += row.conversions;
645
- total.revenueCents += row.revenueCents;
646
- total.commissionCents += row.commissionCents;
647
- totals.set(key, total);
873
+ if (rollups) {
874
+ for (const row of rollups) {
875
+ const key = String(row.affiliateId);
876
+ const traffic = visits.get(key) ?? {
877
+ visits: 0,
878
+ visitors: new Set<string>(),
879
+ sketch: createVisitorSketch(),
880
+ };
881
+ traffic.visits += row.visits;
882
+ traffic.sketch = unionVisitorSketches(
883
+ traffic.sketch,
884
+ row.visitorSketch,
885
+ );
886
+ visits.set(key, traffic);
887
+ const total = totals.get(key) ?? {
888
+ conversions: 0,
889
+ revenueCents: 0,
890
+ commissionCents: 0,
891
+ };
892
+ total.conversions += row.conversions;
893
+ total.revenueCents += row.revenueCents;
894
+ total.commissionCents += row.commissionCents;
895
+ totals.set(key, total);
896
+ }
897
+ } else {
898
+ await assertSourceAvailable(
899
+ ctx,
900
+ [args.siteId],
901
+ ["affiliateStats", "sessions"],
902
+ args.from,
903
+ );
904
+ const stats = await ctx.db
905
+ .query("affiliateDailyStats")
906
+ .withIndex("by_siteId_and_bucketStart", (range) =>
907
+ range
908
+ .eq("siteId", args.siteId)
909
+ .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
910
+ .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
911
+ )
912
+ .take(MAX_REPORT_BUCKETS + 1);
913
+ const sessions = await ctx.db
914
+ .query("sessions")
915
+ .withIndex("by_siteId_and_startedAt", (range) =>
916
+ range
917
+ .eq("siteId", args.siteId)
918
+ .gte("startedAt", args.from)
919
+ .lte("startedAt", args.to),
920
+ )
921
+ .take(MAX_REPORT_BUCKETS + 1);
922
+ failIfIncomplete(stats, MAX_REPORT_BUCKETS, "affiliate stat buckets");
923
+ failIfIncomplete(sessions, MAX_REPORT_BUCKETS, "affiliate sessions");
924
+ for (const session of sessions) {
925
+ if (!session.affiliateId) continue;
926
+ const key = String(session.affiliateId);
927
+ const total = visits.get(key) ?? {
928
+ visits: 0,
929
+ visitors: new Set<string>(),
930
+ sketch: createVisitorSketch(),
931
+ };
932
+ total.visits += 1;
933
+ total.visitors.add(session.visitorId);
934
+ visits.set(key, total);
935
+ }
936
+ for (const row of stats) {
937
+ const key = String(row.affiliateId);
938
+ const total = totals.get(key) ?? {
939
+ conversions: 0,
940
+ revenueCents: 0,
941
+ commissionCents: 0,
942
+ };
943
+ total.conversions += row.conversions;
944
+ total.revenueCents += row.revenueCents;
945
+ total.commissionCents += row.commissionCents;
946
+ totals.set(key, total);
947
+ }
648
948
  }
649
949
  return {
650
950
  affiliates: affiliates.map((affiliate) => {
@@ -658,17 +958,498 @@ export const affiliatesReport = query({
658
958
  ...affiliate,
659
959
  ...performance,
660
960
  visits: traffic?.visits ?? 0,
661
- uniqueVisitors: traffic?.visitors.size ?? 0,
961
+ uniqueVisitors: rollups
962
+ ? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
963
+ : traffic?.visitors.size ?? 0,
662
964
  };
663
965
  }),
664
- truncated:
665
- affiliateRows.length > MAX_AFFILIATES_PER_SITE ||
666
- stats.length > 5_000 ||
667
- sessions.length > 5_000,
966
+ truncated: false,
967
+ metadata: {
968
+ basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
969
+ coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
970
+ datasets: rollups
971
+ ? ["affiliateRollups"]
972
+ : ["affiliateStats", "sessions"],
973
+ complete: true,
974
+ generation: rollups ? 1 : null,
975
+ }),
976
+ range: rangeSemantics(args.from, args.to),
977
+ uniqueVisitors: {
978
+ basis: rollups ? "estimatedUnique" as const : "exactLegacy" as const,
979
+ exact: !rollups,
980
+ },
981
+ },
668
982
  };
669
983
  },
670
984
  });
671
985
 
986
+ const coverageDatasetValidator = v.union(
987
+ v.literal("overviewHour"),
988
+ v.literal("overviewDay"),
989
+ v.literal("events"),
990
+ v.literal("sessions"),
991
+ v.literal("conversions"),
992
+ v.literal("goals"),
993
+ v.literal("funnels"),
994
+ v.literal("affiliates"),
995
+ );
996
+
997
+ export const dataCoverage = query({
998
+ args: {
999
+ siteId: v.id("sites"),
1000
+ from: v.number(),
1001
+ to: v.number(),
1002
+ },
1003
+ returns: v.object({
1004
+ siteId: v.id("sites"),
1005
+ from: v.number(),
1006
+ to: v.number(),
1007
+ datasets: v.array(v.object({
1008
+ dataset: coverageDatasetValidator,
1009
+ coverage: resultCoverageValidator,
1010
+ })),
1011
+ }),
1012
+ handler: async (ctx, args) => {
1013
+ validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
1014
+ const datasets = [
1015
+ "overviewHour",
1016
+ "overviewDay",
1017
+ "events",
1018
+ "sessions",
1019
+ "conversions",
1020
+ "goals",
1021
+ "funnels",
1022
+ "affiliates",
1023
+ ] as const;
1024
+ return {
1025
+ siteId: args.siteId,
1026
+ from: args.from,
1027
+ to: args.to,
1028
+ datasets: await Promise.all(datasets.map(async (dataset) => ({
1029
+ dataset,
1030
+ coverage: await datasetCoverageForRange(
1031
+ ctx,
1032
+ args.siteId,
1033
+ dataset,
1034
+ args.from,
1035
+ args.to,
1036
+ ),
1037
+ }))),
1038
+ };
1039
+ },
1040
+ });
1041
+
1042
+ async function dailyRollupsAvailable(
1043
+ ctx: QueryCtx,
1044
+ siteId: Id<"sites">,
1045
+ from: number,
1046
+ to: number,
1047
+ dataset: "goalRollups" | "funnelRollups" | "affiliateRollups",
1048
+ ) {
1049
+ if (!isAlignedRange(from, to, DAY_MS)) return false;
1050
+ const coverage = await ctx.db
1051
+ .query("analyticsCoverage")
1052
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1053
+ .unique();
1054
+ if (!coverage || coverage.rollupCompleteFrom > from) {
1055
+ return false;
1056
+ }
1057
+ const retainedBefore = coverage.retained.find(
1058
+ (item) => item.dataset === dataset,
1059
+ )?.before;
1060
+ return retainedBefore === undefined || from >= retainedBefore;
1061
+ }
1062
+
1063
+ async function loadGoalDailyRollups(
1064
+ ctx: QueryCtx,
1065
+ siteId: Id<"sites">,
1066
+ goalIds: Id<"goals">[],
1067
+ from: number,
1068
+ to: number,
1069
+ ) {
1070
+ const days = Math.floor((to - from) / DAY_MS) + 1;
1071
+ requireFeatureRollupBudget(goalIds.length, days, "goal daily rollups");
1072
+ const rows: Doc<"goalDailyRollups">[] = [];
1073
+ for (const goalId of goalIds) {
1074
+ const perGoalLimit = days * REPORT_ROLLUP_SHARDS;
1075
+ const goalRows = await ctx.db
1076
+ .query("goalDailyRollups")
1077
+ .withIndex("by_siteId_and_goalId_and_bucketStart", (range) =>
1078
+ range
1079
+ .eq("siteId", siteId)
1080
+ .eq("goalId", goalId)
1081
+ .gte("bucketStart", from)
1082
+ .lte("bucketStart", to),
1083
+ )
1084
+ .take(perGoalLimit + 1);
1085
+ failIfIncomplete(goalRows, perGoalLimit, "goal daily rollups");
1086
+ rows.push(...goalRows);
1087
+ await requireFeatureReadHeadroom(ctx, "goal daily rollups");
1088
+ }
1089
+ return rows;
1090
+ }
1091
+
1092
+ async function loadFunnelDailyRollups(
1093
+ ctx: QueryCtx,
1094
+ siteId: Id<"sites">,
1095
+ funnelIds: Id<"funnels">[],
1096
+ from: number,
1097
+ to: number,
1098
+ ) {
1099
+ const days = Math.floor((to - from) / DAY_MS) + 1;
1100
+ requireFeatureRollupBudget(funnelIds.length, days, "funnel daily rollups");
1101
+ const rows: Doc<"funnelDailyRollups">[] = [];
1102
+ for (const funnelId of funnelIds) {
1103
+ const perFunnelLimit = days * REPORT_ROLLUP_SHARDS;
1104
+ const funnelRows = await ctx.db
1105
+ .query("funnelDailyRollups")
1106
+ .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
1107
+ range
1108
+ .eq("siteId", siteId)
1109
+ .eq("funnelId", funnelId)
1110
+ .gte("bucketStart", from)
1111
+ .lte("bucketStart", to),
1112
+ )
1113
+ .take(perFunnelLimit + 1);
1114
+ failIfIncomplete(funnelRows, perFunnelLimit, "funnel daily rollups");
1115
+ rows.push(...funnelRows);
1116
+ await requireFeatureReadHeadroom(ctx, "funnel daily rollups");
1117
+ }
1118
+ return rows;
1119
+ }
1120
+
1121
+ async function loadAffiliateDailyRollups(
1122
+ ctx: QueryCtx,
1123
+ siteId: Id<"sites">,
1124
+ affiliateIds: Id<"affiliates">[],
1125
+ from: number,
1126
+ to: number,
1127
+ ) {
1128
+ const days = Math.floor((to - from) / DAY_MS) + 1;
1129
+ requireFeatureRollupBudget(
1130
+ affiliateIds.length,
1131
+ days,
1132
+ "affiliate daily rollups",
1133
+ );
1134
+ const rows: Doc<"affiliateDailyRollups">[] = [];
1135
+ for (const affiliateId of affiliateIds) {
1136
+ const perAffiliateLimit = days * REPORT_ROLLUP_SHARDS;
1137
+ const affiliateRows = await ctx.db
1138
+ .query("affiliateDailyRollups")
1139
+ .withIndex("by_siteId_and_affiliateId_and_bucketStart", (range) =>
1140
+ range
1141
+ .eq("siteId", siteId)
1142
+ .eq("affiliateId", affiliateId)
1143
+ .gte("bucketStart", from)
1144
+ .lte("bucketStart", to),
1145
+ )
1146
+ .take(perAffiliateLimit + 1);
1147
+ failIfIncomplete(affiliateRows, perAffiliateLimit, "affiliate daily rollups");
1148
+ rows.push(...affiliateRows);
1149
+ await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
1150
+ }
1151
+ return rows;
1152
+ }
1153
+
1154
+ function requireFeatureRollupBudget(
1155
+ entities: number,
1156
+ days: number,
1157
+ dataset: string,
1158
+ ) {
1159
+ const maximumRows = entities * days * REPORT_ROLLUP_SHARDS;
1160
+ if (days > MAX_FEATURE_ROLLUP_DAYS || maximumRows > MAX_FEATURE_ROLLUP_ROWS) {
1161
+ fail("REPORT_INCOMPLETE", `${dataset} exceeds its complete read budget`, {
1162
+ dataset,
1163
+ maximumDays: MAX_FEATURE_ROLLUP_DAYS,
1164
+ maximumRows: MAX_FEATURE_ROLLUP_ROWS,
1165
+ });
1166
+ }
1167
+ }
1168
+
1169
+ async function requireFeatureReadHeadroom(ctx: QueryCtx, dataset: string) {
1170
+ const metrics = await ctx.meta.getTransactionMetrics();
1171
+ if (
1172
+ metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
1173
+ metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE
1174
+ ) {
1175
+ fail("REPORT_INCOMPLETE", `${dataset} exceeds its byte or document budget`, {
1176
+ dataset,
1177
+ bytesRead: metrics.bytesRead.used,
1178
+ documentsRead: metrics.documentsRead.used,
1179
+ });
1180
+ }
1181
+ }
1182
+
1183
+ function failIfIncomplete<T>(rows: T[], maximum: number, dataset: string) {
1184
+ if (rows.length > maximum) {
1185
+ fail("REPORT_INCOMPLETE", `${dataset} exceeds the complete report read budget`, {
1186
+ maximumRows: maximum,
1187
+ dataset,
1188
+ });
1189
+ }
1190
+ }
1191
+
1192
+ async function coverageForRange(
1193
+ ctx: QueryCtx,
1194
+ siteIds: Id<"sites">[],
1195
+ from: number,
1196
+ to: number,
1197
+ options: {
1198
+ datasets: CoverageSource[];
1199
+ complete?: boolean;
1200
+ generation?: 1 | null;
1201
+ },
1202
+ ) {
1203
+ const controls = await Promise.all(siteIds.map((siteId) =>
1204
+ ctx.db
1205
+ .query("analyticsCoverage")
1206
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1207
+ .unique()
1208
+ ));
1209
+ const sourceRanges = await Promise.all(siteIds.map((siteId) =>
1210
+ sourceSetRange(ctx, siteId, options.datasets)
1211
+ ));
1212
+ const retainedBefore = controls.reduce<number | null>((latest, control) => {
1213
+ const before = control?.retained
1214
+ .filter((item) => options.datasets.some((dataset) => dataset === item.dataset))
1215
+ .reduce<number | undefined>(
1216
+ (latest, item) => Math.max(latest ?? 0, item.before),
1217
+ undefined,
1218
+ );
1219
+ return before === undefined ? latest : Math.max(latest ?? 0, before);
1220
+ }, null);
1221
+ const availableFrom = options.generation === 1
1222
+ ? controls.length > 0 && controls.every(Boolean)
1223
+ ? Math.max(...controls.map((control) => control!.rollupCompleteFrom))
1224
+ : null
1225
+ : sourceRanges.length > 0 && sourceRanges.every(Boolean)
1226
+ ? Math.max(...sourceRanges.map((range) => range!.from))
1227
+ : null;
1228
+ const availableThrough = sourceRanges.length > 0 && sourceRanges.every(Boolean)
1229
+ ? Math.min(...sourceRanges.map((range) => range!.through))
1230
+ : null;
1231
+ let state: "complete" | "partial" | "retained" | "unavailable";
1232
+ if (retainedBefore !== null && to < retainedBefore) {
1233
+ state = "unavailable";
1234
+ } else if (retainedBefore !== null && from < retainedBefore) {
1235
+ state = "partial";
1236
+ } else if (options.complete || (
1237
+ availableFrom !== null &&
1238
+ availableThrough !== null &&
1239
+ from >= availableFrom &&
1240
+ to <= availableThrough
1241
+ )) {
1242
+ state = "complete";
1243
+ } else if (sourceRanges.some(Boolean)) {
1244
+ state = "retained";
1245
+ } else {
1246
+ state = "unavailable";
1247
+ }
1248
+ return {
1249
+ state,
1250
+ generation: options.generation ?? null,
1251
+ availableFrom,
1252
+ availableThrough,
1253
+ retainedBefore,
1254
+ };
1255
+ }
1256
+
1257
+ async function assertSourceAvailable(
1258
+ ctx: QueryCtx,
1259
+ siteIds: Id<"sites">[],
1260
+ sources: CoverageSource[],
1261
+ from: number,
1262
+ ) {
1263
+ const rows = await Promise.all(siteIds.map((siteId) =>
1264
+ ctx.db
1265
+ .query("analyticsCoverage")
1266
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1267
+ .unique()
1268
+ ));
1269
+ const cutoff = rows.reduce<number | null>((latest, row) => {
1270
+ for (const retained of row?.retained ?? []) {
1271
+ if (sources.some((source) => source === retained.dataset)) {
1272
+ latest = Math.max(latest ?? 0, retained.before);
1273
+ }
1274
+ }
1275
+ return latest;
1276
+ }, null);
1277
+ if (cutoff !== null && from < cutoff) {
1278
+ fail("REPORT_INCOMPLETE", "requested report range intersects retained data", {
1279
+ retainedBefore: cutoff,
1280
+ });
1281
+ }
1282
+ }
1283
+
1284
+ async function datasetCoverageForRange(
1285
+ ctx: QueryCtx,
1286
+ siteId: Id<"sites">,
1287
+ dataset: PublicCoverageDataset,
1288
+ from: number,
1289
+ to: number,
1290
+ ) {
1291
+ const sourceOptions: CoverageSource[][] = dataset === "goals"
1292
+ ? [["goalRollups"], ["goalCompletions"]]
1293
+ : dataset === "funnels"
1294
+ ? [["funnelRollups"], ["funnelSteps"]]
1295
+ : dataset === "affiliates"
1296
+ ? [["affiliateRollups"], ["affiliateStats", "sessions"]]
1297
+ : [[dataset]];
1298
+ const options = (await Promise.all(sourceOptions.map(async (sources) => {
1299
+ const rollupDataset = sources[0] as
1300
+ | "goalRollups"
1301
+ | "funnelRollups"
1302
+ | "affiliateRollups";
1303
+ const isRollup = rollupDataset.endsWith("Rollups");
1304
+ const complete = isRollup
1305
+ ? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
1306
+ : false;
1307
+ if (isRollup && !complete) return null;
1308
+ return coverageForRange(ctx, [siteId], from, to, {
1309
+ datasets: sources,
1310
+ complete,
1311
+ generation: complete ? 1 : null,
1312
+ });
1313
+ }))).filter((option): option is NonNullable<typeof option> => option !== null);
1314
+ return options.sort((left, right) =>
1315
+ coverageRank(right.state) - coverageRank(left.state)
1316
+ )[0]!;
1317
+ }
1318
+
1319
+ type PublicCoverageDataset =
1320
+ | "overviewHour"
1321
+ | "overviewDay"
1322
+ | "events"
1323
+ | "sessions"
1324
+ | "conversions"
1325
+ | "goals"
1326
+ | "funnels"
1327
+ | "affiliates";
1328
+
1329
+ type CoverageSource =
1330
+ | "overviewHour"
1331
+ | "overviewDay"
1332
+ | "events"
1333
+ | "sessions"
1334
+ | "conversions"
1335
+ | "goalCompletions"
1336
+ | "funnelSteps"
1337
+ | "affiliateStats"
1338
+ | "goalRollups"
1339
+ | "funnelRollups"
1340
+ | "affiliateRollups";
1341
+
1342
+ type SourceRange = { from: number; through: number };
1343
+
1344
+ async function sourceSetRange(
1345
+ ctx: QueryCtx,
1346
+ siteId: Id<"sites">,
1347
+ sources: CoverageSource[],
1348
+ ): Promise<SourceRange | null> {
1349
+ const ranges = (await Promise.all(
1350
+ sources.map((source) => sourceRange(ctx, siteId, source)),
1351
+ )).filter((range): range is SourceRange => range !== null);
1352
+ if (ranges.length === 0) return null;
1353
+ return {
1354
+ from: Math.min(...ranges.map((range) => range.from)),
1355
+ through: Math.max(...ranges.map((range) => range.through)),
1356
+ };
1357
+ }
1358
+
1359
+ async function sourceRange(
1360
+ ctx: QueryCtx,
1361
+ siteId: Id<"sites">,
1362
+ source: CoverageSource,
1363
+ ): Promise<SourceRange | null> {
1364
+ if (source === "overviewHour" || source === "overviewDay") {
1365
+ const granularity = source === "overviewHour" ? "hour" as const : "day" as const;
1366
+ const [first, last] = await Promise.all([
1367
+ ctx.db.query("aggregateBuckets")
1368
+ .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
1369
+ range.eq("siteId", siteId).eq("granularity", granularity),
1370
+ ).order("asc").take(1),
1371
+ ctx.db.query("aggregateBuckets")
1372
+ .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
1373
+ range.eq("siteId", siteId).eq("granularity", granularity),
1374
+ ).order("desc").take(1),
1375
+ ]);
1376
+ return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1377
+ }
1378
+ if (source === "events") {
1379
+ const [first, last] = await Promise.all([
1380
+ ctx.db.query("eventBatches")
1381
+ .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1382
+ .order("asc").take(1),
1383
+ ctx.db.query("eventBatches")
1384
+ .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1385
+ .order("desc").take(1),
1386
+ ]);
1387
+ return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1388
+ }
1389
+ if (source === "sessions") {
1390
+ const [first, last] = await Promise.all([
1391
+ ctx.db.query("sessions")
1392
+ .withIndex("by_siteId_and_startedAt", (range) => range.eq("siteId", siteId))
1393
+ .order("asc").take(1),
1394
+ ctx.db.query("sessions")
1395
+ .withIndex("by_siteId_and_lastSeenAt", (range) => range.eq("siteId", siteId))
1396
+ .order("desc").take(1),
1397
+ ]);
1398
+ return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
1399
+ }
1400
+ if (source === "conversions") {
1401
+ const [first, last] = await Promise.all([
1402
+ ctx.db.query("conversions")
1403
+ .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1404
+ .order("asc").take(1),
1405
+ ctx.db.query("conversions")
1406
+ .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1407
+ .order("desc").take(1),
1408
+ ]);
1409
+ return bounds(first[0]?.timestamp, last[0]?.timestamp);
1410
+ }
1411
+ if (source === "goalCompletions") {
1412
+ const [first, last] = await Promise.all([
1413
+ ctx.db.query("goalCompletions")
1414
+ .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1415
+ .order("asc").take(1),
1416
+ ctx.db.query("goalCompletions")
1417
+ .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1418
+ .order("desc").take(1),
1419
+ ]);
1420
+ return bounds(first[0]?.timestamp, last[0]?.timestamp);
1421
+ }
1422
+ const table = source === "funnelSteps"
1423
+ ? "funnelStepBuckets" as const
1424
+ : source === "affiliateStats"
1425
+ ? "affiliateDailyStats" as const
1426
+ : source === "goalRollups"
1427
+ ? "goalDailyRollups" as const
1428
+ : source === "funnelRollups"
1429
+ ? "funnelDailyRollups" as const
1430
+ : "affiliateDailyRollups" as const;
1431
+ const [first, last] = await Promise.all([
1432
+ ctx.db.query(table)
1433
+ .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1434
+ .order("asc").take(1),
1435
+ ctx.db.query(table)
1436
+ .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1437
+ .order("desc").take(1),
1438
+ ]);
1439
+ return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1440
+ }
1441
+
1442
+ function bounds(from: number | undefined, through: number | undefined) {
1443
+ return from === undefined || through === undefined ? null : { from, through };
1444
+ }
1445
+
1446
+ function coverageRank(state: "complete" | "partial" | "retained" | "unavailable") {
1447
+ if (state === "complete") return 3;
1448
+ if (state === "retained") return 2;
1449
+ if (state === "partial") return 1;
1450
+ return 0;
1451
+ }
1452
+
672
1453
 
673
1454
  type MetricTotals = {
674
1455
  events: number;
@@ -718,15 +1499,46 @@ function validateSiteIds<T>(siteIds: T[]): T[] {
718
1499
 
719
1500
  function validateRange(from: number, to: number, maxDays: number) {
720
1501
  if (
721
- !Number.isFinite(from) ||
722
- !Number.isFinite(to) ||
1502
+ !Number.isSafeInteger(from) ||
1503
+ !Number.isSafeInteger(to) ||
723
1504
  from < 0 ||
724
- to <= from ||
725
- to - from > maxDays * DAY_MS
1505
+ to < from ||
1506
+ to - from >= maxDays * DAY_MS
726
1507
  ) {
727
- fail("INVALID_ARGUMENT", `range must be positive, ordered, and at most ${maxDays} days`);
1508
+ fail(
1509
+ "INVALID_ARGUMENT",
1510
+ `range must use inclusive integer milliseconds and span at most ${maxDays} UTC days`,
1511
+ );
728
1512
  }
729
- return { from: Math.trunc(from), to: Math.trunc(to) };
1513
+ return { from, to };
1514
+ }
1515
+
1516
+ function isAlignedRange(from: number, to: number, intervalMs: number) {
1517
+ return from % intervalMs === 0 && (to + 1) % intervalMs === 0;
1518
+ }
1519
+
1520
+ function requireAlignedRange(
1521
+ from: number,
1522
+ to: number,
1523
+ intervalMs: number,
1524
+ report: string,
1525
+ ) {
1526
+ if (!isAlignedRange(from, to, intervalMs)) {
1527
+ fail(
1528
+ "REPORT_INCOMPLETE",
1529
+ `${report} requires complete inclusive UTC bucket boundaries`,
1530
+ { from, to, intervalMs, timezone: "UTC" },
1531
+ );
1532
+ }
1533
+ }
1534
+
1535
+ function rangeSemantics(from: number, to: number) {
1536
+ return {
1537
+ from,
1538
+ to,
1539
+ boundary: "inclusive" as const,
1540
+ timezone: "UTC" as const,
1541
+ };
730
1542
  }
731
1543
 
732
1544
  type KeysetPaginationInput = {