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

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