@iann29/rastro 0.1.0-alpha.3 → 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 (86) hide show
  1. package/README.md +264 -51
  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 +4 -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 +151 -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 +10 -0
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +10 -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/http.d.ts.map +1 -1
  34. package/dist/component/http.js +46 -8
  35. package/dist/component/http.js.map +1 -1
  36. package/dist/component/ingest.d.ts.map +1 -1
  37. package/dist/component/ingest.js +238 -18
  38. package/dist/component/ingest.js.map +1 -1
  39. package/dist/component/live.d.ts.map +1 -1
  40. package/dist/component/live.js +6 -3
  41. package/dist/component/live.js.map +1 -1
  42. package/dist/component/reports.d.ts +111 -2
  43. package/dist/component/reports.d.ts.map +1 -1
  44. package/dist/component/reports.js +639 -78
  45. package/dist/component/reports.js.map +1 -1
  46. package/dist/component/retention.d.ts +75 -1
  47. package/dist/component/retention.d.ts.map +1 -1
  48. package/dist/component/retention.js +517 -54
  49. package/dist/component/retention.js.map +1 -1
  50. package/dist/component/sanitize.d.ts +4 -1
  51. package/dist/component/sanitize.d.ts.map +1 -1
  52. package/dist/component/sanitize.js +9 -4
  53. package/dist/component/sanitize.js.map +1 -1
  54. package/dist/component/schema.d.ts +187 -1
  55. package/dist/component/schema.js +100 -1
  56. package/dist/component/schema.js.map +1 -1
  57. package/dist/component/sites.d.ts.map +1 -1
  58. package/dist/component/sites.js +5 -1
  59. package/dist/component/sites.js.map +1 -1
  60. package/dist/component/validators.d.ts +26 -1
  61. package/dist/component/validators.d.ts.map +1 -1
  62. package/dist/component/validators.js +7 -0
  63. package/dist/component/validators.js.map +1 -1
  64. package/docs/benchmarks/2026-08-20-realistic.md +4 -4
  65. package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
  66. package/docs/federation-setup.md +395 -0
  67. package/docs/federation.md +258 -0
  68. package/docs/upgrading.md +130 -0
  69. package/llms.txt +65 -0
  70. package/package.json +32 -6
  71. package/scripts/generate-federation-keys.mjs +20 -0
  72. package/src/component/_generated/api.ts +4 -0
  73. package/src/component/_generated/component.ts +211 -1
  74. package/src/component/cardinality.ts +116 -0
  75. package/src/component/constants.ts +10 -0
  76. package/src/component/coverage.ts +25 -0
  77. package/src/component/errors.ts +2 -1
  78. package/src/component/http.ts +76 -6
  79. package/src/component/ingest.ts +387 -12
  80. package/src/component/live.ts +8 -3
  81. package/src/component/reports.ts +891 -86
  82. package/src/component/retention.ts +624 -88
  83. package/src/component/sanitize.ts +15 -4
  84. package/src/component/schema.ts +110 -0
  85. package/src/component/sites.ts +5 -1
  86. package/src/component/validators.ts +8 -0
@@ -18,12 +18,21 @@ import {
18
18
  MAX_BYTES_PER_SESSION_WINDOW,
19
19
  MAX_EVENTS_PER_SESSION_WINDOW,
20
20
  MAX_FUNNELS_PER_SITE,
21
+ MAX_FUNNEL_STEPS,
21
22
  MAX_GOALS_PER_SITE,
22
23
  RATE_LIMIT_WINDOW_MS,
24
+ REPORT_ROLLUP_SHARDS,
23
25
  SECONDARY_AGGREGATE_SHARDS,
24
26
  SITE_INGEST_SHARDS,
25
27
  } from "./constants.js";
28
+ import {
29
+ addVisitor,
30
+ createVisitorSketch,
31
+ unionVisitorSketches,
32
+ type VisitorSketch,
33
+ } from "./cardinality.js";
26
34
  import { fail } from "./errors.js";
