@iann29/rastro 0.1.0-alpha.0

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 (126) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +352 -0
  3. package/assets/rastro-live-atlas.png +0 -0
  4. package/dist/client/_generated/_ignore.d.ts +1 -0
  5. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  6. package/dist/client/_generated/_ignore.js +3 -0
  7. package/dist/client/_generated/_ignore.js.map +1 -0
  8. package/dist/client/index.d.ts +960 -0
  9. package/dist/client/index.d.ts.map +1 -0
  10. package/dist/client/index.js +591 -0
  11. package/dist/client/index.js.map +1 -0
  12. package/dist/component/_generated/api.d.ts +60 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -0
  14. package/dist/component/_generated/api.js +31 -0
  15. package/dist/component/_generated/api.js.map +1 -0
  16. package/dist/component/_generated/component.d.ts +569 -0
  17. package/dist/component/_generated/component.d.ts.map +1 -0
  18. package/dist/component/_generated/component.js +11 -0
  19. package/dist/component/_generated/component.js.map +1 -0
  20. package/dist/component/_generated/dataModel.d.ts +46 -0
  21. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  22. package/dist/component/_generated/dataModel.js +11 -0
  23. package/dist/component/_generated/dataModel.js.map +1 -0
  24. package/dist/component/_generated/server.d.ts +133 -0
  25. package/dist/component/_generated/server.d.ts.map +1 -0
  26. package/dist/component/_generated/server.js +80 -0
  27. package/dist/component/_generated/server.js.map +1 -0
  28. package/dist/component/affiliates.d.ts +28 -0
  29. package/dist/component/affiliates.d.ts.map +1 -0
  30. package/dist/component/affiliates.js +110 -0
  31. package/dist/component/affiliates.js.map +1 -0
  32. package/dist/component/constants.d.ts +28 -0
  33. package/dist/component/constants.d.ts.map +1 -0
  34. package/dist/component/constants.js +28 -0
  35. package/dist/component/constants.js.map +1 -0
  36. package/dist/component/convex.config.d.ts +3 -0
  37. package/dist/component/convex.config.d.ts.map +1 -0
  38. package/dist/component/convex.config.js +3 -0
  39. package/dist/component/convex.config.js.map +1 -0
  40. package/dist/component/errors.d.ts +3 -0
  41. package/dist/component/errors.d.ts.map +1 -0
  42. package/dist/component/errors.js +5 -0
  43. package/dist/component/errors.js.map +1 -0
  44. package/dist/component/funnels.d.ts +36 -0
  45. package/dist/component/funnels.d.ts.map +1 -0
  46. package/dist/component/funnels.js +117 -0
  47. package/dist/component/funnels.js.map +1 -0
  48. package/dist/component/goals.d.ts +30 -0
  49. package/dist/component/goals.d.ts.map +1 -0
  50. package/dist/component/goals.js +110 -0
  51. package/dist/component/goals.js.map +1 -0
  52. package/dist/component/guards.d.ts +2 -0
  53. package/dist/component/guards.d.ts.map +1 -0
  54. package/dist/component/guards.js +4 -0
  55. package/dist/component/guards.js.map +1 -0
  56. package/dist/component/http.d.ts +3 -0
  57. package/dist/component/http.d.ts.map +1 -0
  58. package/dist/component/http.js +270 -0
  59. package/dist/component/http.js.map +1 -0
  60. package/dist/component/ingest.d.ts +51 -0
  61. package/dist/component/ingest.d.ts.map +1 -0
  62. package/dist/component/ingest.js +991 -0
  63. package/dist/component/ingest.js.map +1 -0
  64. package/dist/component/live.d.ts +9 -0
  65. package/dist/component/live.d.ts.map +1 -0
  66. package/dist/component/live.js +36 -0
  67. package/dist/component/live.js.map +1 -0
  68. package/dist/component/reports.d.ts +330 -0
  69. package/dist/component/reports.d.ts.map +1 -0
  70. package/dist/component/reports.js +595 -0
  71. package/dist/component/reports.js.map +1 -0
  72. package/dist/component/retention.d.ts +10 -0
  73. package/dist/component/retention.d.ts.map +1 -0
  74. package/dist/component/retention.js +107 -0
  75. package/dist/component/retention.js.map +1 -0
  76. package/dist/component/sanitize.d.ts +20 -0
  77. package/dist/component/sanitize.d.ts.map +1 -0
  78. package/dist/component/sanitize.js +269 -0
  79. package/dist/component/sanitize.js.map +1 -0
  80. package/dist/component/schema.d.ts +517 -0
  81. package/dist/component/schema.d.ts.map +1 -0
  82. package/dist/component/schema.js +209 -0
  83. package/dist/component/schema.js.map +1 -0
  84. package/dist/component/sites.d.ts +54 -0
  85. package/dist/component/sites.d.ts.map +1 -0
  86. package/dist/component/sites.js +164 -0
  87. package/dist/component/sites.js.map +1 -0
  88. package/dist/component/validators.d.ts +377 -0
  89. package/dist/component/validators.d.ts.map +1 -0
  90. package/dist/component/validators.js +176 -0
  91. package/dist/component/validators.js.map +1 -0
  92. package/dist/react/index.d.ts +40 -0
  93. package/dist/react/index.d.ts.map +1 -0
  94. package/dist/react/index.js +31 -0
  95. package/dist/react/index.js.map +1 -0
  96. package/dist/tracker/generated.d.ts +5 -0
  97. package/dist/tracker/generated.d.ts.map +1 -0
  98. package/dist/tracker/generated.js +6 -0
  99. package/dist/tracker/generated.js.map +1 -0
  100. package/dist/tracker/tracker.d.ts +27 -0
  101. package/dist/tracker/tracker.d.ts.map +1 -0
  102. package/dist/tracker/tracker.js +108 -0
  103. package/dist/tracker/tracker.js.map +1 -0
  104. package/dist/tracker.min.js +1 -0
  105. package/package.json +121 -0
  106. package/src/component/_generated/api.ts +76 -0
  107. package/src/component/_generated/component.ts +706 -0
  108. package/src/component/_generated/dataModel.ts +60 -0
  109. package/src/component/_generated/server.ts +169 -0
  110. package/src/component/affiliates.ts +119 -0
  111. package/src/component/constants.ts +28 -0
  112. package/src/component/convex.config.ts +3 -0
  113. package/src/component/errors.ts +20 -0
  114. package/src/component/funnels.ts +134 -0
  115. package/src/component/goals.ts +122 -0
  116. package/src/component/guards.ts +3 -0
  117. package/src/component/http.ts +301 -0
  118. package/src/component/ingest.ts +1364 -0
  119. package/src/component/live.ts +36 -0
  120. package/src/component/reports.ts +720 -0
  121. package/src/component/retention.ts +180 -0
  122. package/src/component/sanitize.ts +278 -0
  123. package/src/component/schema.ts +232 -0
  124. package/src/component/sites.ts +188 -0
  125. package/src/component/validators.ts +226 -0
  126. package/src/test.ts +19 -0
