@iann29/rastro 0.1.0-alpha.8 → 0.1.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 (105) hide show
  1. package/README.md +274 -75
  2. package/agent/integration.md +223 -65
  3. package/agent/manifest.json +34 -13
  4. package/agent/manifest.schema.json +53 -11
  5. package/dist/client/federation.d.ts +196 -11
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +160 -6
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/heat.d.ts +37 -0
  10. package/dist/client/heat.d.ts.map +1 -0
  11. package/dist/client/heat.js +43 -0
  12. package/dist/client/heat.js.map +1 -0
  13. package/dist/client/index.d.ts +448 -105
  14. package/dist/client/index.d.ts.map +1 -1
  15. package/dist/client/index.js +207 -17
  16. package/dist/client/index.js.map +1 -1
  17. package/dist/component/_generated/api.d.ts +6 -0
  18. package/dist/component/_generated/api.d.ts.map +1 -1
  19. package/dist/component/_generated/api.js.map +1 -1
  20. package/dist/component/_generated/component.d.ts +53 -16
  21. package/dist/component/_generated/component.d.ts.map +1 -1
  22. package/dist/component/constants.d.ts +5 -1
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +8 -1
  25. package/dist/component/constants.js.map +1 -1
  26. package/dist/component/coverage.d.ts +9 -0
  27. package/dist/component/coverage.d.ts.map +1 -1
  28. package/dist/component/coverage.js +36 -6
  29. package/dist/component/coverage.js.map +1 -1
  30. package/dist/component/eventStore.d.ts +1 -1
  31. package/dist/component/geo.d.ts +2 -0
  32. package/dist/component/geo.d.ts.map +1 -1
  33. package/dist/component/geo.js +56 -19
  34. package/dist/component/geo.js.map +1 -1
  35. package/dist/component/http.d.ts.map +1 -1
  36. package/dist/component/http.js +49 -27
  37. package/dist/component/http.js.map +1 -1
  38. package/dist/component/ingest.d.ts +1 -1
  39. package/dist/component/ingest.d.ts.map +1 -1
  40. package/dist/component/ingest.js +252 -305
  41. package/dist/component/ingest.js.map +1 -1
  42. package/dist/component/localTime.d.ts +25 -0
  43. package/dist/component/localTime.d.ts.map +1 -0
  44. package/dist/component/localTime.js +126 -0
  45. package/dist/component/localTime.js.map +1 -0
  46. package/dist/component/reports.d.ts +46 -15
  47. package/dist/component/reports.d.ts.map +1 -1
  48. package/dist/component/reports.js +193 -38
  49. package/dist/component/reports.js.map +1 -1
  50. package/dist/component/retention.js +26 -8
  51. package/dist/component/retention.js.map +1 -1
  52. package/dist/component/rollupStore.d.ts +320 -0
  53. package/dist/component/rollupStore.d.ts.map +1 -0
  54. package/dist/component/rollupStore.js +596 -0
  55. package/dist/component/rollupStore.js.map +1 -0
  56. package/dist/component/rollups.d.ts +20 -0
  57. package/dist/component/rollups.d.ts.map +1 -0
  58. package/dist/component/rollups.js +73 -0
  59. package/dist/component/rollups.js.map +1 -0
  60. package/dist/component/sanitize.d.ts +5 -0
  61. package/dist/component/sanitize.d.ts.map +1 -1
  62. package/dist/component/sanitize.js +15 -0
  63. package/dist/component/sanitize.js.map +1 -1
  64. package/dist/component/schema.d.ts +349 -15
  65. package/dist/component/schema.js +25 -0
  66. package/dist/component/schema.js.map +1 -1
  67. package/dist/component/sites.d.ts +12 -0
  68. package/dist/component/sites.d.ts.map +1 -1
  69. package/dist/component/sites.js +30 -0
  70. package/dist/component/sites.js.map +1 -1
  71. package/dist/component/validators.d.ts +41 -20
  72. package/dist/component/validators.d.ts.map +1 -1
  73. package/dist/component/validators.js +24 -3
  74. package/dist/component/validators.js.map +1 -1
  75. package/dist/tracker/generated.d.ts +7 -4
  76. package/dist/tracker/generated.d.ts.map +1 -1
  77. package/dist/tracker/generated.js +7 -4
  78. package/dist/tracker/generated.js.map +1 -1
  79. package/dist/tracker/tracker.d.ts +1 -1
  80. package/dist/tracker/tracker.d.ts.map +1 -1
  81. package/dist/tracker/tracker.js +24 -2
  82. package/dist/tracker/tracker.js.map +1 -1
  83. package/dist/tracker.min.js +1 -1
  84. package/docs/federation-setup.md +104 -27
  85. package/docs/federation.md +168 -31
  86. package/docs/upgrading.md +191 -16
  87. package/llms.txt +9 -7
  88. package/package.json +2 -1
  89. package/src/component/_generated/api.ts +6 -0
  90. package/src/component/_generated/component.ts +50 -12
  91. package/src/component/constants.ts +8 -1
  92. package/src/component/coverage.ts +46 -6
  93. package/src/component/geo.ts +82 -28
  94. package/src/component/http.ts +62 -26
  95. package/src/component/ingest.ts +378 -440
  96. package/src/component/localTime.ts +167 -0
  97. package/src/component/reports.ts +261 -39
  98. package/src/component/retention.ts +25 -7
  99. package/src/component/rollupStore.ts +799 -0
  100. package/src/component/rollups.ts +82 -0
  101. package/src/component/sanitize.ts +14 -0
  102. package/src/component/schema.ts +35 -0
  103. package/src/component/sites.ts +32 -0
  104. package/src/component/validators.ts +28 -1
  105. package/src/tracker/generated.ts +12 -0
