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

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 (79) hide show
  1. package/README.md +98 -18
  2. package/dist/component/_generated/api.d.ts +7 -1
  3. package/dist/component/_generated/api.d.ts.map +1 -1
  4. package/dist/component/_generated/api.js.map +1 -1
  5. package/dist/component/constants.d.ts +11 -6
  6. package/dist/component/constants.d.ts.map +1 -1
  7. package/dist/component/constants.js +11 -6
  8. package/dist/component/constants.js.map +1 -1
  9. package/dist/component/convex.config.d.ts +2 -2
  10. package/dist/component/convex.config.d.ts.map +1 -1
  11. package/dist/component/convex.config.js +4 -1
  12. package/dist/component/convex.config.js.map +1 -1
  13. package/dist/component/eventStore.d.ts +102 -0
  14. package/dist/component/eventStore.d.ts.map +1 -0
  15. package/dist/component/eventStore.js +463 -0
  16. package/dist/component/eventStore.js.map +1 -0
  17. package/dist/component/goals.d.ts.map +1 -1
  18. package/dist/component/goals.js +17 -4
  19. package/dist/component/goals.js.map +1 -1
  20. package/dist/component/http.d.ts.map +1 -1
  21. package/dist/component/http.js +39 -7
  22. package/dist/component/http.js.map +1 -1
  23. package/dist/component/ingest.d.ts.map +1 -1
  24. package/dist/component/ingest.js +520 -207
  25. package/dist/component/ingest.js.map +1 -1
  26. package/dist/component/live.d.ts +8 -0
  27. package/dist/component/live.d.ts.map +1 -1
  28. package/dist/component/live.js +72 -8
  29. package/dist/component/live.js.map +1 -1
  30. package/dist/component/migrations.d.ts +17 -0
  31. package/dist/component/migrations.d.ts.map +1 -0
  32. package/dist/component/migrations.js +44 -0
  33. package/dist/component/migrations.js.map +1 -0
  34. package/dist/component/reports.d.ts +16 -16
  35. package/dist/component/reports.d.ts.map +1 -1
  36. package/dist/component/reports.js +228 -65
  37. package/dist/component/reports.js.map +1 -1
  38. package/dist/component/retention.d.ts.map +1 -1
  39. package/dist/component/retention.js +15 -10
  40. package/dist/component/retention.js.map +1 -1
  41. package/dist/component/sanitize.d.ts.map +1 -1
  42. package/dist/component/sanitize.js +11 -3
  43. package/dist/component/sanitize.js.map +1 -1
  44. package/dist/component/schema.d.ts +132 -9
  45. package/dist/component/schema.js +32 -7
  46. package/dist/component/schema.js.map +1 -1
  47. package/dist/component/sites.d.ts.map +1 -1
  48. package/dist/component/sites.js +27 -18
  49. package/dist/component/sites.js.map +1 -1
  50. package/dist/component/validators.d.ts +96 -0
  51. package/dist/component/validators.d.ts.map +1 -1
  52. package/dist/component/validators.js +12 -0
  53. package/dist/component/validators.js.map +1 -1
  54. package/dist/tracker/generated.d.ts +4 -4
  55. package/dist/tracker/generated.d.ts.map +1 -1
  56. package/dist/tracker/generated.js +4 -4
  57. package/dist/tracker/generated.js.map +1 -1
  58. package/dist/tracker/tracker.js +33 -22
  59. package/dist/tracker/tracker.js.map +1 -1
  60. package/dist/tracker.min.js +1 -1
  61. package/docs/benchmarks/2026-08-20-realistic.md +171 -0
  62. package/package.json +15 -5
  63. package/scripts/benchmark-ingest.mjs +499 -0
  64. package/src/component/_generated/api.ts +7 -1
  65. package/src/component/constants.ts +11 -6
  66. package/src/component/convex.config.ts +5 -1
  67. package/src/component/eventStore.ts +684 -0
  68. package/src/component/goals.ts +25 -4
  69. package/src/component/http.ts +49 -6
  70. package/src/component/ingest.ts +671 -215
  71. package/src/component/live.ts +83 -8
  72. package/src/component/migrations.ts +52 -0
  73. package/src/component/reports.ts +295 -86
  74. package/src/component/retention.ts +15 -12
  75. package/src/component/sanitize.ts +10 -3
  76. package/src/component/schema.ts +35 -7
  77. package/src/component/sites.ts +31 -18
  78. package/src/component/validators.ts +16 -0
  79. package/src/test.ts +2 -0
@@ -5,10 +5,13 @@ import { mutation } from "./_generated/server.js";
5
5
  import { internal } from "./_generated/api.js";
