@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
@@ -1,8 +1,9 @@
1
1
  import { v } from "convex/values";
2
2
  import { mutation } from "./_generated/server.js";
3
3
  import { internal } from "./_generated/api.js";
4
- import { AGGREGATE_SHARDS, DAY_MS, DIMENSION_SLOTS, HOUR_MS, LIVE_SESSION_TTL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, RATE_LIMIT_WINDOW_MS, SITE_INGEST_SHARDS, } from "./constants.js";
4
+ import { AGGREGATE_SHARDS, MAX_BATCH_EVENT_GROUPS, DAY_MS, HOURLY_AGGREGATE_SHARDS, DIMENSION_SLOTS, HOUR_MS, LIVE_SESSION_TTL_MS, LIVE_SWEEP_INTERVAL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, RATE_LIMIT_WINDOW_MS, SECONDARY_AGGREGATE_SHARDS, SITE_INGEST_SHARDS, } from "./constants.js";
5
5
  import { fail } from "./errors.js";
6
+ import { bufferEventBatch, eventBatchKey, eventBucketStart, findTelemetry, flushEventBatches, insertEventBatch, inspectTelemetryMinute, } from "./eventStore.js";
6
7
  import { cleanString, ensureBatchBytes, isValidMoney, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
7
8
  import { ingestContextValidator, ingestResultValidator, trackerEventValidator, trustedConversionValidator, } from "./validators.js";
8
9
  export const ingestBatch = mutation({
@@ -37,25 +38,84 @@ export const ingestBatch = mutation({
37
38
  const eventByteCounts = await consumeSiteRateLimit(ctx, args.siteId, args.events, now);
38
39
  const context = sanitizeContext(args.context);
39
40
  const aggregateDeltas = new Map();
41
+ const sessionRateWindows = new Map();
42
+ const batchState = {
43
+ sessions: new Map(),
44
+ liveSessions: new Map(),
45
+ visitorAttributions: new Map(),
46
+ funnelProgress: new Map(),
47
+ eventBatches: new Map(),
48
+ liveSweepSites: new Set(),
49
+ };
40
50
  let goals = null;
41
51
  let funnels = null;
42
52
  let accepted = 0;
43
53
  let duplicates = 0;
44
54
  let rejected = 0;
45
- for (let eventIndex = 0; eventIndex < args.events.length; eventIndex += 1) {
46
- const input = args.events[eventIndex];
47
- let event;
55
+ const events = args.events.map((input) => {
48
56
  try {
49
- event = sanitizeEvent(input, now);
57
+ return sanitizeEvent(input, now);
50
58
  }
51
59
  catch {
60
+ return null;
61
+ }
62
+ });
63
+ const duplicateInBatch = new Set();
64
+ const eventIdsByGroup = new Map();
65
+ const requestGroups = new Set();
66
+ const dedupeGroups = new Map();
67
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
68
+ const event = events[eventIndex];
69
+ if (!event)
70
+ continue;
71
+ const bucketStart = eventBucketStart(event.timestamp);
72
+ const groupKey = eventBatchKey(event.sessionId, bucketStart);
73
+ requestGroups.add(groupKey);
74
+ if (event.type === "heartbeat")
75
+ continue;
76
+ const eventIds = eventIdsByGroup.get(groupKey) ?? new Set();
77
+ if (eventIds.has(event.eventId)) {
78
+ duplicateInBatch.add(eventIndex);
79
+ continue;
80
+ }
81
+ eventIds.add(event.eventId);
82
+ eventIdsByGroup.set(groupKey, eventIds);
83
+ const group = dedupeGroups.get(groupKey) ?? {
84
+ sessionId: event.sessionId,
85
+ bucketStart,
86
+ eventIds: new Set(),
87
+ };
88
+ group.eventIds.add(event.eventId);
89
+ dedupeGroups.set(groupKey, group);
90
+ }
91
+ if (requestGroups.size > MAX_BATCH_EVENT_GROUPS) {
92
+ fail("BATCH_TOO_LARGE", `events may span at most ${MAX_BATCH_EVENT_GROUPS} session-minute groups`, { limit: MAX_BATCH_EVENT_GROUPS });
93
+ }
94
+ const persistedEventIds = new Map();
95
+ await Promise.all([...dedupeGroups].map(async ([key, group]) => {
96
+ const { eventIds, persistedCount } = await inspectTelemetryMinute(ctx, {
97
+ siteId: args.siteId,
98
+ sessionId: group.sessionId,
99
+ bucketStart: group.bucketStart,
100
+ candidateEventIds: group.eventIds,
101
+ });
102
+ const newCount = [...group.eventIds].filter((eventId) => !eventIds.has(eventId)).length;
103
+ if (newCount > 0 &&
104
+ persistedCount + newCount > MAX_EVENTS_PER_SESSION_WINDOW) {
105
+ fail("RATE_LIMITED", "session event minute limit exceeded", {
106
+ limit: MAX_EVENTS_PER_SESSION_WINDOW,
107
+ });
108
+ }
109
+ persistedEventIds.set(key, eventIds);
110
+ }));
111
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
112
+ const event = events[eventIndex];
113
+ if (!event) {
52
114
  rejected += 1;
53
115
  continue;
54
116
  }
55
- const existingSession = await ctx.db
56
- .query("sessions")
57
- .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", args.siteId).eq("sessionId", event.sessionId))
58
- .unique();
117
+ const sessionRow = await loadSession(ctx, args.siteId, event.sessionId, batchState);
118
+ const existingSession = sessionRow.current;
59
119
  if (existingSession && existingSession.visitorId !== event.visitorId) {
60
120
  rejected += 1;
61
121
  continue;
@@ -65,31 +125,29 @@ export const ingestBatch = mutation({
65
125
  rejected += 1;
66
126
  continue;
67
127
  }
68
- const liveSession = await ctx.db
69
- .query("liveSessions")
70
- .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", args.siteId).eq("sessionId", event.sessionId))
71
- .unique();
72
- if (liveSession?.lastHeartbeatEventId === event.eventId) {
128
+ const liveSession = (await loadLiveSession(ctx, args.siteId, event.sessionId, batchState)).current;
129
+ if (liveSession &&
130
+ (liveSession.lastHeartbeatEventId === event.eventId ||
131
+ liveSession.recentHeartbeatEventIds?.includes(event.eventId))) {
73
132
  duplicates += 1;
74
133
  continue;
75
134
  }
76
- if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now))) {
135
+ if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
77
136
  rejected += 1;
78
137
  continue;
79
138
  }
80
- await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now);
139
+ await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
81
140
  accepted += 1;
82
141
  continue;
83
142
  }
84
- const duplicate = await ctx.db
85
- .query("events")
86
- .withIndex("by_siteId_and_eventId", (range) => range.eq("siteId", args.siteId).eq("eventId", event.eventId))
87
- .unique();
88
- if (duplicate) {
143
+ if (duplicateInBatch.has(eventIndex) ||
144
+ persistedEventIds
145
+ .get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
146
+ ?.has(event.eventId)) {
89
147
  duplicates += 1;
90
148
  continue;
91
149
  }
92
- if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now))) {
150
+ if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
93
151
  rejected += 1;
94
152
  continue;
95
153
  }
@@ -118,9 +176,13 @@ export const ingestBatch = mutation({
118
176
  financialConversion: false,
119
177
  trustedGoalKey: undefined,
120
178
  now,
179
+ batchState,
121
180
  });
181
+ bufferEventBatch(args.siteId, event, batchState.eventBatches);
122
182
  accepted += 1;
123
183
  }
