@iann29/rastro 0.1.0-alpha.1 → 0.1.0-alpha.3

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 (68) hide show
  1. package/README.md +80 -18
  2. package/dist/component/_generated/api.d.ts +4 -0
  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/_generated/component.d.ts +10 -0
  6. package/dist/component/_generated/component.d.ts.map +1 -1
  7. package/dist/component/_generated/server.d.ts +4 -0
  8. package/dist/component/_generated/server.d.ts.map +1 -1
  9. package/dist/component/_generated/server.js.map +1 -1
  10. package/dist/component/constants.d.ts +2 -0
  11. package/dist/component/constants.d.ts.map +1 -1
  12. package/dist/component/constants.js +2 -0
  13. package/dist/component/constants.js.map +1 -1
  14. package/dist/component/convex.config.d.ts +6 -1
  15. package/dist/component/convex.config.js +9 -1
  16. package/dist/component/convex.config.js.map +1 -1
  17. package/dist/component/diagnostics.d.ts +9 -0
  18. package/dist/component/diagnostics.d.ts.map +1 -0
  19. package/dist/component/diagnostics.js +47 -0
  20. package/dist/component/diagnostics.js.map +1 -0
  21. package/dist/component/eventStore.d.ts +18 -8
  22. package/dist/component/eventStore.d.ts.map +1 -1
  23. package/dist/component/eventStore.js +134 -147
  24. package/dist/component/eventStore.js.map +1 -1
  25. package/dist/component/geo.d.ts +71 -0
  26. package/dist/component/geo.d.ts.map +1 -0
  27. package/dist/component/geo.js +610 -0
  28. package/dist/component/geo.js.map +1 -0
  29. package/dist/component/http.d.ts.map +1 -1
  30. package/dist/component/http.js +124 -11
  31. package/dist/component/http.js.map +1 -1
  32. package/dist/component/ingest.d.ts.map +1 -1
  33. package/dist/component/ingest.js +34 -14
  34. package/dist/component/ingest.js.map +1 -1
  35. package/dist/component/reports.d.ts +8 -8
  36. package/dist/component/reports.d.ts.map +1 -1
  37. package/dist/component/reports.js +10 -30
  38. package/dist/component/reports.js.map +1 -1
  39. package/dist/component/schema.d.ts +47 -52
  40. package/dist/component/schema.js +21 -20
  41. package/dist/component/schema.js.map +1 -1
  42. package/dist/component/validators.d.ts +49 -49
  43. package/dist/component/validators.d.ts.map +1 -1
  44. package/dist/component/validators.js +4 -11
  45. package/dist/component/validators.js.map +1 -1
  46. package/dist/tracker/generated.d.ts +4 -4
  47. package/dist/tracker/generated.d.ts.map +1 -1
  48. package/dist/tracker/generated.js +4 -4
  49. package/dist/tracker/generated.js.map +1 -1
  50. package/dist/tracker/tracker.js +7 -6
  51. package/dist/tracker/tracker.js.map +1 -1
  52. package/dist/tracker.min.js +1 -1
  53. package/docs/benchmarks/2026-08-20-realistic.md +1 -1
  54. package/package.json +1 -1
  55. package/scripts/benchmark-ingest.mjs +81 -32
  56. package/src/component/_generated/api.ts +4 -0
  57. package/src/component/_generated/component.ts +13 -0
  58. package/src/component/_generated/server.ts +4 -0
  59. package/src/component/constants.ts +2 -0
  60. package/src/component/convex.config.ts +11 -1
  61. package/src/component/diagnostics.ts +64 -0
  62. package/src/component/eventStore.ts +195 -178
  63. package/src/component/geo.ts +779 -0
  64. package/src/component/http.ts +184 -17
  65. package/src/component/ingest.ts +49 -15
  66. package/src/component/reports.ts +12 -38
  67. package/src/component/schema.ts +23 -21
  68. package/src/component/validators.ts +7 -12
@@ -1,3 +1,13 @@
1
1
  import { defineComponent } from "convex/server";