6
6
  import {
7
7
  AGGREGATE_SHARDS,
8
+ MAX_BATCH_EVENT_GROUPS,
8
9
  DAY_MS,
10
+ HOURLY_AGGREGATE_SHARDS,
9
11
  DIMENSION_SLOTS,
10
12
  HOUR_MS,
11
13
  LIVE_SESSION_TTL_MS,
14
+ LIVE_SWEEP_INTERVAL_MS,
12
15
  MAX_BATCH_EVENTS,
13
16
  MAX_SITE_BYTES_PER_SHARD_WINDOW,
14
17
  MAX_SITE_EVENTS_PER_SHARD_WINDOW,
@@ -17,9 +20,20 @@ import {
17
20
  MAX_FUNNELS_PER_SITE,
18
21
  MAX_GOALS_PER_SITE,
19
22
  RATE_LIMIT_WINDOW_MS,
23
+ SECONDARY_AGGREGATE_SHARDS,
20
24
  SITE_INGEST_SHARDS,
21
25
  } from "./constants.js";
22
26
  import { fail } from "./errors.js";
27
+ import {
28
+ bufferEventBatch,
29
+ eventBatchKey,
30
+ eventBucketStart,
31
+ type EventBatchBuffer,
32
+ findTelemetry,
33
+ flushEventBatches,
34
+ insertEventBatch,
35
+ inspectTelemetryMinute,
36
+ } from "./eventStore.js";
23
37
  import {
24
38
  cleanString,
25
39
  ensureBatchBytes,
@@ -93,6 +107,35 @@ type SessionState = {
93
107
  affiliateExpiresAt?: number;
94
108
  };
95
109
 
110
+ type SessionRateWindowState = {
111
+ siteId: Id<"sites">;
112
+ sessionId: string;
113
+ window: Doc<"ingestWindows"> | null;
114
+ eventCount: number;
115
+ byteCount: number;
116
+ dirty: boolean;
117
+ };
118
+
119
+ type CachedRow<T> = {
120
+ current: T | null;
121
+ dirty: boolean;
122
+ };
123
+
124
+ type CachedDocument<TableName extends keyof import("./_generated/dataModel.js").DataModel> =
125
+ Omit<Doc<TableName>, "_creationTime"> & { _creationTime?: number };
126
+
127
+ type BatchState = {
128
+ sessions: Map<string, CachedRow<CachedDocument<"sessions">>>;
129
+ liveSessions: Map<string, CachedRow<CachedDocument<"liveSessions">>>;
130
+ visitorAttributions: Map<
131
+ string,
132
+ CachedRow<CachedDocument<"visitorAttributions">>
133
+ >;
134
+ funnelProgress: Map<string, CachedRow<CachedDocument<"funnelProgress">>>;
135
+ eventBatches: EventBatchBuffer;
136
+ liveSweepSites: Set<string>;
137
+ };
138
+
96
139
  export const ingestBatch = mutation({
97
140
  args: {
98
141
  siteId: v.id("sites"),
@@ -129,27 +172,101 @@ export const ingestBatch = mutation({
129
172
  );
130
173
  const context = sanitizeContext(args.context);
131
174
  const aggregateDeltas: AggregateDeltas = new Map();
175
+ const sessionRateWindows = new Map<string, SessionRateWindowState>();
176
+ const batchState: BatchState = {
177
+ sessions: new Map(),
178
+ liveSessions: new Map(),
179
+ visitorAttributions: new Map(),
180
+ funnelProgress: new Map(),
181
+ eventBatches: new Map(),
182
+ liveSweepSites: new Set(),
183
+ };
132
184
  let goals: Doc<"goals">[] | null = null;
133
185
  let funnels: Doc<"funnels">[] | null = null;
134
186
  let accepted = 0;
135
187
  let duplicates = 0;
136
188
  let rejected = 0;
137
- for (let eventIndex = 0; eventIndex < args.events.length; eventIndex += 1) {
138
- const input = args.events[eventIndex];
139
- let event: TrackerEvent;
189
+ const events = args.events.map((input) => {
140
190
  try {
141
- event = sanitizeEvent(input, now);
191
+ return sanitizeEvent(input, now);
142
192
  } catch {
193
+ return null;
194
+ }
195
+ });
196
+ const duplicateInBatch = new Set<number>();
197
+ const eventIdsByGroup = new Map<string, Set<string>>();
198
+ const requestGroups = new Set<string>();
199
+ const dedupeGroups = new Map<
200
+ string,
201
+ { sessionId: string; bucketStart: number; eventIds: Set<string> }
202
+ >();
203
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
204
+ const event = events[eventIndex];
205
+ if (!event) continue;
206
+ const bucketStart = eventBucketStart(event.timestamp);
207
+ const groupKey = eventBatchKey(event.sessionId, bucketStart);
208
+ requestGroups.add(groupKey);
209
+ if (event.type === "heartbeat") continue;
210
+ const eventIds = eventIdsByGroup.get(groupKey) ?? new Set<string>();
211
+ if (eventIds.has(event.eventId)) {
212
+ duplicateInBatch.add(eventIndex);
213
+ continue;
214
+ }
215
+ eventIds.add(event.eventId);
216
+ eventIdsByGroup.set(groupKey, eventIds);
217
+ const group = dedupeGroups.get(groupKey) ?? {
218
+ sessionId: event.sessionId,
219
+ bucketStart,
220
+ eventIds: new Set<string>(),
221
+ };
222
+ group.eventIds.add(event.eventId);
223
+ dedupeGroups.set(groupKey, group);
224
+ }
225
+ if (requestGroups.size > MAX_BATCH_EVENT_GROUPS) {
226
+ fail(
227
+ "BATCH_TOO_LARGE",
228
+ `events may span at most ${MAX_BATCH_EVENT_GROUPS} session-minute groups`,
229
+ { limit: MAX_BATCH_EVENT_GROUPS },
230
+ );
231
+ }
232
+ const persistedEventIds = new Map<string, Set<string>>();
233
+ await Promise.all(
234
+ [...dedupeGroups].map(async ([key, group]) => {
235
+ const { eventIds, persistedCount } = await inspectTelemetryMinute(ctx, {
236
+ siteId: args.siteId,
237
+ sessionId: group.sessionId,
238
+ bucketStart: group.bucketStart,
239
+ candidateEventIds: group.eventIds,
240
+ });
241
+ const newCount = [...group.eventIds].filter((eventId) =>
242
+ !eventIds.has(eventId)
243
+ ).length;
244
+ if (
245
+ newCount > 0 &&
246
+ persistedCount + newCount > MAX_EVENTS_PER_SESSION_WINDOW
247
+ ) {
248
+ fail("RATE_LIMITED", "session event minute limit exceeded", {
249
+ limit: MAX_EVENTS_PER_SESSION_WINDOW,
250
+ });
251
+ }
252
+ persistedEventIds.set(key, eventIds);
253
+ }),
254
+ );
255
+
256
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
257
+ const event = events[eventIndex];
258
+ if (!event) {
143
259
  rejected += 1;
144
260
  continue;
145
261
  }
146
262
 
147
- const existingSession = await ctx.db
148
- .query("sessions")
149
- .withIndex("by_siteId_and_sessionId", (range) =>
150
- range.eq("siteId", args.siteId).eq("sessionId", event.sessionId),
151
- )
152
- .unique();
263
+ const sessionRow = await loadSession(
264
+ ctx,
265
+ args.siteId,
266
+ event.sessionId,
267
+ batchState,
268
+ );
269
+ const existingSession = sessionRow.current;
153
270
  if (existingSession && existingSession.visitorId !== event.visitorId) {
154
271
  rejected += 1;
155
272
  continue;
@@ -160,13 +277,19 @@ export const ingestBatch = mutation({
160
277
  rejected += 1;
161
278
  continue;
162
279
  }
163
- const liveSession = await ctx.db
164
- .query("liveSessions")
165
- .withIndex("by_siteId_and_sessionId", (range) =>
166
- range.eq("siteId", args.siteId).eq("sessionId", event.sessionId),
280
+ const liveSession = (
281
+ await loadLiveSession(
282
+ ctx,
283
+ args.siteId,
284
+ event.sessionId,
285
+ batchState,
167
286
  )
168
- .unique();
169
- if (liveSession?.lastHeartbeatEventId === event.eventId) {
287
+ ).current;
288
+ if (
289
+ liveSession &&
290
+ (liveSession.lastHeartbeatEventId === event.eventId ||
291
+ liveSession.recentHeartbeatEventIds?.includes(event.eventId))
292
+ ) {
170
293
  duplicates += 1;
171
294
  continue;
172
295
  }
@@ -177,6 +300,7 @@ export const ingestBatch = mutation({
177
300
  event.sessionId,
178
301
  eventByteCounts[eventIndex],
179
302
  now,
303
+ sessionRateWindows,
180
304
  ))
181
305
  ) {
182
306
  rejected += 1;
@@ -189,18 +313,18 @@ export const ingestBatch = mutation({
189
313
  event,
190
314
  context,
191
315
  now,
316
+ batchState,
192
317
  );
193
318
  accepted += 1;
194
319
  continue;
195
320
  }
196
321
 
197
- const duplicate = await ctx.db
198
- .query("events")
199
- .withIndex("by_siteId_and_eventId", (range) =>
200
- range.eq("siteId", args.siteId).eq("eventId", event.eventId),
201
- )
202
- .unique();
203
- if (duplicate) {
322
+ if (
323
+ duplicateInBatch.has(eventIndex) ||
324
+ persistedEventIds
325
+ .get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
326
+ ?.has(event.eventId)
327
+ ) {
204
328
  duplicates += 1;
205
329
  continue;
206
330
  }
@@ -212,6 +336,7 @@ export const ingestBatch = mutation({
212
336
  event.sessionId,
213
337
  eventByteCounts[eventIndex],
214
338
  now,
339
+ sessionRateWindows,
215
340
  ))
216
341
  ) {
217
342
  rejected += 1;
@@ -243,9 +368,13 @@ export const ingestBatch = mutation({
243
368
  financialConversion: false,
244
369
  trustedGoalKey: undefined,
245
370
  now,
371
+ batchState,
246
372
  });
373
+ bufferEventBatch(args.siteId, event, batchState.eventBatches);
247
374
  accepted += 1;
248
375
  }
376
+ await flushBatchState(ctx, batchState);
377
+ await flushSessionRateLimits(ctx, sessionRateWindows, now);
249
378
  await flushAggregates(ctx, aggregateDeltas);
250
379
  return { accepted, duplicates, rejected };
251
380
  },
@@ -261,14 +390,18 @@ export const trackConversion = mutation({
261
390
  fail("INVALID_ARGUMENT", "revenueCents must be an integer between 0 and 1000000000");
262
391
  }
263
392
  let event: TrackerEvent;
393
+ let trustedGoalKey: string | undefined;
264
394
  try {
395
+ trustedGoalKey = args.goalKey
396
+ ? sanitizeSlug(args.goalKey, "goalKey")
397
+ : undefined;
265
398
  event = sanitizeEvent(
266
399
  {
267
400
  eventId: sanitizeOpaqueId(args.eventId, "eventId"),
268
401
  sessionId: sanitizeOpaqueId(args.sessionId, "sessionId"),
269
402
  visitorId: sanitizeOpaqueId(args.visitorId, "visitorId"),
270
403
  type: "conversion",
271
- name: args.goalKey ? sanitizeSlug(args.goalKey, "goalKey") : "conversion",
404
+ name: trustedGoalKey ?? "conversion",
272
405
  path: sanitizePath(args.path ?? "/conversion"),
273
406
  timestamp: args.timestamp,
274
407
  sequence: 0,
@@ -283,12 +416,6 @@ export const trackConversion = mutation({
283
416
  }
284
417
 
285
418
  const siteCurrency = site.currency ?? "USD";
286
- if (event.currency !== siteCurrency) {
287
- fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
288
- siteCurrency,
289
- conversionCurrency: event.currency ?? "USD",
290
- });
291
- }
292
419
  const priorConversion = await ctx.db
293
420
  .query("conversions")
294
421
  .withIndex("by_siteId_and_eventId", (range) =>
@@ -296,15 +423,23 @@ export const trackConversion = mutation({
296
423
  )
297
424
  .unique();
298
425
  if (priorConversion) {
426
+ if (!sameTrustedConversion(priorConversion, event, trustedGoalKey)) {
427
+ fail(
428
+ "CONFLICT",
429
+ "conversion eventId is already associated with a different payload",
430
+ );
431
+ }
299
432
  return { accepted: 0, duplicates: 1, rejected: 0 };
300
433
  }
434
+ if (event.currency !== siteCurrency) {
435
+ fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
436
+ siteCurrency,
437
+ conversionCurrency: event.currency ?? "USD",
438
+ });
439
+ }
301
440
 
302
- const existingTelemetry = await ctx.db
303
- .query("events")
304
- .withIndex("by_siteId_and_eventId", (range) =>
305
- range.eq("siteId", args.siteId).eq("eventId", event.eventId),
306
- )
307
- .unique();
441
+ const telemetry = await findTelemetry(ctx, args.siteId, event);
442
+ const existingTelemetry = telemetry.match;
308
443
  if (
309
444
  existingTelemetry &&
310
445
  (existingTelemetry.sessionId !== event.sessionId ||
@@ -321,17 +456,15 @@ export const trackConversion = mutation({
321
456
  if (existingSession && existingSession.visitorId !== event.visitorId) {
322
457
  fail("CONFLICT", "session belongs to a different visitor");
323
458
  }
324
- const conversionContext: IngestContext = existingTelemetry
325
- ? {
326
- country: existingTelemetry.country,
327
- city: existingTelemetry.city,
328
- latitude: existingTelemetry.latitude,
329
- longitude: existingTelemetry.longitude,
330
- browser: existingTelemetry.browser,
331
- os: existingTelemetry.os,
332
- device: existingTelemetry.device,
333
- }
334
- : sanitizeContext(undefined);
459
+ const conversionContext: IngestContext = {
460
+ country: existingTelemetry?.country ?? existingSession?.country,
461
+ city: existingTelemetry?.city ?? existingSession?.city,
462
+ latitude: existingTelemetry?.latitude ?? existingSession?.latitude,
463
+ longitude: existingTelemetry?.longitude ?? existingSession?.longitude,
464
+ browser: existingTelemetry?.browser ?? existingSession?.browser,
465
+ os: existingTelemetry?.os ?? existingSession?.os,
466
+ device: existingTelemetry?.device ?? existingSession?.device,
467
+ };
335
468
 
336
469
  const aggregateDeltas: AggregateDeltas = new Map();
337
470
  await accountEvent(ctx, {
@@ -342,25 +475,134 @@ export const trackConversion = mutation({
342
475
  goals: [],
343
476
  funnels: [],
344
477
  aggregateDeltas,
345
- recordTelemetry: existingTelemetry === null,
478
+ recordTelemetry: existingTelemetry === null && !telemetry.saturated,
346
479
  financialConversion: true,
347
- trustedGoalKey: args.goalKey
348
- ? sanitizeSlug(args.goalKey, "goalKey")
349
- : undefined,
480
+ trustedGoalKey,
350
481
  now: Date.now(),
351
482
  });
483
+ if (!existingTelemetry && !telemetry.saturated) {
484
+ await insertEventBatch(ctx, args.siteId, event);
485
+ }
352
486
  await flushAggregates(ctx, aggregateDeltas);
353
487
  return { accepted: 1, duplicates: 0, rejected: 0 };
354
488
  },
355
489
  });
356
490
 
491
+ function sameTrustedConversion(
492
+ existing: Doc<"conversions">,
493
+ event: TrackerEvent,
494
+ goalKey: string | undefined,
495
+ ): boolean {
496
+ return (
497
+ existing.source === "trusted" &&
498
+ existing.sessionId === event.sessionId &&
499
+ existing.visitorId === event.visitorId &&
500
+ existing.timestamp === event.timestamp &&
501
+ existing.path === event.path &&
502
+ existing.revenueCents === (event.revenueCents ?? 0) &&
503
+ existing.currency === (event.currency ?? "USD") &&
504
+ existing.goalKey === goalKey
505
+ );
506
+ }
507
+
508
+ async function loadSession(
509
+ ctx: MutationCtx,
510
+ siteId: Id<"sites">,
511
+ sessionId: string,
512
+ batchState: BatchState,
513
+ ): Promise<CachedRow<CachedDocument<"sessions">>> {
514
+ const cached = batchState.sessions.get(sessionId);
515
+ if (cached) return cached;
516
+ const current = await ctx.db
517
+ .query("sessions")
518
+ .withIndex("by_siteId_and_sessionId", (range) =>
519
+ range.eq("siteId", siteId).eq("sessionId", sessionId),
520
+ )
521
+ .unique();
522
+ const loaded = { current, dirty: false };
523
+ batchState.sessions.set(sessionId, loaded);
524
+ return loaded;
525
+ }
526
+
527
+ async function loadLiveSession(
528
+ ctx: MutationCtx,
529
+ siteId: Id<"sites">,
530
+ sessionId: string,
531
+ batchState: BatchState,
532
+ ): Promise<CachedRow<CachedDocument<"liveSessions">>> {
533
+ const cached = batchState.liveSessions.get(sessionId);
534
+ if (cached) return cached;
535
+ const current = await ctx.db
536
+ .query("liveSessions")
537
+ .withIndex("by_siteId_and_sessionId", (range) =>
538
+ range.eq("siteId", siteId).eq("sessionId", sessionId),
539
+ )
540
+ .unique();
541
+ const loaded = { current, dirty: false };
542
+ batchState.liveSessions.set(sessionId, loaded);
543
+ return loaded;
544
+ }
545
+
546
+ async function flushBatchState(ctx: MutationCtx, batchState: BatchState) {
547
+ for (const state of batchState.sessions.values()) {
548
+ if (state.dirty && state.current) {
549
+ await ctx.db.replace(
550
+ "sessions",
551
+ state.current._id,
552
+ withoutSystemFields(state.current),
553
+ );
554
+ }
555
+ }
556
+ for (const state of batchState.liveSessions.values()) {
557
+ if (state.dirty && state.current) {
558
+ await ctx.db.replace(
559
+ "liveSessions",
560
+ state.current._id,
561
+ withoutSystemFields(state.current),
562
+ );
563
+ }
564
+ }
565
+ for (const state of batchState.visitorAttributions.values()) {
566
+ if (state.dirty && state.current) {
567
+ await ctx.db.replace(
568
+ "visitorAttributions",
569
+ state.current._id,
570
+ withoutSystemFields(state.current),
571
+ );
572
+ }
573
+ }
574
+ for (const state of batchState.funnelProgress.values()) {
575
+ if (state.dirty && state.current) {
576
+ await ctx.db.replace(
577
+ "funnelProgress",
578
+ state.current._id,
579
+ withoutSystemFields(state.current),
580
+ );
581
+ }
582
+ }
583
+ await flushEventBatches(ctx, batchState.eventBatches);
584
+ }
585
+
586
+ function withoutSystemFields<
587
+ T extends { _id: unknown; _creationTime?: number },
588
+ >(document: T): Omit<T, "_id" | "_creationTime"> {
589
+ const {
590
+ _id,
591
+ _creationTime,
592
+ ...fields
593
+ } = document;
594
+ void _id;
595
+ void _creationTime;
596
+ return fields;
597
+ }
598
+
357
599
  async function accountEvent(
358
600
  ctx: MutationCtx,
359
601
  input: {
360
602
  siteId: Id<"sites">;
361
603
  event: TrackerEvent;
362
604
  context: IngestContext;
363
- existingSession: Doc<"sessions"> | null;
605
+ existingSession: CachedDocument<"sessions"> | null;
364
606
  goals: Doc<"goals">[];
365
607
  funnels: Doc<"funnels">[];
366
608
  aggregateDeltas: AggregateDeltas;
@@ -368,6 +610,7 @@ async function accountEvent(
368
610
  financialConversion: boolean;
369
611
  trustedGoalKey: string | undefined;
370
612
  now: number;
613
+ batchState?: BatchState;
371
614
  },
372
615
  ) {
373
616
  const { event } = input;
@@ -379,61 +622,57 @@ async function accountEvent(
379
622
  input.context,
380
623
  input.existingSession,
381
624
  input.financialConversion,
625
+ input.batchState,
382
626
  )
383
627
  : await accountTrustedSession(ctx, input.existingSession, event);
384
628
 
385
- if (input.recordTelemetry) {
386
- await ctx.db.insert("events", {
387
- siteId: input.siteId,
388
- eventId: event.eventId,
389
- sessionId: event.sessionId,
390
- visitorId: event.visitorId,
391
- type: event.type,
392
- ...(event.name ? { name: event.name } : {}),
393
- path: event.path,
394
- ...(event.referrer ? { referrer: event.referrer } : {}),
395
- timestamp: event.timestamp,
396
- sequence: event.sequence,
397
- ...(event.target ? { target: event.target } : {}),
398
- ...(event.href ? { href: event.href } : {}),
399
- ...(event.properties ? { properties: event.properties } : {}),
400
- ...(event.revenueCents !== undefined
401
- ? { revenueCents: event.revenueCents }
402
- : {}),
403
- ...(event.currency ? { currency: event.currency } : {}),
404
- ...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
405
- ...(input.context.country ? { country: input.context.country } : {}),
406
- ...(input.context.city ? { city: input.context.city } : {}),
407
- ...(input.context.latitude !== undefined
408
- ? { latitude: input.context.latitude }
409
- : {}),
410
- ...(input.context.longitude !== undefined
411
- ? { longitude: input.context.longitude }
412
- : {}),
413
- browser: input.context.browser ?? "Other",
414
- os: input.context.os ?? "Other",
415
- device: input.context.device ?? "unknown",
416
- });
417
- }
418
-
419
629
  const matchedGoalKeys: string[] = [];
420
- for (const goal of input.goals) {
421
- if (!goal.active || !goalMatches(goal, event)) continue;
422
- const prior = await ctx.db
630
+ const matchingGoals = input.goals.filter((goal) =>
631
+ goal.active && goalMatches(goal, event)
632
+ );
633
+ if (matchingGoals.length > 1) {
634
+ fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
635
+ }
636
+ for (const goal of matchingGoals) {
637
+ const completionEventId = JSON.stringify([
638
+ event.sessionId,
639
+ eventBucketStart(event.timestamp),
640
+ event.eventId,
641
+ ]);
642
+ const scopedPrior = (await ctx.db
423
643
  .query("goalCompletions")
424
644
  .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
425
645
  range
426
646
  .eq("siteId", input.siteId)
427
647
  .eq("goalId", goal._id)
428
- .eq("eventId", event.eventId),
648
+ .eq("eventId", completionEventId),
429
649
  )
430
- .unique();
431
- if (prior) continue;
650
+ .take(2))
651
+ .find((completion) => completion.idempotencyVersion === 2);
652
+ const legacyPrior = scopedPrior
653
+ ? undefined
654
+ : (await ctx.db
655
+ .query("goalCompletions")
656
+ .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
657
+ range
658
+ .eq("siteId", input.siteId)
659
+ .eq("goalId", goal._id)
660
+ .eq("eventId", event.eventId),
661
+ )
662
+ .take(2))
663
+ .find((completion) =>
664
+ completion.idempotencyVersion === undefined &&
665
+ completion.sessionId === event.sessionId &&
666
+ eventBucketStart(completion.timestamp) ===
667
+ eventBucketStart(event.timestamp)
668
+ );
669
+ if (scopedPrior || legacyPrior) continue;
432
670
  await ctx.db.insert("goalCompletions", {
433
671
  siteId: input.siteId,
434
672
  goalId: goal._id,
435
673
  goalKey: goal.key,
436
- eventId: event.eventId,
674
+ eventId: completionEventId,
675
+ idempotencyVersion: 2,
437
676
  sessionId: event.sessionId,
438
677
  visitorId: event.visitorId,
439
678
  timestamp: event.timestamp,
@@ -444,7 +683,7 @@ async function accountEvent(
444
683
 
445
684
  for (const funnel of input.funnels) {
446
685
  if (funnel.active) {
447
- await advanceFunnel(ctx, input.siteId, funnel, event);
686
+ await advanceFunnel(ctx, input.siteId, funnel, event, input.batchState);
448
687
  }
449
688
  }
450
689
 
@@ -491,13 +730,14 @@ async function accountEvent(
491
730
  input.context,
492
731
  session,
493
732
  input.now,
733
+ input.batchState,
494
734
  );
495
735
  }
496
736
  }
497
737
 
498
738
  async function accountTrustedSession(
499
739
  ctx: MutationCtx,
500
- existing: Doc<"sessions"> | null,
740
+ existing: CachedDocument<"sessions"> | null,
501
741
  event: TrackerEvent,
502
742
  ): Promise<SessionState> {
503
743
  if (!existing) {
@@ -529,18 +769,25 @@ async function upsertSession(
529
769
  siteId: Id<"sites">,
530
770
  event: TrackerEvent,
531
771
  context: IngestContext,
532
- existing: Doc<"sessions"> | null,
772
+ existing: CachedDocument<"sessions"> | null,
533
773
  financialConversion: boolean,
774
+ batchState?: BatchState,
534
775
  ): Promise<SessionState> {
535
776
  const source = sourceFromReferrer(event.referrer);
536
777
  const utmSource = stringProperty(event.properties, "utm_source");
537
778
  const utmMedium = stringProperty(event.properties, "utm_medium");
538
779
  const utmCampaign = stringProperty(event.properties, "utm_campaign");
539
- const attribution = await attributionForEvent(ctx, siteId, event, existing);
780
+ const attribution = await attributionForEvent(
781
+ ctx,
782
+ siteId,
783
+ event,
784
+ existing,
785
+ batchState,
786
+ );
540
787
  if (existing) {
541
788
  const startedAt = Math.min(existing.startedAt, event.timestamp);
542
789
  const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
543
- await ctx.db.patch("sessions", existing._id, {
790
+ const updates = {
544
791
  startedAt,
545
792
  lastSeenAt,
546
793
  entryPath: event.timestamp < existing.startedAt ? event.path : existing.entryPath,
@@ -566,7 +813,15 @@ async function upsertSession(
566
813
  browser: context.browser ?? existing.browser,
567
814
  os: context.os ?? existing.os,
568
815
  device: context.device ?? existing.device,
569
- });
816
+ };
817
+ if (batchState) {
818
+ const state = batchState.sessions.get(event.sessionId);
819
+ if (!state) throw new Error("session cache was not initialized");
820
+ state.current = { ...existing, ...updates };
821
+ state.dirty = true;
822
+ } else {
823
+ await ctx.db.patch("sessions", existing._id, updates);
824
+ }
570
825
  return {
571
826
  isNewSession: false,
572
827
  isNewVisitorHour:
@@ -584,14 +839,27 @@ async function upsertSession(
584
839
  };
585
840
  }
586
841
 
587
- const priorVisit = await ctx.db
842
+ const priorVisits = await ctx.db
588
843
  .query("sessions")
589
844
  .withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) =>
590
845
  range.eq("siteId", siteId).eq("visitorId", event.visitorId),
591
846
  )
592
847
  .order("desc")
593
848
  .take(1);
594
- await ctx.db.insert("sessions", {
849
+ let priorVisit: CachedDocument<"sessions"> | null = priorVisits[0] ?? null;
850
+ if (batchState) {
851
+ for (const state of batchState.sessions.values()) {
852
+ const candidate = state.current;
853
+ if (
854
+ candidate?.visitorId === event.visitorId &&
855
+ candidate.sessionId !== event.sessionId &&
856
+ (!priorVisit || candidate.lastSeenAt > priorVisit.lastSeenAt)
857
+ ) {
858
+ priorVisit = candidate;
859
+ }
860
+ }
861
+ }
862
+ const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
595
863
  siteId,
596
864
  sessionId: event.sessionId,
597
865
  visitorId: event.visitorId,
@@ -620,16 +888,25 @@ async function upsertSession(
620
888
  ? { currency: event.currency }
621
889
  : {}),
622
890
  ...(attribution ?? {}),
623
- });
891
+ };
892
+ const sessionId = await ctx.db.insert("sessions", fields);
893
+ if (batchState) {
894
+ const state = batchState.sessions.get(event.sessionId);
895
+ if (!state) throw new Error("session cache was not initialized");
896
+ state.current = {
897
+ ...fields,
898
+ _id: sessionId,
899
+ };
900
+ }
624
901
  return {
625
902
  isNewSession: true,
626
903
  isNewVisitorHour:
627
- priorVisit.length === 0 ||
628
- Math.floor(priorVisit[0].lastSeenAt / HOUR_MS) <
904
+ priorVisit === null ||
905
+ Math.floor(priorVisit.lastSeenAt / HOUR_MS) <
629
906
  Math.floor(event.timestamp / HOUR_MS),
630
907
  isNewVisitorDay:
631
- priorVisit.length === 0 ||
632
- Math.floor(priorVisit[0].lastSeenAt / DAY_MS) <
908
+ priorVisit === null ||
909
+ Math.floor(priorVisit.lastSeenAt / DAY_MS) <
633
910
  Math.floor(event.timestamp / DAY_MS),
634
911
  startedAt: event.timestamp,
635
912
  source,
@@ -680,7 +957,8 @@ async function attributionForEvent(
680
957
  ctx: MutationCtx,
681
958
  siteId: Id<"sites">,
682
959
  event: TrackerEvent,
683
- existingSession: Doc<"sessions"> | null,
960
+ existingSession: CachedDocument<"sessions"> | null,
961
+ batchState?: BatchState,
684
962
  ): Promise<AttributionSnapshot | undefined> {
685
963
  const sessionAttribution = existingSession
686
964
  ? attributionSnapshot(existingSession)
@@ -696,12 +974,25 @@ async function attributionForEvent(
696
974
  return sessionAttribution;
697
975
  }
698
976
 
699
- const current = await ctx.db
700
- .query("visitorAttributions")
701
- .withIndex("by_siteId_and_visitorId", (range) =>
702
- range.eq("siteId", siteId).eq("visitorId", event.visitorId),
703
- )
704
- .unique();
977
+ let attributionState = batchState?.visitorAttributions.get(event.visitorId);
978
+ if (batchState && !attributionState) {
979
+ const current = await ctx.db
980
+ .query("visitorAttributions")
981
+ .withIndex("by_siteId_and_visitorId", (range) =>
982
+ range.eq("siteId", siteId).eq("visitorId", event.visitorId),
983
+ )
984
+ .unique();
985
+ attributionState = { current, dirty: false };
986
+ batchState.visitorAttributions.set(event.visitorId, attributionState);
987
+ }
988
+ const current = attributionState
989
+ ? attributionState.current
990
+ : await ctx.db
991
+ .query("visitorAttributions")
992
+ .withIndex("by_siteId_and_visitorId", (range) =>
993
+ range.eq("siteId", siteId).eq("visitorId", event.visitorId),
994
+ )
995
+ .unique();
705
996
  if (current) {
706
997
  const snapshot: AttributionSnapshot = {
707
998
  affiliateId: current.affiliateId,
@@ -733,22 +1024,30 @@ async function attributionForEvent(
733
1024
 
734
1025
  const attributedAt = event.timestamp;
735
1026
  const expiresAt = attributedAt + affiliate.attributionWindowDays * DAY_MS;
1027
+ const fields = {
1028
+ siteId,
1029
+ visitorId: event.visitorId,
1030
+ affiliateId: affiliate._id,
1031
+ affiliateSlug: affiliate.slug,
1032
+ attributedAt,
1033
+ expiresAt,
1034
+ };
736
1035
  if (current) {
737
- await ctx.db.patch("visitorAttributions", current._id, {
738
- affiliateId: affiliate._id,
739
- affiliateSlug: affiliate.slug,
740
- attributedAt,
741
- expiresAt,
742
- });
1036
+ if (attributionState) {
1037
+ attributionState.current = { ...current, ...fields };
1038
+ attributionState.dirty = true;
1039
+ } else {
1040
+ await ctx.db.patch("visitorAttributions", current._id, fields);
1041
+ }
743
1042
  } else {
744
- await ctx.db.insert("visitorAttributions", {
745
- siteId,
746
- visitorId: event.visitorId,
747
- affiliateId: affiliate._id,
748
- affiliateSlug: affiliate.slug,
749
- attributedAt,
750
- expiresAt,
751
- });
1043
+ const attributionId = await ctx.db.insert("visitorAttributions", fields);
1044
+ if (attributionState) {
1045
+ attributionState.current = {
1046
+ ...fields,
1047
+ _id: attributionId,
1048
+ _creationTime: event.timestamp,
1049
+ };
1050
+ }
752
1051
  }
753
1052
  return {
754
1053
  affiliateId: affiliate._id,
@@ -761,14 +1060,15 @@ async function attributionForEvent(
761
1060
  async function accountHeartbeat(
762
1061
  ctx: MutationCtx,
763
1062
  siteId: Id<"sites">,
764
- session: Doc<"sessions">,
1063
+ session: CachedDocument<"sessions">,
765
1064
  event: TrackerEvent,
766
1065
  context: IngestContext,
767
1066
  now: number,
1067
+ batchState?: BatchState,
768
1068
  ) {
769
1069
  const startedAt = Math.min(session.startedAt, event.timestamp);
770
1070
  const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
771
- await ctx.db.patch("sessions", session._id, {
1071
+ const updates = {
772
1072
  startedAt,
773
1073
  lastSeenAt,
774
1074
  exitPath: event.timestamp >= session.lastSeenAt
@@ -782,7 +1082,15 @@ async function accountHeartbeat(
782
1082
  browser: context.browser ?? session.browser,
783
1083
  os: context.os ?? session.os,
784
1084
  device: context.device ?? session.device,
785
- });
1085
+ };
1086
+ if (batchState) {
1087
+ const state = batchState.sessions.get(event.sessionId);
1088
+ if (!state) throw new Error("session cache was not initialized");
1089
+ state.current = { ...session, ...updates };
1090
+ state.dirty = true;
1091
+ } else {
1092
+ await ctx.db.patch("sessions", session._id, updates);
1093
+ }
786
1094
  await updateLiveSession(
787
1095
  ctx,
788
1096
  siteId,
@@ -799,6 +1107,7 @@ async function accountHeartbeat(
799
1107
  affiliateSlug: session.affiliateSlug,
800
1108
  },
801
1109
  now,
1110
+ batchState,
802
1111
  );
803
1112
  }
804
1113
 
@@ -830,15 +1139,17 @@ async function consumeSiteRateLimit(
830
1139
  window: Doc<"siteIngestWindows"> | null;
831
1140
  }> = [];
832
1141
  for (const [shard, increment] of increments) {
833
- const window = await ctx.db
1142
+ const rows = await ctx.db
834
1143
  .query("siteIngestWindows")
835
1144
  .withIndex("by_siteId_and_shard_and_windowStart", (range) =>
836
- range
837
- .eq("siteId", siteId)
838
- .eq("shard", shard)
839
- .eq("windowStart", windowStart),
1145
+ range.eq("siteId", siteId).eq("shard", shard),
840
1146
  )
841
- .unique();
1147
+ .order("desc")
1148
+ .take(1);
1149
+ const storedWindow = rows[0] ?? null;
1150
+ const window = storedWindow?.windowStart === windowStart
1151
+ ? storedWindow
1152
+ : null;
842
1153
  if (
843
1154
  (window?.eventCount ?? 0) + increment.eventCount >
844
1155
  MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
@@ -856,14 +1167,21 @@ async function consumeSiteRateLimit(
856
1167
  SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
857
1168
  });
858
1169
  }
859
- windows.push({ shard, increment, window });
1170
+ windows.push({ shard, increment, window: storedWindow });
860
1171
  }
861
1172
 
862
1173
  for (const { shard, increment, window } of windows) {
863
1174
  if (window) {
864
1175
  await ctx.db.patch("siteIngestWindows", window._id, {
865
- eventCount: window.eventCount + increment.eventCount,
866
- byteCount: window.byteCount + increment.byteCount,
1176
+ windowStart,
1177
+ eventCount:
1178
+ window.windowStart === windowStart
1179
+ ? window.eventCount + increment.eventCount
1180
+ : increment.eventCount,
1181
+ byteCount:
1182
+ window.windowStart === windowStart
1183
+ ? window.byteCount + increment.byteCount
1184
+ : increment.byteCount,
867
1185
  updatedAt: now,
868
1186
  });
869
1187
  } else {
@@ -886,43 +1204,69 @@ async function consumeRateLimit(
886
1204
  sessionId: string,
887
1205
  byteCount: number,
888
1206
  now: number,
1207
+ states: Map<string, SessionRateWindowState>,
889
1208
  ): Promise<boolean> {
890
1209
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
891
- const window = await ctx.db
892
- .query("ingestWindows")
893
- .withIndex("by_siteId_and_sessionId_and_windowStart", (range) =>
894
- range
895
- .eq("siteId", siteId)
896
- .eq("sessionId", sessionId)
897
- .eq("windowStart", windowStart),
898
- )
899
- .unique();
1210
+ let state = states.get(sessionId);
1211
+ if (!state) {
1212
+ const rows = await ctx.db
1213
+ .query("ingestWindows")
1214
+ .withIndex("by_siteId_and_sessionId_and_windowStart", (range) =>
1215
+ range.eq("siteId", siteId).eq("sessionId", sessionId),
1216
+ )
1217
+ .order("desc")
1218
+ .take(1);
1219
+ const window = rows[0] ?? null;
1220
+ state = {
1221
+ siteId,
1222
+ sessionId,
1223
+ window,
1224
+ eventCount: window?.windowStart === windowStart ? window.eventCount : 0,
1225
+ byteCount: window?.windowStart === windowStart ? window.byteCount : 0,
1226
+ dirty: false,
1227
+ };
1228
+ states.set(sessionId, state);
1229
+ }
900
1230
  if (
901
- window &&
902
- (window.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
903
- window.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW)
1231
+ state.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
1232
+ state.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW
904
1233
  ) {
905
1234
  return false;
906
1235
  }
907
- if (window) {
908
- await ctx.db.patch("ingestWindows", window._id, {
909
- eventCount: window.eventCount + 1,
910
- byteCount: window.byteCount + byteCount,
911
- updatedAt: now,
912
- });
913
- } else {
914
- await ctx.db.insert("ingestWindows", {
915
- siteId,
916
- sessionId,
917
- windowStart,
918
- eventCount: 1,
919
- byteCount,
920
- updatedAt: now,
921
- });
922
- }
1236
+ state.eventCount += 1;
1237
+ state.byteCount += byteCount;
1238
+ state.dirty = true;
923
1239
  return true;
924
1240
  }
925
1241
 
1242
+ async function flushSessionRateLimits(
1243
+ ctx: MutationCtx,
1244
+ states: Map<string, SessionRateWindowState>,
1245
+ now: number,
1246
+ ) {
1247
+ const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1248
+ for (const state of states.values()) {
1249
+ if (!state.dirty) continue;
1250
+ if (state.window) {
1251
+ await ctx.db.patch("ingestWindows", state.window._id, {
1252
+ windowStart,
1253
+ eventCount: state.eventCount,
1254
+ byteCount: state.byteCount,
1255
+ updatedAt: now,
1256
+ });
1257
+ } else {
1258
+ await ctx.db.insert("ingestWindows", {
1259
+ siteId: state.siteId,
1260
+ sessionId: state.sessionId,
1261
+ windowStart,
1262
+ eventCount: state.eventCount,
1263
+ byteCount: state.byteCount,
1264
+ updatedAt: now,
1265
+ });
1266
+ }
1267
+ }
1268
+ }
1269
+
926
1270
  async function updateLiveSession(
927
1271
  ctx: MutationCtx,
928
1272
  siteId: Id<"sites">,
@@ -930,23 +1274,32 @@ async function updateLiveSession(
930
1274
  context: IngestContext,
931
1275
  session: SessionState,
932
1276
  now: number,
1277
+ batchState?: BatchState,
933
1278
  ) {
934
- const existing = await ctx.db
935
- .query("liveSessions")
936
- .withIndex("by_siteId_and_sessionId", (range) =>
937
- range.eq("siteId", siteId).eq("sessionId", event.sessionId),
938
- )
939
- .unique();
940
- const renewsExpiry =
941
- !existing ||
942
- existing.expiresAt <= now ||
943
- event.type === "pageview" ||
944
- event.type === "heartbeat";
945
- const deadline = renewsExpiry
946
- ? now + LIVE_SESSION_TTL_MS
947
- : existing.expiresAt;
1279
+ await ensureLiveSweep(ctx, siteId, now, batchState);
1280
+ const liveState = batchState
1281
+ ? await loadLiveSession(ctx, siteId, event.sessionId, batchState)
1282
+ : undefined;
1283
+ const existing = liveState
1284
+ ? liveState.current
1285
+ : await ctx.db
1286
+ .query("liveSessions")
1287
+ .withIndex("by_siteId_and_sessionId", (range) =>
1288
+ range.eq("siteId", siteId).eq("sessionId", event.sessionId),
1289
+ )
1290
+ .unique();
1291
+ const deadline = now + LIVE_SESSION_TTL_MS;
948
1292
  if (existing) {
949
- await ctx.db.patch("liveSessions", existing._id, {
1293
+ const recentHeartbeatEventIds = event.type === "heartbeat"
1294
+ ? [
1295
+ ...(existing.recentHeartbeatEventIds ??
1296
+ (existing.lastHeartbeatEventId
1297
+ ? [existing.lastHeartbeatEventId]
1298
+ : [])),
1299
+ event.eventId,
1300
+ ].slice(-8)
1301
+ : undefined;
1302
+ const updates = {
950
1303
  visitorId: event.visitorId,
951
1304
  path: event.path,
952
1305
  lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
@@ -962,13 +1315,22 @@ async function updateLiveSession(
962
1315
  pageviewCount: session.pageviewCount,
963
1316
  eventCount: session.eventCount,
964
1317
  ...(event.type === "heartbeat"
965
- ? { lastHeartbeatEventId: event.eventId }
1318
+ ? {
1319
+ lastHeartbeatEventId: event.eventId,
1320
+ recentHeartbeatEventIds,
1321
+ }
966
1322
  : {}),
967
- });
1323
+ };
1324
+ if (liveState) {
1325
+ liveState.current = { ...existing, ...updates };
1326
+ liveState.dirty = true;
1327
+ } else {
1328
+ await ctx.db.patch("liveSessions", existing._id, updates);
1329
+ }
968
1330
  return;
969
1331
  }
970
1332
 
971
- const liveSessionId = await ctx.db.insert("liveSessions", {
1333
+ const fields: Omit<Doc<"liveSessions">, "_id" | "_creationTime"> = {
972
1334
  siteId,
973
1335
  sessionId: event.sessionId,
974
1336
  visitorId: event.visitorId,
@@ -976,7 +1338,6 @@ async function updateLiveSession(
976
1338
  startedAt: event.timestamp,
977
1339
  lastSeenAt: event.timestamp,
978
1340
  expiresAt: deadline,
979
- expiryChainDeadline: deadline,
980
1341
  ...(context.country ? { country: context.country } : {}),
981
1342
  ...(context.city ? { city: context.city } : {}),
982
1343
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
@@ -988,13 +1349,42 @@ async function updateLiveSession(
988
1349
  pageviewCount: session.pageviewCount,
989
1350
  eventCount: session.eventCount,
990
1351
  ...(event.type === "heartbeat"
991
- ? { lastHeartbeatEventId: event.eventId }
1352
+ ? {
1353
+ lastHeartbeatEventId: event.eventId,
1354
+ recentHeartbeatEventIds: [event.eventId],
1355
+ }
992
1356
  : {}),
993
- });
994
- await ctx.scheduler.runAt(deadline, internal.live.expire, {
995
- liveSessionId,
996
- scheduledDeadline: deadline,
997
- });
1357
+ };
1358
+ const liveSessionId = await ctx.db.insert("liveSessions", fields);
1359
+ if (liveState) {
1360
+ liveState.current = {
1361
+ ...fields,
1362
+ _id: liveSessionId,
1363
+ };
1364
+ }
1365
+ }
1366
+
1367
+ async function ensureLiveSweep(
1368
+ ctx: MutationCtx,
1369
+ siteId: Id<"sites">,
1370
+ now: number,
1371
+ batchState?: BatchState,
1372
+ ) {
1373
+ const key = String(siteId);
1374
+ if (batchState?.liveSweepSites.has(key)) return;
1375
+ const existing = await ctx.db
1376
+ .query("liveSweepStates")
1377
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1378
+ .unique();
1379
+ if (!existing) {
1380
+ const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
1381
+ await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1382
+ await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
1383
+ siteId,
1384
+ scheduledAt,
1385
+ });
1386
+ }
1387
+ batchState?.liveSweepSites.add(key);
998
1388
  }
999
1389
 
1000
1390
  function goalMatches(goal: Doc<"goals">, event: TrackerEvent): boolean {
@@ -1009,20 +1399,38 @@ async function advanceFunnel(
1009
1399
  siteId: Id<"sites">,
1010
1400
  funnel: Doc<"funnels">,
1011
1401
  event: TrackerEvent,
1402
+ batchState?: BatchState,
1012
1403
  ) {
1013
- const progress = await ctx.db
1014
- .query("funnelProgress")
1015
- .withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
1016
- range
1017
- .eq("siteId", siteId)
1018
- .eq("funnelId", funnel._id)
1019
- .eq("visitorId", event.visitorId),
1020
- )
1021
- .unique();
1404
+ const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
1405
+ let progressState = batchState?.funnelProgress.get(cacheKey);
1406
+ if (batchState && !progressState) {
1407
+ const current = await ctx.db
1408
+ .query("funnelProgress")
1409
+ .withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
1410
+ range
1411
+ .eq("siteId", siteId)
1412
+ .eq("funnelId", funnel._id)
1413
+ .eq("visitorId", event.visitorId),
1414
+ )
1415
+ .unique();
1416
+ progressState = { current, dirty: false };
1417
+ batchState.funnelProgress.set(cacheKey, progressState);
1418
+ }
1419
+ const progress = progressState
1420
+ ? progressState.current
1421
+ : await ctx.db
1422
+ .query("funnelProgress")
1423
+ .withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
1424
+ range
1425
+ .eq("siteId", siteId)
1426
+ .eq("funnelId", funnel._id)
1427
+ .eq("visitorId", event.visitorId),
1428
+ )
1429
+ .unique();
1022
1430
  const matchesFirst = stepMatches(funnel.steps[0], event);
1023
1431
  if (!progress) {
1024
1432
  if (!matchesFirst) return;
1025
- await ctx.db.insert("funnelProgress", {
1433
+ const fields: Omit<Doc<"funnelProgress">, "_id" | "_creationTime"> = {
1026
1434
  siteId,
1027
1435
  funnelId: funnel._id,
1028
1436
  visitorId: event.visitorId,
@@ -1030,20 +1438,40 @@ async function advanceFunnel(
1030
1438
  currentStep: 1,
1031
1439
  startedAt: event.timestamp,
1032
1440
  lastStepAt: event.timestamp,
1033
- });
1441
+ };
1442
+ const progressId = await ctx.db.insert("funnelProgress", fields);
1443
+ if (progressState) {
1444
+ progressState.current = {
1445
+ ...fields,
1446
+ _id: progressId,
1447
+ };
1448
+ }
1034
1449
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1035
1450
  return;
1036
1451
  }
1037
1452
  if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt) return;
1038
1453
  if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
1039
1454
  if (!matchesFirst) return;
1040
- await ctx.db.patch("funnelProgress", progress._id, {
1455
+ const updated = {
1456
+ ...progress,
1041
1457
  sessionId: event.sessionId,
1042
1458
  currentStep: 1,
1043
1459
  startedAt: event.timestamp,
1044
1460
  lastStepAt: event.timestamp,
1045
- completedAt: undefined,
1046
- });
1461
+ };
1462
+ delete updated.completedAt;
1463
+ if (progressState) {
1464
+ progressState.current = updated;
1465
+ progressState.dirty = true;
1466
+ } else {
1467
+ await ctx.db.patch("funnelProgress", progress._id, {
1468
+ sessionId: event.sessionId,
1469
+ currentStep: 1,
1470
+ startedAt: event.timestamp,
1471
+ lastStepAt: event.timestamp,
1472
+ completedAt: undefined,
1473
+ });
1474
+ }
1047
1475
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1048
1476
  return;
1049
1477
  }
@@ -1051,12 +1479,18 @@ async function advanceFunnel(
1051
1479
  if (!nextStep || !stepMatches(nextStep, event)) return;
1052
1480
  const currentStep = progress.currentStep + 1;
1053
1481
  const completed = currentStep === funnel.steps.length;
1054
- await ctx.db.patch("funnelProgress", progress._id, {
1482
+ const updates = {
1055
1483
  sessionId: event.sessionId,
1056
1484
  currentStep,
1057
1485
  lastStepAt: event.timestamp,
1058
1486
  ...(completed ? { completedAt: event.timestamp } : {}),
1059
- });
1487
+ };
1488
+ if (progressState) {
1489
+ progressState.current = { ...progress, ...updates };
1490
+ progressState.dirty = true;
1491
+ } else {
1492
+ await ctx.db.patch("funnelProgress", progress._id, updates);
1493
+ }
1060
1494
  await incrementFunnelStep(
1061
1495
  ctx,
1062
1496
  siteId,
@@ -1085,7 +1519,7 @@ async function incrementFunnelStep(
1085
1519
  completed: boolean,
1086
1520
  ) {
1087
1521
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1088
- const shard = stableHash(event.visitorId) % AGGREGATE_SHARDS;
1522
+ const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
1089
1523
  const bucket = await ctx.db
1090
1524
  .query("funnelStepBuckets")
1091
1525
  .withIndex(
@@ -1155,7 +1589,7 @@ async function accountConversion(
1155
1589
  });
1156
1590
  if (commissionAffiliate && attribution) {
1157
1591
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1158
- const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
1592
+ const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
1159
1593
  const stats = await ctx.db
1160
1594
  .query("affiliateDailyStats")
1161
1595
  .withIndex(
@@ -1198,7 +1632,6 @@ function foldAggregates(
1198
1632
  recordTelemetry: boolean,
1199
1633
  financialConversion: boolean,
1200
1634
  ) {
1201
- const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
1202
1635
  const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
1203
1636
  const dimensionDeltas = dimensions.map(
1204
1637
  (dimension): DimensionDelta => ({
@@ -1208,6 +1641,8 @@ function foldAggregates(
1208
1641
  }),
1209
1642
  );
1210
1643
  for (const granularity of ["hour", "day"] as const) {
1644
+ const shard = stableHash(event.sessionId) %
1645
+ (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
1211
1646
  const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1212
1647
  const bucketStart = Math.floor(event.timestamp / interval) * interval;
1213
1648
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
@@ -1325,7 +1760,7 @@ function mergeDimensions(
1325
1760
  const typeSlots = result.filter(
1326
1761
  (candidate) => candidate.type === addition.type,
1327
1762
  );
1328
- if (typeSlots.length < DIMENSION_SLOTS) {
1763
+ if (typeSlots.length < DIMENSION_SLOTS - 1) {
1329
1764
  const occupied = Array.from(
1330
1765
  { length: DIMENSION_SLOTS },
1331
1766
  () => false,
@@ -1342,15 +1777,36 @@ function mergeDimensions(
1342
1777
  });
1343
1778
  continue;
1344
1779
  }
1345
- if (addition.count === 0) continue;
1780
+ const overflow = typeSlots.find(
1781
+ (candidate) => candidate.value === "(other)",
1782
+ );
1783
+ if (overflow) {
1784
+ overflow.count += addition.count;
1785
+ overflow.revenueCents += addition.revenueCents;
1786
+ continue;
1787
+ }
1788
+ if (addition.count === 0 && addition.revenueCents === 0) continue;
1789
+ if (typeSlots.length < DIMENSION_SLOTS) {
1790
+ const occupied = new Set(typeSlots.map((candidate) => candidate.slot));
1791
+ let slot = stableHash("(other)") % DIMENSION_SLOTS;
1792
+ while (occupied.has(slot)) slot = (slot + 1) % DIMENSION_SLOTS;
1793
+ result.push({
1794
+ type: addition.type,
1795
+ slot,
1796
+ value: "(other)",
1797
+ count: addition.count,
1798
+ revenueCents: addition.revenueCents,
1799
+ });
1800
+ continue;
1801
+ }
1346
1802
 
1347
1803
  let replacement = typeSlots[0];
1348
1804
  for (const candidate of typeSlots.slice(1)) {
1349
1805
  if (candidate.count < replacement.count) replacement = candidate;
1350
1806
  }
1351
- replacement.value = addition.value;
1807
+ replacement.value = "(other)";
1352
1808
  replacement.count += addition.count;
1353
- replacement.revenueCents = addition.revenueCents;
1809
+ replacement.revenueCents += addition.revenueCents;
1354
1810
  }
1355
1811
  return result;
1356
1812
  }