@iann29/rastro 0.1.0-alpha.2 → 0.1.0-alpha.4

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