@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
@@ -1,7 +1,8 @@
1
1
  import { paginationOptsValidator, paginationResultValidator, } from "convex/server";
2
2
  import { v } from "convex/values";
3
3
  import { query } from "./_generated/server.js";
4
- import { AGGREGATE_SHARDS, DAY_MS, HOUR_MS, HOURLY_AGGREGATE_SHARDS, MAX_AFFILIATES_PER_SITE, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, MAX_JOURNEY_EVENTS, MAX_LIVE_VISITORS, MAX_OVERVIEW_AGGREGATE_ROWS, MAX_REPORT_RANGE_DAYS, MAX_REPORT_SITES, } from "./constants.js";
4
+ import { AGGREGATE_SHARDS, DAY_MS, FEATURE_REPORT_BYTE_RESERVE, FEATURE_REPORT_DOCUMENT_RESERVE, HOUR_MS, HOURLY_AGGREGATE_SHARDS, MAX_AFFILIATES_PER_SITE, MAX_FUNNELS_PER_SITE, MAX_FEATURE_ROLLUP_DAYS, MAX_FEATURE_ROLLUP_ROWS, MAX_GOALS_PER_SITE, MAX_JOURNEY_EVENTS, MAX_LIVE_VISITORS, MAX_OVERVIEW_AGGREGATE_ROWS, MAX_REPORT_BUCKETS, MAX_REPORT_RANGE_DAYS, MAX_REPORT_SITES, REPORT_ROLLUP_SHARDS, } from "./constants.js";
5
+ import { createVisitorSketch, estimateVisitors, unionVisitorSketches, } from "./cardinality.js";
5
6
  import { fail } from "./errors.js";
6
7
  import { compareJourneyEvents, hydrateEventContexts, loadSessionJourneyEvents, loadVisitorJourneyEvents, } from "./eventStore.js";
7
8
  import { isPlainRecord } from "./guards.js";
@@ -71,6 +72,25 @@ const topItemValidator = v.object({
71
72
  count: v.number(),
72
73
  revenueCents: v.number(),
73
74
  });
