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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +98 -18
  2. package/dist/component/_generated/api.d.ts +7 -1
  3. package/dist/component/_generated/api.d.ts.map +1 -1
  4. package/dist/component/_generated/api.js.map +1 -1
  5. package/dist/component/constants.d.ts +11 -6
  6. package/dist/component/constants.d.ts.map +1 -1
  7. package/dist/component/constants.js +11 -6
  8. package/dist/component/constants.js.map +1 -1
  9. package/dist/component/convex.config.d.ts +2 -2
  10. package/dist/component/convex.config.d.ts.map +1 -1
  11. package/dist/component/convex.config.js +4 -1
  12. package/dist/component/convex.config.js.map +1 -1
  13. package/dist/component/eventStore.d.ts +102 -0
  14. package/dist/component/eventStore.d.ts.map +1 -0
  15. package/dist/component/eventStore.js +463 -0
  16. package/dist/component/eventStore.js.map +1 -0
  17. package/dist/component/goals.d.ts.map +1 -1
  18. package/dist/component/goals.js +17 -4
  19. package/dist/component/goals.js.map +1 -1
  20. package/dist/component/http.d.ts.map +1 -1
  21. package/dist/component/http.js +39 -7
  22. package/dist/component/http.js.map +1 -1
  23. package/dist/component/ingest.d.ts.map +1 -1
  24. package/dist/component/ingest.js +520 -207
  25. package/dist/component/ingest.js.map +1 -1
  26. package/dist/component/live.d.ts +8 -0
  27. package/dist/component/live.d.ts.map +1 -1
  28. package/dist/component/live.js +72 -8
  29. package/dist/component/live.js.map +1 -1
  30. package/dist/component/migrations.d.ts +17 -0
  31. package/dist/component/migrations.d.ts.map +1 -0
  32. package/dist/component/migrations.js +44 -0
  33. package/dist/component/migrations.js.map +1 -0
  34. package/dist/component/reports.d.ts +16 -16
  35. package/dist/component/reports.d.ts.map +1 -1
  36. package/dist/component/reports.js +228 -65
  37. package/dist/component/reports.js.map +1 -1
  38. package/dist/component/retention.d.ts.map +1 -1
  39. package/dist/component/retention.js +15 -10
  40. package/dist/component/retention.js.map +1 -1
  41. package/dist/component/sanitize.d.ts.map +1 -1
  42. package/dist/component/sanitize.js +11 -3
  43. package/dist/component/sanitize.js.map +1 -1
  44. package/dist/component/schema.d.ts +132 -9
  45. package/dist/component/schema.js +32 -7
  46. package/dist/component/schema.js.map +1 -1
  47. package/dist/component/sites.d.ts.map +1 -1
  48. package/dist/component/sites.js +27 -18
  49. package/dist/component/sites.js.map +1 -1
  50. package/dist/component/validators.d.ts +96 -0
  51. package/dist/component/validators.d.ts.map +1 -1
  52. package/dist/component/validators.js +12 -0
  53. package/dist/component/validators.js.map +1 -1
  54. package/dist/tracker/generated.d.ts +4 -4
  55. package/dist/tracker/generated.d.ts.map +1 -1
  56. package/dist/tracker/generated.js +4 -4
  57. package/dist/tracker/generated.js.map +1 -1
  58. package/dist/tracker/tracker.js +33 -22
  59. package/dist/tracker/tracker.js.map +1 -1
  60. package/dist/tracker.min.js +1 -1
  61. package/docs/benchmarks/2026-08-20-realistic.md +171 -0
  62. package/package.json +15 -5
  63. package/scripts/benchmark-ingest.mjs +499 -0
  64. package/src/component/_generated/api.ts +7 -1
  65. package/src/component/constants.ts +11 -6
  66. package/src/component/convex.config.ts +5 -1
  67. package/src/component/eventStore.ts +684 -0
  68. package/src/component/goals.ts +25 -4
  69. package/src/component/http.ts +49 -6
  70. package/src/component/ingest.ts +671 -215
  71. package/src/component/live.ts +83 -8
  72. package/src/component/migrations.ts +52 -0
  73. package/src/component/reports.ts +295 -86
  74. package/src/component/retention.ts +15 -12
  75. package/src/component/sanitize.ts +10 -3
  76. package/src/component/schema.ts +35 -7
  77. package/src/component/sites.ts +31 -18
  78. package/src/component/validators.ts +16 -0
  79. package/src/test.ts +2 -0
