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

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 (179) hide show
  1. package/README.md +693 -69
  2. package/agent/integration.md +801 -0
  3. package/agent/manifest.json +205 -0
  4. package/agent/manifest.schema.json +444 -0
  5. package/dist/client/federation.d.ts +381 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +274 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2673 -15
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +565 -33
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +22 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +315 -4
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +4 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/affiliates.d.ts.map +1 -1
  22. package/dist/component/affiliates.js +6 -2
  23. package/dist/component/affiliates.js.map +1 -1
  24. package/dist/component/cardinality.d.ts +12 -0
  25. package/dist/component/cardinality.d.ts.map +1 -0
  26. package/dist/component/cardinality.js +94 -0
  27. package/dist/component/cardinality.js.map +1 -0
  28. package/dist/component/constants.d.ts +28 -1
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +44 -1
  31. package/dist/component/constants.js.map +1 -1
  32. package/dist/component/convex.config.d.ts +6 -1
  33. package/dist/component/convex.config.js +9 -1
  34. package/dist/component/convex.config.js.map +1 -1
  35. package/dist/component/coverage.d.ts +27 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +56 -0
  38. package/dist/component/coverage.js.map +1 -0
  39. package/dist/component/diagnostics.d.ts +9 -0
  40. package/dist/component/diagnostics.d.ts.map +1 -0
  41. package/dist/component/diagnostics.js +47 -0
  42. package/dist/component/diagnostics.js.map +1 -0
  43. package/dist/component/errors.d.ts +1 -1
  44. package/dist/component/errors.d.ts.map +1 -1
  45. package/dist/component/errors.js.map +1 -1
  46. package/dist/component/eventStore.d.ts +21 -9
  47. package/dist/component/eventStore.d.ts.map +1 -1
  48. package/dist/component/eventStore.js +142 -152
  49. package/dist/component/eventStore.js.map +1 -1
  50. package/dist/component/funnels.d.ts.map +1 -1
  51. package/dist/component/funnels.js +5 -3
  52. package/dist/component/funnels.js.map +1 -1
  53. package/dist/component/geo.d.ts +73 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +648 -0
  56. package/dist/component/geo.js.map +1 -0
  57. package/dist/component/goals.d.ts.map +1 -1
  58. package/dist/component/goals.js +8 -5
  59. package/dist/component/goals.js.map +1 -1
  60. package/dist/component/guards.d.ts.map +1 -1
  61. package/dist/component/guards.js.map +1 -1
  62. package/dist/component/http.d.ts.map +1 -1
  63. package/dist/component/http.js +281 -62
  64. package/dist/component/http.js.map +1 -1
  65. package/dist/component/identity.d.ts +13 -0
  66. package/dist/component/identity.d.ts.map +1 -0
  67. package/dist/component/identity.js +58 -0
  68. package/dist/component/identity.js.map +1 -0
  69. package/dist/component/ingest.d.ts +3 -1
  70. package/dist/component/ingest.d.ts.map +1 -1
  71. package/dist/component/ingest.js +498 -95
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts.map +1 -1
  74. package/dist/component/live.js +7 -6
  75. package/dist/component/live.js.map +1 -1
  76. package/dist/component/localTime.d.ts +25 -0
  77. package/dist/component/localTime.d.ts.map +1 -0
  78. package/dist/component/localTime.js +126 -0
  79. package/dist/component/localTime.js.map +1 -0
  80. package/dist/component/reports.d.ts +266 -10
  81. package/dist/component/reports.d.ts.map +1 -1
  82. package/dist/component/reports.js +1243 -137
  83. package/dist/component/reports.js.map +1 -1
  84. package/dist/component/retention.d.ts +75 -1
  85. package/dist/component/retention.d.ts.map +1 -1
  86. package/dist/component/retention.js +561 -38
  87. package/dist/component/retention.js.map +1 -1
  88. package/dist/component/rollupStore.d.ts +320 -0
  89. package/dist/component/rollupStore.d.ts.map +1 -0
  90. package/dist/component/rollupStore.js +596 -0
  91. package/dist/component/rollupStore.js.map +1 -0
  92. package/dist/component/rollups.d.ts +20 -0
  93. package/dist/component/rollups.d.ts.map +1 -0
  94. package/dist/component/rollups.js +73 -0
  95. package/dist/component/rollups.js.map +1 -0
  96. package/dist/component/sanitize.d.ts +24 -1
  97. package/dist/component/sanitize.d.ts.map +1 -1
  98. package/dist/component/sanitize.js +96 -16
  99. package/dist/component/sanitize.js.map +1 -1
  100. package/dist/component/schema.d.ts +687 -65
  101. package/dist/component/schema.js +187 -20
  102. package/dist/component/schema.js.map +1 -1
  103. package/dist/component/sites.d.ts +12 -0
  104. package/dist/component/sites.d.ts.map +1 -1
  105. package/dist/component/sites.js +41 -7
  106. package/dist/component/sites.js.map +1 -1
  107. package/dist/component/useragent.d.ts +9 -0
  108. package/dist/component/useragent.d.ts.map +1 -0
  109. package/dist/component/useragent.js +152 -0
  110. package/dist/component/useragent.js.map +1 -0
  111. package/dist/component/validators.d.ts +124 -69
  112. package/dist/component/validators.d.ts.map +1 -1
  113. package/dist/component/validators.js +35 -14
  114. package/dist/component/validators.js.map +1 -1
  115. package/dist/component/visitors.d.ts +19 -0
  116. package/dist/component/visitors.d.ts.map +1 -0
  117. package/dist/component/visitors.js +86 -0
  118. package/dist/component/visitors.js.map +1 -0
  119. package/dist/component/vitals.d.ts +41 -0
  120. package/dist/component/vitals.d.ts.map +1 -0
  121. package/dist/component/vitals.js +115 -0
  122. package/dist/component/vitals.js.map +1 -0
  123. package/dist/react/index.d.ts.map +1 -1
  124. package/dist/react/index.js.map +1 -1
  125. package/dist/tracker/generated.d.ts +11 -4
  126. package/dist/tracker/generated.d.ts.map +1 -1
  127. package/dist/tracker/generated.js +11 -4
  128. package/dist/tracker/generated.js.map +1 -1
  129. package/dist/tracker/tracker.d.ts +1 -1
  130. package/dist/tracker/tracker.d.ts.map +1 -1
  131. package/dist/tracker/tracker.js +57 -20
  132. package/dist/tracker/tracker.js.map +1 -1
  133. package/dist/tracker/vitals.d.ts +10 -0
  134. package/dist/tracker/vitals.d.ts.map +1 -0
  135. package/dist/tracker/vitals.js +140 -0
  136. package/dist/tracker/vitals.js.map +1 -0
  137. package/dist/tracker.min.js +1 -1
  138. package/dist/vitals.min.js +1 -0
  139. package/docs/benchmarks/2026-08-20-realistic.md +76 -76
  140. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  141. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  142. package/docs/federation-setup.md +464 -0
  143. package/docs/federation.md +352 -0
  144. package/docs/upgrading.md +344 -0
  145. package/llms.txt +72 -0
  146. package/package.json +55 -11
  147. package/scripts/benchmark-ingest.mjs +175 -73
  148. package/scripts/generate-federation-keys.mjs +20 -0
  149. package/src/component/_generated/api.ts +22 -0
  150. package/src/component/_generated/component.ts +381 -4
  151. package/src/component/_generated/server.ts +4 -0
  152. package/src/component/affiliates.ts +20 -5
  153. package/src/component/cardinality.ts +117 -0
  154. package/src/component/constants.ts +44 -1
  155. package/src/component/convex.config.ts +11 -1
  156. package/src/component/coverage.ts +71 -0
  157. package/src/component/diagnostics.ts +65 -0
  158. package/src/component/errors.ts +2 -1
  159. package/src/component/eventStore.ts +217 -193
  160. package/src/component/funnels.ts +19 -16
  161. package/src/component/geo.ts +835 -0
  162. package/src/component/goals.ts +29 -21
  163. package/src/component/guards.ts +3 -1
  164. package/src/component/http.ts +404 -70
  165. package/src/component/identity.ts +74 -0
  166. package/src/component/ingest.ts +894 -188
  167. package/src/component/live.ts +13 -7
  168. package/src/component/localTime.ts +167 -0
  169. package/src/component/reports.ts +1872 -197
  170. package/src/component/retention.ts +788 -96
  171. package/src/component/rollupStore.ts +799 -0
  172. package/src/component/rollups.ts +82 -0
  173. package/src/component/sanitize.ts +144 -29
  174. package/src/component/schema.ts +217 -21
  175. package/src/component/sites.ts +59 -12
  176. package/src/component/useragent.ts +171 -0
  177. package/src/component/validators.ts +49 -14
  178. package/src/component/visitors.ts +116 -0
  179. package/src/component/vitals.ts +146 -0