@@ -0,0 +1,799 @@
1
+ import { v } from "convex/values";
2
+ import { internal } from "./_generated/api.js";
3
+ import type { Doc, Id } from "./_generated/dataModel.js";
4
+ import type { MutationCtx } from "./_generated/server.js";
5
+ import {
6
+ addVisitor,
7
+ createVisitorSketch,
8
+ unionVisitorSketches,
9
+ type VisitorSketch,
10
+ } from "./cardinality.js";
11
+ import {
12
+ DAY_MS,
13
+ MAX_FUNNEL_STEPS,
14
+ MAX_SITE_MAP_ROUTES_PER_DAY,
15
+ REPORT_ROLLUP_SHARDS,
16
+ ROLLUP_FOLD_LEASE_MS,
17
+ ROUTE_TRANSITION_SLOTS,
18
+ SITE_MAP_OTHER_ROUTE,
19
+ VITAL_PAGE_SLOTS,
20
+ } from "./constants.js";
21
+ import { fail } from "./errors.js";
22
+ import { stableHash } from "./sanitize.js";
23
+ import { vitalMetricValidator } from "./validators.js";
24
+ import {
25
+ mergeVitalHistograms,
26
+ VITAL_ALL,
27
+ VITAL_OTHER_PAGES,
28
+ type VitalMetric,
29
+ } from "./vitals.js";
30
+
31
+ /**
32
+ * Report rollups (goal, funnel, affiliate, route and Web Vitals daily rows)
33
+ * are the hottest documents in the store: a goal on the home page or one
34
+ * affiliate carrying a campaign means every batch read-modify-writes the same
35
+ * two rows, and under concurrent load Convex's optimistic retries run out and
36
+ * whole batches are lost (#52). Ingestion therefore never touches a rollup
37
+ * row. It appends the batch's deltas to `reportRollupQueue` — an insert
38
+ * conflicts with nothing — and one leased fold job per site merges the queue
39
+ * into the rows a few hundred milliseconds later, so every rollup row has a
40
+ * single writer.
41
+ */
42
+
43
+ export type GoalRollupDelta = {
44
+ siteId: Id<"sites">;
45
+ goalId: Id<"goals">;
46
+ bucketStart: number;
47
+ shard: number;
48
+ goalKey: string;
49
+ completions: number;
50
+ valueCents: number;
51
+ };
52
+ export type FunnelRollupDelta = {
53
+ siteId: Id<"sites">;
54
+ funnelId: Id<"funnels">;
55
+ bucketStart: number;
56
+ shard: number;
57
+ steps: Map<number, { step: number; entrants: number; completions: number }>;
58
+ };
59
+ export type FunnelStepDelta = {
60
+ siteId: Id<"sites">;
61
+ funnelId: Id<"funnels">;
62
+ bucketStart: number;
63
+ step: number;
64
+ shard: number;
65
+ entrants: number;
66
+ completions: number;
67
+ };
68
+ export type AffiliateRollupDelta = {
69
+ siteId: Id<"sites">;
70
+ affiliateId: Id<"affiliates">;
71
+ bucketStart: number;
72
+ shard: number;
73
+ affiliateSlug: string;
74
+ visits: number;
75
+ visitorKeys: Set<string>;
76
+ conversions: number;
77
+ revenueCents: number;
78
+ commissionCents: number;
79
+ };
80
+ export type VitalRollupDelta = {
81
+ siteId: Id<"sites">;
82
+ bucketStart: number;
83
+ device: string;
84
+ page: string;
85
+ shard: number;
86
+ metrics: Map<
87
+ VitalMetric,
88
+ { count: number; sum: number; histogram: number[] }
89
+ >;
90
+ };
91
+ export type RouteRollupDelta = {
92
+ siteId: Id<"sites">;
93
+ bucketStart: number;
94
+ shard: number;
95
+ route: string;
96
+ pageviews: number;
97
+ entries: number;
98
+ continued: number;
99
+ dwellMs: number;
100
+ dwellSamples: number;
101
+ visitorKeys: Set<string>;
102
+ outgoing: Map<string, number>;
103
+ };
104
+ export type ReportRollupDeltas = {
105
+ goals: Map<string, GoalRollupDelta>;
106
+ funnels: Map<string, FunnelRollupDelta>;
107
+ funnelSteps: Map<string, FunnelStepDelta>;
108
+ affiliates: Map<string, AffiliateRollupDelta>;
109
+ vitals: Map<string, VitalRollupDelta>;
110
+ routes: Map<string, RouteRollupDelta>;
111
+ };
112
+
113
+ export function createReportRollupDeltas(): ReportRollupDeltas {
114
+ return {
115
+ goals: new Map(),
116
+ funnels: new Map(),
117
+ funnelSteps: new Map(),
118
+ affiliates: new Map(),
119
+ vitals: new Map(),
120
+ routes: new Map(),
121
+ };
122
+ }
123
+
124
+ export function reportRollupCoordinates(
125
+ siteId: Id<"sites">,
126
+ definitionId: string,
127
+ timestamp: number,
128
+ visitorId: string,
129
+ ) {
130
+ const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
131
+ const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
132
+ return {
133
+ bucketStart,
134
+ shard,
135
+ key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
136
+ };
137
+ }
138
+
139
+ export function countReportRollupRows(deltas: ReportRollupDeltas): number {
140
+ return (
141
+ deltas.goals.size +
142
+ deltas.funnels.size +
143
+ deltas.funnelSteps.size +
144
+ deltas.affiliates.size +
145
+ deltas.vitals.size +
146
+ deltas.routes.size
147
+ );
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // The queue document: the same deltas, serialized. Visitor identity travels as
152
+ // the bounded list of keys a batch saw (a batch is one browser, so usually
153
+ // one) and only becomes a sketch when it is folded into the row.
154
+
155
+ const queuedGoalValidator = v.object({
156
+ goalId: v.id("goals"),
157
+ bucketStart: v.number(),
158
+ shard: v.number(),
159
+ goalKey: v.string(),
160
+ completions: v.number(),
161
+ valueCents: v.number(),
162
+ });
163
+ const queuedFunnelValidator = v.object({
164
+ funnelId: v.id("funnels"),
165
+ bucketStart: v.number(),
166
+ shard: v.number(),
167
+ steps: v.array(
168
+ v.object({
169
+ step: v.number(),
170
+ entrants: v.number(),
171
+ completions: v.number(),
172
+ }),
173
+ ),
174
+ });
175
+ const queuedFunnelStepValidator = v.object({
176
+ funnelId: v.id("funnels"),
177
+ bucketStart: v.number(),
178
+ step: v.number(),
179
+ shard: v.number(),
180
+ entrants: v.number(),
181
+ completions: v.number(),
182
+ });
183
+ const queuedAffiliateValidator = v.object({
184
+ affiliateId: v.id("affiliates"),
185
+ bucketStart: v.number(),
186
+ shard: v.number(),
187
+ affiliateSlug: v.string(),
188
+ visits: v.number(),
189
+ visitorKeys: v.array(v.string()),
190
+ conversions: v.number(),
191
+ revenueCents: v.number(),
192
+ commissionCents: v.number(),
193
+ });
194
+ const queuedRouteValidator = v.object({
195
+ route: v.string(),
196
+ bucketStart: v.number(),
197
+ shard: v.number(),
198
+ pageviews: v.number(),
199
+ entries: v.number(),
200
+ continued: v.number(),
201
+ dwellMs: v.number(),
202
+ dwellSamples: v.number(),
203
+ visitorKeys: v.array(v.string()),
204
+ outgoing: v.array(v.object({ route: v.string(), count: v.number() })),
205
+ });
206
+ const queuedVitalValidator = v.object({
207
+ bucketStart: v.number(),
208
+ device: v.string(),
209
+ page: v.string(),
210
+ shard: v.number(),
211
+ metrics: v.array(
212
+ v.object({
213
+ metric: vitalMetricValidator,
214
+ count: v.number(),
215
+ sum: v.number(),
216
+ histogram: v.array(v.number()),
217
+ }),
218
+ ),
219
+ });
220
+
221
+ export const reportRollupQueueFields = {
222
+ siteId: v.id("sites"),
223
+ createdAt: v.number(),
224
+ goals: v.array(queuedGoalValidator),
225
+ funnels: v.array(queuedFunnelValidator),
226
+ funnelSteps: v.array(queuedFunnelStepValidator),
227
+ affiliates: v.array(queuedAffiliateValidator),
228
+ routes: v.array(queuedRouteValidator),
229
+ vitals: v.array(queuedVitalValidator),
230
+ };
231
+
232
+ export const rollupFoldStateFields = {
233
+ siteId: v.id("sites"),
234
+ leaseUntil: v.number(),
235
+ token: v.string(),
236
+ };
237
+
238
+ type QueuedRollups = Omit<Doc<"reportRollupQueue">, "_id" | "_creationTime">;
239
+
240
+ function serializeReportRollupDeltas(
241
+ siteId: Id<"sites">,
242
+ deltas: ReportRollupDeltas,
243
+ createdAt: number,
244
+ ): QueuedRollups {
245
+ return {
246
+ siteId,
247
+ createdAt,
248
+ goals: [...deltas.goals.values()].map(
249
+ ({ goalId, bucketStart, shard, goalKey, completions, valueCents }) => ({
250
+ goalId,
251
+ bucketStart,
252
+ shard,
253
+ goalKey,
254
+ completions,
255
+ valueCents,
256
+ }),
257
+ ),
258
+ funnels: [...deltas.funnels.values()].map(
259
+ ({ funnelId, bucketStart, shard, steps }) => ({
260
+ funnelId,
261
+ bucketStart,
262
+ shard,
263
+ steps: [...steps.values()].map((step) => ({ ...step })),
264
+ }),
265
+ ),
266
+ funnelSteps: [...deltas.funnelSteps.values()].map(
267
+ ({ funnelId, bucketStart, step, shard, entrants, completions }) => ({
268
+ funnelId,
269
+ bucketStart,
270
+ step,
271
+ shard,
272
+ entrants,
273
+ completions,
274
+ }),
275
+ ),
276
+ affiliates: [...deltas.affiliates.values()].map(
277
+ ({ siteId: _siteId, visitorKeys, ...rest }) => ({
278
+ ...rest,
279
+ visitorKeys: [...visitorKeys],
280
+ }),
281
+ ),
282
+ routes: [...deltas.routes.values()].map(
283
+ ({ siteId: _siteId, visitorKeys, outgoing, ...rest }) => ({
284
+ ...rest,
285
+ visitorKeys: [...visitorKeys],
286
+ outgoing: [...outgoing].map(([route, count]) => ({ route, count })),
287
+ }),
288
+ ),
289
+ vitals: [...deltas.vitals.values()].map(
290
+ ({ siteId: _siteId, metrics, ...rest }) => ({
291
+ ...rest,
292
+ metrics: [...metrics].map(([metric, entry]) => ({
293
+ metric,
294
+ count: entry.count,
295
+ sum: entry.sum,
296
+ histogram: [...entry.histogram],
297
+ })),
298
+ }),
299
+ ),
300
+ };
301
+ }
302
+
303
+ /** Accumulates one queued document into the deltas the fold will write. */
304
+ export function mergeQueuedRollups(
305
+ deltas: ReportRollupDeltas,
306
+ queued: Doc<"reportRollupQueue">,
307
+ ) {
308
+ const siteId = queued.siteId;
309
+ for (const goal of queued.goals) {
310
+ const key = `${siteId}:${goal.goalId}:${goal.bucketStart}:${goal.shard}`;
311
+ const current = deltas.goals.get(key) ?? {
312
+ siteId,
313
+ goalId: goal.goalId,
314
+ bucketStart: goal.bucketStart,
315
+ shard: goal.shard,
316
+ goalKey: goal.goalKey,
317
+ completions: 0,
318
+ valueCents: 0,
319
+ };
320
+ current.completions += goal.completions;
321
+ current.valueCents += goal.valueCents;
322
+ current.goalKey = goal.goalKey;
323
+ deltas.goals.set(key, current);
324
+ }
325
+ for (const funnel of queued.funnels) {
326
+ const key = `${siteId}:${funnel.funnelId}:${funnel.bucketStart}:${funnel.shard}`;
327
+ const current = deltas.funnels.get(key) ?? {
328
+ siteId,
329
+ funnelId: funnel.funnelId,
330
+ bucketStart: funnel.bucketStart,
331
+ shard: funnel.shard,
332
+ steps: new Map(),
333
+ };
334
+ for (const step of funnel.steps) {
335
+ const entry = current.steps.get(step.step) ?? {
336
+ step: step.step,
337
+ entrants: 0,
338
+ completions: 0,
339
+ };
340
+ entry.entrants += step.entrants;
341
+ entry.completions += step.completions;
342
+ current.steps.set(step.step, entry);
343
+ }
344
+ deltas.funnels.set(key, current);
345
+ }
346
+ for (const step of queued.funnelSteps) {
347
+ const key = `${siteId}:${step.funnelId}:${step.bucketStart}:${step.step}:${step.shard}`;
348
+ const current = deltas.funnelSteps.get(key) ?? {
349
+ siteId,
350
+ funnelId: step.funnelId,
351
+ bucketStart: step.bucketStart,
352
+ step: step.step,
353
+ shard: step.shard,
354
+ entrants: 0,
355
+ completions: 0,
356
+ };
357
+ current.entrants += step.entrants;
358
+ current.completions += step.completions;
359
+ deltas.funnelSteps.set(key, current);
360
+ }
361
+ for (const affiliate of queued.affiliates) {
362
+ const key = `${siteId}:${affiliate.affiliateId}:${affiliate.bucketStart}:${affiliate.shard}`;
363
+ const current = deltas.affiliates.get(key) ?? {
364
+ siteId,
365
+ affiliateId: affiliate.affiliateId,
366
+ bucketStart: affiliate.bucketStart,
367
+ shard: affiliate.shard,
368
+ affiliateSlug: affiliate.affiliateSlug,
369
+ visits: 0,
370
+ visitorKeys: new Set<string>(),
371
+ conversions: 0,
372
+ revenueCents: 0,
373
+ commissionCents: 0,
374
+ };
375
+ current.affiliateSlug = affiliate.affiliateSlug;
376
+ current.visits += affiliate.visits;
377
+ current.conversions += affiliate.conversions;
378
+ current.revenueCents += affiliate.revenueCents;
379
+ current.commissionCents += affiliate.commissionCents;
380
+ for (const visitorKey of affiliate.visitorKeys) {
381
+ current.visitorKeys.add(visitorKey);
382
+ }
383
+ deltas.affiliates.set(key, current);
384
+ }
385
+ for (const route of queued.routes) {
386
+ const key = `${siteId}:route:${route.route}:${route.bucketStart}:${route.shard}`;
387
+ const current = deltas.routes.get(key) ?? {
388
+ siteId,
389
+ bucketStart: route.bucketStart,
390
+ shard: route.shard,
391
+ route: route.route,
392
+ pageviews: 0,
393
+ entries: 0,
394
+ continued: 0,
395
+ dwellMs: 0,
396
+ dwellSamples: 0,
397
+ visitorKeys: new Set<string>(),
398
+ outgoing: new Map<string, number>(),
399
+ };
400
+ current.pageviews += route.pageviews;
401
+ current.entries += route.entries;
402
+ current.continued += route.continued;
403
+ current.dwellMs += route.dwellMs;
404
+ current.dwellSamples += route.dwellSamples;
405
+ for (const visitorKey of route.visitorKeys) {
406
+ current.visitorKeys.add(visitorKey);
407
+ }
408
+ for (const transition of route.outgoing) {
409
+ current.outgoing.set(
410
+ transition.route,
411
+ (current.outgoing.get(transition.route) ?? 0) + transition.count,
412
+ );
413
+ }
414
+ deltas.routes.set(key, current);
415
+ }
416
+ for (const vital of queued.vitals) {
417
+ const key = JSON.stringify([
418
+ siteId,
419
+ vital.bucketStart,
420
+ vital.device,
421
+ vital.page,
422
+ vital.shard,
423
+ ]);
424
+ const current = deltas.vitals.get(key) ?? {
425
+ siteId,
426
+ bucketStart: vital.bucketStart,
427
+ device: vital.device,
428
+ page: vital.page,
429
+ shard: vital.shard,
430
+ metrics: new Map<
431
+ VitalMetric,
432
+ { count: number; sum: number; histogram: number[] }
433
+ >(),
434
+ };
435
+ for (const metric of vital.metrics) {
436
+ const entry = current.metrics.get(metric.metric);
437
+ if (entry) {
438
+ entry.count += metric.count;
439
+ entry.sum += metric.sum;
440
+ entry.histogram = mergeVitalHistograms(
441
+ entry.histogram,
442
+ metric.histogram,
443
+ );
444
+ } else {
445
+ current.metrics.set(metric.metric, {
446
+ count: metric.count,
447
+ sum: metric.sum,
448
+ histogram: [...metric.histogram],
449
+ });
450
+ }
451
+ }
452
+ deltas.vitals.set(key, current);
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Appends a batch's rollup deltas to the site's queue and makes sure a fold
458
+ * job will pick them up. The only document this reads is the site's fold
459
+ * state, which the fold job rewrites about twice a minute under load, so
460
+ * concurrent ingests almost never retry because of it.
461
+ */
462
+ export async function enqueueReportRollups(
463
+ ctx: MutationCtx,
464
+ siteId: Id<"sites">,
465
+ deltas: ReportRollupDeltas,
466
+ now: number,
467
+ ) {
468
+ if (countReportRollupRows(deltas) === 0) return;
469
+ await ctx.db.insert(
470
+ "reportRollupQueue",
471
+ serializeReportRollupDeltas(siteId, deltas, now),
472
+ );
473
+ await ensureRollupFold(ctx, siteId, now);
474
+ }
475
+
476
+ export async function ensureRollupFold(
477
+ ctx: MutationCtx,
478
+ siteId: Id<"sites">,
479
+ now: number,
480
+ ) {
481
+ const state = await ctx.db
482
+ .query("rollupFoldStates")
483
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
484
+ .unique();
485
+ if (state && state.leaseUntil > now) return;
486
+ const token = crypto.randomUUID();
487
+ const leaseUntil = now + ROLLUP_FOLD_LEASE_MS;
488
+ if (state) {
489
+ await ctx.db.patch("rollupFoldStates", state._id, { leaseUntil, token });
490
+ } else {
491
+ await ctx.db.insert("rollupFoldStates", { siteId, leaseUntil, token });
492
+ }
493
+ await ctx.scheduler.runAfter(0, internal.rollups.fold, { siteId, token });
494
+ }
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // Writing the rows. Only the fold job calls this, one site at a time.
498
+
499
+ export async function flushReportRollups(
500
+ ctx: MutationCtx,
501
+ deltas: ReportRollupDeltas,
502
+ ) {
503
+ for (const delta of deltas.goals.values()) {
504
+ const existing = await ctx.db
505
+ .query("goalDailyRollups")
506
+ .withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) =>
507
+ range
508
+ .eq("siteId", delta.siteId)
509
+ .eq("goalId", delta.goalId)
510
+ .eq("bucketStart", delta.bucketStart)
511
+ .eq("shard", delta.shard),
512
+ )
513
+ .unique();
514
+ const fields = {
515
+ siteId: delta.siteId,
516
+ goalId: delta.goalId,
517
+ bucketStart: delta.bucketStart,
518
+ shard: delta.shard,
519
+ generation: 1 as const,
520
+ goalKey: delta.goalKey,
521
+ completions: (existing?.completions ?? 0) + delta.completions,
522
+ valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
523
+ };
524
+ if (existing) {
525
+ await ctx.db.replace("goalDailyRollups", existing._id, fields);
526
+ } else {
527
+ await ctx.db.insert("goalDailyRollups", fields);
528
+ }
529
+ }
530
+ for (const delta of deltas.funnels.values()) {
531
+ const existing = await ctx.db
532
+ .query("funnelDailyRollups")
533
+ .withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) =>
534
+ range
535
+ .eq("siteId", delta.siteId)
536
+ .eq("funnelId", delta.funnelId)
537
+ .eq("bucketStart", delta.bucketStart)
538
+ .eq("shard", delta.shard),
539
+ )
540
+ .unique();
541
+ const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
542
+ for (const addition of delta.steps.values()) {
543
+ const current = steps.find((step) => step.step === addition.step);
544
+ if (current) {
545
+ current.entrants += addition.entrants;
546
+ current.completions += addition.completions;
547
+ } else {
548
+ if (steps.length >= MAX_FUNNEL_STEPS) {
549
+ fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
550
+ }
551
+ steps.push({ ...addition });
552
+ }
553
+ }
554
+ const fields = {
555
+ siteId: delta.siteId,
556
+ funnelId: delta.funnelId,
557
+ bucketStart: delta.bucketStart,
558
+ shard: delta.shard,
559
+ generation: 1 as const,
560
+ steps,
561
+ };
562
+ if (existing) {
563
+ await ctx.db.replace("funnelDailyRollups", existing._id, fields);
564
+ } else {
565
+ await ctx.db.insert("funnelDailyRollups", fields);
566
+ }
567
+ }
568
+ for (const delta of deltas.funnelSteps.values()) {
569
+ const bucket = await ctx.db
570
+ .query("funnelStepBuckets")
571
+ .withIndex(
572
+ "by_siteId_and_funnelId_and_bucketStart_and_step_and_shard",
573
+ (range) =>
574
+ range
575
+ .eq("siteId", delta.siteId)
576
+ .eq("funnelId", delta.funnelId)
577
+ .eq("bucketStart", delta.bucketStart)
578
+ .eq("step", delta.step)
579
+ .eq("shard", delta.shard),
580
+ )
581
+ .unique();
582
+ if (bucket) {
583
+ await ctx.db.patch("funnelStepBuckets", bucket._id, {
584
+ entrants: bucket.entrants + delta.entrants,
585
+ completions: bucket.completions + delta.completions,
586
+ });
587
+ } else {
588
+ await ctx.db.insert("funnelStepBuckets", {
589
+ siteId: delta.siteId,
590
+ funnelId: delta.funnelId,
591
+ bucketStart: delta.bucketStart,
592
+ step: delta.step,
593
+ shard: delta.shard,
594
+ entrants: delta.entrants,
595
+ completions: delta.completions,
596
+ });
597
+ }
598
+ }
599
+ for (const delta of deltas.affiliates.values()) {
600
+ const existing = await ctx.db
601
+ .query("affiliateDailyRollups")
602
+ .withIndex(
603
+ "by_siteId_and_affiliateId_and_bucketStart_and_shard",
604
+ (range) =>
605
+ range
606
+ .eq("siteId", delta.siteId)
607
+ .eq("affiliateId", delta.affiliateId)
608
+ .eq("bucketStart", delta.bucketStart)
609
+ .eq("shard", delta.shard),
610
+ )
611
+ .unique();
612
+ const fields = {
613
+ siteId: delta.siteId,
614
+ affiliateId: delta.affiliateId,
615
+ bucketStart: delta.bucketStart,
616
+ shard: delta.shard,
617
+ generation: 1 as const,
618
+ affiliateSlug: delta.affiliateSlug,
619
+ visits: (existing?.visits ?? 0) + delta.visits,
620
+ visitorSketch: sketchWithKeys(existing?.visitorSketch, delta.visitorKeys),
621
+ conversions: (existing?.conversions ?? 0) + delta.conversions,
622
+ revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
623
+ commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
624
+ };
625
+ if (existing) {
626
+ await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
627
+ } else {
628
+ await ctx.db.insert("affiliateDailyRollups", fields);
629
+ }
630
+ }
631
+ const routeDayRowCounts = new Map<string, number>();
632
+ for (const delta of deltas.routes.values()) {
633
+ await upsertRouteRollup(ctx, delta, routeDayRowCounts);
634
+ }
635
+ for (const delta of deltas.vitals.values()) {
636
+ await upsertVitalRollup(ctx, delta);
637
+ }
638
+ }
639
+
640
+ function sketchWithKeys(
641
+ existing: VisitorSketch | undefined,
642
+ visitorKeys: Set<string>,
643
+ ): VisitorSketch {
644
+ let sketch = createVisitorSketch();
645
+ for (const visitorKey of visitorKeys) {
646
+ sketch = addVisitor(sketch, visitorKey);
647
+ }
648
+ return existing ? unionVisitorSketches(existing, sketch) : sketch;
649
+ }
650
+
651
+ async function upsertRouteRollup(
652
+ ctx: MutationCtx,
653
+ delta: RouteRollupDelta,
654
+ dayRowCounts: Map<string, number>,
655
+ ) {
656
+ const existing = await ctx.db
657
+ .query("routeDailyRollups")
658
+ .withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) =>
659
+ range
660
+ .eq("siteId", delta.siteId)
661
+ .eq("route", delta.route)
662
+ .eq("bucketStart", delta.bucketStart)
663
+ .eq("shard", delta.shard),
664
+ )
665
+ .unique();
666
+ if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
667
+ // A new route row spends one bounded read per fold to respect the daily
668
+ // cap; past it, the day's remaining routes fold into "(other)".
669
+ const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
670
+ const dayKey = `${delta.siteId}:${delta.bucketStart}`;
671
+ let dayRows = dayRowCounts.get(dayKey);
672
+ if (dayRows === undefined) {
673
+ dayRows = (
674
+ await ctx.db
675
+ .query("routeDailyRollups")
676
+ .withIndex("by_siteId_and_bucketStart", (range) =>
677
+ range
678
+ .eq("siteId", delta.siteId)
679
+ .eq("bucketStart", delta.bucketStart),
680
+ )
681
+ .take(capRows + 1)
682
+ ).length;
683
+ }
684
+ if (dayRows >= capRows) {
685
+ await upsertRouteRollup(
686
+ ctx,
687
+ { ...delta, route: SITE_MAP_OTHER_ROUTE },
688
+ dayRowCounts,
689
+ );
690
+ return;
691
+ }
692
+ dayRowCounts.set(dayKey, dayRows + 1);
693
+ }
694
+ const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
695
+ for (const [target, count] of delta.outgoing) {
696
+ let entry = outgoing.find((candidate) => candidate.route === target);
697
+ if (!entry) {
698
+ const named = outgoing.filter(
699
+ (candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE,
700
+ ).length;
701
+ const route =
702
+ target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
703
+ ? SITE_MAP_OTHER_ROUTE
704
+ : target;
705
+ entry = outgoing.find((candidate) => candidate.route === route);
706
+ if (!entry) {
707
+ entry = { route, count: 0 };
708
+ outgoing.push(entry);
709
+ }
710
+ }
711
+ entry.count += count;
712
+ }
713
+ const fields = {
714
+ siteId: delta.siteId,
715
+ bucketStart: delta.bucketStart,
716
+ shard: delta.shard,
717
+ generation: 1 as const,
718
+ route: delta.route,
719
+ pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
720
+ entries: (existing?.entries ?? 0) + delta.entries,
721
+ continued: (existing?.continued ?? 0) + delta.continued,
722
+ dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
723
+ dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
724
+ visitorSketch: sketchWithKeys(existing?.visitorSketch, delta.visitorKeys),
725
+ outgoing,
726
+ };
727
+ if (existing) {
728
+ await ctx.db.replace("routeDailyRollups", existing._id, fields);
729
+ } else {
730
+ await ctx.db.insert("routeDailyRollups", fields);
731
+ }
732
+ }
733
+
734
+ async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
735
+ const existing = await ctx.db
736
+ .query("vitalRollups")
737
+ .withIndex("by_key", (range) =>
738
+ range
739
+ .eq("siteId", delta.siteId)
740
+ .eq("bucketStart", delta.bucketStart)
741
+ .eq("device", delta.device)
742
+ .eq("page", delta.page)
743
+ .eq("shard", delta.shard),
744
+ )
745
+ .unique();
746
+ if (
747
+ !existing &&
748
+ delta.device === VITAL_ALL &&
749
+ delta.page !== VITAL_OTHER_PAGES
750
+ ) {
751
+ // A new page row spends one bounded read to respect the daily page slots;
752
+ // past the cap, the day's remaining pages fold into "(other)".
753
+ const pageRows = await ctx.db
754
+ .query("vitalRollups")
755
+ .withIndex("by_key", (range) =>
756
+ range
757
+ .eq("siteId", delta.siteId)
758
+ .eq("bucketStart", delta.bucketStart)
759
+ .eq("device", VITAL_ALL),
760
+ )
761
+ .take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
762
+ if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
763
+ await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
764
+ return;
765
+ }
766
+ }
767
+ const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
768
+ for (const [metric, addition] of delta.metrics) {
769
+ const entry = metrics.find((candidate) => candidate.metric === metric);
770
+ if (entry) {
771
+ entry.count += addition.count;
772
+ entry.sum += addition.sum;
773
+ entry.histogram = mergeVitalHistograms(
774
+ entry.histogram,
775
+ addition.histogram,
776
+ );
777
+ } else {
778
+ metrics.push({
779
+ metric,
780
+ count: addition.count,
781
+ sum: addition.sum,
782
+ histogram: [...addition.histogram],
783
+ });
784
+ }
785
+ }
786
+ const fields = {
787
+ siteId: delta.siteId,
788
+ bucketStart: delta.bucketStart,
789
+ device: delta.device,
790
+ page: delta.page,
791
+ shard: delta.shard,
792
+ metrics,
793
+ };
794
+ if (existing) {
795
+ await ctx.db.replace("vitalRollups", existing._id, fields);
796
+ } else {
797
+ await ctx.db.insert("vitalRollups", fields);
798
+ }
799
+ }