35
+ import { ensureAnalyticsControl } from "./coverage.js";
27
36
  import {
28
37
  bufferEventBatch,
29
38
  eventBatchKey,
@@ -36,8 +45,8 @@ import {
36
45
  } from "./eventStore.js";
37
46
  import {
38
47
  cleanString,
39
- ensureBatchBytes,
40
48
  isValidMoney,
49
+ measureBatchBytes,
41
50
  originAllowed,
42
51
  sanitizeContext,
43
52
  sanitizeCurrency,
@@ -81,11 +90,49 @@ type AggregateDelta = {
81
90
  outboundClicks: number;
82
91
  sessions: number;
83
92
  visitors: number;
93
+ visitorSketch: VisitorSketch;
84
94
  conversions: number;
85
95
  revenueCents: number;
86
96
  dimensions: Doc<"aggregateBuckets">["dimensions"];
87
97
  };
88
98
  type AggregateDeltas = Map<string, AggregateDelta>;
99
+ type GoalRollupDelta = {
100
+ siteId: Id<"sites">;
101
+ goalId: Id<"goals">;
102
+ bucketStart: number;
103
+ shard: number;
104
+ goalKey: string;
105
+ completions: number;
106
+ valueCents: number;
107
+ };
108
+ type FunnelRollupDelta = {
109
+ siteId: Id<"sites">;
110
+ funnelId: Id<"funnels">;
111
+ bucketStart: number;
112
+ shard: number;
113
+ steps: Map<number, {
114
+ step: number;
115
+ entrants: number;
116
+ completions: number;
117
+ }>;
118
+ };
119
+ type AffiliateRollupDelta = {
120
+ siteId: Id<"sites">;
121
+ affiliateId: Id<"affiliates">;
122
+ bucketStart: number;
123
+ shard: number;
124
+ affiliateSlug: string;
125
+ visits: number;
126
+ visitorSketch: VisitorSketch;
127
+ conversions: number;
128
+ revenueCents: number;
129
+ commissionCents: number;
130
+ };
131
+ type ReportRollupDeltas = {
132
+ goals: Map<string, GoalRollupDelta>;
133
+ funnels: Map<string, FunnelRollupDelta>;
134
+ affiliates: Map<string, AffiliateRollupDelta>;
135
+ };
89
136
  type AttributionSnapshot = {
90
137
  affiliateId: Id<"affiliates">;
91
138
  affiliateSlug: string;
@@ -161,21 +208,25 @@ export const ingestBatch = mutation({
161
208
  limit: MAX_BATCH_EVENTS,
162
209
  });
163
210
  }
211
+ let eventByteCounts: number[];
164
212
  try {
165
- ensureBatchBytes({ siteId: String(args.siteId), events: args.events });
213
+ ({ eventByteCounts } = measureBatchBytes(String(args.siteId), args.events));
166
214
  } catch {
167
215
  fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
168
216
  }
169
217
 
170
218
  const now = Date.now();
171
- const eventByteCounts = await consumeSiteRateLimit(
219
+ await ensureAnalyticsControl(ctx, args.siteId, now);
220
+ await consumeSiteRateLimit(
172
221
  ctx,
173
222
  args.siteId,
174
223
  args.events,
224
+ eventByteCounts,
175
225
  now,
176
226
  );
177
227
  const context = sanitizeContext(args.context);
178
228
  const aggregateDeltas: AggregateDeltas = new Map();
229
+ const reportRollupDeltas = createReportRollupDeltas();
179
230
  const sessionRateWindows = new Map<string, SessionRateWindowState>();
180
231
  const batchState: BatchState = {
181
232
  sessions: new Map(),
@@ -367,6 +418,7 @@ export const ingestBatch = mutation({
367
418
  goals: canMatchDefinition ? goals ?? [] : [],
368
419
  funnels: canMatchDefinition ? funnels ?? [] : [],
369
420
  aggregateDeltas,
421
+ reportRollupDeltas,
370
422
  recordTelemetry: true,
371
423
  financialConversion: false,
372
424
  trustedGoalKey: undefined,
@@ -384,6 +436,7 @@ export const ingestBatch = mutation({
384
436
  await flushBatchState(ctx, batchState);
385
437
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
386
438
  await flushAggregates(ctx, aggregateDeltas);
439
+ await flushReportRollups(ctx, reportRollupDeltas);
387
440
  return { accepted, duplicates, rejected };
388
441
  },
389
442
  });
@@ -475,6 +528,7 @@ export const trackConversion = mutation({
475
528
  };
476
529
 
477
530
  const aggregateDeltas: AggregateDeltas = new Map();
531
+ const reportRollupDeltas = createReportRollupDeltas();
478
532
  const session = await accountEvent(ctx, {
479
533
  siteId: args.siteId,
480
534
  event,
@@ -483,6 +537,7 @@ export const trackConversion = mutation({
483
537
  goals: [],
484
538
  funnels: [],
485
539
  aggregateDeltas,
540
+ reportRollupDeltas,
486
541
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
487
542
  financialConversion: true,
488
543
  trustedGoalKey,
@@ -497,6 +552,8 @@ export const trackConversion = mutation({
497
552
  );
498
553
  }
499
554
  await flushAggregates(ctx, aggregateDeltas);
555
+ await flushReportRollups(ctx, reportRollupDeltas);
556
+ await ensureAnalyticsControl(ctx, args.siteId, Date.now());
500
557
  return { accepted: 1, duplicates: 0, rejected: 0 };
501
558
  },
502
559
  });
@@ -619,6 +676,7 @@ async function accountEvent(
619
676
  goals: Doc<"goals">[];
620
677
  funnels: Doc<"funnels">[];
621
678
  aggregateDeltas: AggregateDeltas;
679
+ reportRollupDeltas: ReportRollupDeltas;
622
680
  recordTelemetry: boolean;
623
681
  financialConversion: boolean;
624
682
  trustedGoalKey: string | undefined;
@@ -635,6 +693,7 @@ async function accountEvent(
635
693
  input.context,
636
694
  input.existingSession,
637
695
  input.financialConversion,
696
+ input.reportRollupDeltas,
638
697
  input.batchState,
639
698
  )
640
699
  : await accountTrustedSession(ctx, input.existingSession, event);
@@ -691,12 +750,25 @@ async function accountEvent(
691
750
  timestamp: event.timestamp,
692
751
  valueCents: goal.valueCents ?? 0,
693
752
  });
753
+ foldGoalRollup(
754
+ input.reportRollupDeltas,
755
+ input.siteId,
756
+ event,
757
+ goal,
758
+ );
694
759
  matchedGoalKeys.push(goal.key);
695
760
  }
696
761
 
697
762
  for (const funnel of input.funnels) {
698
763
  if (funnel.active) {
699
- await advanceFunnel(ctx, input.siteId, funnel, event, input.batchState);
764
+ await advanceFunnel(
765
+ ctx,
766
+ input.siteId,
767
+ funnel,
768
+ event,
769
+ input.reportRollupDeltas,
770
+ input.batchState,
771
+ );
700
772
  }
701
773
  }
702
774
 
@@ -707,6 +779,7 @@ async function accountEvent(
707
779
  session,
708
780
  event,
709
781
  input.trustedGoalKey,
782
+ input.reportRollupDeltas,
710
783
  );
711
784
  }
712
785
 
@@ -789,6 +862,7 @@ async function upsertSession(
789
862
  context: IngestContext,
790
863
  existing: CachedDocument<"sessions"> | null,
791
864
  financialConversion: boolean,
865
+ reportRollupDeltas: ReportRollupDeltas,
792
866
  batchState?: BatchState,
793
867
  ): Promise<SessionState> {
794
868
  const source = sourceFromReferrer(event.referrer);
@@ -803,6 +877,14 @@ async function upsertSession(
803
877
  batchState,
804
878
  );
805
879
  if (existing) {
880
+ if (attribution && existing.affiliateId !== attribution.affiliateId) {
881
+ foldAffiliateVisitRollup(
882
+ reportRollupDeltas,
883
+ siteId,
884
+ event,
885
+ attribution,
886
+ );
887
+ }
806
888
  const startedAt = Math.min(existing.startedAt, event.timestamp);
807
889
  const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
808
890
  const updates = {
@@ -912,6 +994,14 @@ async function upsertSession(
912
994
  ...(attribution ?? {}),
913
995
  };
914
996
  const sessionId = await ctx.db.insert("sessions", fields);
997
+ if (attribution) {
998
+ foldAffiliateVisitRollup(
999
+ reportRollupDeltas,
1000
+ siteId,
1001
+ event,
1002
+ attribution,
1003
+ );
1004
+ }
915
1005
  if (batchState) {
916
1006
  const state = batchState.sessions.get(event.sessionId);
917
1007
  if (!state) throw new Error("session cache was not initialized");
@@ -1145,17 +1235,15 @@ async function consumeSiteRateLimit(
1145
1235
  ctx: MutationCtx,
1146
1236
  siteId: Id<"sites">,
1147
1237
  events: TrackerEvent[],
1238
+ eventByteCounts: number[],
1148
1239
  now: number,
1149
- ): Promise<number[]> {
1240
+ ): Promise<void> {
1150
1241
  const windowStart =
1151
1242
  Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1152
1243
  const increments = new Map<number, { eventCount: number; byteCount: number }>();
1153
- const eventByteCounts: number[] = [];
1154
- const encoder = new TextEncoder();
1155
1244
  for (let index = 0; index < events.length; index += 1) {
1156
1245
  const event = events[index];
1157
- const byteCount = encoder.encode(JSON.stringify(event)).byteLength;
1158
- eventByteCounts.push(byteCount);
1246
+ const byteCount = eventByteCounts[index];
1159
1247
  const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
1160
1248
  const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
1161
1249
  current.eventCount += 1;
@@ -1225,7 +1313,6 @@ async function consumeSiteRateLimit(
1225
1313
  });
1226
1314
  }
1227
1315
  }
1228
- return eventByteCounts;
1229
1316
  }
1230
1317
 
1231
1318
  async function consumeRateLimit(
@@ -1410,9 +1497,13 @@ async function ensureLiveSweep(
1410
1497
  .query("liveSweepStates")
1411
1498
  .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1412
1499
  .unique();
1413
- if (!existing) {
1500
+ if (!existing || existing.scheduledAt <= now) {
1414
1501
  const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
1415
- await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1502
+ if (existing) {
1503
+ await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
1504
+ } else {
1505
+ await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1506
+ }
1416
1507
  await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
1417
1508
  siteId,
1418
1509
  scheduledAt,
@@ -1433,6 +1524,7 @@ async function advanceFunnel(
1433
1524
  siteId: Id<"sites">,
1434
1525
  funnel: Doc<"funnels">,
1435
1526
  event: TrackerEvent,
1527
+ reportRollupDeltas: ReportRollupDeltas,
1436
1528
  batchState?: BatchState,
1437
1529
  ) {
1438
1530
  const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
@@ -1481,6 +1573,7 @@ async function advanceFunnel(
1481
1573
  };
1482
1574
  }
1483
1575
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1576
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1484
1577
  return;
1485
1578
  }
1486
1579
  if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt) return;
@@ -1507,6 +1600,7 @@ async function advanceFunnel(
1507
1600
  });
1508
1601
  }
1509
1602
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1603
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1510
1604
  return;
1511
1605
  }
1512
1606
  const nextStep = funnel.steps[progress.currentStep];
@@ -1533,6 +1627,14 @@ async function advanceFunnel(
1533
1627
  progress.currentStep,
1534
1628
  completed,
1535
1629
  );
1630
+ foldFunnelRollup(
1631
+ reportRollupDeltas,
1632
+ siteId,
1633
+ funnel._id,
1634
+ event,
1635
+ progress.currentStep,
1636
+ completed,
1637
+ );
1536
1638
  }
1537
1639
 
1538
1640
  function stepMatches(
@@ -1591,6 +1693,7 @@ async function accountConversion(
1591
1693
  session: SessionState,
1592
1694
  event: TrackerEvent,
1593
1695
  trustedGoalKey: string | undefined,
1696
+ reportRollupDeltas: ReportRollupDeltas,
1594
1697
  ) {
1595
1698
  const revenueCents = event.revenueCents ?? 0;
1596
1699
  const currency = event.currency ?? "USD";
@@ -1622,6 +1725,15 @@ async function accountConversion(
1622
1725
  commissionCents,
1623
1726
  });
1624
1727
  if (commissionAffiliate && attribution) {
1728
+ foldAffiliateConversionRollup(
1729
+ reportRollupDeltas,
1730
+ siteId,
1731
+ event,
1732
+ commissionAffiliate,
1733
+ attribution.affiliateSlug,
1734
+ revenueCents,
1735
+ commissionCents,
1736
+ );
1625
1737
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1626
1738
  const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
1627
1739
  const stats = await ctx.db
@@ -1657,6 +1769,254 @@ async function accountConversion(
1657
1769
  }
1658
1770
  }
1659
1771
 
1772
+ function createReportRollupDeltas(): ReportRollupDeltas {
1773
+ return {
1774
+ goals: new Map(),
1775
+ funnels: new Map(),
1776
+ affiliates: new Map(),
1777
+ };
1778
+ }
1779
+
1780
+ function reportRollupCoordinates(
1781
+ siteId: Id<"sites">,
1782
+ definitionId: string,
1783
+ timestamp: number,
1784
+ visitorId: string,
1785
+ ) {
1786
+ const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
1787
+ const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
1788
+ return {
1789
+ bucketStart,
1790
+ shard,
1791
+ key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
1792
+ };
1793
+ }
1794
+
1795
+ function foldGoalRollup(
1796
+ deltas: ReportRollupDeltas,
1797
+ siteId: Id<"sites">,
1798
+ event: TrackerEvent,
1799
+ goal: Doc<"goals">,
1800
+ ) {
1801
+ const coordinates = reportRollupCoordinates(
1802
+ siteId,
1803
+ String(goal._id),
1804
+ event.timestamp,
1805
+ event.visitorId,
1806
+ );
1807
+ const current = deltas.goals.get(coordinates.key) ?? {
1808
+ siteId,
1809
+ goalId: goal._id,
1810
+ bucketStart: coordinates.bucketStart,
1811
+ shard: coordinates.shard,
1812
+ goalKey: goal.key,
1813
+ completions: 0,
1814
+ valueCents: 0,
1815
+ };
1816
+ current.completions += 1;
1817
+ current.valueCents += goal.valueCents ?? 0;
1818
+ deltas.goals.set(coordinates.key, current);
1819
+ }
1820
+
1821
+ function foldFunnelRollup(
1822
+ deltas: ReportRollupDeltas,
1823
+ siteId: Id<"sites">,
1824
+ funnelId: Id<"funnels">,
1825
+ event: TrackerEvent,
1826
+ step: number,
1827
+ completed: boolean,
1828
+ ) {
1829
+ const coordinates = reportRollupCoordinates(
1830
+ siteId,
1831
+ String(funnelId),
1832
+ event.timestamp,
1833
+ event.visitorId,
1834
+ );
1835
+ const rollup = deltas.funnels.get(coordinates.key) ?? {
1836
+ siteId,
1837
+ funnelId,
1838
+ bucketStart: coordinates.bucketStart,
1839
+ shard: coordinates.shard,
1840
+ steps: new Map(),
1841
+ };
1842
+ const current = rollup.steps.get(step) ?? {
1843
+ step,
1844
+ entrants: 0,
1845
+ completions: 0,
1846
+ };
1847
+ current.entrants += 1;
1848
+ current.completions += Number(completed);
1849
+ rollup.steps.set(step, current);
1850
+ deltas.funnels.set(coordinates.key, rollup);
1851
+ }
1852
+
1853
+ function foldAffiliateVisitRollup(
1854
+ deltas: ReportRollupDeltas,
1855
+ siteId: Id<"sites">,
1856
+ event: TrackerEvent,
1857
+ attribution: AttributionSnapshot,
1858
+ ) {
1859
+ const coordinates = reportRollupCoordinates(
1860
+ siteId,
1861
+ String(attribution.affiliateId),
1862
+ event.timestamp,
1863
+ event.visitorId,
1864
+ );
1865
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1866
+ siteId,
1867
+ affiliateId: attribution.affiliateId,
1868
+ bucketStart: coordinates.bucketStart,
1869
+ shard: coordinates.shard,
1870
+ affiliateSlug: attribution.affiliateSlug,
1871
+ visits: 0,
1872
+ visitorSketch: createVisitorSketch(),
1873
+ conversions: 0,
1874
+ revenueCents: 0,
1875
+ commissionCents: 0,
1876
+ };
1877
+ current.visits += 1;
1878
+ current.visitorSketch = addVisitor(current.visitorSketch, event.visitorId);
1879
+ deltas.affiliates.set(coordinates.key, current);
1880
+ }
1881
+
1882
+ function foldAffiliateConversionRollup(
1883
+ deltas: ReportRollupDeltas,
1884
+ siteId: Id<"sites">,
1885
+ event: TrackerEvent,
1886
+ affiliate: Doc<"affiliates">,
1887
+ affiliateSlug: string,
1888
+ revenueCents: number,
1889
+ commissionCents: number,
1890
+ ) {
1891
+ const coordinates = reportRollupCoordinates(
1892
+ siteId,
1893
+ String(affiliate._id),
1894
+ event.timestamp,
1895
+ event.visitorId,
1896
+ );
1897
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1898
+ siteId,
1899
+ affiliateId: affiliate._id,
1900
+ bucketStart: coordinates.bucketStart,
1901
+ shard: coordinates.shard,
1902
+ affiliateSlug,
1903
+ visits: 0,
1904
+ visitorSketch: createVisitorSketch(),
1905
+ conversions: 0,
1906
+ revenueCents: 0,
1907
+ commissionCents: 0,
1908
+ };
1909
+ current.conversions += 1;
1910
+ current.revenueCents += revenueCents;
1911
+ current.commissionCents += commissionCents;
1912
+ deltas.affiliates.set(coordinates.key, current);
1913
+ }
1914
+
1915
+ async function flushReportRollups(
1916
+ ctx: MutationCtx,
1917
+ deltas: ReportRollupDeltas,
1918
+ ) {
1919
+ for (const delta of deltas.goals.values()) {
1920
+ const existing = await ctx.db
1921
+ .query("goalDailyRollups")
1922
+ .withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) =>
1923
+ range
1924
+ .eq("siteId", delta.siteId)
1925
+ .eq("goalId", delta.goalId)
1926
+ .eq("bucketStart", delta.bucketStart)
1927
+ .eq("shard", delta.shard),
1928
+ )
1929
+ .unique();
1930
+ const fields = {
1931
+ siteId: delta.siteId,
1932
+ goalId: delta.goalId,
1933
+ bucketStart: delta.bucketStart,
1934
+ shard: delta.shard,
1935
+ generation: 1 as const,
1936
+ goalKey: delta.goalKey,
1937
+ completions: (existing?.completions ?? 0) + delta.completions,
1938
+ valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
1939
+ };
1940
+ if (existing) {
1941
+ await ctx.db.replace("goalDailyRollups", existing._id, fields);
1942
+ } else {
1943
+ await ctx.db.insert("goalDailyRollups", fields);
1944
+ }
1945
+ }
1946
+ for (const delta of deltas.funnels.values()) {
1947
+ const existing = await ctx.db
1948
+ .query("funnelDailyRollups")
1949
+ .withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) =>
1950
+ range
1951
+ .eq("siteId", delta.siteId)
1952
+ .eq("funnelId", delta.funnelId)
1953
+ .eq("bucketStart", delta.bucketStart)
1954
+ .eq("shard", delta.shard),
1955
+ )
1956
+ .unique();
1957
+ const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
1958
+ for (const addition of delta.steps.values()) {
1959
+ const current = steps.find((step) => step.step === addition.step);
1960
+ if (current) {
1961
+ current.entrants += addition.entrants;
1962
+ current.completions += addition.completions;
1963
+ } else {
1964
+ if (steps.length >= MAX_FUNNEL_STEPS) {
1965
+ fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
1966
+ }
1967
+ steps.push(addition);
1968
+ }
1969
+ }
1970
+ const fields = {
1971
+ siteId: delta.siteId,
1972
+ funnelId: delta.funnelId,
1973
+ bucketStart: delta.bucketStart,
1974
+ shard: delta.shard,
1975
+ generation: 1 as const,
1976
+ steps,
1977
+ };
1978
+ if (existing) {
1979
+ await ctx.db.replace("funnelDailyRollups", existing._id, fields);
1980
+ } else {
1981
+ await ctx.db.insert("funnelDailyRollups", fields);
1982
+ }
1983
+ }
1984
+ for (const delta of deltas.affiliates.values()) {
1985
+ const existing = await ctx.db
1986
+ .query("affiliateDailyRollups")
1987
+ .withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) =>
1988
+ range
1989
+ .eq("siteId", delta.siteId)
1990
+ .eq("affiliateId", delta.affiliateId)
1991
+ .eq("bucketStart", delta.bucketStart)
1992
+ .eq("shard", delta.shard),
1993
+ )
1994
+ .unique();
1995
+ const fields = {
1996
+ siteId: delta.siteId,
1997
+ affiliateId: delta.affiliateId,
1998
+ bucketStart: delta.bucketStart,
1999
+ shard: delta.shard,
2000
+ generation: 1 as const,
2001
+ affiliateSlug: delta.affiliateSlug,
2002
+ visits: (existing?.visits ?? 0) + delta.visits,
2003
+ visitorSketch: unionVisitorSketches(
2004
+ existing?.visitorSketch ?? createVisitorSketch(),
2005
+ delta.visitorSketch,
2006
+ ),
2007
+ conversions: (existing?.conversions ?? 0) + delta.conversions,
2008
+ revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
2009
+ commissionCents:
2010
+ (existing?.commissionCents ?? 0) + delta.commissionCents,
2011
+ };
2012
+ if (existing) {
2013
+ await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
2014
+ } else {
2015
+ await ctx.db.insert("affiliateDailyRollups", fields);
2016
+ }
2017
+ }
2018
+ }
2019
+
1660
2020
  function foldAggregates(
1661
2021
  aggregateDeltas: AggregateDeltas,
1662
2022
  siteId: Id<"sites">,
@@ -1694,6 +2054,7 @@ function foldAggregates(
1694
2054
  outboundClicks: 0,
1695
2055
  sessions: 0,
1696
2056
  visitors: 0,
2057
+ visitorSketch: createVisitorSketch(),
1697
2058
  conversions: 0,
1698
2059
  revenueCents: 0,
1699
2060
  dimensions: [],
@@ -1714,6 +2075,9 @@ function foldAggregates(
1714
2075
  ? Number(session.isNewVisitorHour)
1715
2076
  : Number(session.isNewVisitorDay)
1716
2077
  : 0;
2078
+ if (recordTelemetry) {
2079
+ delta.visitorSketch = addVisitor(delta.visitorSketch, event.visitorId);
2080
+ }
1717
2081
  delta.conversions += Number(financialConversion);
1718
2082
  delta.revenueCents += revenueCents;
1719
2083
  delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
@@ -1741,6 +2105,13 @@ async function flushAggregates(
1741
2105
  bucket?.dimensions ?? [],
1742
2106
  delta.dimensions,
1743
2107
  );
2108
+ const priorSketch = bucket?.visitorSketch;
2109
+ const visitorSketch = priorSketch
2110
+ ? unionVisitorSketches(priorSketch, delta.visitorSketch)
2111
+ : delta.visitorSketch;
2112
+ const visitorSketchComplete = bucket
2113
+ ? bucket.visitorSketchComplete === true && priorSketch !== undefined
2114
+ : true;
1744
2115
  if (bucket) {
1745
2116
  await ctx.db.patch("aggregateBuckets", bucket._id, {
1746
2117
  events: bucket.events + delta.events,
@@ -1750,6 +2121,8 @@ async function flushAggregates(
1750
2121
  outboundClicks: bucket.outboundClicks + delta.outboundClicks,
1751
2122
  sessions: bucket.sessions + delta.sessions,
1752
2123
  visitors: bucket.visitors + delta.visitors,
2124
+ visitorSketch,
2125
+ visitorSketchComplete,
1753
2126
  conversions: bucket.conversions + delta.conversions,
1754
2127
  revenueCents: bucket.revenueCents + delta.revenueCents,
1755
2128
  dimensions,
@@ -1767,6 +2140,8 @@ async function flushAggregates(
1767
2140
  outboundClicks: delta.outboundClicks,
1768
2141
  sessions: delta.sessions,
1769
2142
  visitors: delta.visitors,
2143
+ visitorSketch,
2144
+ visitorSketchComplete,
1770
2145
  conversions: delta.conversions,
1771
2146
  revenueCents: delta.revenueCents,
1772
2147
  dimensions,
@@ -23,16 +23,21 @@ export const expire = internalMutation({
23
23
  if (liveSession.expiryChainDeadline !== args.scheduledDeadline) {
24
24
  return { expired: false, rescheduled: false, stale: true };
25
25
  }
26
- if (liveSession.expiresAt > Date.now()) {
26
+ const now = Date.now();
27
+ if (liveSession.expiresAt > now) {
27
28
  const state = await ctx.db
28
29
  .query("liveSweepStates")
29
30
  .withIndex("by_siteId", (range) => range.eq("siteId", liveSession.siteId))
30
31
  .unique();
31
32
  const scheduledAt = Math.min(
32
33
  liveSession.expiresAt,
33
- Date.now() + LIVE_SWEEP_INTERVAL_MS,
34
+ now + LIVE_SWEEP_INTERVAL_MS,
34
35
  );
35
- if (!state || state.scheduledAt > scheduledAt) {
36
+ if (
37
+ !state ||
38
+ state.scheduledAt <= now ||
39
+ state.scheduledAt > scheduledAt
40
+ ) {
36
41
  if (state) {
37
42
  await ctx.db.patch("liveSweepStates", state._id, {
38
43
  scheduledAt,