@@ -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,35 +80,33 @@ 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(
94
+ (nested) => 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 }
116
- : null,
102
+ match:
103
+ batch && nestedEvent
104
+ ? {
105
+ sessionId: batch.sessionId,
106
+ visitorId: batch.visitorId,
107
+ ...eventContext(nestedEvent),
108
+ }
109
+ : null,
117
110
  saturated,
118
111
  };
119
112
  }
@@ -122,6 +115,7 @@ export function bufferEventBatch(
122
115
  siteId: Id<"sites">,
123
116
  event: TrackerEvent,
124
117
  buffer: EventBatchBuffer,
118
+ country: string,
125
119
  ) {
126
120
  const bucketStart = eventBucketStart(event.timestamp);
127
121
  const key = eventBatchKey(event.sessionId, bucketStart);
@@ -130,7 +124,7 @@ export function bufferEventBatch(
130
124
  if (existing.visitorId !== event.visitorId) {
131
125
  throw new Error("event batch visitor does not match its session");
132
126
  }
133
- existing.events.push(eventForBatch(event));
127
+ existing.events.push(eventForBatch(event, country));
134
128
  return;
135
129
  }
136
130
  buffer.set(key, {
@@ -138,7 +132,7 @@ export function bufferEventBatch(
138
132
  sessionId: event.sessionId,
139
133
  visitorId: event.visitorId,
140
134
  bucketStart,
141
- events: [eventForBatch(event)],
135
+ events: [eventForBatch(event, country)],
142
136
  });
143
137
  }
144
138
 
@@ -146,13 +140,14 @@ export async function insertEventBatch(
146
140
  ctx: MutationCtx,
147
141
  siteId: Id<"sites">,
148
142
  event: TrackerEvent,
143
+ country: string,
149
144
  ) {
150
145
  await ctx.db.insert("eventBatches", {
151
146
  siteId,
152
147
  sessionId: event.sessionId,
153
148
  visitorId: event.visitorId,
154
149
  bucketStart: eventBucketStart(event.timestamp),
155
- events: [eventForBatch(event)],
150
+ events: [eventForBatch(event, country)],
156
151
  });
157
152
  }
158
153
 
@@ -183,8 +178,7 @@ export async function loadSessionJourneyEvents(
183
178
  // be read completely before applying the cursor.
184
179
  const loadBatches = async () => {
185
180
  const batches: Doc<"eventBatches">[] = [];
186
- let eventCount = 0;
187
- const requiredEvents = input.resultLimit + MAX_EVENTS_PER_SESSION_WINDOW;
181
+ let qualifyingEvents = 0;
188
182
  let completedBucket: number | undefined;
189
183
  const query = ctx.db
190
184
  .query("eventBatches")
@@ -197,82 +191,95 @@ export async function loadSessionJourneyEvents(
197
191
  )
198
192
  .order("asc");
199
193
  for await (const batch of query) {
200
- if (completedBucket !== undefined && batch.bucketStart !== completedBucket) {
194
+ if (
195
+ completedBucket !== undefined &&
196
+ batch.bucketStart !== completedBucket
197
+ ) {
201
198
  break;
202
199
  }
203
200
  batches.push(batch);
204
- eventCount += batch.events.length;
205
- if (eventCount >= requiredEvents) completedBucket = batch.bucketStart;
201
+ qualifyingEvents += batch.events.reduce((count, _event, index) => {
202
+ const event = eventFromBatch(batch, index);
203
+ return (
204
+ count +
205
+ Number(
206
+ event.timestamp >= input.from &&
207
+ event.timestamp <= input.to &&
208
+ (!input.cursor ||
209
+ compareJourneyEventToCursor(event, input.cursor) > 0),
210
+ )
211
+ );
212
+ }, 0);
213
+ if (qualifyingEvents >= input.resultLimit) {
214
+ completedBucket = batch.bucketStart;
215
+ }
206
216
  }
207
217
  return batches;
208
218
  };
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) =>
219
+ const batches = await loadBatches();
220
+ const rows: RawJourneyEvent[] = [];
221
+ appendBatchEvents(rows, batches);
222
+ return rows
223
+ .filter(
224
+ (event) =>
225
+ event.timestamp >= input.from &&
226
+ event.timestamp <= input.to &&
227
+ (!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0),
228
+ )
229
+ .sort(compareJourneyEvents)
230
+ .slice(0, input.resultLimit);
231
+ }
232
+
233
+ export async function loadVisitorJourneyEvents(
234
+ ctx: QueryCtx,
235
+ input: {
236
+ siteId: Id<"sites">;
237
+ visitorId: string;
238
+ from: number;
239
+ to: number;
240
+ resultLimit: number;
241
+ },
242
+ ): Promise<RawJourneyEvent[]> {
243
+ const firstBucket = eventBucketStart(input.from);
244
+ const lastBucket = eventBucketStart(input.to);
245
+ const loadBatches = async () => {
246
+ const batches: Doc<"eventBatches">[] = [];
247
+ let qualifyingEvents = 0;
248
+ let completedBucket: number | undefined;
249
+ const query = ctx.db
250
+ .query("eventBatches")
251
+ .withIndex("by_siteId_and_visitorId_and_bucketStart", (range) =>
227
252
  range
228
253
  .eq("siteId", input.siteId)
229
- .eq("sessionId", input.sessionId)
230
- .eq("timestamp", input.cursor!.timestamp)
231
- .gt("_creationTime", input.cursor!.creationTime),
254
+ .eq("visitorId", input.visitorId)
255
+ .gte("bucketStart", firstBucket)
256
+ .lte("bucketStart", lastBucket),
232
257
  )
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));
258
+ .order("asc");
259
+ for await (const batch of query) {
260
+ if (
261
+ completedBucket !== undefined &&
262
+ batch.bucketStart !== completedBucket
263
+ ) {
264
+ break;
265
+ }
266
+ batches.push(batch);
267
+ await requireJourneyReadHeadroom(ctx);
268
+ qualifyingEvents += batch.events.filter(
269
+ (event) => event.timestamp >= input.from && event.timestamp <= input.to,
270
+ ).length;
271
+ if (qualifyingEvents >= input.resultLimit) {
272
+ completedBucket = batch.bucketStart;
273
+ }
247
274
  }
248
- return rows;
275
+ return batches;
249
276
  };
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
- }
277
+ const batches = await loadBatches();
278
+ const rows: RawJourneyEvent[] = [];
279
+ appendBatchEvents(rows, batches);
271
280
  return rows