75
+ const coverageStateValidator = v.union(v.literal("complete"), v.literal("partial"), v.literal("retained"), v.literal("unavailable"));
76
+ const resultCoverageValidator = v.object({
77
+ state: coverageStateValidator,
78
+ generation: v.union(v.number(), v.null()),
79
+ availableFrom: v.union(v.number(), v.null()),
80
+ availableThrough: v.union(v.number(), v.null()),
81
+ retainedBefore: v.union(v.number(), v.null()),
82
+ });
83
+ const rangeSemanticsValidator = v.object({
84
+ from: v.number(),
85
+ to: v.number(),
86
+ boundary: v.literal("inclusive"),
87
+ timezone: v.literal("UTC"),
88
+ });
89
+ const reportMetadataValidator = v.object({
90
+ basis: v.union(v.literal("dailyRollup"), v.literal("boundedLegacy")),
91
+ coverage: resultCoverageValidator,
92
+ range: rangeSemanticsValidator,
93
+ });
74
94
  export const overview = query({
75
95
  args: {
76
96
  siteIds: v.array(v.id("sites")),
@@ -92,6 +112,32 @@ export const overview = query({
92
112
  topBrowsers: v.array(topItemValidator),
93
113
  topEvents: v.array(topItemValidator),
94
114
  topGoals: v.array(topItemValidator),
115
+ metadata: v.object({
116
+ visitors: v.object({
117
+ basis: v.union(v.literal("estimatedUnique"), v.literal("bucketOccurrences")),
118
+ exact: v.boolean(),
119
+ legacyIncomplete: v.boolean(),
120
+ sketchVersion: v.union(v.number(), v.null()),
121
+ precision: v.union(v.number(), v.null()),
122
+ }),
123
+ breakdowns: v.object({
124
+ topSources: v.literal("eventVolume"),
125
+ topPages: v.literal("eventVolume"),
126
+ topCountries: v.literal("eventVolume"),
127
+ topDevices: v.literal("eventVolume"),
128
+ topBrowsers: v.literal("eventVolume"),
129
+ topEvents: v.literal("eventVolume"),
130
+ topGoals: v.literal("goalCompletionVolume"),
131
+ }),
132
+ coverage: resultCoverageValidator,
133
+ range: rangeSemanticsValidator,
134
+ features: v.object({
135
+ sessionReplay: v.literal("unsupported"),
136
+ errorInsights: v.literal("unsupported"),
137
+ outboundLinks: v.literal("eventVolume"),
138
+ botDetection: v.literal("unsupported"),
139
+ }),
140
+ }),
95
141
  }),
96
142
  handler: async (ctx, args) => {
97
143
  const siteIds = validateSiteIds(args.siteIds);
@@ -114,6 +160,7 @@ export const overview = query({
114
160
  fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
115
161
  }
116
162
  const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
163
+ requireAlignedRange(from, to, intervalMs, `${interval} overview`);
117
164
  const firstBucket = Math.floor(from / intervalMs) * intervalMs;
118
165
  const lastBucket = Math.floor(to / intervalMs) * intervalMs;
119
166
  const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
@@ -124,7 +171,15 @@ export const overview = query({
124
171
  }
125
172
  const totals = emptyTotals();
126
173
  const points = new Map();
174
+ const visitorSketch = createVisitorSketch();
175
+ let mergedVisitorSketch = visitorSketch;
176
+ const pointVisitorSketches = new Map();
177
+ let visitorsComplete = true;
127
178
  const dimensions = new Map();
179
+ const overviewDataset = interval === "hour"
180
+ ? "overviewHour"
181
+ : "overviewDay";
182
+ await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
128
183
  for (const siteId of siteIds) {
129
184
  const buckets = await ctx.db
130
185
  .query("aggregateBuckets")
@@ -133,12 +188,21 @@ export const overview = query({
133
188
  .eq("granularity", interval)
134
189
  .gte("bucketStart", firstBucket)
135
190
  .lte("bucketStart", lastBucket))
136
- .take(perSiteLimit);
191
+ .take(perSiteLimit + 1);
192
+ failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
137
193
  for (const bucket of buckets) {
138
194
  addMetrics(totals, bucket);
139
195
  const point = points.get(bucket.bucketStart) ?? emptyTotals();
140
196
  addMetrics(point, bucket);
141
197
  points.set(bucket.bucketStart, point);
198
+ if (bucket.visitorSketch && bucket.visitorSketchComplete === true) {
199
+ mergedVisitorSketch = unionVisitorSketches(mergedVisitorSketch, bucket.visitorSketch);
200
+ pointVisitorSketches.set(bucket.bucketStart, unionVisitorSketches(pointVisitorSketches.get(bucket.bucketStart) ??
201
+ createVisitorSketch(bucket.visitorSketch.precision), bucket.visitorSketch));
202
+ }
203
+ else if (bucket.events > 0) {
204
+ visitorsComplete = false;
205
+ }
142
206
  for (const dimension of bucket.dimensions) {
143
207
  const key = `${dimension.type}:${dimension.value}`;
144
208
  const current = dimensions.get(key) ?? {
@@ -157,6 +221,17 @@ export const overview = query({
157
221
  .map(([, value]) => value)
158
222
  .sort((left, right) => right.count - left.count || left.value.localeCompare(right.value))
159
223
  .slice(0, 10);
224
+ if (visitorsComplete) {
225
+ totals.visitors = estimateVisitors(mergedVisitorSketch);
226
+ for (const [timestamp, point] of points) {
227
+ point.visitors = estimateVisitors(pointVisitorSketches.get(timestamp) ?? createVisitorSketch());
228
+ }
229
+ }
230
+ const coverage = await coverageForRange(ctx, siteIds, from, to, {
231
+ datasets: [overviewDataset],
232
+ complete: visitorsComplete,
233
+ });
234
+ const visitorBasis = visitorsComplete ? "estimatedUnique" : "bucketOccurrences";
160
235
  return {
161
236
  from,
162
237
  to,
@@ -173,6 +248,32 @@ export const overview = query({
173
248
  topBrowsers: top("browser"),
174
249
  topEvents: top("event"),
175
250
  topGoals: top("goal"),
251
+ metadata: {
252
+ visitors: {
253
+ basis: visitorBasis,
254
+ exact: false,
255
+ legacyIncomplete: !visitorsComplete,
256
+ sketchVersion: visitorsComplete ? 1 : null,
257
+ precision: visitorsComplete ? 6 : null,
258
+ },
259
+ breakdowns: {
260
+ topSources: "eventVolume",
261
+ topPages: "eventVolume",
262
+ topCountries: "eventVolume",
263
+ topDevices: "eventVolume",
264
+ topBrowsers: "eventVolume",
265
+ topEvents: "eventVolume",
266
+ topGoals: "goalCompletionVolume",
267
+ },
268
+ coverage,
269
+ range: rangeSemantics(from, to),
270
+ features: {
271
+ sessionReplay: "unsupported",
272
+ errorInsights: "unsupported",
273
+ outboundLinks: "eventVolume",
274
+ botDetection: "unsupported",
275
+ },
276
+ },
176
277
  };
177
278
  },
178
279
  });
@@ -236,6 +337,7 @@ export const listSessions = query({
236
337
  returns: paginationResultValidator(sessionDocumentValidator),
237
338
  handler: async (ctx, args) => {
238
339
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
340
+ await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
239
341
  const pagination = parseKeysetPagination(args.paginationOpts, {
240
342
  kind: "sessions",
241
343
  siteId: String(args.siteId),
@@ -274,7 +376,8 @@ export const listSessions = query({
274
376
  .order("desc")
275
377
  .take(take)));
276
378
  }
277
- return keysetPaginationResult(rows, pagination, (row) => ({
379
+ const publicRows = rows.map(({ geoLookupAttemptedAt: _attemptedAt, ...row }) => row);
380
+ return keysetPaginationResult(publicRows, pagination, (row) => ({
278
381
  timestamp: row.lastSeenAt,
279
382
  creationTime: row._creationTime,
280
383
  }));
@@ -291,6 +394,7 @@ export const sessionJourney = query({
291
394
  returns: paginationResultValidator(eventDocumentValidator),
292
395
  handler: async (ctx, args) => {
293
396
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
397
+ await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
294
398
  const pagination = parseJourneyPagination(args.paginationOpts, {
295
399
  kind: "events",
296
400
  siteId: String(args.siteId),
@@ -320,6 +424,7 @@ export const listConversions = query({
320
424
  returns: paginationResultValidator(conversionDocumentValidator),
321
425
  handler: async (ctx, args) => {
322
426
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
427
+ await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
323
428
  const pagination = parseKeysetPagination(args.paginationOpts, {
324
429
  kind: "conversions",
325
430
  siteId: String(args.siteId),
@@ -376,6 +481,7 @@ export const visitorJourney = query({
376
481
  handler: async (ctx, args) => {
377
482
  const siteIds = validateSiteIds(args.siteIds);
378
483
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
484
+ await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
379
485
  const limit = args.limit ?? 200;
380
486
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
381
487
  fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
@@ -410,34 +516,63 @@ export const goalsReport = query({
410
516
  returns: v.object({
411
517
  goals: v.array(goalReportItemValidator),
412
518
  truncated: v.boolean(),
519
+ metadata: reportMetadataValidator,
413
520
  }),
414
521
  handler: async (ctx, args) => {
415
522
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
416
- const goals = await ctx.db
523
+ const goalRows = await ctx.db
417
524
  .query("goals")
418
525
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
419
- .take(MAX_GOALS_PER_SITE);
420
- const completions = await ctx.db
421
- .query("goalCompletions")
422
- .withIndex("by_siteId_and_timestamp", (range) => range
423
- .eq("siteId", args.siteId)
424
- .gte("timestamp", args.from)
425
- .lte("timestamp", args.to))
426
- .take(5_001);
526
+ .take(MAX_GOALS_PER_SITE + 1);
527
+ failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
528
+ const goals = goalRows;
427
529
  const totals = new Map();
428
- for (const completion of completions.slice(0, 5_000)) {
429
- const key = String(completion.goalId);
430
- const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
431
- total.completions += 1;
432
- total.valueCents += completion.valueCents;
433
- totals.set(key, total);
530
+ const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "goalRollups");
531
+ const rollups = useRollups
532
+ ? await loadGoalDailyRollups(ctx, args.siteId, goals.map((goal) => goal._id), args.from, args.to)
533
+ : null;
534
+ if (rollups) {
535
+ for (const row of rollups) {
536
+ const key = String(row.goalId);
537
+ const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
538
+ total.completions += row.completions;
539
+ total.valueCents += row.valueCents;
540
+ totals.set(key, total);
541
+ }
542
+ }
543
+ else {
544
+ await assertSourceAvailable(ctx, [args.siteId], ["goalCompletions"], args.from);
545
+ const completions = await ctx.db
546
+ .query("goalCompletions")
547
+ .withIndex("by_siteId_and_timestamp", (range) => range
548
+ .eq("siteId", args.siteId)
549
+ .gte("timestamp", args.from)
550
+ .lte("timestamp", args.to))
551
+ .take(MAX_REPORT_BUCKETS + 1);
552
+ failIfIncomplete(completions, MAX_REPORT_BUCKETS, "goal completions");
553
+ for (const completion of completions) {
554
+ const key = String(completion.goalId);
555
+ const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
556
+ total.completions += 1;
557
+ total.valueCents += completion.valueCents;
558
+ totals.set(key, total);
559
+ }
434
560
  }
435
561
  return {
436
562
  goals: goals.map((goal) => ({
437
563
  ...goal,
438
564
  ...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
439
565
  })),
440
- truncated: completions.length > 5_000,
566
+ truncated: false,
567
+ metadata: {
568
+ basis: rollups ? "dailyRollup" : "boundedLegacy",
569
+ coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
570
+ datasets: [rollups ? "goalRollups" : "goalCompletions"],
571
+ complete: true,
572
+ generation: rollups ? 1 : null,
573
+ }),
574
+ range: rangeSemantics(args.from, args.to),
575
+ },
441
576
  };
442
577
  },
443
578
  });
@@ -446,33 +581,56 @@ const funnelReportItemValidator = funnelFieldsValidator.extend({
446
581
  _creationTime: v.number(),
447
582
  stepsReport: v.array(v.object({ step: v.number(), name: v.string(), entrants: v.number(), completions: v.number() })),
448
583
  truncated: v.boolean(),
584
+ metadata: reportMetadataValidator,
449
585
  });
450
586
  export const funnelsReport = query({
451
587
  args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
452
588
  returns: v.array(funnelReportItemValidator),
453
589
  handler: async (ctx, args) => {
454
590
  validateRange(args.from, args.to, 90);
455
- const funnels = await ctx.db
591
+ requireAlignedRange(args.from, args.to, DAY_MS, "funnel report");
592
+ const funnelRows = await ctx.db
456
593
  .query("funnels")
457
594
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
458
- .take(MAX_FUNNELS_PER_SITE);
595
+ .take(MAX_FUNNELS_PER_SITE + 1);
596
+ failIfIncomplete(funnelRows, MAX_FUNNELS_PER_SITE, "funnel definitions");
597
+ const funnels = funnelRows;
598
+ const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "funnelRollups");
599
+ const rollups = useRollups
600
+ ? await loadFunnelDailyRollups(ctx, args.siteId, funnels.map((funnel) => funnel._id), args.from, args.to)
601
+ : null;
602
+ if (!rollups) {
603
+ await assertSourceAvailable(ctx, [args.siteId], ["funnelSteps"], args.from);
604
+ }
605
+ const reportCoverage = await coverageForRange(ctx, [args.siteId], args.from, args.to, {
606
+ datasets: [rollups ? "funnelRollups" : "funnelSteps"],
607
+ complete: true,
608
+ generation: rollups ? 1 : null,
609
+ });
459
610
  const reports = [];
460
611
  for (const funnel of funnels) {
461
- const buckets = await ctx.db
462
- .query("funnelStepBuckets")
463
- .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
464
- .eq("siteId", args.siteId)
465
- .eq("funnelId", funnel._id)
466
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
467
- .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
468
- .take(601);
469
612
  const stepsReport = funnel.steps.map((step, index) => ({
470
613
  step: index,
471
614
  name: step.name,
472
615
  entrants: 0,
473
616
  completions: 0,
474
617
  }));
475
- for (const bucket of buckets.slice(0, 600)) {
618
+ const buckets = rollups
619
+ ? rollups
620
+ .filter((row) => row.funnelId === funnel._id)
621
+ .flatMap((row) => row.steps)
622
+ : await ctx.db
623
+ .query("funnelStepBuckets")
624
+ .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
625
+ .eq("siteId", args.siteId)
626
+ .eq("funnelId", funnel._id)
627
+ .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
628
+ .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
629
+ .take(MAX_REPORT_BUCKETS + 1);
630
+ if (!rollups) {
631
+ failIfIncomplete(buckets, MAX_REPORT_BUCKETS, "funnel step buckets");
632
+ }
633
+ for (const bucket of buckets) {
476
634
  const step = stepsReport[bucket.step];
477
635
  if (step) {
478
636
  step.entrants += bucket.entrants;
@@ -482,7 +640,12 @@ export const funnelsReport = query({
482
640
  reports.push({
483
641
  ...funnel,
484
642
  stepsReport,
485
- truncated: buckets.length > 600,
643
+ truncated: false,
644
+ metadata: {
645
+ basis: rollups ? "dailyRollup" : "boundedLegacy",
646
+ coverage: reportCoverage,
647
+ range: rangeSemantics(args.from, args.to),
648
+ },
486
649
  });
487
650
  }
488
651
  return reports;
@@ -502,54 +665,94 @@ export const affiliatesReport = query({
502
665
  returns: v.object({
503
666
  affiliates: v.array(affiliateReportItemValidator),
504
667
  truncated: v.boolean(),
668
+ metadata: reportMetadataValidator.extend({
669
+ uniqueVisitors: v.object({
670
+ basis: v.union(v.literal("estimatedUnique"), v.literal("exactLegacy")),
671
+ exact: v.boolean(),
672
+ }),
673
+ }),
505
674
  }),
506
675
  handler: async (ctx, args) => {
507
676
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
677
+ requireAlignedRange(args.from, args.to, DAY_MS, "affiliate report");
508
678
  const affiliateRows = await ctx.db
509
679
  .query("affiliates")
510
680
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
511
681
  .order("desc")
512
682
  .take(MAX_AFFILIATES_PER_SITE + 1);
513
- const affiliates = affiliateRows.slice(0, MAX_AFFILIATES_PER_SITE);
514
- const stats = await ctx.db
515
- .query("affiliateDailyStats")
516
- .withIndex("by_siteId_and_bucketStart", (range) => range
517
- .eq("siteId", args.siteId)
518
- .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
519
- .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
520
- .take(5_001);
521
- const sessions = await ctx.db
522
- .query("sessions")
523
- .withIndex("by_siteId_and_startedAt", (range) => range
524
- .eq("siteId", args.siteId)
525
- .gte("startedAt", args.from)
526
- .lte("startedAt", args.to))
527
- .take(5_001);
683
+ failIfIncomplete(affiliateRows, MAX_AFFILIATES_PER_SITE, "affiliate definitions");
684
+ const affiliates = affiliateRows;
685
+ const useRollups = await dailyRollupsAvailable(ctx, args.siteId, args.from, args.to, "affiliateRollups");
686
+ const rollups = useRollups
687
+ ? await loadAffiliateDailyRollups(ctx, args.siteId, affiliates.map((affiliate) => affiliate._id), args.from, args.to)
688
+ : null;
528
689
  const visits = new Map();
529
- for (const session of sessions.slice(0, 5_000)) {
530
- if (!session.affiliateId)
531
- continue;
532
- const key = String(session.affiliateId);
533
- const total = visits.get(key) ?? {
534
- visits: 0,
535
- visitors: new Set(),
536
- };
537
- total.visits += 1;
538
- total.visitors.add(session.visitorId);
539
- visits.set(key, total);
540
- }
541
690
  const totals = new Map();
542
- for (const row of stats.slice(0, 5_000)) {
543
- const key = String(row.affiliateId);
544
- const total = totals.get(key) ?? {
545
- conversions: 0,
546
- revenueCents: 0,
547
- commissionCents: 0,
548
- };
549
- total.conversions += row.conversions;
550
- total.revenueCents += row.revenueCents;
551
- total.commissionCents += row.commissionCents;
552
- totals.set(key, total);
691
+ if (rollups) {
692
+ for (const row of rollups) {
693
+ const key = String(row.affiliateId);
694
+ const traffic = visits.get(key) ?? {
695
+ visits: 0,
696
+ visitors: new Set(),
697
+ sketch: createVisitorSketch(),
698
+ };
699
+ traffic.visits += row.visits;
700
+ traffic.sketch = unionVisitorSketches(traffic.sketch, row.visitorSketch);
701
+ visits.set(key, traffic);
702
+ const total = totals.get(key) ?? {
703
+ conversions: 0,
704
+ revenueCents: 0,
705
+ commissionCents: 0,
706
+ };
707
+ total.conversions += row.conversions;
708
+ total.revenueCents += row.revenueCents;
709
+ total.commissionCents += row.commissionCents;
710
+ totals.set(key, total);
711
+ }
712
+ }
713
+ else {
714
+ await assertSourceAvailable(ctx, [args.siteId], ["affiliateStats", "sessions"], args.from);
715
+ const stats = await ctx.db
716
+ .query("affiliateDailyStats")
717
+ .withIndex("by_siteId_and_bucketStart", (range) => range
718
+ .eq("siteId", args.siteId)
719
+ .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
720
+ .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS))
721
+ .take(MAX_REPORT_BUCKETS + 1);
722
+ const sessions = await ctx.db
723
+ .query("sessions")
724
+ .withIndex("by_siteId_and_startedAt", (range) => range
725
+ .eq("siteId", args.siteId)
726
+ .gte("startedAt", args.from)
727
+ .lte("startedAt", args.to))
728
+ .take(MAX_REPORT_BUCKETS + 1);
729
+ failIfIncomplete(stats, MAX_REPORT_BUCKETS, "affiliate stat buckets");
730
+ failIfIncomplete(sessions, MAX_REPORT_BUCKETS, "affiliate sessions");
731
+ for (const session of sessions) {
732
+ if (!session.affiliateId)
733
+ continue;
734
+ const key = String(session.affiliateId);
735
+ const total = visits.get(key) ?? {
736
+ visits: 0,
737
+ visitors: new Set(),
738
+ sketch: createVisitorSketch(),
739
+ };
740
+ total.visits += 1;
741
+ total.visitors.add(session.visitorId);
742
+ visits.set(key, total);
743
+ }
744
+ for (const row of stats) {
745
+ const key = String(row.affiliateId);
746
+ const total = totals.get(key) ?? {
747
+ conversions: 0,
748
+ revenueCents: 0,
749
+ commissionCents: 0,
750
+ };
751
+ total.conversions += row.conversions;
752
+ total.revenueCents += row.revenueCents;
753
+ total.commissionCents += row.commissionCents;
754
+ totals.set(key, total);
755
+ }
553
756
  }
554
757
  return {
555
758
  affiliates: affiliates.map((affiliate) => {
@@ -563,15 +766,358 @@ export const affiliatesReport = query({
563
766
  ...affiliate,
564
767
  ...performance,
565
768
  visits: traffic?.visits ?? 0,
566
- uniqueVisitors: traffic?.visitors.size ?? 0,
769
+ uniqueVisitors: rollups
770
+ ? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
771
+ : traffic?.visitors.size ?? 0,
567
772
  };
568
773
  }),
569
- truncated: affiliateRows.length > MAX_AFFILIATES_PER_SITE ||
570
- stats.length > 5_000 ||
571
- sessions.length > 5_000,
774
+ truncated: false,
775
+ metadata: {
776
+ basis: rollups ? "dailyRollup" : "boundedLegacy",
777
+ coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
778
+ datasets: rollups
779
+ ? ["affiliateRollups"]
780
+ : ["affiliateStats", "sessions"],
781
+ complete: true,
782
+ generation: rollups ? 1 : null,
783
+ }),
784
+ range: rangeSemantics(args.from, args.to),
785
+ uniqueVisitors: {
786
+ basis: rollups ? "estimatedUnique" : "exactLegacy",
787
+ exact: !rollups,
788
+ },
789
+ },
572
790
  };
573
791
  },
574
792
  });
793
+ const coverageDatasetValidator = v.union(v.literal("overviewHour"), v.literal("overviewDay"), v.literal("events"), v.literal("sessions"), v.literal("conversions"), v.literal("goals"), v.literal("funnels"), v.literal("affiliates"));
794
+ export const dataCoverage = query({
795
+ args: {
796
+ siteId: v.id("sites"),
797
+ from: v.number(),
798
+ to: v.number(),
799
+ },
800
+ returns: v.object({
801
+ siteId: v.id("sites"),
802
+ from: v.number(),
803
+ to: v.number(),
804
+ datasets: v.array(v.object({
805
+ dataset: coverageDatasetValidator,
806
+ coverage: resultCoverageValidator,
807
+ })),
808
+ }),
809
+ handler: async (ctx, args) => {
810
+ validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
811
+ const datasets = [
812
+ "overviewHour",
813
+ "overviewDay",
814
+ "events",
815
+ "sessions",
816
+ "conversions",
817
+ "goals",
818
+ "funnels",
819
+ "affiliates",
820
+ ];
821
+ return {
822
+ siteId: args.siteId,
823
+ from: args.from,
824
+ to: args.to,
825
+ datasets: await Promise.all(datasets.map(async (dataset) => ({
826
+ dataset,
827
+ coverage: await datasetCoverageForRange(ctx, args.siteId, dataset, args.from, args.to),
828
+ }))),
829
+ };
830
+ },
831
+ });
832
+ async function dailyRollupsAvailable(ctx, siteId, from, to, dataset) {
833
+ if (!isAlignedRange(from, to, DAY_MS))
834
+ return false;
835
+ const coverage = await ctx.db
836
+ .query("analyticsCoverage")
837
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
838
+ .unique();
839
+ if (!coverage || coverage.rollupCompleteFrom > from) {
840
+ return false;
841
+ }
842
+ const retainedBefore = coverage.retained.find((item) => item.dataset === dataset)?.before;
843
+ return retainedBefore === undefined || from >= retainedBefore;
844
+ }
845
+ async function loadGoalDailyRollups(ctx, siteId, goalIds, from, to) {
846
+ const days = Math.floor((to - from) / DAY_MS) + 1;
847
+ requireFeatureRollupBudget(goalIds.length, days, "goal daily rollups");
848
+ const rows = [];
849
+ for (const goalId of goalIds) {
850
+ const perGoalLimit = days * REPORT_ROLLUP_SHARDS;
851
+ const goalRows = await ctx.db
852
+ .query("goalDailyRollups")
853
+ .withIndex("by_siteId_and_goalId_and_bucketStart", (range) => range
854
+ .eq("siteId", siteId)
855
+ .eq("goalId", goalId)
856
+ .gte("bucketStart", from)
857
+ .lte("bucketStart", to))
858
+ .take(perGoalLimit + 1);
859
+ failIfIncomplete(goalRows, perGoalLimit, "goal daily rollups");
860
+ rows.push(...goalRows);
861
+ await requireFeatureReadHeadroom(ctx, "goal daily rollups");
862
+ }
863
+ return rows;
864
+ }
865
+ async function loadFunnelDailyRollups(ctx, siteId, funnelIds, from, to) {
866
+ const days = Math.floor((to - from) / DAY_MS) + 1;
867
+ requireFeatureRollupBudget(funnelIds.length, days, "funnel daily rollups");
868
+ const rows = [];
869
+ for (const funnelId of funnelIds) {
870
+ const perFunnelLimit = days * REPORT_ROLLUP_SHARDS;
871
+ const funnelRows = await ctx.db
872
+ .query("funnelDailyRollups")
873
+ .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) => range
874
+ .eq("siteId", siteId)
875
+ .eq("funnelId", funnelId)
876
+ .gte("bucketStart", from)
877
+ .lte("bucketStart", to))
878
+ .take(perFunnelLimit + 1);
879
+ failIfIncomplete(funnelRows, perFunnelLimit, "funnel daily rollups");
880
+ rows.push(...funnelRows);
881
+ await requireFeatureReadHeadroom(ctx, "funnel daily rollups");
882
+ }
883
+ return rows;
884
+ }
885
+ async function loadAffiliateDailyRollups(ctx, siteId, affiliateIds, from, to) {
886
+ const days = Math.floor((to - from) / DAY_MS) + 1;
887
+ requireFeatureRollupBudget(affiliateIds.length, days, "affiliate daily rollups");
888
+ const rows = [];
889
+ for (const affiliateId of affiliateIds) {
890
+ const perAffiliateLimit = days * REPORT_ROLLUP_SHARDS;
891
+ const affiliateRows = await ctx.db
892
+ .query("affiliateDailyRollups")
893
+ .withIndex("by_siteId_and_affiliateId_and_bucketStart", (range) => range
894
+ .eq("siteId", siteId)
895
+ .eq("affiliateId", affiliateId)
896
+ .gte("bucketStart", from)
897
+ .lte("bucketStart", to))
898
+ .take(perAffiliateLimit + 1);
899
+ failIfIncomplete(affiliateRows, perAffiliateLimit, "affiliate daily rollups");
900
+ rows.push(...affiliateRows);
901
+ await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
902
+ }
903
+ return rows;
904
+ }
905
+ function requireFeatureRollupBudget(entities, days, dataset) {
906
+ const maximumRows = entities * days * REPORT_ROLLUP_SHARDS;
907
+ if (days > MAX_FEATURE_ROLLUP_DAYS || maximumRows > MAX_FEATURE_ROLLUP_ROWS) {
908
+ fail("REPORT_INCOMPLETE", `${dataset} exceeds its complete read budget`, {
909
+ dataset,
910
+ maximumDays: MAX_FEATURE_ROLLUP_DAYS,
911
+ maximumRows: MAX_FEATURE_ROLLUP_ROWS,
912
+ });
913
+ }
914
+ }
915
+ async function requireFeatureReadHeadroom(ctx, dataset) {
916
+ const metrics = await ctx.meta.getTransactionMetrics();
917
+ if (metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
918
+ metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE) {
919
+ fail("REPORT_INCOMPLETE", `${dataset} exceeds its byte or document budget`, {
920
+ dataset,
921
+ bytesRead: metrics.bytesRead.used,
922
+ documentsRead: metrics.documentsRead.used,
923
+ });
924
+ }
925
+ }
926
+ function failIfIncomplete(rows, maximum, dataset) {
927
+ if (rows.length > maximum) {
928
+ fail("REPORT_INCOMPLETE", `${dataset} exceeds the complete report read budget`, {
929
+ maximumRows: maximum,
930
+ dataset,
931
+ });
932
+ }
933
+ }
934
+ async function coverageForRange(ctx, siteIds, from, to, options) {
935
+ const controls = await Promise.all(siteIds.map((siteId) => ctx.db
936
+ .query("analyticsCoverage")
937
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
938
+ .unique()));
939
+ const sourceRanges = await Promise.all(siteIds.map((siteId) => sourceSetRange(ctx, siteId, options.datasets)));
940
+ const retainedBefore = controls.reduce((latest, control) => {
941
+ const before = control?.retained
942
+ .filter((item) => options.datasets.some((dataset) => dataset === item.dataset))
943
+ .reduce((latest, item) => Math.max(latest ?? 0, item.before), undefined);
944
+ return before === undefined ? latest : Math.max(latest ?? 0, before);
945
+ }, null);
946
+ const availableFrom = options.generation === 1
947
+ ? controls.length > 0 && controls.every(Boolean)
948
+ ? Math.max(...controls.map((control) => control.rollupCompleteFrom))
949
+ : null
950
+ : sourceRanges.length > 0 && sourceRanges.every(Boolean)
951
+ ? Math.max(...sourceRanges.map((range) => range.from))
952
+ : null;
953
+ const availableThrough = sourceRanges.length > 0 && sourceRanges.every(Boolean)
954
+ ? Math.min(...sourceRanges.map((range) => range.through))
955
+ : null;
956
+ let state;
957
+ if (retainedBefore !== null && to < retainedBefore) {
958
+ state = "unavailable";
959
+ }
960
+ else if (retainedBefore !== null && from < retainedBefore) {
961
+ state = "partial";
962
+ }
963
+ else if (options.complete || (availableFrom !== null &&
964
+ availableThrough !== null &&
965
+ from >= availableFrom &&
966
+ to <= availableThrough)) {
967
+ state = "complete";
968
+ }
969
+ else if (sourceRanges.some(Boolean)) {
970
+ state = "retained";
971
+ }
972
+ else {
973
+ state = "unavailable";
974
+ }
975
+ return {
976
+ state,
977
+ generation: options.generation ?? null,
978
+ availableFrom,
979
+ availableThrough,
980
+ retainedBefore,
981
+ };
982
+ }
983
+ async function assertSourceAvailable(ctx, siteIds, sources, from) {
984
+ const rows = await Promise.all(siteIds.map((siteId) => ctx.db
985
+ .query("analyticsCoverage")
986
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
987
+ .unique()));
988
+ const cutoff = rows.reduce((latest, row) => {
989
+ for (const retained of row?.retained ?? []) {
990
+ if (sources.some((source) => source === retained.dataset)) {
991
+ latest = Math.max(latest ?? 0, retained.before);
992
+ }
993
+ }
994
+ return latest;
995
+ }, null);
996
+ if (cutoff !== null && from < cutoff) {
997
+ fail("REPORT_INCOMPLETE", "requested report range intersects retained data", {
998
+ retainedBefore: cutoff,
999
+ });
1000
+ }
1001
+ }
1002
+ async function datasetCoverageForRange(ctx, siteId, dataset, from, to) {
1003
+ const sourceOptions = dataset === "goals"
1004
+ ? [["goalRollups"], ["goalCompletions"]]
1005
+ : dataset === "funnels"
1006
+ ? [["funnelRollups"], ["funnelSteps"]]
1007
+ : dataset === "affiliates"
1008
+ ? [["affiliateRollups"], ["affiliateStats", "sessions"]]
1009
+ : [[dataset]];
1010
+ const options = (await Promise.all(sourceOptions.map(async (sources) => {
1011
+ const rollupDataset = sources[0];
1012
+ const isRollup = rollupDataset.endsWith("Rollups");
1013
+ const complete = isRollup
1014
+ ? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
1015
+ : false;
1016
+ if (isRollup && !complete)
1017
+ return null;
1018
+ return coverageForRange(ctx, [siteId], from, to, {
1019
+ datasets: sources,
1020
+ complete,
1021
+ generation: complete ? 1 : null,
1022
+ });
1023
+ }))).filter((option) => option !== null);
1024
+ return options.sort((left, right) => coverageRank(right.state) - coverageRank(left.state))[0];
1025
+ }
1026
+ async function sourceSetRange(ctx, siteId, sources) {
1027
+ const ranges = (await Promise.all(sources.map((source) => sourceRange(ctx, siteId, source)))).filter((range) => range !== null);
1028
+ if (ranges.length === 0)
1029
+ return null;
1030
+ return {
1031
+ from: Math.min(...ranges.map((range) => range.from)),
1032
+ through: Math.max(...ranges.map((range) => range.through)),
1033
+ };
1034
+ }
1035
+ async function sourceRange(ctx, siteId, source) {
1036
+ if (source === "overviewHour" || source === "overviewDay") {
1037
+ const granularity = source === "overviewHour" ? "hour" : "day";
1038
+ const [first, last] = await Promise.all([
1039
+ ctx.db.query("aggregateBuckets")
1040
+ .withIndex("by_siteId_and_granularity_and_bucketStart", (range) => range.eq("siteId", siteId).eq("granularity", granularity)).order("asc").take(1),
1041
+ ctx.db.query("aggregateBuckets")
1042
+ .withIndex("by_siteId_and_granularity_and_bucketStart", (range) => range.eq("siteId", siteId).eq("granularity", granularity)).order("desc").take(1),
1043
+ ]);
1044
+ return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1045
+ }
1046
+ if (source === "events") {
1047
+ const [first, last] = await Promise.all([
1048
+ ctx.db.query("eventBatches")
1049
+ .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1050
+ .order("asc").take(1),
1051
+ ctx.db.query("eventBatches")
1052
+ .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1053
+ .order("desc").take(1),
1054
+ ]);
1055
+ return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1056
+ }
1057
+ if (source === "sessions") {
1058
+ const [first, last] = await Promise.all([
1059
+ ctx.db.query("sessions")
1060
+ .withIndex("by_siteId_and_startedAt", (range) => range.eq("siteId", siteId))
1061
+ .order("asc").take(1),
1062
+ ctx.db.query("sessions")
1063
+ .withIndex("by_siteId_and_lastSeenAt", (range) => range.eq("siteId", siteId))
1064
+ .order("desc").take(1),
1065
+ ]);
1066
+ return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
1067
+ }
1068
+ if (source === "conversions") {
1069
+ const [first, last] = await Promise.all([
1070
+ ctx.db.query("conversions")
1071
+ .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1072
+ .order("asc").take(1),
1073
+ ctx.db.query("conversions")
1074
+ .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1075
+ .order("desc").take(1),
1076
+ ]);
1077
+ return bounds(first[0]?.timestamp, last[0]?.timestamp);
1078
+ }
1079
+ if (source === "goalCompletions") {
1080
+ const [first, last] = await Promise.all([
1081
+ ctx.db.query("goalCompletions")
1082
+ .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1083
+ .order("asc").take(1),
1084
+ ctx.db.query("goalCompletions")
1085
+ .withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
1086
+ .order("desc").take(1),
1087
+ ]);
1088
+ return bounds(first[0]?.timestamp, last[0]?.timestamp);
1089
+ }
1090
+ const table = source === "funnelSteps"
1091
+ ? "funnelStepBuckets"
1092
+ : source === "affiliateStats"
1093
+ ? "affiliateDailyStats"
1094
+ : source === "goalRollups"
1095
+ ? "goalDailyRollups"
1096
+ : source === "funnelRollups"
1097
+ ? "funnelDailyRollups"
1098
+ : "affiliateDailyRollups";
1099
+ const [first, last] = await Promise.all([
1100
+ ctx.db.query(table)
1101
+ .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1102
+ .order("asc").take(1),
1103
+ ctx.db.query(table)
1104
+ .withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
1105
+ .order("desc").take(1),
1106
+ ]);
1107
+ return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
1108
+ }
1109
+ function bounds(from, through) {
1110
+ return from === undefined || through === undefined ? null : { from, through };
1111
+ }
1112
+ function coverageRank(state) {
1113
+ if (state === "complete")
1114
+ return 3;
1115
+ if (state === "retained")
1116
+ return 2;
1117
+ if (state === "partial")
1118
+ return 1;
1119
+ return 0;
1120
+ }
575
1121
  function emptyTotals() {
576
1122
  return {
577
1123
  events: 0,
@@ -604,14 +1150,30 @@ function validateSiteIds(siteIds) {
604
1150
  return unique;
605
1151
  }
606
1152
  function validateRange(from, to, maxDays) {
607
- if (!Number.isFinite(from) ||
608
- !Number.isFinite(to) ||
1153
+ if (!Number.isSafeInteger(from) ||
1154
+ !Number.isSafeInteger(to) ||
609
1155
  from < 0 ||
610
- to <= from ||
611
- to - from > maxDays * DAY_MS) {
612
- fail("INVALID_ARGUMENT", `range must be positive, ordered, and at most ${maxDays} days`);
1156
+ to < from ||
1157
+ to - from >= maxDays * DAY_MS) {
1158
+ fail("INVALID_ARGUMENT", `range must use inclusive integer milliseconds and span at most ${maxDays} UTC days`);
1159
+ }
1160
+ return { from, to };
1161
+ }
1162
+ function isAlignedRange(from, to, intervalMs) {
1163
+ return from % intervalMs === 0 && (to + 1) % intervalMs === 0;
1164
+ }
1165
+ function requireAlignedRange(from, to, intervalMs, report) {
1166
+ if (!isAlignedRange(from, to, intervalMs)) {
1167
+ fail("REPORT_INCOMPLETE", `${report} requires complete inclusive UTC bucket boundaries`, { from, to, intervalMs, timezone: "UTC" });
613
1168
  }
614
- return { from: Math.trunc(from), to: Math.trunc(to) };
1169
+ }
1170
+ function rangeSemantics(from, to) {
1171
+ return {
1172
+ from,
1173
+ to,
1174
+ boundary: "inclusive",
1175
+ timezone: "UTC",
1176
+ };
615
1177
  }
616
1178
  function parseKeysetPagination(options, scope) {
617
1179
  validatePagination(options);