184
+ await flushBatchState(ctx, batchState);
185
+ await flushSessionRateLimits(ctx, sessionRateWindows, now);
124
186
  await flushAggregates(ctx, aggregateDeltas);
125
187
  return { accepted, duplicates, rejected };
126
188
  },
@@ -136,13 +198,17 @@ export const trackConversion = mutation({
136
198
  fail("INVALID_ARGUMENT", "revenueCents must be an integer between 0 and 1000000000");
137
199
  }
138
200
  let event;
201
+ let trustedGoalKey;
139
202
  try {
203
+ trustedGoalKey = args.goalKey
204
+ ? sanitizeSlug(args.goalKey, "goalKey")
205
+ : undefined;
140
206
  event = sanitizeEvent({
141
207
  eventId: sanitizeOpaqueId(args.eventId, "eventId"),
142
208
  sessionId: sanitizeOpaqueId(args.sessionId, "sessionId"),
143
209
  visitorId: sanitizeOpaqueId(args.visitorId, "visitorId"),
144
210
  type: "conversion",
145
- name: args.goalKey ? sanitizeSlug(args.goalKey, "goalKey") : "conversion",
211
+ name: trustedGoalKey ?? "conversion",
146
212
  path: sanitizePath(args.path ?? "/conversion"),
147
213
  timestamp: args.timestamp,
148
214
  sequence: 0,
@@ -155,23 +221,24 @@ export const trackConversion = mutation({
155
221
  fail("INVALID_ARGUMENT", error instanceof Error ? error.message : "invalid conversion");
156
222
  }
157
223
  const siteCurrency = site.currency ?? "USD";
158
- if (event.currency !== siteCurrency) {
159
- fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
160
- siteCurrency,
161
- conversionCurrency: event.currency ?? "USD",
162
- });
163
- }
164
224
  const priorConversion = await ctx.db
165
225
  .query("conversions")
166
226
  .withIndex("by_siteId_and_eventId", (range) => range.eq("siteId", args.siteId).eq("eventId", event.eventId))
167
227
  .unique();
168
228
  if (priorConversion) {
229
+ if (!sameTrustedConversion(priorConversion, event, trustedGoalKey)) {
230
+ fail("CONFLICT", "conversion eventId is already associated with a different payload");
231
+ }
169
232
  return { accepted: 0, duplicates: 1, rejected: 0 };
170
233
  }
171
- const existingTelemetry = await ctx.db
172
- .query("events")
173
- .withIndex("by_siteId_and_eventId", (range) => range.eq("siteId", args.siteId).eq("eventId", event.eventId))
174
- .unique();
234
+ if (event.currency !== siteCurrency) {
235
+ fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
236
+ siteCurrency,
237
+ conversionCurrency: event.currency ?? "USD",
238
+ });
239
+ }
240
+ const telemetry = await findTelemetry(ctx, args.siteId, event);
241
+ const existingTelemetry = telemetry.match;
175
242
  if (existingTelemetry &&
176
243
  (existingTelemetry.sessionId !== event.sessionId ||
177
244
  existingTelemetry.visitorId !== event.visitorId)) {
@@ -184,17 +251,15 @@ export const trackConversion = mutation({
184
251
  if (existingSession && existingSession.visitorId !== event.visitorId) {
185
252
  fail("CONFLICT", "session belongs to a different visitor");
186
253
  }
187
- const conversionContext = existingTelemetry
188
- ? {
189
- country: existingTelemetry.country,
190
- city: existingTelemetry.city,
191
- latitude: existingTelemetry.latitude,
192
- longitude: existingTelemetry.longitude,
193
- browser: existingTelemetry.browser,
194
- os: existingTelemetry.os,
195
- device: existingTelemetry.device,
196
- }
197
- : sanitizeContext(undefined);
254
+ const conversionContext = {
255
+ country: existingTelemetry?.country ?? existingSession?.country,
256
+ city: existingTelemetry?.city ?? existingSession?.city,
257
+ latitude: existingTelemetry?.latitude ?? existingSession?.latitude,
258
+ longitude: existingTelemetry?.longitude ?? existingSession?.longitude,
259
+ browser: existingTelemetry?.browser ?? existingSession?.browser,
260
+ os: existingTelemetry?.os ?? existingSession?.os,
261
+ device: existingTelemetry?.device ?? existingSession?.device,
262
+ };
198
263
  const aggregateDeltas = new Map();
199
264
  await accountEvent(ctx, {
200
265
  siteId: args.siteId,
@@ -204,73 +269,126 @@ export const trackConversion = mutation({
204
269
  goals: [],
205
270
  funnels: [],
206
271
  aggregateDeltas,
207
- recordTelemetry: existingTelemetry === null,
272
+ recordTelemetry: existingTelemetry === null && !telemetry.saturated,
208
273
  financialConversion: true,
209
- trustedGoalKey: args.goalKey
210
- ? sanitizeSlug(args.goalKey, "goalKey")
211
- : undefined,
274
+ trustedGoalKey,
212
275
  now: Date.now(),
213
276
  });
277
+ if (!existingTelemetry && !telemetry.saturated) {
278
+ await insertEventBatch(ctx, args.siteId, event);
279
+ }
214
280
  await flushAggregates(ctx, aggregateDeltas);
215
281
  return { accepted: 1, duplicates: 0, rejected: 0 };
216
282
  },
217
283
  });
284
+ function sameTrustedConversion(existing, event, goalKey) {
285
+ return (existing.source === "trusted" &&
286
+ existing.sessionId === event.sessionId &&
287
+ existing.visitorId === event.visitorId &&
288
+ existing.timestamp === event.timestamp &&
289
+ existing.path === event.path &&
290
+ existing.revenueCents === (event.revenueCents ?? 0) &&
291
+ existing.currency === (event.currency ?? "USD") &&
292
+ existing.goalKey === goalKey);
293
+ }
294
+ async function loadSession(ctx, siteId, sessionId, batchState) {
295
+ const cached = batchState.sessions.get(sessionId);
296
+ if (cached)
297
+ return cached;
298
+ const current = await ctx.db
299
+ .query("sessions")
300
+ .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
301
+ .unique();
302
+ const loaded = { current, dirty: false };
303
+ batchState.sessions.set(sessionId, loaded);
304
+ return loaded;
305
+ }
306
+ async function loadLiveSession(ctx, siteId, sessionId, batchState) {
307
+ const cached = batchState.liveSessions.get(sessionId);
308
+ if (cached)
309
+ return cached;
310
+ const current = await ctx.db
311
+ .query("liveSessions")
312
+ .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
313
+ .unique();
314
+ const loaded = { current, dirty: false };
315
+ batchState.liveSessions.set(sessionId, loaded);
316
+ return loaded;
317
+ }
318
+ async function flushBatchState(ctx, batchState) {
319
+ for (const state of batchState.sessions.values()) {
320
+ if (state.dirty && state.current) {
321
+ await ctx.db.replace("sessions", state.current._id, withoutSystemFields(state.current));
322
+ }
323
+ }
324
+ for (const state of batchState.liveSessions.values()) {
325
+ if (state.dirty && state.current) {
326
+ await ctx.db.replace("liveSessions", state.current._id, withoutSystemFields(state.current));
327
+ }
328
+ }
329
+ for (const state of batchState.visitorAttributions.values()) {
330
+ if (state.dirty && state.current) {
331
+ await ctx.db.replace("visitorAttributions", state.current._id, withoutSystemFields(state.current));
332
+ }
333
+ }
334
+ for (const state of batchState.funnelProgress.values()) {
335
+ if (state.dirty && state.current) {
336
+ await ctx.db.replace("funnelProgress", state.current._id, withoutSystemFields(state.current));
337
+ }
338
+ }
339
+ await flushEventBatches(ctx, batchState.eventBatches);
340
+ }
341
+ function withoutSystemFields(document) {
342
+ const { _id, _creationTime, ...fields } = document;
343
+ void _id;
344
+ void _creationTime;
345
+ return fields;
346
+ }
218
347
  async function accountEvent(ctx, input) {
219
348
  const { event } = input;
220
349
  const session = input.recordTelemetry
221
- ? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion)
350
+ ? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion, input.batchState)
222
351
  : await accountTrustedSession(ctx, input.existingSession, event);
223
- if (input.recordTelemetry) {
224
- await ctx.db.insert("events", {
225
- siteId: input.siteId,
226
- eventId: event.eventId,
227
- sessionId: event.sessionId,
228
- visitorId: event.visitorId,
229
- type: event.type,
230
- ...(event.name ? { name: event.name } : {}),
231
- path: event.path,
232
- ...(event.referrer ? { referrer: event.referrer } : {}),
233
- timestamp: event.timestamp,
234
- sequence: event.sequence,
235
- ...(event.target ? { target: event.target } : {}),
236
- ...(event.href ? { href: event.href } : {}),
237
- ...(event.properties ? { properties: event.properties } : {}),
238
- ...(event.revenueCents !== undefined
239
- ? { revenueCents: event.revenueCents }
240
- : {}),
241
- ...(event.currency ? { currency: event.currency } : {}),
242
- ...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
243
- ...(input.context.country ? { country: input.context.country } : {}),
244
- ...(input.context.city ? { city: input.context.city } : {}),
245
- ...(input.context.latitude !== undefined
246
- ? { latitude: input.context.latitude }
247
- : {}),
248
- ...(input.context.longitude !== undefined
249
- ? { longitude: input.context.longitude }
250
- : {}),
251
- browser: input.context.browser ?? "Other",
252
- os: input.context.os ?? "Other",
253
- device: input.context.device ?? "unknown",
254
- });
255
- }
256
352
  const matchedGoalKeys = [];
257
- for (const goal of input.goals) {
258
- if (!goal.active || !goalMatches(goal, event))
259
- continue;
260
- const prior = await ctx.db
353
+ const matchingGoals = input.goals.filter((goal) => goal.active && goalMatches(goal, event));
354
+ if (matchingGoals.length > 1) {
355
+ fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
356
+ }
357
+ for (const goal of matchingGoals) {
358
+ const completionEventId = JSON.stringify([
359
+ event.sessionId,
360
+ eventBucketStart(event.timestamp),
361
+ event.eventId,
362
+ ]);
363
+ const scopedPrior = (await ctx.db
261
364
  .query("goalCompletions")
262
365
  .withIndex("by_siteId_and_goalId_and_eventId", (range) => range
263
366
  .eq("siteId", input.siteId)
264
367
  .eq("goalId", goal._id)
265
- .eq("eventId", event.eventId))
266
- .unique();
267
- if (prior)
368
+ .eq("eventId", completionEventId))
369
+ .take(2))
370
+ .find((completion) => completion.idempotencyVersion === 2);
371
+ const legacyPrior = scopedPrior
372
+ ? undefined
373
+ : (await ctx.db
374
+ .query("goalCompletions")
375
+ .withIndex("by_siteId_and_goalId_and_eventId", (range) => range
376
+ .eq("siteId", input.siteId)
377
+ .eq("goalId", goal._id)
378
+ .eq("eventId", event.eventId))
379
+ .take(2))
380
+ .find((completion) => completion.idempotencyVersion === undefined &&
381
+ completion.sessionId === event.sessionId &&
382
+ eventBucketStart(completion.timestamp) ===
383
+ eventBucketStart(event.timestamp));
384
+ if (scopedPrior || legacyPrior)
268
385
  continue;
269
386
  await ctx.db.insert("goalCompletions", {
270
387
  siteId: input.siteId,
271
388
  goalId: goal._id,
272
389
  goalKey: goal.key,
273
- eventId: event.eventId,
390
+ eventId: completionEventId,
391
+ idempotencyVersion: 2,
274
392
  sessionId: event.sessionId,
275
393
  visitorId: event.visitorId,
276
394
  timestamp: event.timestamp,
@@ -280,7 +398,7 @@ async function accountEvent(ctx, input) {
280
398
  }
281
399
  for (const funnel of input.funnels) {
282
400
  if (funnel.active) {
283
- await advanceFunnel(ctx, input.siteId, funnel, event);
401
+ await advanceFunnel(ctx, input.siteId, funnel, event, input.batchState);
284
402
  }
285
403
  }
286
404
  if (input.financialConversion) {
@@ -301,7 +419,7 @@ async function accountEvent(ctx, input) {
301
419
  foldAggregates(input.aggregateDeltas, input.siteId, event, session, dimensions, input.recordTelemetry, input.financialConversion);
302
420
  if (input.recordTelemetry &&
303
421
  event.timestamp >= input.now - LIVE_SESSION_TTL_MS) {
304
- await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now);
422
+ await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now, input.batchState);
305
423
  }
306
424
  }
307
425
  async function accountTrustedSession(ctx, existing, event) {
@@ -328,16 +446,16 @@ async function accountTrustedSession(ctx, existing, event) {
328
446
  affiliateExpiresAt: existing.affiliateExpiresAt,
329
447
  };
330
448
  }
331
- async function upsertSession(ctx, siteId, event, context, existing, financialConversion) {
449
+ async function upsertSession(ctx, siteId, event, context, existing, financialConversion, batchState) {
332
450
  const source = sourceFromReferrer(event.referrer);
333
451
  const utmSource = stringProperty(event.properties, "utm_source");
334
452
  const utmMedium = stringProperty(event.properties, "utm_medium");
335
453
  const utmCampaign = stringProperty(event.properties, "utm_campaign");
336
- const attribution = await attributionForEvent(ctx, siteId, event, existing);
454
+ const attribution = await attributionForEvent(ctx, siteId, event, existing, batchState);
337
455
  if (existing) {
338
456
  const startedAt = Math.min(existing.startedAt, event.timestamp);
339
457
  const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
340
- await ctx.db.patch("sessions", existing._id, {
458
+ const updates = {
341
459
  startedAt,
342
460
  lastSeenAt,
343
461
  entryPath: event.timestamp < existing.startedAt ? event.path : existing.entryPath,
@@ -362,7 +480,17 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
362
480
  browser: context.browser ?? existing.browser,
363
481
  os: context.os ?? existing.os,
364
482
  device: context.device ?? existing.device,
365
- });
483
+ };
484
+ if (batchState) {
485
+ const state = batchState.sessions.get(event.sessionId);
486
+ if (!state)
487
+ throw new Error("session cache was not initialized");
488
+ state.current = { ...existing, ...updates };
489
+ state.dirty = true;
490
+ }
491
+ else {
492
+ await ctx.db.patch("sessions", existing._id, updates);
493
+ }
366
494
  return {
367
495
  isNewSession: false,
368
496
  isNewVisitorHour: Math.floor(existing.lastSeenAt / HOUR_MS) <
@@ -376,12 +504,23 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
376
504
  ...(attribution ?? {}),
377
505
  };
378
506
  }
379
- const priorVisit = await ctx.db
507
+ const priorVisits = await ctx.db
380
508
  .query("sessions")
381
509
  .withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
382
510
  .order("desc")
383
511
  .take(1);
384
- await ctx.db.insert("sessions", {
512
+ let priorVisit = priorVisits[0] ?? null;
513
+ if (batchState) {
514
+ for (const state of batchState.sessions.values()) {
515
+ const candidate = state.current;
516
+ if (candidate?.visitorId === event.visitorId &&
517
+ candidate.sessionId !== event.sessionId &&
518
+ (!priorVisit || candidate.lastSeenAt > priorVisit.lastSeenAt)) {
519
+ priorVisit = candidate;
520
+ }
521
+ }
522
+ }
523
+ const fields = {
385
524
  siteId,
386
525
  sessionId: event.sessionId,
387
526
  visitorId: event.visitorId,
@@ -410,14 +549,24 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
410
549
  ? { currency: event.currency }
411
550
  : {}),
412
551
  ...(attribution ?? {}),
413
- });
552
+ };
553
+ const sessionId = await ctx.db.insert("sessions", fields);
554
+ if (batchState) {
555
+ const state = batchState.sessions.get(event.sessionId);
556
+ if (!state)
557
+ throw new Error("session cache was not initialized");
558
+ state.current = {
559
+ ...fields,
560
+ _id: sessionId,
561
+ };
562
+ }
414
563
  return {
415
564
  isNewSession: true,
416
- isNewVisitorHour: priorVisit.length === 0 ||
417
- Math.floor(priorVisit[0].lastSeenAt / HOUR_MS) <
565
+ isNewVisitorHour: priorVisit === null ||
566
+ Math.floor(priorVisit.lastSeenAt / HOUR_MS) <
418
567
  Math.floor(event.timestamp / HOUR_MS),
419
- isNewVisitorDay: priorVisit.length === 0 ||
420
- Math.floor(priorVisit[0].lastSeenAt / DAY_MS) <
568
+ isNewVisitorDay: priorVisit === null ||
569
+ Math.floor(priorVisit.lastSeenAt / DAY_MS) <
421
570
  Math.floor(event.timestamp / DAY_MS),
422
571
  startedAt: event.timestamp,
423
572
  source,
@@ -446,7 +595,7 @@ function attributionApplies(attribution, timestamp) {
446
595
  }
447
596
  // The first valid touch wins through its inclusive expiry. Only a later,
448
597
  // in-order valid touch may replace the visitor's single bounded record.
449
- async function attributionForEvent(ctx, siteId, event, existingSession) {
598
+ async function attributionForEvent(ctx, siteId, event, existingSession, batchState) {
450
599
  const sessionAttribution = existingSession
451
600
  ? attributionSnapshot(existingSession)
452
601
  : undefined;
@@ -458,10 +607,21 @@ async function attributionForEvent(ctx, siteId, event, existingSession) {
458
607
  attributionApplies(sessionAttribution, event.timestamp)) {
459
608
  return sessionAttribution;
460
609
  }
461
- const current = await ctx.db
462
- .query("visitorAttributions")
463
- .withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
464
- .unique();
610
+ let attributionState = batchState?.visitorAttributions.get(event.visitorId);
611
+ if (batchState && !attributionState) {
612
+ const current = await ctx.db
613
+ .query("visitorAttributions")
614
+ .withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
615
+ .unique();
616
+ attributionState = { current, dirty: false };
617
+ batchState.visitorAttributions.set(event.visitorId, attributionState);
618
+ }
619
+ const current = attributionState
620
+ ? attributionState.current
621
+ : await ctx.db
622
+ .query("visitorAttributions")
623
+ .withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
624
+ .unique();
465
625
  if (current) {
466
626
  const snapshot = {
467
627
  affiliateId: current.affiliateId,
@@ -489,23 +649,32 @@ async function attributionForEvent(ctx, siteId, event, existingSession) {
489
649
  }
490
650
  const attributedAt = event.timestamp;
491
651
  const expiresAt = attributedAt + affiliate.attributionWindowDays * DAY_MS;
652
+ const fields = {
653
+ siteId,
654
+ visitorId: event.visitorId,
655
+ affiliateId: affiliate._id,
656
+ affiliateSlug: affiliate.slug,
657
+ attributedAt,
658
+ expiresAt,
659
+ };
492
660
  if (current) {
493
- await ctx.db.patch("visitorAttributions", current._id, {
494
- affiliateId: affiliate._id,
495
- affiliateSlug: affiliate.slug,
496
- attributedAt,
497
- expiresAt,
498
- });
661
+ if (attributionState) {
662
+ attributionState.current = { ...current, ...fields };
663
+ attributionState.dirty = true;
664
+ }
665
+ else {
666
+ await ctx.db.patch("visitorAttributions", current._id, fields);
667
+ }
499
668
  }
500
669
  else {
501
- await ctx.db.insert("visitorAttributions", {
502
- siteId,
503
- visitorId: event.visitorId,
504
- affiliateId: affiliate._id,
505
- affiliateSlug: affiliate.slug,
506
- attributedAt,
507
- expiresAt,
508
- });
670
+ const attributionId = await ctx.db.insert("visitorAttributions", fields);
671
+ if (attributionState) {
672
+ attributionState.current = {
673
+ ...fields,
674
+ _id: attributionId,
675
+ _creationTime: event.timestamp,
676
+ };
677
+ }
509
678
  }
510
679
  return {
511
680
  affiliateId: affiliate._id,
@@ -514,10 +683,10 @@ async function attributionForEvent(ctx, siteId, event, existingSession) {
514
683
  affiliateExpiresAt: expiresAt,
515
684
  };
516
685
  }
517
- async function accountHeartbeat(ctx, siteId, session, event, context, now) {
686
+ async function accountHeartbeat(ctx, siteId, session, event, context, now, batchState) {
518
687
  const startedAt = Math.min(session.startedAt, event.timestamp);
519
688
  const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
520
- await ctx.db.patch("sessions", session._id, {
689
+ const updates = {
521
690
  startedAt,
522
691
  lastSeenAt,
523
692
  exitPath: event.timestamp >= session.lastSeenAt
@@ -531,7 +700,17 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now) {
531
700
  browser: context.browser ?? session.browser,
532
701
  os: context.os ?? session.os,
533
702
  device: context.device ?? session.device,
534
- });
703
+ };
704
+ if (batchState) {
705
+ const state = batchState.sessions.get(event.sessionId);
706
+ if (!state)
707
+ throw new Error("session cache was not initialized");
708
+ state.current = { ...session, ...updates };
709
+ state.dirty = true;
710
+ }
711
+ else {
712
+ await ctx.db.patch("sessions", session._id, updates);
713
+ }
535
714
  await updateLiveSession(ctx, siteId, event, context, {
536
715
  isNewSession: false,
537
716
  isNewVisitorHour: false,
@@ -541,7 +720,7 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now) {
541
720
  pageviewCount: session.pageviewCount,
542
721
  eventCount: session.eventCount,
543
722
  affiliateSlug: session.affiliateSlug,
544
- }, now);
723
+ }, now, batchState);
545
724
  }
546
725
  async function consumeSiteRateLimit(ctx, siteId, events, now) {
547
726
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
@@ -560,13 +739,15 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
560
739
  }
561
740
  const windows = [];
562
741
  for (const [shard, increment] of increments) {
563
- const window = await ctx.db
742
+ const rows = await ctx.db
564
743
  .query("siteIngestWindows")
565
- .withIndex("by_siteId_and_shard_and_windowStart", (range) => range
566
- .eq("siteId", siteId)
567
- .eq("shard", shard)
568
- .eq("windowStart", windowStart))
569
- .unique();
744
+ .withIndex("by_siteId_and_shard_and_windowStart", (range) => range.eq("siteId", siteId).eq("shard", shard))
745
+ .order("desc")
746
+ .take(1);
747
+ const storedWindow = rows[0] ?? null;
748
+ const window = storedWindow?.windowStart === windowStart
749
+ ? storedWindow
750
+ : null;
570
751
  if ((window?.eventCount ?? 0) + increment.eventCount >
571
752
  MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
572
753
  (window?.byteCount ?? 0) + increment.byteCount >
@@ -580,13 +761,18 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
580
761
  siteByteLimit: SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
581
762
  });
582
763
  }
583
- windows.push({ shard, increment, window });
764
+ windows.push({ shard, increment, window: storedWindow });
584
765
  }
585
766
  for (const { shard, increment, window } of windows) {
586
767
  if (window) {
587
768
  await ctx.db.patch("siteIngestWindows", window._id, {
588
- eventCount: window.eventCount + increment.eventCount,
589
- byteCount: window.byteCount + increment.byteCount,
769
+ windowStart,
770
+ eventCount: window.windowStart === windowStart
771
+ ? window.eventCount + increment.eventCount
772
+ : increment.eventCount,
773
+ byteCount: window.windowStart === windowStart
774
+ ? window.byteCount + increment.byteCount
775
+ : increment.byteCount,
590
776
  updatedAt: now,
591
777
  });
592
778
  }
@@ -603,53 +789,83 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
603
789
  }
604
790
  return eventByteCounts;
605
791
  }
606
- async function consumeRateLimit(ctx, siteId, sessionId, byteCount, now) {
792
+ async function consumeRateLimit(ctx, siteId, sessionId, byteCount, now, states) {
607
793
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
608
- const window = await ctx.db
609
- .query("ingestWindows")
610
- .withIndex("by_siteId_and_sessionId_and_windowStart", (range) => range
611
- .eq("siteId", siteId)
612
- .eq("sessionId", sessionId)
613
- .eq("windowStart", windowStart))
614
- .unique();
615
- if (window &&
616
- (window.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
617
- window.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW)) {
618
- return false;
619
- }
620
- if (window) {
621
- await ctx.db.patch("ingestWindows", window._id, {
622
- eventCount: window.eventCount + 1,
623
- byteCount: window.byteCount + byteCount,
624
- updatedAt: now,
625
- });
626
- }
627
- else {
628
- await ctx.db.insert("ingestWindows", {
794
+ let state = states.get(sessionId);
795
+ if (!state) {
796
+ const rows = await ctx.db
797
+ .query("ingestWindows")
798
+ .withIndex("by_siteId_and_sessionId_and_windowStart", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
799
+ .order("desc")
800
+ .take(1);
801
+ const window = rows[0] ?? null;
802
+ state = {
629
803
  siteId,
630
804
  sessionId,
631
- windowStart,
632
- eventCount: 1,
633
- byteCount,
634
- updatedAt: now,
635
- });
805
+ window,
806
+ eventCount: window?.windowStart === windowStart ? window.eventCount : 0,
807
+ byteCount: window?.windowStart === windowStart ? window.byteCount : 0,
808
+ dirty: false,
809
+ };
810
+ states.set(sessionId, state);
811
+ }
812
+ if (state.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
813
+ state.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW) {
814
+ return false;
636
815
  }
816
+ state.eventCount += 1;
817
+ state.byteCount += byteCount;
818
+ state.dirty = true;
637
819
  return true;
638
820
  }
639
- async function updateLiveSession(ctx, siteId, event, context, session, now) {
640
- const existing = await ctx.db
641
- .query("liveSessions")
642
- .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", event.sessionId))
643
- .unique();
644
- const renewsExpiry = !existing ||
645
- existing.expiresAt <= now ||
646
- event.type === "pageview" ||
647
- event.type === "heartbeat";
648
- const deadline = renewsExpiry
649
- ? now + LIVE_SESSION_TTL_MS
650
- : existing.expiresAt;
821
+ async function flushSessionRateLimits(ctx, states, now) {
822
+ const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
823
+ for (const state of states.values()) {
824
+ if (!state.dirty)
825
+ continue;
826
+ if (state.window) {
827
+ await ctx.db.patch("ingestWindows", state.window._id, {
828
+ windowStart,
829
+ eventCount: state.eventCount,
830
+ byteCount: state.byteCount,
831
+ updatedAt: now,
832
+ });
833
+ }
834
+ else {
835
+ await ctx.db.insert("ingestWindows", {
836
+ siteId: state.siteId,
837
+ sessionId: state.sessionId,
838
+ windowStart,
839
+ eventCount: state.eventCount,
840
+ byteCount: state.byteCount,
841
+ updatedAt: now,
842
+ });
843
+ }
844
+ }
845
+ }
846
+ async function updateLiveSession(ctx, siteId, event, context, session, now, batchState) {
847
+ await ensureLiveSweep(ctx, siteId, now, batchState);
848
+ const liveState = batchState
849
+ ? await loadLiveSession(ctx, siteId, event.sessionId, batchState)
850
+ : undefined;
851
+ const existing = liveState
852
+ ? liveState.current
853
+ : await ctx.db
854
+ .query("liveSessions")
855
+ .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", event.sessionId))
856
+ .unique();
857
+ const deadline = now + LIVE_SESSION_TTL_MS;
651
858
  if (existing) {
652
- await ctx.db.patch("liveSessions", existing._id, {
859
+ const recentHeartbeatEventIds = event.type === "heartbeat"
860
+ ? [
861
+ ...(existing.recentHeartbeatEventIds ??
862
+ (existing.lastHeartbeatEventId
863
+ ? [existing.lastHeartbeatEventId]
864
+ : [])),
865
+ event.eventId,
866
+ ].slice(-8)
867
+ : undefined;
868
+ const updates = {
653
869
  visitorId: event.visitorId,
654
870
  path: event.path,
655
871
  lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
@@ -665,12 +881,22 @@ async function updateLiveSession(ctx, siteId, event, context, session, now) {
665
881
  pageviewCount: session.pageviewCount,
666
882
  eventCount: session.eventCount,
667
883
  ...(event.type === "heartbeat"
668
- ? { lastHeartbeatEventId: event.eventId }
884
+ ? {
885
+ lastHeartbeatEventId: event.eventId,
886
+ recentHeartbeatEventIds,
887
+ }
669
888
  : {}),
670
- });
889
+ };
890
+ if (liveState) {
891
+ liveState.current = { ...existing, ...updates };
892
+ liveState.dirty = true;
893
+ }
894
+ else {
895
+ await ctx.db.patch("liveSessions", existing._id, updates);
896
+ }
671
897
  return;
672
898
  }
673
- const liveSessionId = await ctx.db.insert("liveSessions", {
899
+ const fields = {
674
900
  siteId,
675
901
  sessionId: event.sessionId,
676
902
  visitorId: event.visitorId,
@@ -678,7 +904,6 @@ async function updateLiveSession(ctx, siteId, event, context, session, now) {
678
904
  startedAt: event.timestamp,
679
905
  lastSeenAt: event.timestamp,
680
906
  expiresAt: deadline,
681
- expiryChainDeadline: deadline,
682
907
  ...(context.country ? { country: context.country } : {}),
683
908
  ...(context.city ? { city: context.city } : {}),
684
909
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
@@ -690,13 +915,37 @@ async function updateLiveSession(ctx, siteId, event, context, session, now) {
690
915
  pageviewCount: session.pageviewCount,
691
916
  eventCount: session.eventCount,
692
917
  ...(event.type === "heartbeat"
693
- ? { lastHeartbeatEventId: event.eventId }
918
+ ? {
919
+ lastHeartbeatEventId: event.eventId,
920
+ recentHeartbeatEventIds: [event.eventId],
921
+ }
694
922
  : {}),
695
- });
696
- await ctx.scheduler.runAt(deadline, internal.live.expire, {
697
- liveSessionId,
698
- scheduledDeadline: deadline,
699
- });
923
+ };
924
+ const liveSessionId = await ctx.db.insert("liveSessions", fields);
925
+ if (liveState) {
926
+ liveState.current = {
927
+ ...fields,
928
+ _id: liveSessionId,
929
+ };
930
+ }
931
+ }
932
+ async function ensureLiveSweep(ctx, siteId, now, batchState) {
933
+ const key = String(siteId);
934
+ if (batchState?.liveSweepSites.has(key))
935
+ return;
936
+ const existing = await ctx.db
937
+ .query("liveSweepStates")
938
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
939
+ .unique();
940
+ if (!existing) {
941
+ const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
942
+ await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
943
+ await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
944
+ siteId,
945
+ scheduledAt,
946
+ });
947
+ }
948
+ batchState?.liveSweepSites.add(key);
700
949
  }
701
950
  function goalMatches(goal, event) {
702
951
  if (goal.matchType === "pageview") {
@@ -704,19 +953,34 @@ function goalMatches(goal, event) {
704
953
  }
705
954
  return event.type === "custom" && event.name === goal.matchValue;
706
955
  }
707
- async function advanceFunnel(ctx, siteId, funnel, event) {
708
- const progress = await ctx.db
709
- .query("funnelProgress")
710
- .withIndex("by_siteId_and_funnelId_and_visitorId", (range) => range
711
- .eq("siteId", siteId)
712
- .eq("funnelId", funnel._id)
713
- .eq("visitorId", event.visitorId))
714
- .unique();
956
+ async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
957
+ const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
958
+ let progressState = batchState?.funnelProgress.get(cacheKey);
959
+ if (batchState && !progressState) {
960
+ const current = await ctx.db
961
+ .query("funnelProgress")
962
+ .withIndex("by_siteId_and_funnelId_and_visitorId", (range) => range
963
+ .eq("siteId", siteId)
964
+ .eq("funnelId", funnel._id)
965
+ .eq("visitorId", event.visitorId))
966
+ .unique();
967
+ progressState = { current, dirty: false };
968
+ batchState.funnelProgress.set(cacheKey, progressState);
969
+ }
970
+ const progress = progressState
971
+ ? progressState.current
972
+ : await ctx.db
973
+ .query("funnelProgress")
974
+ .withIndex("by_siteId_and_funnelId_and_visitorId", (range) => range
975
+ .eq("siteId", siteId)
976
+ .eq("funnelId", funnel._id)
977
+ .eq("visitorId", event.visitorId))
978
+ .unique();
715
979
  const matchesFirst = stepMatches(funnel.steps[0], event);
716
980
  if (!progress) {
717
981
  if (!matchesFirst)
718
982
  return;
719
- await ctx.db.insert("funnelProgress", {
983
+ const fields = {
720
984
  siteId,
721
985
  funnelId: funnel._id,
722
986
  visitorId: event.visitorId,
@@ -724,7 +988,14 @@ async function advanceFunnel(ctx, siteId, funnel, event) {
724
988
  currentStep: 1,
725
989
  startedAt: event.timestamp,
726
990
  lastStepAt: event.timestamp,
727
- });
991
+ };
992
+ const progressId = await ctx.db.insert("funnelProgress", fields);
993
+ if (progressState) {
994
+ progressState.current = {
995
+ ...fields,
996
+ _id: progressId,
997
+ };
998
+ }
728
999
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
729
1000
  return;
730
1001
  }
@@ -733,13 +1004,27 @@ async function advanceFunnel(ctx, siteId, funnel, event) {
733
1004
  if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
734
1005
  if (!matchesFirst)
735
1006
  return;
736
- await ctx.db.patch("funnelProgress", progress._id, {
1007
+ const updated = {
1008
+ ...progress,
737
1009
  sessionId: event.sessionId,
738
1010
  currentStep: 1,
739
1011
  startedAt: event.timestamp,
740
1012
  lastStepAt: event.timestamp,
741
- completedAt: undefined,
742
- });
1013
+ };
1014
+ delete updated.completedAt;
1015
+ if (progressState) {
1016
+ progressState.current = updated;
1017
+ progressState.dirty = true;
1018
+ }
1019
+ else {
1020
+ await ctx.db.patch("funnelProgress", progress._id, {
1021
+ sessionId: event.sessionId,
1022
+ currentStep: 1,
1023
+ startedAt: event.timestamp,
1024
+ lastStepAt: event.timestamp,
1025
+ completedAt: undefined,
1026
+ });
1027
+ }
743
1028
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
744
1029
  return;
745
1030
  }
@@ -748,12 +1033,19 @@ async function advanceFunnel(ctx, siteId, funnel, event) {
748
1033
  return;
749
1034
  const currentStep = progress.currentStep + 1;
750
1035
  const completed = currentStep === funnel.steps.length;
751
- await ctx.db.patch("funnelProgress", progress._id, {
1036
+ const updates = {
752
1037
  sessionId: event.sessionId,
753
1038
  currentStep,
754
1039
  lastStepAt: event.timestamp,
755
1040
  ...(completed ? { completedAt: event.timestamp } : {}),
756
- });
1041
+ };
1042
+ if (progressState) {
1043
+ progressState.current = { ...progress, ...updates };
1044
+ progressState.dirty = true;
1045
+ }
1046
+ else {
1047
+ await ctx.db.patch("funnelProgress", progress._id, updates);
1048
+ }
757
1049
  await incrementFunnelStep(ctx, siteId, funnel._id, event, progress.currentStep, completed);
758
1050
  }
759
1051
  function stepMatches(step, event) {
@@ -763,7 +1055,7 @@ function stepMatches(step, event) {
763
1055
  }
764
1056
  async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed) {
765
1057
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
766
- const shard = stableHash(event.visitorId) % AGGREGATE_SHARDS;
1058
+ const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
767
1059
  const bucket = await ctx.db
768
1060
  .query("funnelStepBuckets")
769
1061
  .withIndex("by_siteId_and_funnelId_and_bucketStart_and_step_and_shard", (range) => range
@@ -821,7 +1113,7 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
821
1113
  });
822
1114
  if (commissionAffiliate && attribution) {
823
1115
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
824
- const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
1116
+ const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
825
1117
  const stats = await ctx.db
826
1118
  .query("affiliateDailyStats")
827
1119
  .withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) => range
@@ -852,7 +1144,6 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
852
1144
  }
853
1145
  }
854
1146
  function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion) {
855
- const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
856
1147
  const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
857
1148
  const dimensionDeltas = dimensions.map((dimension) => ({
858
1149
  ...dimension,
@@ -860,6 +1151,8 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
860
1151
  revenueCents,
861
1152
  }));
862
1153
  for (const granularity of ["hour", "day"]) {
1154
+ const shard = stableHash(event.sessionId) %
1155
+ (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
863
1156
  const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
864
1157
  const bucketStart = Math.floor(event.timestamp / interval) * interval;
865
1158
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
@@ -955,7 +1248,7 @@ function mergeDimensions(current, additions) {
955
1248
  continue;
956
1249
  }
957
1250
  const typeSlots = result.filter((candidate) => candidate.type === addition.type);
958
- if (typeSlots.length < DIMENSION_SLOTS) {
1251
+ if (typeSlots.length < DIMENSION_SLOTS - 1) {
959
1252
  const occupied = Array.from({ length: DIMENSION_SLOTS }, () => false);
960
1253
  for (const candidate of typeSlots)
961
1254
  occupied[candidate.slot] = true;
@@ -971,16 +1264,36 @@ function mergeDimensions(current, additions) {
971
1264
  });
972
1265
  continue;
973
1266
  }
974
- if (addition.count === 0)
1267
+ const overflow = typeSlots.find((candidate) => candidate.value === "(other)");
1268
+ if (overflow) {
1269
+ overflow.count += addition.count;
1270
+ overflow.revenueCents += addition.revenueCents;
975
1271
  continue;
1272
+ }
1273
+ if (addition.count === 0 && addition.revenueCents === 0)
1274
+ continue;
1275
+ if (typeSlots.length < DIMENSION_SLOTS) {
1276
+ const occupied = new Set(typeSlots.map((candidate) => candidate.slot));
1277
+ let slot = stableHash("(other)") % DIMENSION_SLOTS;
1278
+ while (occupied.has(slot))
1279
+ slot = (slot + 1) % DIMENSION_SLOTS;
1280
+ result.push({
1281
+ type: addition.type,
1282
+ slot,
1283
+ value: "(other)",
1284
+ count: addition.count,
1285
+ revenueCents: addition.revenueCents,
1286
+ });
1287
+ continue;
1288
+ }
976
1289
  let replacement = typeSlots[0];
977
1290
  for (const candidate of typeSlots.slice(1)) {
978
1291
  if (candidate.count < replacement.count)
979
1292
  replacement = candidate;
980
1293
  }
981
- replacement.value = addition.value;
1294
+ replacement.value = "(other)";
982
1295
  replacement.count += addition.count;
983
- replacement.revenueCents = addition.revenueCents;
1296
+ replacement.revenueCents += addition.revenueCents;
984
1297
  }
985
1298
  return result;
986
1299
  }