2
+ import { v } from "convex/values";
2
3
 
3
- export default defineComponent("rastroAnalytics");
4
+ export default defineComponent("rastroAnalytics", {
5
+ env: {
6
+ RASTRO_GEOIP_PROVIDER: v.optional(
7
+ v.union(v.literal("ipinfo"), v.literal("ipwhois")),
8
+ ),
9
+ RASTRO_GEOIP_TOKEN: v.optional(v.string()),
10
+ RASTRO_GEOIP_DAILY_LIMIT: v.optional(v.string()),
11
+ RASTRO_TRUST_PROXY: v.optional(v.literal("true")),
12
+ },
13
+ });
@@ -0,0 +1,64 @@
1
+ import { v } from "convex/values";
2
+ import { query } from "./_generated/server.js";
3
+ import { fail } from "./errors.js";
4
+ import { sanitizeOpaqueId } from "./sanitize.js";
5
+
6
+ const MAX_SESSION_TOTAL_IDS = 100;
7
+
8
+ export const sessionEventTotals = query({
9
+ args: {
10
+ siteId: v.id("sites"),
11
+ sessionIds: v.array(v.string()),
12
+ },
13
+ returns: v.object({
14
+ requestedSessions: v.number(),
15
+ foundSessions: v.number(),
16
+ storedEvents: v.number(),
17
+ }),
18
+ handler: async (ctx, args) => {
19
+ if (
20
+ args.sessionIds.length < 1 ||
21
+ args.sessionIds.length > MAX_SESSION_TOTAL_IDS
22
+ ) {
23
+ fail(
24
+ "INVALID_ARGUMENT",
25
+ `sessionIds must contain between 1 and ${MAX_SESSION_TOTAL_IDS} IDs`,
26
+ );
27
+ }
28
+ let sessionIds: string[];
29
+ try {
30
+ sessionIds = args.sessionIds.map((sessionId) => {
31
+ const sanitized = sanitizeOpaqueId(sessionId, "sessionId");
32
+ if (sanitized !== sessionId) throw new Error("sessionId is not canonical");
33
+ return sanitized;
34
+ });
35
+ } catch (error) {
36
+ fail(
37
+ "INVALID_ARGUMENT",
38
+ error instanceof Error ? error.message : "invalid sessionId",
39
+ );
40
+ }
41
+ if (new Set(sessionIds).size !== sessionIds.length) {
42
+ fail("INVALID_ARGUMENT", "sessionIds must be unique");
43
+ }
44
+
45
+ const sessions = await Promise.all(
46
+ sessionIds.map((sessionId) =>
47
+ ctx.db
48
+ .query("sessions")
49
+ .withIndex("by_siteId_and_sessionId", (range) =>
50
+ range.eq("siteId", args.siteId).eq("sessionId", sessionId),
51
+ )
52
+ .unique()
53
+ ),
54
+ );
55
+ return {
56
+ requestedSessions: sessionIds.length,
57
+ foundSessions: sessions.filter((session) => session !== null).length,
58
+ storedEvents: sessions.reduce(
59
+ (total, session) => total + (session?.eventCount ?? 0),
60
+ 0,
61
+ ),
62
+ };
63
+ },
64
+ });
@@ -1,9 +1,12 @@
1
1
  import type { Doc, Id } from "./_generated/dataModel.js";
2
2
  import type { MutationCtx, QueryCtx } from "./_generated/server.js";
3
3
  import {
4
+ JOURNEY_READ_BYTE_RESERVE,
5
+ JOURNEY_READ_DOCUMENT_RESERVE,
4
6
  MAX_EVENTS_PER_SESSION_WINDOW,
5
7
  RATE_LIMIT_WINDOW_MS,
6
8
  } from "./constants.js";
9
+ import { fail } from "./errors.js";
7
10
  import type { TrackerEvent } from "./validators.js";
8
11
 
