@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,720 @@
1
+ import {
2
+ paginationOptsValidator,
3
+ paginationResultValidator,
4
+ } from "convex/server";
5
+ import { v } from "convex/values";
6
+ import { query } from "./_generated/server.js";
7
+ import {
8
+ AGGREGATE_SHARDS,
9
+ DAY_MS,
10
+ HOUR_MS,
11
+ MAX_AFFILIATES_PER_SITE,
12
+ MAX_FUNNELS_PER_SITE,
13
+ MAX_GOALS_PER_SITE,
14
+ MAX_JOURNEY_EVENTS,
15
+ MAX_LIVE_VISITORS,
16
+ MAX_MANUAL_PAGINATION_OFFSET,
17
+ MAX_OVERVIEW_AGGREGATE_ROWS,
18
+ MAX_REPORT_RANGE_DAYS,
19
+ MAX_REPORT_SITES,
20
+ } from "./constants.js";
21
+ import { fail } from "./errors.js";
22
+ import { isPlainRecord } from "./guards.js";
23
+ import {
24
+ affiliateFieldsValidator,
25
+ eventFieldsValidator,
26
+ funnelFieldsValidator,
27
+ goalFieldsValidator,
28
+ sessionFieldsValidator,
29
+ } from "./validators.js";
30
+
31
+ const sessionDocumentValidator = sessionFieldsValidator.extend({
32
+ _id: v.id("sessions"),
33
+ _creationTime: v.number(),
34
+ });
35
+ const eventDocumentValidator = eventFieldsValidator.extend({
36
+ _id: v.id("events"),
37
+ _creationTime: v.number(),
38
+ });
39
+ const liveSessionDocumentValidator = v.object({
40
+ _id: v.id("liveSessions"),
41
+ _creationTime: v.number(),
42
+ siteId: v.id("sites"),
43
+ sessionId: v.string(),
44
+ visitorId: v.string(),
45
+ path: v.string(),
46
+ startedAt: v.number(),
47
+ lastSeenAt: v.number(),
48
+ expiresAt: v.number(),
49
+ country: v.optional(v.string()),
50
+ city: v.optional(v.string()),
51
+ latitude: v.optional(v.number()),
52
+ longitude: v.optional(v.number()),
53
+ browser: v.string(),
54
+ os: v.string(),
55
+ source: v.string(),
56
+ device: v.string(),
57
+ pageviewCount: v.number(),
58
+ eventCount: v.number(),
59
+ });
60
+ const conversionDocumentValidator = v.object({
61
+ _id: v.id("conversions"),
62
+ _creationTime: v.number(),
63
+ siteId: v.id("sites"),
64
+ eventId: v.string(),
65
+ sessionId: v.string(),
66
+ visitorId: v.string(),
67
+ timestamp: v.number(),
68
+ path: v.string(),
69
+ revenueCents: v.number(),
70
+ currency: v.string(),
71
+ source: v.union(v.literal("tracker"), v.literal("trusted")),
72
+ goalKey: v.optional(v.string()),
73
+ affiliateId: v.optional(v.id("affiliates")),
74
+ affiliateAttributedAt: v.optional(v.number()),
75
+ affiliateExpiresAt: v.optional(v.number()),
76
+ affiliateSlug: v.optional(v.string()),
77
+ commissionCents: v.number(),
78
+ });
79
+ const metricTotalsValidator = v.object({
80
+ events: v.number(),
81
+ pageviews: v.number(),
82
+ clicks: v.number(),
83
+ customEvents: v.number(),
84
+ outboundClicks: v.number(),
85
+ sessions: v.number(),
86
+ visitors: v.number(),
87
+ conversions: v.number(),
88
+ revenueCents: v.number(),
89
+ });
90
+ const timePointValidator = metricTotalsValidator.extend({ timestamp: v.number() });
91
+ const topItemValidator = v.object({
92
+ value: v.string(),
93
+ count: v.number(),
94
+ revenueCents: v.number(),
95
+ });
96
+
97
+ export const overview = query({
98
+ args: {
99
+ siteIds: v.array(v.id("sites")),
100
+ from: v.number(),
101
+ to: v.number(),
102
+ interval: v.optional(v.union(v.literal("hour"), v.literal("day"))),
103
+ },
104
+ returns: v.object({
105
+ from: v.number(),
106
+ to: v.number(),
107
+ interval: v.union(v.literal("hour"), v.literal("day")),
108
+ currency: v.string(),
109
+ totals: metricTotalsValidator,
110
+ timeSeries: v.array(timePointValidator),
111
+ topSources: v.array(topItemValidator),
112
+ topPages: v.array(topItemValidator),
113
+ topCountries: v.array(topItemValidator),
114
+ topDevices: v.array(topItemValidator),
115
+ topBrowsers: v.array(topItemValidator),
116
+ topEvents: v.array(topItemValidator),
117
+ topGoals: v.array(topItemValidator),
118
+ }),
119
+ handler: async (ctx, args) => {
120
+ const siteIds = validateSiteIds(args.siteIds);
121
+ const currencies = new Set<string>();
122
+ for (const siteId of siteIds) {
123
+ const site = await ctx.db.get("sites", siteId);
124
+ if (!site) fail("NOT_FOUND", "site not found", { siteId: String(siteId) });
125
+ currencies.add(site.currency ?? "USD");
126
+ }
127
+ if (currencies.size !== 1) {
128
+ fail("CURRENCY_MISMATCH", "overview sites must use the same currency", {
129
+ currencies: [...currencies].sort().join(","),
130
+ });
131
+ }
132
+ const currency = [...currencies][0];
133
+ const { from, to } = validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
134
+ const interval = args.interval ?? (to - from <= 7 * DAY_MS ? "hour" : "day");
135
+ if (interval === "hour" && to - from > 7 * DAY_MS) {
136
+ fail("INVALID_ARGUMENT", "hourly reports are limited to seven days");
137
+ }
138
+ const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
139
+ const firstBucket = Math.floor(from / intervalMs) * intervalMs;
140
+ const lastBucket = Math.floor(to / intervalMs) * intervalMs;
141
+ const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
142
+ const perSiteLimit = bucketCount * AGGREGATE_SHARDS;
143
+ if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
144
+ fail(
145
+ "LIMIT_EXCEEDED",
146
+ "overview range is too wide for the requested number of sites",
147
+ { maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS },
148
+ );
149
+ }
150
+ const totals = emptyTotals();
151
+ const points = new Map<number, MetricTotals>();
152
+ const dimensions = new Map<string, { value: string; count: number; revenueCents: number }>();
153
+
154
+ for (const siteId of siteIds) {
155
+ const buckets = await ctx.db
156
+ .query("aggregateBuckets")
157
+ .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
158
+ range
159
+ .eq("siteId", siteId)
160
+ .eq("granularity", interval)
161
+ .gte("bucketStart", firstBucket)
162
+ .lte("bucketStart", lastBucket),
163
+ )
164
+ .take(perSiteLimit);
165
+ for (const bucket of buckets) {
166
+ addMetrics(totals, bucket);
167
+ const point = points.get(bucket.bucketStart) ?? emptyTotals();
168
+ addMetrics(point, bucket);
169
+ points.set(bucket.bucketStart, point);
170
+ for (const dimension of bucket.dimensions) {
171
+ const key = `${dimension.type}:${dimension.value}`;
172
+ const current = dimensions.get(key) ?? {
173
+ value: dimension.value,
174
+ count: 0,
175
+ revenueCents: 0,
176
+ };
177
+ current.count += dimension.count;
178
+ current.revenueCents += dimension.revenueCents;
179
+ dimensions.set(key, current);
180
+ }
181
+ }
182
+ }
183
+
184
+ const top = (type: string) =>
185
+ [...dimensions.entries()]
186
+ .filter(([key]) => key.startsWith(`${type}:`))
187
+ .map(([, value]) => value)
188
+ .sort((left, right) => right.count - left.count || left.value.localeCompare(right.value))
189
+ .slice(0, 10);
190
+ return {
191
+ from,
192
+ to,
193
+ interval,
194
+ currency,
195
+ totals,
196
+ timeSeries: [...points.entries()]
197
+ .sort(([left], [right]) => left - right)
198
+ .map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
199
+ topSources: top("source"),
200
+ topPages: top("page"),
201
+ topCountries: top("country"),
202
+ topDevices: top("device"),
203
+ topBrowsers: top("browser"),
204
+ topEvents: top("event"),
205
+ topGoals: top("goal"),
206
+ };
207
+ },
208
+ });
209
+
210
+ export const liveVisitors = query({
211
+ args: {
212
+ siteIds: v.array(v.id("sites")),
213
+ now: v.number(),
214
+ limit: v.optional(v.number()),
215
+ },
216
+ returns: v.array(liveSessionDocumentValidator),
217
+ handler: async (ctx, args) => {
218
+ const siteIds = validateSiteIds(args.siteIds);
219
+ const limit = args.limit ?? 100;
220
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIVE_VISITORS) {
221
+ fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_LIVE_VISITORS}`);
222
+ }
223
+ if (!Number.isFinite(args.now) || args.now < 0) {
224
+ fail("INVALID_ARGUMENT", "now must be a positive timestamp");
225
+ }
226
+ const rows = [];
227
+ for (const siteId of siteIds) {
228
+ rows.push(
229
+ ...(await ctx.db
230
+ .query("liveSessions")
231
+ .withIndex("by_siteId_and_expiresAt", (range) =>
232
+ range.eq("siteId", siteId).gte("expiresAt", args.now),
233
+ )
234
+ .order("desc")
235
+ .take(limit)),
236
+ );
237
+ }
238
+ return rows
239
+ .sort((left, right) => right.lastSeenAt - left.lastSeenAt)
240
+ .slice(0, limit)
241
+ .map((row) => ({
242
+ _id: row._id,
243
+ _creationTime: row._creationTime,
244
+ siteId: row.siteId,
245
+ sessionId: row.sessionId,
246
+ visitorId: row.visitorId,
247
+ path: row.path,
248
+ startedAt: row.startedAt,
249
+ lastSeenAt: row.lastSeenAt,
250
+ expiresAt: row.expiresAt,
251
+ ...(row.country ? { country: row.country } : {}),
252
+ ...(row.city ? { city: row.city } : {}),
253
+ ...(row.latitude !== undefined ? { latitude: row.latitude } : {}),
254
+ ...(row.longitude !== undefined ? { longitude: row.longitude } : {}),
255
+ browser: row.browser,
256
+ os: row.os,
257
+ source: row.source,
258
+ device: row.device,
259
+ pageviewCount: row.pageviewCount,
260
+ eventCount: row.eventCount,
261
+ }));
262
+ },
263
+ });
264
+
265
+ export const listSessions = query({
266
+ args: {
267
+ siteId: v.id("sites"),
268
+ from: v.number(),
269
+ to: v.number(),
270
+ paginationOpts: paginationOptsValidator,
271
+ },
272
+ returns: paginationResultValidator(sessionDocumentValidator),
273
+ handler: async (ctx, args) => {
274
+ validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
275
+ const pagination = parseManualPagination(args.paginationOpts);
276
+ const rows = await ctx.db
277
+ .query("sessions")
278
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
279
+ range
280
+ .eq("siteId", args.siteId)
281
+ .gte("lastSeenAt", args.from)
282
+ .lte("lastSeenAt", args.to),
283
+ )
284
+ .order("desc")
285
+ .take(pagination.offset + pagination.numItems + 1);
286
+ return manualPaginationResult(rows, pagination);
287
+ },
288
+ });
289
+
290
+ export const sessionJourney = query({
291
+ args: {
292
+ siteId: v.id("sites"),
293
+ sessionId: v.string(),
294
+ from: v.number(),
295
+ to: v.number(),
296
+ paginationOpts: paginationOptsValidator,
297
+ },
298
+ returns: paginationResultValidator(eventDocumentValidator),
299
+ handler: async (ctx, args) => {
300
+ validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
301
+ const pagination = parseManualPagination(args.paginationOpts);
302
+ const rows = await ctx.db
303
+ .query("events")
304
+ .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
305
+ range
306
+ .eq("siteId", args.siteId)
307
+ .eq("sessionId", args.sessionId)
308
+ .gte("timestamp", args.from)
309
+ .lte("timestamp", args.to),
310
+ )
311
+ .order("asc")
312
+ .take(pagination.offset + pagination.numItems + 1);
313
+ return manualPaginationResult(rows, pagination);
314
+ },
315
+ });
316
+
317
+ export const listConversions = query({
318
+ args: {
319
+ siteId: v.id("sites"),
320
+ from: v.number(),
321
+ to: v.number(),
322
+ paginationOpts: paginationOptsValidator,
323
+ },
324
+ returns: paginationResultValidator(conversionDocumentValidator),
325
+ handler: async (ctx, args) => {
326
+ validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
327
+ const pagination = parseManualPagination(args.paginationOpts);
328
+ const rows = await ctx.db
329
+ .query("conversions")
330
+ .withIndex("by_siteId_and_timestamp", (range) =>
331
+ range
332
+ .eq("siteId", args.siteId)
333
+ .gte("timestamp", args.from)
334
+ .lte("timestamp", args.to),
335
+ )
336
+ .order("desc")
337
+ .take(pagination.offset + pagination.numItems + 1);
338
+ return manualPaginationResult(rows, pagination);
339
+ },
340
+ });
341
+
342
+ export const visitorJourney = query({
343
+ args: {
344
+ siteIds: v.array(v.id("sites")),
345
+ visitorId: v.string(),
346
+ from: v.number(),
347
+ to: v.number(),
348
+ limit: v.optional(v.number()),
349
+ },
350
+ returns: v.array(eventDocumentValidator),
351
+ handler: async (ctx, args) => {
352
+ const siteIds = validateSiteIds(args.siteIds);
353
+ validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
354
+ const limit = args.limit ?? 200;
355
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
356
+ fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
357
+ }
358
+ const rows = [];
359
+ for (const siteId of siteIds) {
360
+ rows.push(
361
+ ...(await ctx.db
362
+ .query("events")
363
+ .withIndex("by_siteId_and_visitorId_and_timestamp", (range) =>
364
+ range
365
+ .eq("siteId", siteId)
366
+ .eq("visitorId", args.visitorId)
367
+ .gte("timestamp", args.from)
368
+ .lte("timestamp", args.to),
369
+ )
370
+ .order("asc")
371
+ .take(limit)),
372
+ );
373
+ }
374
+ return rows
375
+ .sort((left, right) => left.timestamp - right.timestamp || left.sequence - right.sequence)
376
+ .slice(0, limit);
377
+ },
378
+ });
379
+
380
+ const goalReportItemValidator = goalFieldsValidator.extend({
381
+ _id: v.id("goals"),
382
+ _creationTime: v.number(),
383
+ completions: v.number(),
384
+ valueCents: v.number(),
385
+ });
386
+
387
+ export const goalsReport = query({
388
+ args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
389
+ returns: v.object({
390
+ goals: v.array(goalReportItemValidator),
391
+ truncated: v.boolean(),
392
+ }),
393
+ handler: async (ctx, args) => {
394
+ validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
395
+ const goals = await ctx.db
396
+ .query("goals")
397
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
398
+ .take(MAX_GOALS_PER_SITE);
399
+ const completions = await ctx.db
400
+ .query("goalCompletions")
401
+ .withIndex("by_siteId_and_timestamp", (range) =>
402
+ range
403
+ .eq("siteId", args.siteId)
404
+ .gte("timestamp", args.from)
405
+ .lte("timestamp", args.to),
406
+ )
407
+ .take(5_001);
408
+ const totals = new Map<string, { completions: number; valueCents: number }>();
409
+ for (const completion of completions.slice(0, 5_000)) {
410
+ const key = String(completion.goalId);
411
+ const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
412
+ total.completions += 1;
413
+ total.valueCents += completion.valueCents;
414
+ totals.set(key, total);
415
+ }
416
+ return {
417
+ goals: goals.map((goal) => ({
418
+ ...goal,
419
+ ...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
420
+ })),
421
+ truncated: completions.length > 5_000,
422
+ };
423
+ },
424
+ });
425
+
426
+ const funnelReportItemValidator = funnelFieldsValidator.extend({
427
+ _id: v.id("funnels"),
428
+ _creationTime: v.number(),
429
+ stepsReport: v.array(
430
+ v.object({ step: v.number(), name: v.string(), entrants: v.number(), completions: v.number() }),
431
+ ),
432
+ truncated: v.boolean(),
433
+ });
434
+
435
+ export const funnelsReport = query({
436
+ args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
437
+ returns: v.array(funnelReportItemValidator),
438
+ handler: async (ctx, args) => {
439
+ validateRange(args.from, args.to, 90);
440
+ const funnels = await ctx.db
441
+ .query("funnels")
442
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
443
+ .take(MAX_FUNNELS_PER_SITE);
444
+ const reports = [];
445
+ for (const funnel of funnels) {
446
+ const buckets = await ctx.db
447
+ .query("funnelStepBuckets")
448
+ .withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
449
+ range
450
+ .eq("siteId", args.siteId)
451
+ .eq("funnelId", funnel._id)
452
+ .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
453
+ .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
454
+ )
455
+ .take(601);
456
+ const stepsReport = funnel.steps.map((step, index) => ({
457
+ step: index,
458
+ name: step.name,
459
+ entrants: 0,
460
+ completions: 0,
461
+ }));
462
+ for (const bucket of buckets.slice(0, 600)) {
463
+ const step = stepsReport[bucket.step];
464
+ if (step) {
465
+ step.entrants += bucket.entrants;
466
+ step.completions += bucket.completions;
467
+ }
468
+ }
469
+ reports.push({
470
+ ...funnel,
471
+ stepsReport,
472
+ truncated: buckets.length > 600,
473
+ });
474
+ }
475
+ return reports;
476
+ },
477
+ });
478
+
479
+ const affiliateReportItemValidator = affiliateFieldsValidator.extend({
480
+ _id: v.id("affiliates"),
481
+ _creationTime: v.number(),
482
+ visits: v.number(),
483
+ uniqueVisitors: v.number(),
484
+ conversions: v.number(),
485
+ revenueCents: v.number(),
486
+ commissionCents: v.number(),
487
+ });
488
+
489
+ export const affiliatesReport = query({
490
+ args: { siteId: v.id("sites"), from: v.number(), to: v.number() },
491
+ returns: v.object({
492
+ affiliates: v.array(affiliateReportItemValidator),
493
+ truncated: v.boolean(),
494
+ }),
495
+ handler: async (ctx, args) => {
496
+ validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
497
+ const affiliateRows = await ctx.db
498
+ .query("affiliates")
499
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
500
+ .order("desc")
501
+ .take(MAX_AFFILIATES_PER_SITE + 1);
502
+ const affiliates = affiliateRows.slice(0, MAX_AFFILIATES_PER_SITE);
503
+ const stats = await ctx.db
504
+ .query("affiliateDailyStats")
505
+ .withIndex("by_siteId_and_bucketStart", (range) =>
506
+ range
507
+ .eq("siteId", args.siteId)
508
+ .gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
509
+ .lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
510
+ )
511
+ .take(5_001);
512
+ const sessions = await ctx.db
513
+ .query("sessions")
514
+ .withIndex("by_siteId_and_startedAt", (range) =>
515
+ range
516
+ .eq("siteId", args.siteId)
517
+ .gte("startedAt", args.from)
518
+ .lte("startedAt", args.to),
519
+ )
520
+ .take(5_001);
521
+ const visits = new Map<string, {
522
+ visits: number;
523
+ visitors: Set<string>;
524
+ }>();
525
+ for (const session of sessions.slice(0, 5_000)) {
526
+ if (!session.affiliateId) continue;
527
+ const key = String(session.affiliateId);
528
+ const total = visits.get(key) ?? {
529
+ visits: 0,
530
+ visitors: new Set<string>(),
531
+ };
532
+ total.visits += 1;
533
+ total.visitors.add(session.visitorId);
534
+ visits.set(key, total);
535
+ }
536
+ const totals = new Map<string, {
537
+ conversions: number;
538
+ revenueCents: number;
539
+ commissionCents: number;
540
+ }>();
541
+ for (const row of stats.slice(0, 5_000)) {
542
+ const key = String(row.affiliateId);
543
+ const total = totals.get(key) ?? {
544
+ conversions: 0,
545
+ revenueCents: 0,
546
+ commissionCents: 0,
547
+ };
548
+ total.conversions += row.conversions;
549
+ total.revenueCents += row.revenueCents;
550
+ total.commissionCents += row.commissionCents;
551
+ totals.set(key, total);
552
+ }
553
+ return {
554
+ affiliates: affiliates.map((affiliate) => {
555
+ const performance = totals.get(String(affiliate._id)) ?? {
556
+ conversions: 0,
557
+ revenueCents: 0,
558
+ commissionCents: 0,
559
+ };
560
+ const traffic = visits.get(String(affiliate._id));
561
+ return {
562
+ ...affiliate,
563
+ ...performance,
564
+ visits: traffic?.visits ?? 0,
565
+ uniqueVisitors: traffic?.visitors.size ?? 0,
566
+ };
567
+ }),
568
+ truncated:
569
+ affiliateRows.length > MAX_AFFILIATES_PER_SITE ||
570
+ stats.length > 5_000 ||
571
+ sessions.length > 5_000,
572
+ };
573
+ },
574
+ });
575
+
576
+
577
+ type MetricTotals = {
578
+ events: number;
579
+ pageviews: number;
580
+ clicks: number;
581
+ customEvents: number;
582
+ outboundClicks: number;
583
+ sessions: number;
584
+ visitors: number;
585
+ conversions: number;
586
+ revenueCents: number;
587
+ };
588
+
589
+ function emptyTotals(): MetricTotals {
590
+ return {
591
+ events: 0,
592
+ pageviews: 0,
593
+ clicks: 0,
594
+ customEvents: 0,
595
+ outboundClicks: 0,
596
+ sessions: 0,
597
+ visitors: 0,
598
+ conversions: 0,
599
+ revenueCents: 0,
600
+ };
601
+ }
602
+
603
+ function addMetrics(target: MetricTotals, source: MetricTotals) {
604
+ target.events += source.events;
605
+ target.pageviews += source.pageviews;
606
+ target.clicks += source.clicks;
607
+ target.customEvents += source.customEvents;
608
+ target.outboundClicks += source.outboundClicks;
609
+ target.sessions += source.sessions;
610
+ target.visitors += source.visitors;
611
+ target.conversions += source.conversions;
612
+ target.revenueCents += source.revenueCents;
613
+ }
614
+
615
+ function validateSiteIds<T>(siteIds: T[]): T[] {
616
+ const unique = [...new Set(siteIds)];
617
+ if (unique.length < 1 || unique.length > MAX_REPORT_SITES || unique.length !== siteIds.length) {
618
+ fail("INVALID_ARGUMENT", `siteIds must contain 1-${MAX_REPORT_SITES} unique IDs`);
619
+ }
620
+ return unique;
621
+ }
622
+
623
+ function validateRange(from: number, to: number, maxDays: number) {
624
+ if (
625
+ !Number.isFinite(from) ||
626
+ !Number.isFinite(to) ||
627
+ from < 0 ||
628
+ to <= from ||
629
+ to - from > maxDays * DAY_MS
630
+ ) {
631
+ fail("INVALID_ARGUMENT", `range must be positive, ordered, and at most ${maxDays} days`);
632
+ }
633
+ return { from: Math.trunc(from), to: Math.trunc(to) };
634
+ }
635
+
636
+ type ManualPaginationInput = {
637
+ cursor: string | null;
638
+ numItems: number;
639
+ maximumRowsRead?: number;
640
+ maximumBytesRead?: number;
641
+ };
642
+
643
+ type ManualPagination = {
644
+ offset: number;
645
+ numItems: number;
646
+ };
647
+
648
+ function parseManualPagination(
649
+ options: ManualPaginationInput,
650
+ ): ManualPagination {
651
+ validatePagination(options);
652
+ if (options.cursor === null) {
653
+ return { offset: 0, numItems: options.numItems };
654
+ }
655
+ let decoded: unknown;
656
+ try {
657
+ decoded = JSON.parse(atob(options.cursor));
658
+ } catch {
659
+ fail("INVALID_ARGUMENT", "pagination cursor is malformed");
660
+ }
661
+ if (
662
+ !isPlainRecord(decoded) ||
663
+ decoded.v !== 1 ||
664
+ typeof decoded.o !== "number" ||
665
+ !Number.isSafeInteger(decoded.o) ||
666
+ decoded.o < 0 ||
667
+ decoded.o > MAX_MANUAL_PAGINATION_OFFSET
668
+ ) {
669
+ fail("INVALID_ARGUMENT", "pagination cursor is invalid or exceeds the offset limit");
670
+ }
671
+ return { offset: decoded.o, numItems: options.numItems };
672
+ }
673
+
674
+ function manualPaginationResult<T>(
675
+ rows: T[],
676
+ pagination: ManualPagination,
677
+ ) {
678
+ const page = rows.slice(
679
+ pagination.offset,
680
+ pagination.offset + pagination.numItems,
681
+ );
682
+ const nextOffset = pagination.offset + page.length;
683
+ const isDone =
684
+ rows.length <= pagination.offset + pagination.numItems ||
685
+ nextOffset >= MAX_MANUAL_PAGINATION_OFFSET;
686
+ return {
687
+ page,
688
+ isDone,
689
+ continueCursor: btoa(JSON.stringify({
690
+ v: 1,
691
+ o: Math.min(nextOffset, MAX_MANUAL_PAGINATION_OFFSET),
692
+ })),
693
+ };
694
+ }
695
+
696
+ function validatePagination(options: {
697
+ numItems: number;
698
+ maximumRowsRead?: number;
699
+ maximumBytesRead?: number;
700
+ }) {
701
+ if (!Number.isSafeInteger(options.numItems) || options.numItems < 1 || options.numItems > 100) {
702
+ fail("INVALID_ARGUMENT", "pagination numItems must be an integer between 1 and 100");
703
+ }
704
+ if (
705
+ options.maximumRowsRead !== undefined &&
706
+ (!Number.isSafeInteger(options.maximumRowsRead) ||
707
+ options.maximumRowsRead < 1 ||
708
+ options.maximumRowsRead > 1_000)
709
+ ) {
710
+ fail("INVALID_ARGUMENT", "maximumRowsRead must be an integer between 1 and 1000");
711
+ }
712
+ if (
713
+ options.maximumBytesRead !== undefined &&
714
+ (!Number.isSafeInteger(options.maximumBytesRead) ||
715
+ options.maximumBytesRead < 1 ||
716
+ options.maximumBytesRead > 8_000_000)
717
+ ) {
718
+ fail("INVALID_ARGUMENT", "maximumBytesRead must be an integer between 1 and 8000000");
719
+ }
720
+ }