272
- .filter((event) =>
273
- event.timestamp >= input.from &&
274
- event.timestamp <= input.to &&
275
- (!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0)
281
+ .filter(
282
+ (event) => event.timestamp >= input.from && event.timestamp <= input.to,
276
283
  )
277
284
  .sort(compareJourneyEvents)
278
285
  .slice(0, input.resultLimit);
@@ -282,9 +289,11 @@ export function compareJourneyEvents(
282
289
  left: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
283
290
  right: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
284
291
  ): number {
285
- return left.timestamp - right.timestamp ||
292
+ return (
293
+ left.timestamp - right.timestamp ||
286
294
  left._creationTime - right._creationTime ||
287
- left._id.localeCompare(right._id);
295
+ left._id.localeCompare(right._id)
296
+ );
288
297
  }
289
298
 
290
299
  export async function hydrateEventContexts(
@@ -308,8 +317,8 @@ export async function hydrateEventContexts(
308
317
  }
309
318
  const sessions = new Map(
310
319
  await Promise.all(
311
- [...sessionPromises].map(async ([key, session]) =>
312
- [key, await session] as const
320
+ [...sessionPromises].map(
321
+ async ([key, session]) => [key, await session] as const,
313
322
  ),
314
323
  ),
315
324
  );
@@ -342,32 +351,19 @@ export async function deleteTelemetryBefore(
342
351
  ) {
343
352
  const batchCutoff = eventBucketStart(input.before);
344
353
  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);
354
+ const batches = await ctx.db
355
+ .query("eventBatches")
356
+ .withIndex("by_siteId_and_bucketStart", (range) =>
357
+ range.eq("siteId", input.siteId).lt("bucketStart", batchCutoff),
358
+ )
359
+ .take(limit);
360
+ const rows = batches.slice(0, input.batchSize);
360
361
  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
- }
362
+ await ctx.db.delete("eventBatches", row._id);
366
363
  }
367
364
  return {
368
365
  deleted: rows.length,
369
- hasMore:
370
- legacyEvents.length + batches.length > input.batchSize,
366
+ hasMore: batches.length > input.batchSize,
371
367
  };
372
368
  }