9
12
  export type TelemetryMatch = {
@@ -34,7 +37,16 @@ export type JourneyCursor = {
34
37
  id: string;
35
38
  };
36
39
 
37
- export type RawJourneyEvent = Omit<Doc<"events">, "_id"> & { _id: string };
40
+ export type RawJourneyEvent = Omit<
41
+ Doc<"eventBatches">["events"][number],
42
+ "aggregateCountry"
43
+ > & {
44
+ _id: string;
45
+ _creationTime: number;
46
+ siteId: Id<"sites">;
47
+ sessionId: string;
48
+ visitorId: string;
49
+ };
38
50
 
39
51
  export function eventBucketStart(timestamp: number): number {
40
52
  return Math.floor(timestamp / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
@@ -50,32 +62,15 @@ export async function inspectTelemetryMinute(
50
62
  siteId: Id<"sites">;
51
63
  sessionId: string;
52
64
  bucketStart: number;
53
- candidateEventIds: Set<string>;
54
65
  },
55
66
  ) {
56
- const [legacyEvents, batches] = await loadTelemetryMinute(ctx, input);
57
- const eventIds = new Set([
58
- ...legacyEvents.map((event) => event.eventId),
59
- ...batches.flatMap((batch) =>
60
- batch.events.map((event) => event.eventId),
61
- ),
62
- ]);
63
- if (legacyEvents.length > MAX_EVENTS_PER_SESSION_WINDOW) {
64
- await Promise.all(
65
- [...input.candidateEventIds]
66
- .filter((eventId) => !eventIds.has(eventId))
67
- .map(async (eventId) => {
68
- const legacy = await findLegacyTelemetry(ctx, {
69
- ...input,
70
- eventId,
71
- });
72
- if (legacy) eventIds.add(eventId);
73
- }),
74
- );
75
- }
67
+ const batches = await loadTelemetryMinute(ctx, input);
68
+ const eventIds = new Set(
69
+ batches.flatMap((batch) => batch.events.map((event) => event.eventId)),
70
+ );
76
71
  return {
77
72
  eventIds,
78
- persistedCount: telemetryMinuteCount(legacyEvents, batches),
73
+ persistedCount: telemetryMinuteCount(batches),
79
74
  };
80
75
  }
81
76
 
@@ -85,34 +80,31 @@ export async function findTelemetry(
85
80
  event: TrackerEvent,
86
81
  ): Promise<{ match: TelemetryMatch | null; saturated: boolean }> {
87
82
  const bucketStart = eventBucketStart(event.timestamp);
88
- const [legacyEvents, batches] = await loadTelemetryMinute(ctx, {
83
+ const batches = await loadTelemetryMinute(ctx, {
89
84
  siteId,
90
85
  sessionId: event.sessionId,
91
86
  bucketStart,
92
87
  });
93
- const persistedCount = telemetryMinuteCount(legacyEvents, batches);
94
- let legacyEvent = legacyEvents.find((candidate) =>
95
- candidate.eventId === event.eventId
96
- );
97
- if (
98
- !legacyEvent &&
99
- legacyEvents.length > MAX_EVENTS_PER_SESSION_WINDOW
100
- ) {
101
- legacyEvent = await findLegacyTelemetry(ctx, {
102
- siteId,
103
- sessionId: event.sessionId,
104
- bucketStart,
105
- eventId: event.eventId,
106
- }) ?? undefined;
107
- }
88
+ const persistedCount = telemetryMinuteCount(batches);
108
89
  const saturated = persistedCount >= MAX_EVENTS_PER_SESSION_WINDOW;
109
- if (legacyEvent) return { match: legacyEvent, saturated };
110
- const batch = batches.find((candidate) =>
111
- candidate.events.some((nested) => nested.eventId === event.eventId)
112
- );
90
+ let batch: Doc<"eventBatches"> | undefined;
91
+ let nestedEvent: Doc<"eventBatches">["events"][number] | undefined;
92
+ for (const candidate of batches) {
93
+ nestedEvent = candidate.events.find((nested) =>
94
+ nested.eventId === event.eventId
95
+ );
96
+ if (nestedEvent) {
97
+ batch = candidate;
98
+ break;
99
+ }
100
+ }
113
101
  return {
114
- match: batch
115
- ? { sessionId: batch.sessionId, visitorId: batch.visitorId }
102
+ match: batch && nestedEvent
103
+ ? {
104
+ sessionId: batch.sessionId,
105
+ visitorId: batch.visitorId,
106
+ ...eventContext(nestedEvent),
107
+ }
116
108
  : null,
117
109
  saturated,
118
110
  };
@@ -122,6 +114,7 @@ export function bufferEventBatch(
122
114
  siteId: Id<"sites">,
123
115
  event: TrackerEvent,
124
116
  buffer: EventBatchBuffer,
117
+ country: string,
125
118
  ) {
126
119
  const bucketStart = eventBucketStart(event.timestamp);
127
120
  const key = eventBatchKey(event.sessionId, bucketStart);
@@ -130,7 +123,7 @@ export function bufferEventBatch(
130
123
  if (existing.visitorId !== event.visitorId) {
131
124
  throw new Error("event batch visitor does not match its session");
132
125
  }
133
- existing.events.push(eventForBatch(event));
126
+ existing.events.push(eventForBatch(event, country));
134
127
  return;
135
128
  }
136
129
  buffer.set(key, {
@@ -138,7 +131,7 @@ export function bufferEventBatch(
138
131
  sessionId: event.sessionId,
139
132
  visitorId: event.visitorId,
140
133
  bucketStart,
141
- events: [eventForBatch(event)],
134
+ events: [eventForBatch(event, country)],
142
135
  });
143
136
  }
144
137
 
@@ -146,13 +139,14 @@ export async function insertEventBatch(
146
139
  ctx: MutationCtx,
147
140
  siteId: Id<"sites">,
148
141
  event: TrackerEvent,
142
+ country: string,
149
143
  ) {
150
144
  await ctx.db.insert("eventBatches", {
151
145
  siteId,
152
146
  sessionId: event.sessionId,
153
147
  visitorId: event.visitorId,
154
148
  bucketStart: eventBucketStart(event.timestamp),
155
- events: [eventForBatch(event)],
149
+ events: [eventForBatch(event, country)],
156
150
  });
157
151
  }
158
152
 
@@ -183,8 +177,7 @@ export async function loadSessionJourneyEvents(
183
177
  // be read completely before applying the cursor.
184
178
  const loadBatches = async () => {
185
179
  const batches: Doc<"eventBatches">[] = [];
186
- let eventCount = 0;
187
- const requiredEvents = input.resultLimit + MAX_EVENTS_PER_SESSION_WINDOW;
180
+ let qualifyingEvents = 0;
188
181
  let completedBucket: number | undefined;
189
182
  const query = ctx.db
190
183
  .query("eventBatches")
@@ -201,78 +194,81 @@ export async function loadSessionJourneyEvents(
201
194
  break;
202
195
  }
203
196
  batches.push(batch);
204
- eventCount += batch.events.length;
205
- if (eventCount >= requiredEvents) completedBucket = batch.bucketStart;
197
+ qualifyingEvents += batch.events.reduce((count, _event, index) => {
198
+ const event = eventFromBatch(batch, index);
199
+ return count + Number(
200
+ event.timestamp >= input.from &&
201
+ event.timestamp <= input.to &&
202
+ (!input.cursor ||
203
+ compareJourneyEventToCursor(event, input.cursor) > 0),
204
+ );
205
+ }, 0);
206
+ if (qualifyingEvents >= input.resultLimit) {
207
+ completedBucket = batch.bucketStart;
208
+ }
206
209
  }
207
210
  return batches;
208
211
  };
209
- const loadLegacyEvents = async () => {
210
- const take = input.resultLimit + 1;
211
- if (!input.cursor) {
212
- return ctx.db
213
- .query("events")
214
- .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
215
- range
216
- .eq("siteId", input.siteId)
217
- .eq("sessionId", input.sessionId)
218
- .gte("timestamp", input.from)
219
- .lte("timestamp", input.to),
220
- )
221
- .order("asc")
222
- .take(take);
223
- }
224
- const rows = await ctx.db
225
- .query("events")
226
- .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
212
+ const batches = await loadBatches();
213
+ const rows: RawJourneyEvent[] = [];
214
+ appendBatchEvents(rows, batches);
215
+ return rows
216
+ .filter((event) =>
217
+ event.timestamp >= input.from &&
218
+ event.timestamp <= input.to &&
219
+ (!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0)
220
+ )
221
+ .sort(compareJourneyEvents)
222
+ .slice(0, input.resultLimit);
223
+ }
224
+
225
+ export async function loadVisitorJourneyEvents(
226
+ ctx: QueryCtx,
227
+ input: {
228
+ siteId: Id<"sites">;
229
+ visitorId: string;
230
+ from: number;
231
+ to: number;
232
+ resultLimit: number;
233
+ },
234
+ ): Promise<RawJourneyEvent[]> {
235
+ const firstBucket = eventBucketStart(input.from);
236
+ const lastBucket = eventBucketStart(input.to);
237
+ const loadBatches = async () => {
238
+ const batches: Doc<"eventBatches">[] = [];
239
+ let qualifyingEvents = 0;
240
+ let completedBucket: number | undefined;
241
+ const query = ctx.db
242
+ .query("eventBatches")
243
+ .withIndex("by_siteId_and_visitorId_and_bucketStart", (range) =>
227
244
  range
228
245
  .eq("siteId", input.siteId)
229
- .eq("sessionId", input.sessionId)
230
- .eq("timestamp", input.cursor!.timestamp)
231
- .gt("_creationTime", input.cursor!.creationTime),
246
+ .eq("visitorId", input.visitorId)
247
+ .gte("bucketStart", firstBucket)
248
+ .lte("bucketStart", lastBucket),
232
249
  )
233
- .order("asc")
234
- .take(take);
235
- if (rows.length < take) {
236
- rows.push(...await ctx.db
237
- .query("events")
238
- .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
239
- range
240
- .eq("siteId", input.siteId)
241
- .eq("sessionId", input.sessionId)
242
- .gt("timestamp", input.cursor!.timestamp)
243
- .lte("timestamp", input.to),
244
- )
245
- .order("asc")
246
- .take(take - rows.length));
250
+ .order("asc");
251
+ for await (const batch of query) {
252
+ if (completedBucket !== undefined && batch.bucketStart !== completedBucket) {
253
+ break;
254
+ }
255
+ batches.push(batch);
256
+ await requireJourneyReadHeadroom(ctx);
257
+ qualifyingEvents += batch.events.filter((event) =>
258
+ event.timestamp >= input.from && event.timestamp <= input.to
259
+ ).length;
260
+ if (qualifyingEvents >= input.resultLimit) {
261
+ completedBucket = batch.bucketStart;
262
+ }
247
263
  }
248
- return rows;
264
+ return batches;
249
265
  };
250
- const [legacyEvents, batches] = await Promise.all([
251
- loadLegacyEvents(),
252
- loadBatches(),
253
- ]);
254
- const rows: RawJourneyEvent[] = legacyEvents.map((event) => ({
255
- ...event,
256
- _id: String(event._id),
257
- }));
258
- for (const batch of batches) {
259
- for (let index = 0; index < batch.events.length; index += 1) {
260
- const event = batch.events[index];
261
- rows.push({
262
- ...event,
263
- _id: `eventBatch:${batch._id}:${index.toString().padStart(3, "0")}`,
264
- _creationTime: batch._creationTime,
265
- siteId: batch.siteId,
266
- sessionId: batch.sessionId,
267
- visitorId: batch.visitorId,
268
- });
269
- }
270
- }
266
+ const batches = await loadBatches();
267
+ const rows: RawJourneyEvent[] = [];
268
+ appendBatchEvents(rows, batches);
271
269
  return rows
272
270
  .filter((event) =>
273
- event.timestamp >= input.from &&
274
- event.timestamp <= input.to &&
275
- (!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0)
271
+ event.timestamp >= input.from && event.timestamp <= input.to
276
272
  )
277
273
  .sort(compareJourneyEvents)
278
274
  .slice(0, input.resultLimit);
@@ -342,32 +338,19 @@ export async function deleteTelemetryBefore(
342
338
  ) {
343
339
  const batchCutoff = eventBucketStart(input.before);
344
340
  const limit = input.batchSize + 1;
345
- const [legacyEvents, batches] = await Promise.all([
346
- ctx.db
347
- .query("events")
348
- .withIndex("by_siteId_and_timestamp", (range) =>
349
- range.eq("siteId", input.siteId).lt("timestamp", input.before),
350
- )
351
- .take(limit),
352
- ctx.db
353
- .query("eventBatches")
354
- .withIndex("by_siteId_and_bucketStart", (range) =>
355
- range.eq("siteId", input.siteId).lt("bucketStart", batchCutoff),
356
- )
357
- .take(limit),
358
- ]);
359
- const rows = [...legacyEvents, ...batches].slice(0, input.batchSize);
341
+ const batches = await ctx.db
342
+ .query("eventBatches")
343
+ .withIndex("by_siteId_and_bucketStart", (range) =>
344
+ range.eq("siteId", input.siteId).lt("bucketStart", batchCutoff),
345
+ )
346
+ .take(limit);
347
+ const rows = batches.slice(0, input.batchSize);
360
348
  for (const row of rows) {
361
- if ("bucketStart" in row) {
362
- await ctx.db.delete("eventBatches", row._id);
363
- } else {
364
- await ctx.db.delete("events", row._id);
365
- }
349
+ await ctx.db.delete("eventBatches", row._id);
366
350
  }
367
351
  return {
368
352
  deleted: rows.length,
369
- hasMore:
370
- legacyEvents.length + batches.length > input.batchSize,
353
+ hasMore: batches.length > input.batchSize,
371
354
  };
372
355
  }
373
356
 
@@ -379,61 +362,30 @@ async function loadTelemetryMinute(
379
362
  bucketStart: number;
380
363
  },
381
364
  ) {
382
- return Promise.all([
383
- ctx.db
384
- .query("events")
385
- .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
386
- range
387
- .eq("siteId", input.siteId)
388
- .eq("sessionId", input.sessionId)
389
- .gte("timestamp", input.bucketStart)
390
- .lt("timestamp", input.bucketStart + RATE_LIMIT_WINDOW_MS),
391
- )
392
- .take(MAX_EVENTS_PER_SESSION_WINDOW + 1),
393
- ctx.db
394
- .query("eventBatches")
395
- .withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
396
- range
397
- .eq("siteId", input.siteId)
398
- .eq("sessionId", input.sessionId)
399
- .eq("bucketStart", input.bucketStart),
400
- )
401
- .take(MAX_EVENTS_PER_SESSION_WINDOW + 1),
402
- ]);
403
- }
404
-
405
- async function findLegacyTelemetry(
406
- ctx: MutationCtx,
407
- input: {
408
- siteId: Id<"sites">;
409
- sessionId: string;
410
- bucketStart: number;
411
- eventId: string;
412
- },
413
- ) {
414
- const candidates = await ctx.db
415
- .query("events")
416
- .withIndex("by_siteId_and_eventId", (range) =>
417
- range.eq("siteId", input.siteId).eq("eventId", input.eventId),
365
+ return ctx.db
366
+ .query("eventBatches")
367
+ .withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
368
+ range
369
+ .eq("siteId", input.siteId)
370
+ .eq("sessionId", input.sessionId)
371
+ .eq("bucketStart", input.bucketStart),
418
372
  )
419
- .take(2);
420
- return candidates.find((candidate) =>
421
- candidate.sessionId === input.sessionId &&
422
- eventBucketStart(candidate.timestamp) === input.bucketStart
423
- ) ?? null;
373
+ .take(MAX_EVENTS_PER_SESSION_WINDOW + 1);
424
374
  }
425
375
 
426
376
  function telemetryMinuteCount(
427
- legacyEvents: Array<{ eventId: string }>,
428
377
  batches: Array<{ events: TrackerEvent[] }>,
429
378
  ) {
430
- return legacyEvents.length + batches.reduce(
379
+ return batches.reduce(
431
380
  (total, batch) => total + batch.events.length,
432
381
  0,
433
382
  );
434
383
  }
435
384
 
436
- function eventForBatch(event: TrackerEvent): TrackerEvent {
385
+ function eventForBatch(
386
+ event: TrackerEvent,
387
+ country: string,
388
+ ): TrackerEvent & { aggregateCountry?: string } {
437
389
  return {
438
390
  eventId: event.eventId,
439
391
  sessionId: event.sessionId,
@@ -452,9 +404,74 @@ function eventForBatch(event: TrackerEvent): TrackerEvent {
452
404
  : {}),
453
405
  ...(event.currency ? { currency: event.currency } : {}),
454
406
  ...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
407
+ aggregateCountry: country,
455
408
  };
456
409
  }
457
410
 
411
+ function appendBatchEvents(
412
+ rows: RawJourneyEvent[],
413
+ batches: Doc<"eventBatches">[],
414
+ ) {
415
+ for (const batch of batches) {
416
+ for (let index = 0; index < batch.events.length; index += 1) {
417
+ rows.push(eventFromBatch(batch, index));
418
+ }
419
+ }
420
+ }
421
+
422
+ function eventFromBatch(
423
+ batch: Doc<"eventBatches">,
424
+ index: number,
425
+ ): RawJourneyEvent {
426
+ const { aggregateCountry: _aggregateCountry, ...event } = batch.events[index]!;
427
+ void _aggregateCountry;
428
+ return {
429
+ ...event,
430
+ _id: `eventBatch:${batch._id}:${index.toString().padStart(3, "0")}`,
431
+ _creationTime: batch._creationTime,
432
+ siteId: batch.siteId,
433
+ sessionId: batch.sessionId,
434
+ visitorId: batch.visitorId,
435
+ };
436
+ }
437
+
438
+ function eventContext(event: {
439
+ country?: string;
440
+ city?: string;
441
+ latitude?: number;
442
+ longitude?: number;
443
+ browser?: string;
444
+ os?: string;
445
+ device?: string;
446
+ }) {
447
+ return {
448
+ ...(event.country ? { country: event.country } : {}),
449
+ ...(event.city ? { city: event.city } : {}),
450
+ ...(event.latitude !== undefined ? { latitude: event.latitude } : {}),
451
+ ...(event.longitude !== undefined ? { longitude: event.longitude } : {}),
452
+ ...(event.browser ? { browser: event.browser } : {}),
453
+ ...(event.os ? { os: event.os } : {}),
454
+ ...(event.device ? { device: event.device } : {}),
455
+ };
456
+ }
457
+
458
+ async function requireJourneyReadHeadroom(ctx: QueryCtx) {
459
+ const metrics = await ctx.meta.getTransactionMetrics();
460
+ if (
461
+ metrics.bytesRead.remaining < JOURNEY_READ_BYTE_RESERVE ||
462
+ metrics.documentsRead.remaining < JOURNEY_READ_DOCUMENT_RESERVE
463
+ ) {
464
+ fail(
465
+ "LIMIT_EXCEEDED",
466
+ "visitor journey exceeds the safe read budget",
467
+ {
468
+ bytesRead: metrics.bytesRead.used,
469
+ documentsRead: metrics.documentsRead.used,
470
+ },
471
+ );
472
+ }
473
+ }
474
+
458
475
  function compareJourneyEventToCursor(
459
476
  event: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
460
477
  cursor: JourneyCursor,