@@ -0,0 +1,684 @@
1
+ import type { Doc, Id } from "./_generated/dataModel.js";
2
+ import type { MutationCtx, QueryCtx } from "./_generated/server.js";
3
+ import {
4
+ JOURNEY_READ_BYTE_RESERVE,
5
+ JOURNEY_READ_DOCUMENT_RESERVE,
6
+ MAX_BATCH_EVENTS,
7
+ MAX_EVENTS_PER_SESSION_WINDOW,
8
+ RATE_LIMIT_WINDOW_MS,
9
+ } from "./constants.js";
10
+ import { fail } from "./errors.js";
11
+ import type { TrackerEvent } from "./validators.js";
12
+
13
+ export type TelemetryMatch = {
14
+ sessionId: string;
15
+ visitorId: string;
16
+ country?: string;
17
+ city?: string;
18
+ latitude?: number;
19
+ longitude?: number;
20
+ browser?: string;
21
+ os?: string;
22
+ device?: string;
23
+ };
24
+
25
+ type BufferedEventBatch = {
26
+ siteId: Id<"sites">;
27
+ sessionId: string;
28
+ visitorId: string;
29
+ bucketStart: number;
30
+ events: TrackerEvent[];
31
+ };
32
+
33
+ export type EventBatchBuffer = Map<string, BufferedEventBatch>;
34
+
35
+ type MigrationBatch = Pick<
36
+ Doc<"eventBatches">,
37
+ "_id" | "visitorId" | "events" | "migrationVersion"
38
+ >;
39
+
40
+ const migrationBatchCache = new WeakMap<
41
+ MutationCtx,
42
+ Map<string, MigrationBatch[]>
43
+ >();
44
+
45
+ export type JourneyCursor = {
46
+ timestamp: number;
47
+ creationTime: number;
48
+ id: string;
49
+ };
50
+
51
+ export type RawJourneyEvent = Omit<Doc<"events">, "_id"> & { _id: string };
52
+
53
+ export function eventBucketStart(timestamp: number): number {
54
+ return Math.floor(timestamp / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
55
+ }
56
+
57
+ export function eventBatchKey(sessionId: string, bucketStart: number): string {
58
+ return JSON.stringify([sessionId, bucketStart]);
59
+ }
60
+
61
+ export async function inspectTelemetryMinute(
62
+ ctx: MutationCtx,
63
+ input: {
64
+ siteId: Id<"sites">;
65
+ sessionId: string;
66
+ bucketStart: number;
67
+ candidateEventIds: Set<string>;
68
+ },
69
+ ) {
70
+ const [legacyEvents, batches] = await loadTelemetryMinute(ctx, input);
71
+ const eventIds = new Set([
72
+ ...legacyEvents.map((event) => event.eventId),
73
+ ...batches.flatMap((batch) =>
74
+ batch.events.map((event) => event.eventId),
75
+ ),
76
+ ]);
77
+ if (legacyEvents.length > MAX_EVENTS_PER_SESSION_WINDOW) {
78
+ await Promise.all(
79
+ [...input.candidateEventIds]
80
+ .filter((eventId) => !eventIds.has(eventId))
81
+ .map(async (eventId) => {
82
+ const legacy = await findLegacyTelemetry(ctx, {
83
+ ...input,
84
+ eventId,
85
+ });
86
+ if (legacy) eventIds.add(eventId);
87
+ }),
88
+ );
89
+ }
90
+ return {
91
+ eventIds,
92
+ persistedCount: telemetryMinuteCount(legacyEvents, batches),
93
+ };
94
+ }
95
+
96
+ export async function findTelemetry(
97
+ ctx: MutationCtx,
98
+ siteId: Id<"sites">,
99
+ event: TrackerEvent,
100
+ ): Promise<{ match: TelemetryMatch | null; saturated: boolean }> {
101
+ const bucketStart = eventBucketStart(event.timestamp);
102
+ const [legacyEvents, batches] = await loadTelemetryMinute(ctx, {
103
+ siteId,
104
+ sessionId: event.sessionId,
105
+ bucketStart,
106
+ });
107
+ const persistedCount = telemetryMinuteCount(legacyEvents, batches);
108
+ let legacyEvent = legacyEvents.find((candidate) =>
109
+ candidate.eventId === event.eventId
110
+ );
111
+ if (
112
+ !legacyEvent &&
113
+ legacyEvents.length > MAX_EVENTS_PER_SESSION_WINDOW
114
+ ) {
115
+ legacyEvent = await findLegacyTelemetry(ctx, {
116
+ siteId,
117
+ sessionId: event.sessionId,
118
+ bucketStart,
119
+ eventId: event.eventId,
120
+ }) ?? undefined;
121
+ }
122
+ const saturated = persistedCount >= MAX_EVENTS_PER_SESSION_WINDOW;
123
+ if (legacyEvent) return { match: legacyEvent, saturated };
124
+ let batch: Doc<"eventBatches"> | undefined;
125
+ let nestedEvent: Doc<"eventBatches">["events"][number] | undefined;
126
+ for (const candidate of batches) {
127
+ nestedEvent = candidate.events.find((nested) =>
128
+ nested.eventId === event.eventId
129
+ );
130
+ if (nestedEvent) {
131
+ batch = candidate;
132
+ break;
133
+ }
134
+ }
135
+ return {
136
+ match: batch && nestedEvent
137
+ ? {
138
+ sessionId: batch.sessionId,
139
+ visitorId: batch.visitorId,
140
+ ...eventContext(nestedEvent),
141
+ }
142
+ : null,
143
+ saturated,
144
+ };
145
+ }
146
+
147
+ export function bufferEventBatch(
148
+ siteId: Id<"sites">,
149
+ event: TrackerEvent,
150
+ buffer: EventBatchBuffer,
151
+ ) {
152
+ const bucketStart = eventBucketStart(event.timestamp);
153
+ const key = eventBatchKey(event.sessionId, bucketStart);
154
+ const existing = buffer.get(key);
155
+ if (existing) {
156
+ if (existing.visitorId !== event.visitorId) {
157
+ throw new Error("event batch visitor does not match its session");
158
+ }
159
+ existing.events.push(eventForBatch(event));
160
+ return;
161
+ }
162
+ buffer.set(key, {
163
+ siteId,
164
+ sessionId: event.sessionId,
165
+ visitorId: event.visitorId,
166
+ bucketStart,
167
+ events: [eventForBatch(event)],
168
+ });
169
+ }
170
+
171
+ export async function insertEventBatch(
172
+ ctx: MutationCtx,
173
+ siteId: Id<"sites">,
174
+ event: TrackerEvent,
175
+ ) {
176
+ await ctx.db.insert("eventBatches", {
177
+ siteId,
178
+ sessionId: event.sessionId,
179
+ visitorId: event.visitorId,
180
+ bucketStart: eventBucketStart(event.timestamp),
181
+ events: [eventForBatch(event)],
182
+ });
183
+ }
184
+
185
+ export async function flushEventBatches(
186
+ ctx: MutationCtx,
187
+ buffer: EventBatchBuffer,
188
+ ) {
189
+ for (const batch of buffer.values()) {
190
+ await ctx.db.insert("eventBatches", batch);
191
+ }
192
+ }
193
+
194
+ export async function migrateLegacyEventToBatch(
195
+ ctx: MutationCtx,
196
+ event: Doc<"events">,
197
+ ) {
198
+ const bucketStart = eventBucketStart(event.timestamp);
199
+ let transactionCache = migrationBatchCache.get(ctx);
200
+ if (!transactionCache) {
201
+ transactionCache = new Map();
202
+ migrationBatchCache.set(ctx, transactionCache);
203
+ }
204
+ const cacheKey = JSON.stringify([
205
+ event.siteId,
206
+ event.sessionId,
207
+ bucketStart,
208
+ ]);
209
+ let batches = transactionCache.get(cacheKey);
210
+ if (!batches) {
211
+ batches = await ctx.db
212
+ .query("eventBatches")
213
+ .withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
214
+ range
215
+ .eq("siteId", event.siteId)
216
+ .eq("sessionId", event.sessionId)
217
+ .eq("bucketStart", bucketStart),
218
+ )
219
+ .order("desc")
220
+ .take(MAX_EVENTS_PER_SESSION_WINDOW + 1);
221
+ transactionCache.set(cacheKey, batches);
222
+ }
223
+ const duplicateBatch = batches.find((batch) =>
224
+ batch.events.some((candidate) => candidate.eventId === event.eventId)
225
+ );
226
+ const context = eventContext(event);
227
+ if (duplicateBatch) {
228
+ if (Object.keys(context).length > 0) {
229
+ const events = duplicateBatch.events.map((candidate) =>
230
+ candidate.eventId === event.eventId
231
+ ? { ...candidate, ...context }
232
+ : candidate
233
+ );
234
+ await ctx.db.patch("eventBatches", duplicateBatch._id, {
235
+ events,
236
+ });
237
+ duplicateBatch.events = events;
238
+ }
239
+ await ctx.db.delete("events", event._id);
240
+ return;
241
+ }
242
+ const migratedEvent = {
243
+ ...eventForBatch(event),
244
+ ...context,
245
+ };
246
+ const latestBatch = batches[0];
247
+ if (
248
+ latestBatch?.migrationVersion === 1 &&
249
+ latestBatch.visitorId === event.visitorId &&
250
+ latestBatch.events.length < MAX_BATCH_EVENTS
251
+ ) {
252
+ const events = [...latestBatch.events, migratedEvent];
253
+ await ctx.db.patch("eventBatches", latestBatch._id, {
254
+ events,
255
+ });
256
+ latestBatch.events = events;
257
+ } else {
258
+ const batch = {
259
+ siteId: event.siteId,
260
+ sessionId: event.sessionId,
261
+ visitorId: event.visitorId,
262
+ bucketStart,
263
+ events: [migratedEvent],
264
+ migrationVersion: 1 as const,
265
+ };
266
+ const batchId = await ctx.db.insert("eventBatches", batch);
267
+ batches.unshift({ _id: batchId, ...batch });
268
+ }
269
+ await ctx.db.delete("events", event._id);
270
+ }
271
+
272
+ export async function loadSessionJourneyEvents(
273
+ ctx: QueryCtx,
274
+ input: {
275
+ siteId: Id<"sites">;
276
+ sessionId: string;
277
+ from: number;
278
+ to: number;
279
+ cursor: JourneyCursor | null;
280
+ resultLimit: number;
281
+ },
282
+ ): Promise<RawJourneyEvent[]> {
283
+ const startTimestamp = input.cursor?.timestamp ?? input.from;
284
+ const firstBucket = eventBucketStart(startTimestamp);
285
+ const lastBucket = eventBucketStart(input.to);
286
+ // Nested event tiebreakers are not index fields, so the boundary minute must
287
+ // be read completely before applying the cursor.
288
+ const loadBatches = async () => {
289
+ const batches: Doc<"eventBatches">[] = [];
290
+ let eventCount = 0;
291
+ const requiredEvents = input.resultLimit + MAX_EVENTS_PER_SESSION_WINDOW;
292
+ let completedBucket: number | undefined;
293
+ const query = ctx.db
294
+ .query("eventBatches")
295
+ .withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
296
+ range
297
+ .eq("siteId", input.siteId)
298
+ .eq("sessionId", input.sessionId)
299
+ .gte("bucketStart", firstBucket)
300
+ .lte("bucketStart", lastBucket),
301
+ )
302
+ .order("asc");
303
+ for await (const batch of query) {
304
+ if (completedBucket !== undefined && batch.bucketStart !== completedBucket) {
305
+ break;
306
+ }
307
+ batches.push(batch);
308
+ eventCount += batch.events.length;
309
+ if (eventCount >= requiredEvents) completedBucket = batch.bucketStart;
310
+ }
311
+ return batches;
312
+ };
313
+ const loadLegacyEvents = async () => {
314
+ const take = input.resultLimit + 1;
315
+ if (!input.cursor) {
316
+ return ctx.db
317
+ .query("events")
318
+ .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
319
+ range
320
+ .eq("siteId", input.siteId)
321
+ .eq("sessionId", input.sessionId)
322
+ .gte("timestamp", input.from)
323
+ .lte("timestamp", input.to),
324
+ )
325
+ .order("asc")
326
+ .take(take);
327
+ }
328
+ const rows = await ctx.db
329
+ .query("events")
330
+ .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
331
+ range
332
+ .eq("siteId", input.siteId)
333
+ .eq("sessionId", input.sessionId)
334
+ .eq("timestamp", input.cursor!.timestamp)
335
+ .gt("_creationTime", input.cursor!.creationTime),
336
+ )
337
+ .order("asc")
338
+ .take(take);
339
+ if (rows.length < take) {
340
+ rows.push(...await ctx.db
341
+ .query("events")
342
+ .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
343
+ range
344
+ .eq("siteId", input.siteId)
345
+ .eq("sessionId", input.sessionId)
346
+ .gt("timestamp", input.cursor!.timestamp)
347
+ .lte("timestamp", input.to),
348
+ )
349
+ .order("asc")
350
+ .take(take - rows.length));
351
+ }
352
+ return rows;
353
+ };
354
+ const [legacyEvents, batches] = await Promise.all([
355
+ loadLegacyEvents(),
356
+ loadBatches(),
357
+ ]);
358
+ const rows: RawJourneyEvent[] = legacyEvents.map((event) => ({
359
+ ...event,
360
+ _id: String(event._id),
361
+ }));
362
+ appendBatchEvents(rows, batches);
363
+ return rows
364
+ .filter((event) =>
365
+ event.timestamp >= input.from &&
366
+ event.timestamp <= input.to &&
367
+ (!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0)
368
+ )
369
+ .sort(compareJourneyEvents)
370
+ .slice(0, input.resultLimit);
371
+ }
372
+
373
+ export async function loadVisitorJourneyEvents(
374
+ ctx: QueryCtx,
375
+ input: {
376
+ siteId: Id<"sites">;
377
+ visitorId: string;
378
+ from: number;
379
+ to: number;
380
+ resultLimit: number;
381
+ },
382
+ ): Promise<RawJourneyEvent[]> {
383
+ const firstBucket = eventBucketStart(input.from);
384
+ const lastBucket = eventBucketStart(input.to);
385
+ const loadBatches = async () => {
386
+ const batches: Doc<"eventBatches">[] = [];
387
+ let qualifyingEvents = 0;
388
+ let completedBucket: number | undefined;
389
+ const query = ctx.db
390
+ .query("eventBatches")
391
+ .withIndex("by_siteId_and_visitorId_and_bucketStart", (range) =>
392
+ range
393
+ .eq("siteId", input.siteId)
394
+ .eq("visitorId", input.visitorId)
395
+ .gte("bucketStart", firstBucket)
396
+ .lte("bucketStart", lastBucket),
397
+ )
398
+ .order("asc");
399
+ for await (const batch of query) {
400
+ if (completedBucket !== undefined && batch.bucketStart !== completedBucket) {
401
+ break;
402
+ }
403
+ batches.push(batch);
404
+ await requireJourneyReadHeadroom(ctx);
405
+ qualifyingEvents += batch.events.filter((event) =>
406
+ event.timestamp >= input.from && event.timestamp <= input.to
407
+ ).length;
408
+ if (qualifyingEvents >= input.resultLimit) {
409
+ completedBucket = batch.bucketStart;
410
+ }
411
+ }
412
+ return batches;
413
+ };
414
+ const legacyEvents: Doc<"events">[] = [];
415
+ const legacyQuery = ctx.db
416
+ .query("events")
417
+ .withIndex("by_siteId_and_visitorId_and_timestamp", (range) =>
418
+ range
419
+ .eq("siteId", input.siteId)
420
+ .eq("visitorId", input.visitorId)
421
+ .gte("timestamp", input.from)
422
+ .lte("timestamp", input.to),
423
+ )
424
+ .order("asc");
425
+ for await (const event of legacyQuery) {
426
+ legacyEvents.push(event);
427
+ await requireJourneyReadHeadroom(ctx);
428
+ if (legacyEvents.length >= input.resultLimit) break;
429
+ }
430
+ const batches = await loadBatches();
431
+ const rows: RawJourneyEvent[] = legacyEvents.map((event) => ({
432
+ ...event,
433
+ _id: String(event._id),
434
+ }));
435
+ appendBatchEvents(rows, batches);
436
+ return rows
437
+ .filter((event) =>
438
+ event.timestamp >= input.from && event.timestamp <= input.to
439
+ )
440
+ .sort(compareJourneyEvents)
441
+ .slice(0, input.resultLimit);
442
+ }
443
+
444
+ export function compareJourneyEvents(
445
+ left: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
446
+ right: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
447
+ ): number {
448
+ return left.timestamp - right.timestamp ||
449
+ left._creationTime - right._creationTime ||
450
+ left._id.localeCompare(right._id);
451
+ }
452
+
453
+ export async function hydrateEventContexts(
454
+ ctx: QueryCtx,
455
+ rows: RawJourneyEvent[],
456
+ ) {
457
+ const sessionPromises = new Map<string, Promise<Doc<"sessions"> | null>>();
458
+ for (const row of rows) {
459
+ const key = `${row.siteId}:${row.sessionId}`;
460
+ if (!sessionPromises.has(key)) {
461
+ sessionPromises.set(
462
+ key,
463
+ ctx.db
464
+ .query("sessions")
465
+ .withIndex("by_siteId_and_sessionId", (range) =>
466
+ range.eq("siteId", row.siteId).eq("sessionId", row.sessionId),
467
+ )
468
+ .unique(),
469
+ );
470
+ }
471
+ }
472
+ const sessions = new Map(
473
+ await Promise.all(
474
+ [...sessionPromises].map(async ([key, session]) =>
475
+ [key, await session] as const
476
+ ),
477
+ ),
478
+ );
479
+ return rows.map((row) => {
480
+ const session = sessions.get(`${row.siteId}:${row.sessionId}`);
481
+ const country = row.country ?? session?.country;
482
+ const city = row.city ?? session?.city;
483
+ const latitude = row.latitude ?? session?.latitude;
484
+ const longitude = row.longitude ?? session?.longitude;
485
+ return {
486
+ ...row,
487
+ ...(country ? { country } : {}),
488
+ ...(city ? { city } : {}),
489
+ ...(latitude !== undefined ? { latitude } : {}),
490
+ ...(longitude !== undefined ? { longitude } : {}),
491
+ browser: row.browser ?? session?.browser ?? "Other",
492
+ os: row.os ?? session?.os ?? "Other",
493
+ device: row.device ?? session?.device ?? "unknown",
494
+ };
495
+ });
496
+ }
497
+
498
+ export async function deleteTelemetryBefore(
499
+ ctx: MutationCtx,
500
+ input: {
501
+ siteId: Id<"sites">;
502
+ before: number;
503
+ batchSize: number;
504
+ },
505
+ ) {
506
+ const batchCutoff = eventBucketStart(input.before);
507
+ const limit = input.batchSize + 1;
508
+ const [legacyEvents, batches] = await Promise.all([
509
+ ctx.db
510
+ .query("events")
511
+ .withIndex("by_siteId_and_timestamp", (range) =>
512
+ range.eq("siteId", input.siteId).lt("timestamp", input.before),
513
+ )
514
+ .take(limit),
515
+ ctx.db
516
+ .query("eventBatches")
517
+ .withIndex("by_siteId_and_bucketStart", (range) =>
518
+ range.eq("siteId", input.siteId).lt("bucketStart", batchCutoff),
519
+ )
520
+ .take(limit),
521
+ ]);
522
+ const rows = [...legacyEvents, ...batches].slice(0, input.batchSize);
523
+ for (const row of rows) {
524
+ if ("bucketStart" in row) {
525
+ await ctx.db.delete("eventBatches", row._id);
526
+ } else {
527
+ await ctx.db.delete("events", row._id);
528
+ }
529
+ }
530
+ return {
531
+ deleted: rows.length,
532
+ hasMore:
533
+ legacyEvents.length + batches.length > input.batchSize,
534
+ };
535
+ }
536
+
537
+ async function loadTelemetryMinute(
538
+ ctx: MutationCtx,
539
+ input: {
540
+ siteId: Id<"sites">;
541
+ sessionId: string;
542
+ bucketStart: number;
543
+ },
544
+ ) {
545
+ return Promise.all([
546
+ ctx.db
547
+ .query("events")
548
+ .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
549
+ range
550
+ .eq("siteId", input.siteId)
551
+ .eq("sessionId", input.sessionId)
552
+ .gte("timestamp", input.bucketStart)
553
+ .lt("timestamp", input.bucketStart + RATE_LIMIT_WINDOW_MS),
554
+ )
555
+ .take(MAX_EVENTS_PER_SESSION_WINDOW + 1),
556
+ ctx.db
557
+ .query("eventBatches")
558
+ .withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
559
+ range
560
+ .eq("siteId", input.siteId)
561
+ .eq("sessionId", input.sessionId)
562
+ .eq("bucketStart", input.bucketStart),
563
+ )
564
+ .take(MAX_EVENTS_PER_SESSION_WINDOW + 1),
565
+ ]);
566
+ }
567
+
568
+ async function findLegacyTelemetry(
569
+ ctx: MutationCtx,
570
+ input: {
571
+ siteId: Id<"sites">;
572
+ sessionId: string;
573
+ bucketStart: number;
574
+ eventId: string;
575
+ },
576
+ ) {
577
+ const candidates = await ctx.db
578
+ .query("events")
579
+ .withIndex("by_siteId_and_eventId", (range) =>
580
+ range.eq("siteId", input.siteId).eq("eventId", input.eventId),
581
+ )
582
+ .take(2);
583
+ return candidates.find((candidate) =>
584
+ candidate.sessionId === input.sessionId &&
585
+ eventBucketStart(candidate.timestamp) === input.bucketStart
586
+ ) ?? null;
587
+ }
588
+
589
+ function telemetryMinuteCount(
590
+ legacyEvents: Array<{ eventId: string }>,
591
+ batches: Array<{ events: TrackerEvent[] }>,
592
+ ) {
593
+ return legacyEvents.length + batches.reduce(
594
+ (total, batch) => total + batch.events.length,
595
+ 0,
596
+ );
597
+ }
598
+
599
+ function eventForBatch(event: TrackerEvent): TrackerEvent {
600
+ return {
601
+ eventId: event.eventId,
602
+ sessionId: event.sessionId,
603
+ visitorId: event.visitorId,
604
+ type: event.type,
605
+ ...(event.name ? { name: event.name } : {}),
606
+ path: event.path,
607
+ ...(event.referrer ? { referrer: event.referrer } : {}),
608
+ timestamp: event.timestamp,
609
+ sequence: event.sequence,
610
+ ...(event.target ? { target: event.target } : {}),
611
+ ...(event.href ? { href: event.href } : {}),
612
+ ...(event.properties ? { properties: event.properties } : {}),
613
+ ...(event.revenueCents !== undefined
614
+ ? { revenueCents: event.revenueCents }
615
+ : {}),
616
+ ...(event.currency ? { currency: event.currency } : {}),
617
+ ...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
618
+ };
619
+ }
620
+
621
+ function appendBatchEvents(
622
+ rows: RawJourneyEvent[],
623
+ batches: Doc<"eventBatches">[],
624
+ ) {
625
+ for (const batch of batches) {
626
+ for (let index = 0; index < batch.events.length; index += 1) {
627
+ const event = batch.events[index];
628
+ rows.push({
629
+ ...event,
630
+ _id: `eventBatch:${batch._id}:${index.toString().padStart(3, "0")}`,
631
+ _creationTime: batch._creationTime,
632
+ siteId: batch.siteId,
633
+ sessionId: batch.sessionId,
634
+ visitorId: batch.visitorId,
635
+ });
636
+ }
637
+ }
638
+ }
639
+
640
+ function eventContext(event: {
641
+ country?: string;
642
+ city?: string;
643
+ latitude?: number;
644
+ longitude?: number;
645
+ browser?: string;
646
+ os?: string;
647
+ device?: string;
648
+ }) {
649
+ return {
650
+ ...(event.country ? { country: event.country } : {}),
651
+ ...(event.city ? { city: event.city } : {}),
652
+ ...(event.latitude !== undefined ? { latitude: event.latitude } : {}),
653
+ ...(event.longitude !== undefined ? { longitude: event.longitude } : {}),
654
+ ...(event.browser ? { browser: event.browser } : {}),
655
+ ...(event.os ? { os: event.os } : {}),
656
+ ...(event.device ? { device: event.device } : {}),
657
+ };
658
+ }
659
+
660
+ async function requireJourneyReadHeadroom(ctx: QueryCtx) {
661
+ const metrics = await ctx.meta.getTransactionMetrics();
662
+ if (
663
+ metrics.bytesRead.remaining < JOURNEY_READ_BYTE_RESERVE ||
664
+ metrics.documentsRead.remaining < JOURNEY_READ_DOCUMENT_RESERVE
665
+ ) {
666
+ fail(
667
+ "LIMIT_EXCEEDED",
668
+ "visitor journey exceeds the safe read budget",
669
+ {
670
+ bytesRead: metrics.bytesRead.used,
671
+ documentsRead: metrics.documentsRead.used,
672
+ },
673
+ );
674
+ }
675
+ }
676
+
677
+ function compareJourneyEventToCursor(
678
+ event: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
679
+ cursor: JourneyCursor,
680
+ ) {
681
+ return event.timestamp - cursor.timestamp ||
682
+ event._creationTime - cursor.creationTime ||
683
+ event._id.localeCompare(cursor.id);
684
+ }