@@ -0,0 +1,991 @@
1
+ import { v } from "convex/values";
2
+ import { mutation } from "./_generated/server.js";
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";
5
+ import { fail } from "./errors.js";
6
+ import { cleanString, ensureBatchBytes, isValidMoney, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
7
+ import { ingestContextValidator, ingestResultValidator, trackerEventValidator, trustedConversionValidator, } from "./validators.js";
8
+ export const ingestBatch = mutation({
9
+ args: {
10
+ siteId: v.id("sites"),
11
+ origin: v.string(),
12
+ events: v.array(trackerEventValidator),
13
+ context: v.optional(ingestContextValidator),
14
+ },
15
+ returns: ingestResultValidator,
16
+ handler: async (ctx, args) => {
17
+ const site = await ctx.db.get("sites", args.siteId);
18
+ if (!site)
19
+ fail("NOT_FOUND", "site not found");
20
+ if (!originAllowed(args.origin, site.domains)) {
21
+ fail("ORIGIN_NOT_ALLOWED", "request origin is not allowed", {
22
+ siteId: String(args.siteId),
23
+ });
24
+ }
25
+ if (args.events.length < 1 || args.events.length > MAX_BATCH_EVENTS) {
26
+ fail("BATCH_TOO_LARGE", `events must contain 1-${MAX_BATCH_EVENTS} items`, {
27
+ limit: MAX_BATCH_EVENTS,
28
+ });
29
+ }
30
+ try {
31
+ ensureBatchBytes({ siteId: String(args.siteId), events: args.events });
32
+ }
33
+ catch {
34
+ fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
35
+ }
36
+ const now = Date.now();
37
+ const eventByteCounts = await consumeSiteRateLimit(ctx, args.siteId, args.events, now);
38
+ const context = sanitizeContext(args.context);
39
+ const aggregateDeltas = new Map();
40
+ let goals = null;
41
+ let funnels = null;
42
+ let accepted = 0;
43
+ let duplicates = 0;
44
+ let rejected = 0;
45
+ for (let eventIndex = 0; eventIndex < args.events.length; eventIndex += 1) {
46
+ const input = args.events[eventIndex];
47
+ let event;
48
+ try {
49
+ event = sanitizeEvent(input, now);
50
+ }
51
+ catch {
52
+ rejected += 1;
53
+ continue;
54
+ }
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();
59
+ if (existingSession && existingSession.visitorId !== event.visitorId) {
60
+ rejected += 1;
61
+ continue;
62
+ }
63
+ if (event.type === "heartbeat") {
64
+ if (!existingSession || event.timestamp < now - LIVE_SESSION_TTL_MS) {
65
+ rejected += 1;
66
+ continue;
67
+ }
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) {
73
+ duplicates += 1;
74
+ continue;
75
+ }
76
+ if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now))) {
77
+ rejected += 1;
78
+ continue;
79
+ }
80
+ await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now);
81
+ accepted += 1;
82
+ continue;
83
+ }
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) {
89
+ duplicates += 1;
90
+ continue;
91
+ }
92
+ if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now))) {
93
+ rejected += 1;
94
+ continue;
95
+ }
96
+ const canMatchDefinition = event.type === "pageview" || event.type === "custom";
97
+ if (canMatchDefinition && goals === null) {
98
+ goals = await ctx.db
99
+ .query("goals")
100
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
101
+ .take(MAX_GOALS_PER_SITE);
102
+ }
103
+ if (canMatchDefinition && funnels === null) {
104
+ funnels = await ctx.db
105
+ .query("funnels")
106
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
107
+ .take(MAX_FUNNELS_PER_SITE);
108
+ }
109
+ await accountEvent(ctx, {
110
+ siteId: args.siteId,
111
+ event,
112
+ context,
113
+ existingSession,
114
+ goals: canMatchDefinition ? goals ?? [] : [],
115
+ funnels: canMatchDefinition ? funnels ?? [] : [],
116
+ aggregateDeltas,
117
+ recordTelemetry: true,
118
+ financialConversion: false,
119
+ trustedGoalKey: undefined,
120
+ now,
121
+ });
122
+ accepted += 1;
123
+ }
124
+ await flushAggregates(ctx, aggregateDeltas);
125
+ return { accepted, duplicates, rejected };
126
+ },
127
+ });
128
+ export const trackConversion = mutation({
129
+ args: trustedConversionValidator.fields,
130
+ returns: ingestResultValidator,
131
+ handler: async (ctx, args) => {
132
+ const site = await ctx.db.get("sites", args.siteId);
133
+ if (!site)
134
+ fail("NOT_FOUND", "site not found");
135
+ if (!isValidMoney(args.revenueCents)) {
136
+ fail("INVALID_ARGUMENT", "revenueCents must be an integer between 0 and 1000000000");
137
+ }
138
+ let event;
139
+ try {
140
+ event = sanitizeEvent({
141
+ eventId: sanitizeOpaqueId(args.eventId, "eventId"),
142
+ sessionId: sanitizeOpaqueId(args.sessionId, "sessionId"),
143
+ visitorId: sanitizeOpaqueId(args.visitorId, "visitorId"),
144
+ type: "conversion",
145
+ name: args.goalKey ? sanitizeSlug(args.goalKey, "goalKey") : "conversion",
146
+ path: sanitizePath(args.path ?? "/conversion"),
147
+ timestamp: args.timestamp,
148
+ sequence: 0,
149
+ properties: sanitizeProperties(args.properties),
150
+ revenueCents: args.revenueCents,
151
+ currency: sanitizeCurrency(args.currency),
152
+ }, Date.now());
153
+ }
154
+ catch (error) {
155
+ fail("INVALID_ARGUMENT", error instanceof Error ? error.message : "invalid conversion");
156
+ }
157
+ 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
+ const priorConversion = await ctx.db
165
+ .query("conversions")
166
+ .withIndex("by_siteId_and_eventId", (range) => range.eq("siteId", args.siteId).eq("eventId", event.eventId))
167
+ .unique();
168
+ if (priorConversion) {
169
+ return { accepted: 0, duplicates: 1, rejected: 0 };
170
+ }
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();
175
+ if (existingTelemetry &&
176
+ (existingTelemetry.sessionId !== event.sessionId ||
177
+ existingTelemetry.visitorId !== event.visitorId)) {
178
+ fail("CONFLICT", "event belongs to a different session or visitor");
179
+ }
180
+ const existingSession = await ctx.db
181
+ .query("sessions")
182
+ .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", args.siteId).eq("sessionId", event.sessionId))
183
+ .unique();
184
+ if (existingSession && existingSession.visitorId !== event.visitorId) {
185
+ fail("CONFLICT", "session belongs to a different visitor");
186
+ }
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);
198
+ const aggregateDeltas = new Map();
199
+ await accountEvent(ctx, {
200
+ siteId: args.siteId,
201
+ event,
202
+ context: conversionContext,
203
+ existingSession,
204
+ goals: [],
205
+ funnels: [],
206
+ aggregateDeltas,
207
+ recordTelemetry: existingTelemetry === null,
208
+ financialConversion: true,
209
+ trustedGoalKey: args.goalKey
210
+ ? sanitizeSlug(args.goalKey, "goalKey")
211
+ : undefined,
212
+ now: Date.now(),
213
+ });
214
+ await flushAggregates(ctx, aggregateDeltas);
215
+ return { accepted: 1, duplicates: 0, rejected: 0 };
216
+ },
217
+ });
218
+ async function accountEvent(ctx, input) {
219
+ const { event } = input;
220
+ const session = input.recordTelemetry
221
+ ? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion)
222
+ : 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
+ const matchedGoalKeys = [];
257
+ for (const goal of input.goals) {
258
+ if (!goal.active || !goalMatches(goal, event))
259
+ continue;
260
+ const prior = await ctx.db
261
+ .query("goalCompletions")
262
+ .withIndex("by_siteId_and_goalId_and_eventId", (range) => range
263
+ .eq("siteId", input.siteId)
264
+ .eq("goalId", goal._id)
265
+ .eq("eventId", event.eventId))
266
+ .unique();
267
+ if (prior)
268
+ continue;
269
+ await ctx.db.insert("goalCompletions", {
270
+ siteId: input.siteId,
271
+ goalId: goal._id,
272
+ goalKey: goal.key,
273
+ eventId: event.eventId,
274
+ sessionId: event.sessionId,
275
+ visitorId: event.visitorId,
276
+ timestamp: event.timestamp,
277
+ valueCents: goal.valueCents ?? 0,
278
+ });
279
+ matchedGoalKeys.push(goal.key);
280
+ }
281
+ for (const funnel of input.funnels) {
282
+ if (funnel.active) {
283
+ await advanceFunnel(ctx, input.siteId, funnel, event);
284
+ }
285
+ }
286
+ if (input.financialConversion) {
287
+ await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey);
288
+ }
289
+ const dimensions = [
290
+ { type: "source", value: session.source },
291
+ { type: "page", value: event.path },
292
+ { type: "country", value: input.context.country ?? "unknown" },
293
+ { type: "device", value: input.context.device ?? "unknown" },
294
+ { type: "browser", value: input.context.browser ?? "Other" },
295
+ {
296
+ type: "event",
297
+ value: event.type === "custom" ? event.name ?? "custom" : event.type,
298
+ },
299
+ ...matchedGoalKeys.map((value) => ({ type: "goal", value })),
300
+ ];
301
+ foldAggregates(input.aggregateDeltas, input.siteId, event, session, dimensions, input.recordTelemetry, input.financialConversion);
302
+ if (input.recordTelemetry &&
303
+ event.timestamp >= input.now - LIVE_SESSION_TTL_MS) {
304
+ await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now);
305
+ }
306
+ }
307
+ async function accountTrustedSession(ctx, existing, event) {
308
+ if (!existing) {
309
+ fail("CONFLICT", "trusted conversion telemetry has no matching session");
310
+ }
311
+ const revenueCents = event.revenueCents ?? 0;
312
+ await ctx.db.patch("sessions", existing._id, {
313
+ conversionCount: existing.conversionCount + 1,
314
+ revenueCents: existing.revenueCents + revenueCents,
315
+ currency: event.currency ?? "USD",
316
+ });
317
+ return {
318
+ isNewSession: false,
319
+ isNewVisitorHour: false,
320
+ isNewVisitorDay: false,
321
+ startedAt: existing.startedAt,
322
+ source: existing.source,
323
+ pageviewCount: existing.pageviewCount,
324
+ eventCount: existing.eventCount,
325
+ affiliateId: existing.affiliateId,
326
+ affiliateSlug: existing.affiliateSlug,
327
+ affiliateAttributedAt: existing.affiliateAttributedAt,
328
+ affiliateExpiresAt: existing.affiliateExpiresAt,
329
+ };
330
+ }
331
+ async function upsertSession(ctx, siteId, event, context, existing, financialConversion) {
332
+ const source = sourceFromReferrer(event.referrer);
333
+ const utmSource = stringProperty(event.properties, "utm_source");
334
+ const utmMedium = stringProperty(event.properties, "utm_medium");
335
+ const utmCampaign = stringProperty(event.properties, "utm_campaign");
336
+ const attribution = await attributionForEvent(ctx, siteId, event, existing);
337
+ if (existing) {
338
+ const startedAt = Math.min(existing.startedAt, event.timestamp);
339
+ const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
340
+ await ctx.db.patch("sessions", existing._id, {
341
+ startedAt,
342
+ lastSeenAt,
343
+ entryPath: event.timestamp < existing.startedAt ? event.path : existing.entryPath,
344
+ exitPath: event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
345
+ eventCount: existing.eventCount + 1,
346
+ pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
347
+ durationMs: Math.max(0, lastSeenAt - startedAt),
348
+ conversionCount: existing.conversionCount + Number(financialConversion),
349
+ revenueCents: existing.revenueCents +
350
+ (financialConversion ? event.revenueCents ?? 0 : 0),
351
+ ...(financialConversion && event.currency
352
+ ? { currency: event.currency }
353
+ : {}),
354
+ ...(attribution ?? {}),
355
+ ...(utmSource ? { utmSource } : {}),
356
+ ...(utmMedium ? { utmMedium } : {}),
357
+ ...(utmCampaign ? { utmCampaign } : {}),
358
+ ...(context.country ? { country: context.country } : {}),
359
+ ...(context.city ? { city: context.city } : {}),
360
+ ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
361
+ ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
362
+ browser: context.browser ?? existing.browser,
363
+ os: context.os ?? existing.os,
364
+ device: context.device ?? existing.device,
365
+ });
366
+ return {
367
+ isNewSession: false,
368
+ isNewVisitorHour: Math.floor(existing.lastSeenAt / HOUR_MS) <
369
+ Math.floor(event.timestamp / HOUR_MS),
370
+ isNewVisitorDay: Math.floor(existing.lastSeenAt / DAY_MS) <
371
+ Math.floor(event.timestamp / DAY_MS),
372
+ startedAt,
373
+ source: existing.source,
374
+ pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
375
+ eventCount: existing.eventCount + 1,
376
+ ...(attribution ?? {}),
377
+ };
378
+ }
379
+ const priorVisit = await ctx.db
380
+ .query("sessions")
381
+ .withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
382
+ .order("desc")
383
+ .take(1);
384
+ await ctx.db.insert("sessions", {
385
+ siteId,
386
+ sessionId: event.sessionId,
387
+ visitorId: event.visitorId,
388
+ startedAt: event.timestamp,
389
+ lastSeenAt: event.timestamp,
390
+ entryPath: event.path,
391
+ exitPath: event.path,
392
+ ...(event.referrer ? { referrer: event.referrer } : {}),
393
+ source,
394
+ ...(utmSource ? { utmSource } : {}),
395
+ ...(utmMedium ? { utmMedium } : {}),
396
+ ...(utmCampaign ? { utmCampaign } : {}),
397
+ ...(context.country ? { country: context.country } : {}),
398
+ ...(context.city ? { city: context.city } : {}),
399
+ ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
400
+ ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
401
+ browser: context.browser ?? "Other",
402
+ os: context.os ?? "Other",
403
+ device: context.device ?? "unknown",
404
+ eventCount: 1,
405
+ pageviewCount: event.type === "pageview" ? 1 : 0,
406
+ durationMs: 0,
407
+ conversionCount: Number(financialConversion),
408
+ revenueCents: financialConversion ? event.revenueCents ?? 0 : 0,
409
+ ...(financialConversion && event.currency
410
+ ? { currency: event.currency }
411
+ : {}),
412
+ ...(attribution ?? {}),
413
+ });
414
+ return {
415
+ isNewSession: true,
416
+ isNewVisitorHour: priorVisit.length === 0 ||
417
+ Math.floor(priorVisit[0].lastSeenAt / HOUR_MS) <
418
+ Math.floor(event.timestamp / HOUR_MS),
419
+ isNewVisitorDay: priorVisit.length === 0 ||
420
+ Math.floor(priorVisit[0].lastSeenAt / DAY_MS) <
421
+ Math.floor(event.timestamp / DAY_MS),
422
+ startedAt: event.timestamp,
423
+ source,
424
+ pageviewCount: event.type === "pageview" ? 1 : 0,
425
+ eventCount: 1,
426
+ ...(attribution ?? {}),
427
+ };
428
+ }
429
+ function attributionSnapshot(value) {
430
+ if (!value.affiliateId ||
431
+ !value.affiliateSlug ||
432
+ value.affiliateAttributedAt === undefined ||
433
+ value.affiliateExpiresAt === undefined) {
434
+ return undefined;
435
+ }
436
+ return {
437
+ affiliateId: value.affiliateId,
438
+ affiliateSlug: value.affiliateSlug,
439
+ affiliateAttributedAt: value.affiliateAttributedAt,
440
+ affiliateExpiresAt: value.affiliateExpiresAt,
441
+ };
442
+ }
443
+ function attributionApplies(attribution, timestamp) {
444
+ return (timestamp >= attribution.affiliateAttributedAt &&
445
+ timestamp <= attribution.affiliateExpiresAt);
446
+ }
447
+ // The first valid touch wins through its inclusive expiry. Only a later,
448
+ // in-order valid touch may replace the visitor's single bounded record.
449
+ async function attributionForEvent(ctx, siteId, event, existingSession) {
450
+ const sessionAttribution = existingSession
451
+ ? attributionSnapshot(existingSession)
452
+ : undefined;
453
+ if (!event.affiliateSlug && sessionAttribution) {
454
+ return sessionAttribution;
455
+ }
456
+ if (event.affiliateSlug &&
457
+ sessionAttribution &&
458
+ attributionApplies(sessionAttribution, event.timestamp)) {
459
+ return sessionAttribution;
460
+ }
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();
465
+ if (current) {
466
+ const snapshot = {
467
+ affiliateId: current.affiliateId,
468
+ affiliateSlug: current.affiliateSlug,
469
+ affiliateAttributedAt: current.attributedAt,
470
+ affiliateExpiresAt: current.expiresAt,
471
+ };
472
+ if (attributionApplies(snapshot, event.timestamp)) {
473
+ return snapshot;
474
+ }
475
+ if (event.timestamp <= current.expiresAt) {
476
+ return sessionAttribution;
477
+ }
478
+ }
479
+ const affiliateSlug = event.affiliateSlug;
480
+ if (!affiliateSlug) {
481
+ return sessionAttribution;
482
+ }
483
+ const affiliate = await ctx.db
484
+ .query("affiliates")
485
+ .withIndex("by_siteId_and_slug", (range) => range.eq("siteId", siteId).eq("slug", affiliateSlug))
486
+ .unique();
487
+ if (!affiliate?.active) {
488
+ return sessionAttribution;
489
+ }
490
+ const attributedAt = event.timestamp;
491
+ const expiresAt = attributedAt + affiliate.attributionWindowDays * DAY_MS;
492
+ if (current) {
493
+ await ctx.db.patch("visitorAttributions", current._id, {
494
+ affiliateId: affiliate._id,
495
+ affiliateSlug: affiliate.slug,
496
+ attributedAt,
497
+ expiresAt,
498
+ });
499
+ }
500
+ 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
+ });
509
+ }
510
+ return {
511
+ affiliateId: affiliate._id,
512
+ affiliateSlug: affiliate.slug,
513
+ affiliateAttributedAt: attributedAt,
514
+ affiliateExpiresAt: expiresAt,
515
+ };
516
+ }
517
+ async function accountHeartbeat(ctx, siteId, session, event, context, now) {
518
+ const startedAt = Math.min(session.startedAt, event.timestamp);
519
+ const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
520
+ await ctx.db.patch("sessions", session._id, {
521
+ startedAt,
522
+ lastSeenAt,
523
+ exitPath: event.timestamp >= session.lastSeenAt
524
+ ? event.path
525
+ : session.exitPath,
526
+ durationMs: Math.max(0, lastSeenAt - startedAt),
527
+ ...(context.country ? { country: context.country } : {}),
528
+ ...(context.city ? { city: context.city } : {}),
529
+ ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
530
+ ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
531
+ browser: context.browser ?? session.browser,
532
+ os: context.os ?? session.os,
533
+ device: context.device ?? session.device,
534
+ });
535
+ await updateLiveSession(ctx, siteId, event, context, {
536
+ isNewSession: false,
537
+ isNewVisitorHour: false,
538
+ isNewVisitorDay: false,
539
+ startedAt,
540
+ source: session.source,
541
+ pageviewCount: session.pageviewCount,
542
+ eventCount: session.eventCount,
543
+ affiliateSlug: session.affiliateSlug,
544
+ }, now);
545
+ }
546
+ async function consumeSiteRateLimit(ctx, siteId, events, now) {
547
+ const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
548
+ const increments = new Map();
549
+ const eventByteCounts = [];
550
+ const encoder = new TextEncoder();
551
+ for (let index = 0; index < events.length; index += 1) {
552
+ const event = events[index];
553
+ const byteCount = encoder.encode(JSON.stringify(event)).byteLength;
554
+ eventByteCounts.push(byteCount);
555
+ const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
556
+ const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
557
+ current.eventCount += 1;
558
+ current.byteCount += byteCount;
559
+ increments.set(shard, current);
560
+ }
561
+ const windows = [];
562
+ for (const [shard, increment] of increments) {
563
+ const window = await ctx.db
564
+ .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();
570
+ if ((window?.eventCount ?? 0) + increment.eventCount >
571
+ MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
572
+ (window?.byteCount ?? 0) + increment.byteCount >
573
+ MAX_SITE_BYTES_PER_SHARD_WINDOW) {
574
+ fail("RATE_LIMITED", "site ingest budget exceeded", {
575
+ siteShard: shard,
576
+ windowMs: RATE_LIMIT_WINDOW_MS,
577
+ eventLimitPerShard: MAX_SITE_EVENTS_PER_SHARD_WINDOW,
578
+ byteLimitPerShard: MAX_SITE_BYTES_PER_SHARD_WINDOW,
579
+ siteEventLimit: SITE_INGEST_SHARDS * MAX_SITE_EVENTS_PER_SHARD_WINDOW,
580
+ siteByteLimit: SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
581
+ });
582
+ }
583
+ windows.push({ shard, increment, window });
584
+ }
585
+ for (const { shard, increment, window } of windows) {
586
+ if (window) {
587
+ await ctx.db.patch("siteIngestWindows", window._id, {
588
+ eventCount: window.eventCount + increment.eventCount,
589
+ byteCount: window.byteCount + increment.byteCount,
590
+ updatedAt: now,
591
+ });
592
+ }
593
+ else {
594
+ await ctx.db.insert("siteIngestWindows", {
595
+ siteId,
596
+ shard,
597
+ windowStart,
598
+ eventCount: increment.eventCount,
599
+ byteCount: increment.byteCount,
600
+ updatedAt: now,
601
+ });
602
+ }
603
+ }
604
+ return eventByteCounts;
605
+ }
606
+ async function consumeRateLimit(ctx, siteId, sessionId, byteCount, now) {
607
+ 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", {
629
+ siteId,
630
+ sessionId,
631
+ windowStart,
632
+ eventCount: 1,
633
+ byteCount,
634
+ updatedAt: now,
635
+ });
636
+ }
637
+ return true;
638
+ }
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;
651
+ if (existing) {
652
+ await ctx.db.patch("liveSessions", existing._id, {
653
+ visitorId: event.visitorId,
654
+ path: event.path,
655
+ lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
656
+ expiresAt: deadline,
657
+ ...(context.country ? { country: context.country } : {}),
658
+ ...(context.city ? { city: context.city } : {}),
659
+ ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
660
+ ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
661
+ browser: context.browser ?? existing.browser,
662
+ os: context.os ?? existing.os,
663
+ source: session.source,
664
+ device: context.device ?? existing.device,
665
+ pageviewCount: session.pageviewCount,
666
+ eventCount: session.eventCount,
667
+ ...(event.type === "heartbeat"
668
+ ? { lastHeartbeatEventId: event.eventId }
669
+ : {}),
670
+ });
671
+ return;
672
+ }
673
+ const liveSessionId = await ctx.db.insert("liveSessions", {
674
+ siteId,
675
+ sessionId: event.sessionId,
676
+ visitorId: event.visitorId,
677
+ path: event.path,
678
+ startedAt: event.timestamp,
679
+ lastSeenAt: event.timestamp,
680
+ expiresAt: deadline,
681
+ expiryChainDeadline: deadline,
682
+ ...(context.country ? { country: context.country } : {}),
683
+ ...(context.city ? { city: context.city } : {}),
684
+ ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
685
+ ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
686
+ browser: context.browser ?? "Other",
687
+ os: context.os ?? "Other",
688
+ source: session.source,
689
+ device: context.device ?? "unknown",
690
+ pageviewCount: session.pageviewCount,
691
+ eventCount: session.eventCount,
692
+ ...(event.type === "heartbeat"
693
+ ? { lastHeartbeatEventId: event.eventId }
694
+ : {}),
695
+ });
696
+ await ctx.scheduler.runAt(deadline, internal.live.expire, {
697
+ liveSessionId,
698
+ scheduledDeadline: deadline,
699
+ });
700
+ }
701
+ function goalMatches(goal, event) {
702
+ if (goal.matchType === "pageview") {
703
+ return event.type === "pageview" && event.path === goal.matchValue;
704
+ }
705
+ return event.type === "custom" && event.name === goal.matchValue;
706
+ }
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();
715
+ const matchesFirst = stepMatches(funnel.steps[0], event);
716
+ if (!progress) {
717
+ if (!matchesFirst)
718
+ return;
719
+ await ctx.db.insert("funnelProgress", {
720
+ siteId,
721
+ funnelId: funnel._id,
722
+ visitorId: event.visitorId,
723
+ sessionId: event.sessionId,
724
+ currentStep: 1,
725
+ startedAt: event.timestamp,
726
+ lastStepAt: event.timestamp,
727
+ });
728
+ await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
729
+ return;
730
+ }
731
+ if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt)
732
+ return;
733
+ if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
734
+ if (!matchesFirst)
735
+ return;
736
+ await ctx.db.patch("funnelProgress", progress._id, {
737
+ sessionId: event.sessionId,
738
+ currentStep: 1,
739
+ startedAt: event.timestamp,
740
+ lastStepAt: event.timestamp,
741
+ completedAt: undefined,
742
+ });
743
+ await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
744
+ return;
745
+ }
746
+ const nextStep = funnel.steps[progress.currentStep];
747
+ if (!nextStep || !stepMatches(nextStep, event))
748
+ return;
749
+ const currentStep = progress.currentStep + 1;
750
+ const completed = currentStep === funnel.steps.length;
751
+ await ctx.db.patch("funnelProgress", progress._id, {
752
+ sessionId: event.sessionId,
753
+ currentStep,
754
+ lastStepAt: event.timestamp,
755
+ ...(completed ? { completedAt: event.timestamp } : {}),
756
+ });
757
+ await incrementFunnelStep(ctx, siteId, funnel._id, event, progress.currentStep, completed);
758
+ }
759
+ function stepMatches(step, event) {
760
+ return step.type === "pageview"
761
+ ? event.type === "pageview" && event.path === step.value
762
+ : event.type === "custom" && event.name === step.value;
763
+ }
764
+ async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed) {
765
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
766
+ const shard = stableHash(event.visitorId) % AGGREGATE_SHARDS;
767
+ const bucket = await ctx.db
768
+ .query("funnelStepBuckets")
769
+ .withIndex("by_siteId_and_funnelId_and_bucketStart_and_step_and_shard", (range) => range
770
+ .eq("siteId", siteId)
771
+ .eq("funnelId", funnelId)
772
+ .eq("bucketStart", bucketStart)
773
+ .eq("step", step)
774
+ .eq("shard", shard))
775
+ .unique();
776
+ if (bucket) {
777
+ await ctx.db.patch("funnelStepBuckets", bucket._id, {
778
+ entrants: bucket.entrants + 1,
779
+ completions: bucket.completions + (completed ? 1 : 0),
780
+ });
781
+ }
782
+ else {
783
+ await ctx.db.insert("funnelStepBuckets", {
784
+ siteId,
785
+ funnelId,
786
+ bucketStart,
787
+ step,
788
+ shard,
789
+ entrants: 1,
790
+ completions: completed ? 1 : 0,
791
+ });
792
+ }
793
+ }
794
+ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
795
+ const revenueCents = event.revenueCents ?? 0;
796
+ const currency = event.currency ?? "USD";
797
+ const candidate = attributionSnapshot(session);
798
+ const attribution = candidate && attributionApplies(candidate, event.timestamp)
799
+ ? candidate
800
+ : undefined;
801
+ const affiliate = attribution
802
+ ? await ctx.db.get("affiliates", attribution.affiliateId)
803
+ : null;
804
+ const commissionAffiliate = affiliate?.active && affiliate.siteId === siteId ? affiliate : null;
805
+ const commissionCents = commissionAffiliate
806
+ ? Math.round((revenueCents * commissionAffiliate.commissionBps) / 10_000)
807
+ : 0;
808
+ await ctx.db.insert("conversions", {
809
+ siteId,
810
+ eventId: event.eventId,
811
+ sessionId: event.sessionId,
812
+ visitorId: event.visitorId,
813
+ timestamp: event.timestamp,
814
+ path: event.path,
815
+ revenueCents,
816
+ currency,
817
+ source: "trusted",
818
+ ...(trustedGoalKey ? { goalKey: trustedGoalKey } : {}),
819
+ ...(attribution ?? {}),
820
+ commissionCents,
821
+ });
822
+ if (commissionAffiliate && attribution) {
823
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
824
+ const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
825
+ const stats = await ctx.db
826
+ .query("affiliateDailyStats")
827
+ .withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) => range
828
+ .eq("siteId", siteId)
829
+ .eq("affiliateId", commissionAffiliate._id)
830
+ .eq("bucketStart", bucketStart)
831
+ .eq("shard", shard))
832
+ .unique();
833
+ if (stats) {
834
+ await ctx.db.patch("affiliateDailyStats", stats._id, {
835
+ conversions: stats.conversions + 1,
836
+ revenueCents: stats.revenueCents + revenueCents,
837
+ commissionCents: stats.commissionCents + commissionCents,
838
+ });
839
+ }
840
+ else {
841
+ await ctx.db.insert("affiliateDailyStats", {
842
+ siteId,
843
+ affiliateId: commissionAffiliate._id,
844
+ affiliateSlug: attribution.affiliateSlug,
845
+ bucketStart,
846
+ shard,
847
+ conversions: 1,
848
+ revenueCents,
849
+ commissionCents,
850
+ });
851
+ }
852
+ }
853
+ }
854
+ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion) {
855
+ const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
856
+ const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
857
+ const dimensionDeltas = dimensions.map((dimension) => ({
858
+ ...dimension,
859
+ count: Number(recordTelemetry),
860
+ revenueCents,
861
+ }));
862
+ for (const granularity of ["hour", "day"]) {
863
+ const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
864
+ const bucketStart = Math.floor(event.timestamp / interval) * interval;
865
+ const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
866
+ let delta = aggregateDeltas.get(key);
867
+ if (!delta) {
868
+ delta = {
869
+ siteId,
870
+ granularity,
871
+ bucketStart,
872
+ shard,
873
+ events: 0,
874
+ pageviews: 0,
875
+ clicks: 0,
876
+ customEvents: 0,
877
+ outboundClicks: 0,
878
+ sessions: 0,
879
+ visitors: 0,
880
+ conversions: 0,
881
+ revenueCents: 0,
882
+ dimensions: [],
883
+ };
884
+ aggregateDeltas.set(key, delta);
885
+ }
886
+ const eventCount = Number(recordTelemetry);
887
+ delta.events += eventCount;
888
+ delta.pageviews += Number(recordTelemetry && event.type === "pageview");
889
+ delta.clicks += Number(recordTelemetry && event.type === "click");
890
+ delta.customEvents += Number(recordTelemetry && event.type === "custom");
891
+ delta.outboundClicks += Number(recordTelemetry && event.type === "outbound");
892
+ delta.sessions += Number(recordTelemetry && session.isNewSession);
893
+ delta.visitors += recordTelemetry
894
+ ? granularity === "hour"
895
+ ? Number(session.isNewVisitorHour)
896
+ : Number(session.isNewVisitorDay)
897
+ : 0;
898
+ delta.conversions += Number(financialConversion);
899
+ delta.revenueCents += revenueCents;
900
+ delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
901
+ }
902
+ }
903
+ async function flushAggregates(ctx, aggregateDeltas) {
904
+ for (const delta of aggregateDeltas.values()) {
905
+ const bucket = await ctx.db
906
+ .query("aggregateBuckets")
907
+ .withIndex("by_siteId_and_granularity_and_bucketStart_and_shard", (range) => range
908
+ .eq("siteId", delta.siteId)
909
+ .eq("granularity", delta.granularity)
910
+ .eq("bucketStart", delta.bucketStart)
911
+ .eq("shard", delta.shard))
912
+ .unique();
913
+ const dimensions = mergeDimensions(bucket?.dimensions ?? [], delta.dimensions);
914
+ if (bucket) {
915
+ await ctx.db.patch("aggregateBuckets", bucket._id, {
916
+ events: bucket.events + delta.events,
917
+ pageviews: bucket.pageviews + delta.pageviews,
918
+ clicks: bucket.clicks + delta.clicks,
919
+ customEvents: bucket.customEvents + delta.customEvents,
920
+ outboundClicks: bucket.outboundClicks + delta.outboundClicks,
921
+ sessions: bucket.sessions + delta.sessions,
922
+ visitors: bucket.visitors + delta.visitors,
923
+ conversions: bucket.conversions + delta.conversions,
924
+ revenueCents: bucket.revenueCents + delta.revenueCents,
925
+ dimensions,
926
+ });
927
+ }
928
+ else {
929
+ await ctx.db.insert("aggregateBuckets", {
930
+ siteId: delta.siteId,
931
+ granularity: delta.granularity,
932
+ bucketStart: delta.bucketStart,
933
+ shard: delta.shard,
934
+ events: delta.events,
935
+ pageviews: delta.pageviews,
936
+ clicks: delta.clicks,
937
+ customEvents: delta.customEvents,
938
+ outboundClicks: delta.outboundClicks,
939
+ sessions: delta.sessions,
940
+ visitors: delta.visitors,
941
+ conversions: delta.conversions,
942
+ revenueCents: delta.revenueCents,
943
+ dimensions,
944
+ });
945
+ }
946
+ }
947
+ }
948
+ function mergeDimensions(current, additions) {
949
+ const result = current.map((slot) => ({ ...slot }));
950
+ for (const addition of additions) {
951
+ const exact = result.find((candidate) => candidate.type === addition.type && candidate.value === addition.value);
952
+ if (exact) {
953
+ exact.count += addition.count;
954
+ exact.revenueCents += addition.revenueCents;
955
+ continue;
956
+ }
957
+ const typeSlots = result.filter((candidate) => candidate.type === addition.type);
958
+ if (typeSlots.length < DIMENSION_SLOTS) {
959
+ const occupied = Array.from({ length: DIMENSION_SLOTS }, () => false);
960
+ for (const candidate of typeSlots)
961
+ occupied[candidate.slot] = true;
962
+ let slot = stableHash(addition.value) % DIMENSION_SLOTS;
963
+ while (occupied[slot])
964
+ slot = (slot + 1) % DIMENSION_SLOTS;
965
+ result.push({
966
+ type: addition.type,
967
+ slot,
968
+ value: addition.value,
969
+ count: addition.count,
970
+ revenueCents: addition.revenueCents,
971
+ });
972
+ continue;
973
+ }
974
+ if (addition.count === 0)
975
+ continue;
976
+ let replacement = typeSlots[0];
977
+ for (const candidate of typeSlots.slice(1)) {
978
+ if (candidate.count < replacement.count)
979
+ replacement = candidate;
980
+ }
981
+ replacement.value = addition.value;
982
+ replacement.count += addition.count;
983
+ replacement.revenueCents = addition.revenueCents;
984
+ }
985
+ return result;
986
+ }
987
+ function stringProperty(properties, key) {
988
+ const value = properties?.[key];
989
+ return typeof value === "string" ? cleanString(value, 120) || undefined : undefined;
990
+ }
991
+ //# sourceMappingURL=ingest.js.map