373
369
 
@@ -379,61 +375,25 @@ async function loadTelemetryMinute(
379
375
  bucketStart: number;
380
376
  },
381
377
  ) {
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),
378
+ return ctx.db
379
+ .query("eventBatches")
380
+ .withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
381
+ range
382
+ .eq("siteId", input.siteId)
383
+ .eq("sessionId", input.sessionId)
384
+ .eq("bucketStart", input.bucketStart),
418
385
  )
419
- .take(2);
420
- return candidates.find((candidate) =>
421
- candidate.sessionId === input.sessionId &&
422
- eventBucketStart(candidate.timestamp) === input.bucketStart
423
- ) ?? null;
386
+ .take(MAX_EVENTS_PER_SESSION_WINDOW + 1);
424
387
  }
425
388
 
426
- function telemetryMinuteCount(
427
- legacyEvents: Array<{ eventId: string }>,
428
- batches: Array<{ events: TrackerEvent[] }>,
429
- ) {
430
- return legacyEvents.length + batches.reduce(
431
- (total, batch) => total + batch.events.length,
432
- 0,
433
- );
389
+ function telemetryMinuteCount(batches: Array<{ events: TrackerEvent[] }>) {
390
+ return batches.reduce((total, batch) => total + batch.events.length, 0);
434
391
  }
435
392
 
436
- function eventForBatch(event: TrackerEvent): TrackerEvent {
393
+ function eventForBatch(
394
+ event: TrackerEvent,
395
+ country: string,
396
+ ): TrackerEvent & { aggregateCountry?: string } {
437
397
  return {
438
398
  eventId: event.eventId,
439
399
  sessionId: event.sessionId,
@@ -452,14 +412,78 @@ function eventForBatch(event: TrackerEvent): TrackerEvent {
452
412
  : {}),
453
413
  ...(event.currency ? { currency: event.currency } : {}),
454
414
  ...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
415
+ aggregateCountry: country,
416
+ };
417
+ }
418
+
419
+ function appendBatchEvents(
420
+ rows: RawJourneyEvent[],
421
+ batches: Doc<"eventBatches">[],
422
+ ) {
423
+ for (const batch of batches) {
424
+ for (let index = 0; index < batch.events.length; index += 1) {
425
+ rows.push(eventFromBatch(batch, index));
426
+ }
427
+ }
428
+ }
429
+
430
+ function eventFromBatch(
431
+ batch: Doc<"eventBatches">,
432
+ index: number,
433
+ ): RawJourneyEvent {
434
+ const { aggregateCountry: _aggregateCountry, ...event } =
435
+ batch.events[index]!;
436
+ void _aggregateCountry;
437
+ return {
438
+ ...event,
439
+ _id: `eventBatch:${batch._id}:${index.toString().padStart(3, "0")}`,
440
+ _creationTime: batch._creationTime,
441
+ siteId: batch.siteId,
442
+ sessionId: batch.sessionId,
443
+ visitorId: batch.visitorId,
444
+ };
445
+ }
446
+
447
+ function eventContext(event: {
448
+ country?: string;
449
+ city?: string;
450
+ latitude?: number;
451
+ longitude?: number;
452
+ browser?: string;
453
+ os?: string;
454
+ device?: string;
455
+ }) {
456
+ return {
457
+ ...(event.country ? { country: event.country } : {}),
458
+ ...(event.city ? { city: event.city } : {}),
459
+ ...(event.latitude !== undefined ? { latitude: event.latitude } : {}),
460
+ ...(event.longitude !== undefined ? { longitude: event.longitude } : {}),
461
+ ...(event.browser ? { browser: event.browser } : {}),
462
+ ...(event.os ? { os: event.os } : {}),
463
+ ...(event.device ? { device: event.device } : {}),
455
464
  };
456
465
  }
457
466
 
467
+ async function requireJourneyReadHeadroom(ctx: QueryCtx) {
468
+ const metrics = await ctx.meta.getTransactionMetrics();
469
+ if (
470
+ metrics.bytesRead.remaining < JOURNEY_READ_BYTE_RESERVE ||
471
+ metrics.documentsRead.remaining < JOURNEY_READ_DOCUMENT_RESERVE
472
+ ) {
473
+ fail("LIMIT_EXCEEDED", "visitor journey exceeds the safe read budget", {
474
+ bytesRead: metrics.bytesRead.used,
475
+ documentsRead: metrics.documentsRead.used,
476
+ });
477
+ }
478
+ }
479
+
458
480
  function compareJourneyEventToCursor(
459
481
  event: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
460
482
  cursor: JourneyCursor,
461
483
  ) {
462
- return event.timestamp - cursor.timestamp ||
484
+ return (
485
+ event.timestamp - cursor.timestamp ||
463
486
  event._creationTime - cursor.creationTime ||
464
- event._id.localeCompare(cursor.id);
487
+ event._id.localeCompare(cursor.id)
488
+ );
465
489
  }
@@ -1,15 +1,9 @@
1
1
  import { v } from "convex/values";
2
2
  import { mutation, query } from "./_generated/server.js";
3
- import {
4
- MAX_FUNNELS_PER_SITE,
5
- MAX_FUNNEL_STEPS,
6
- } from "./constants.js";
3
+ import { MAX_FUNNELS_PER_SITE, MAX_FUNNEL_STEPS } from "./constants.js";
7
4
  import { fail } from "./errors.js";
8
5
  import { cleanString, sanitizePath, sanitizeSlug } from "./sanitize.js";
9
- import {
10
- funnelFieldsValidator,
11
- funnelStepValidator,
12
- } from "./validators.js";
6
+ import { funnelFieldsValidator, funnelStepValidator } from "./validators.js";
13
7
 
14
8
  const funnelDocumentValidator = funnelFieldsValidator.extend({
15
9
  _id: v.id("funnels"),
@@ -28,7 +22,8 @@ export const upsert = mutation({
28
22
  },
29
23
  returns: v.id("funnels"),
30
24
  handler: async (ctx, args) => {
31
- if (!(await ctx.db.get("sites", args.siteId))) fail("NOT_FOUND", "site not found");
25
+ if (!(await ctx.db.get("sites", args.siteId)))
26
+ fail("NOT_FOUND", "site not found");
32
27
  if (args.steps.length < 2 || args.steps.length > MAX_FUNNEL_STEPS) {
33
28
  fail("INVALID_ARGUMENT", `funnels require 2-${MAX_FUNNEL_STEPS} steps`);
34
29
  }
@@ -37,7 +32,10 @@ export const upsert = mutation({
37
32
  args.conversionWindowMs < 60_000 ||
38
33
  args.conversionWindowMs > 90 * 86_400_000
39
34
  ) {
40
- fail("INVALID_ARGUMENT", "conversionWindowMs must be between one minute and 90 days");
35
+ fail(
36
+ "INVALID_ARGUMENT",
37
+ "conversionWindowMs must be between one minute and 90 days",
38
+ );
41
39
  }
42
40
  let key: string;
43
41
  let steps: Array<{
@@ -49,9 +47,10 @@ export const upsert = mutation({
49
47
  key = sanitizeSlug(args.key, "key");
50
48
  steps = args.steps.map((step) => {
51
49
  const stepName = cleanString(step.name, 80);
52
- const value = step.type === "pageview"
53
- ? sanitizePath(step.value)
54
- : cleanString(step.value, 80);
50
+ const value =
51
+ step.type === "pageview"
52
+ ? sanitizePath(step.value)
53
+ : cleanString(step.value, 80);
55
54
  if (!stepName || !value) {
56
55
  throw new Error("funnel steps require name and value");
57
56
  }
@@ -79,7 +78,8 @@ export const upsert = mutation({
79
78
  const now = Date.now();
80
79
  if (funnelId) {
81
80
  const funnel = await ctx.db.get("funnels", funnelId);
82
- if (!funnel || funnel.siteId !== args.siteId) fail("NOT_FOUND", "funnel not found");
81
+ if (!funnel || funnel.siteId !== args.siteId)
82
+ fail("NOT_FOUND", "funnel not found");
83
83
  await ctx.db.patch("funnels", funnelId, {
84
84
  key,
85
85
  name,
@@ -96,7 +96,9 @@ export const upsert = mutation({
96
96
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
97
97
  .take(MAX_FUNNELS_PER_SITE + 1);
98
98
  if (funnels.length >= MAX_FUNNELS_PER_SITE) {
99
- fail("LIMIT_EXCEEDED", "funnel limit reached", { limit: MAX_FUNNELS_PER_SITE });
99
+ fail("LIMIT_EXCEEDED", "funnel limit reached", {
100
+ limit: MAX_FUNNELS_PER_SITE,
101
+ });
100
102
  }
101
103
  return await ctx.db.insert("funnels", {
102
104
  siteId: args.siteId,
@@ -127,7 +129,8 @@ export const remove = mutation({
127
129
  returns: v.null(),
128
130
  handler: async (ctx, args) => {
129
131
  const funnel = await ctx.db.get("funnels", args.funnelId);
130
- if (!funnel || funnel.siteId !== args.siteId) fail("NOT_FOUND", "funnel not found");
132
+ if (!funnel || funnel.siteId !== args.siteId)
133
+ fail("NOT_FOUND", "funnel not found");
131
134
  await ctx.db.delete("funnels", args.funnelId);
132
135
  return null;
133
136
  },