@iann29/rastro 0.1.0-alpha.1 → 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 (40) hide show
  1. package/README.md +20 -7
  2. package/dist/component/_generated/api.d.ts +5 -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 +2 -0
  6. package/dist/component/constants.d.ts.map +1 -1
  7. package/dist/component/constants.js +2 -0
  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 +8 -0
  14. package/dist/component/eventStore.d.ts.map +1 -1
  15. package/dist/component/eventStore.js +176 -17
  16. package/dist/component/eventStore.js.map +1 -1
  17. package/dist/component/migrations.d.ts +17 -0
  18. package/dist/component/migrations.d.ts.map +1 -0
  19. package/dist/component/migrations.js +44 -0
  20. package/dist/component/migrations.js.map +1 -0
  21. package/dist/component/reports.d.ts.map +1 -1
  22. package/dist/component/reports.js +8 -29
  23. package/dist/component/reports.js.map +1 -1
  24. package/dist/component/schema.d.ts +34 -2
  25. package/dist/component/schema.js +13 -10
  26. package/dist/component/schema.js.map +1 -1
  27. package/dist/component/validators.d.ts +47 -0
  28. package/dist/component/validators.d.ts.map +1 -1
  29. package/dist/component/validators.js +1 -0
  30. package/dist/component/validators.js.map +1 -1
  31. package/package.json +6 -2
  32. package/src/component/_generated/api.ts +5 -1
  33. package/src/component/constants.ts +2 -0
  34. package/src/component/convex.config.ts +5 -1
  35. package/src/component/eventStore.ts +237 -18
  36. package/src/component/migrations.ts +52 -0
  37. package/src/component/reports.ts +8 -37
  38. package/src/component/schema.ts +13 -10
  39. package/src/component/validators.ts +4 -0
  40. package/src/test.ts +2 -0
@@ -1,9 +1,13 @@
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,
6
+ MAX_BATCH_EVENTS,
4
7
  MAX_EVENTS_PER_SESSION_WINDOW,
5
8
  RATE_LIMIT_WINDOW_MS,
6
9
  } from "./constants.js";
10
+ import { fail } from "./errors.js";
7
11
  import type { TrackerEvent } from "./validators.js";
8
12
 
9
13
  export type TelemetryMatch = {
@@ -28,6 +32,16 @@ type BufferedEventBatch = {
28
32
 
29
33
  export type EventBatchBuffer = Map<string, BufferedEventBatch>;
30
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
+
31
45
  export type JourneyCursor = {
32
46
  timestamp: number;
33
47
  creationTime: number;
@@ -107,12 +121,24 @@ export async function findTelemetry(
107
121
  }
108
122
  const saturated = persistedCount >= MAX_EVENTS_PER_SESSION_WINDOW;
109
123
  if (legacyEvent) return { match: legacyEvent, saturated };
110
- const batch = batches.find((candidate) =>
111
- candidate.events.some((nested) => nested.eventId === event.eventId)
112
- );
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
+ }
113
135
  return {
114
- match: batch
115
- ? { sessionId: batch.sessionId, visitorId: batch.visitorId }
136
+ match: batch && nestedEvent
137
+ ? {
138
+ sessionId: batch.sessionId,
139
+ visitorId: batch.visitorId,
140
+ ...eventContext(nestedEvent),
141
+ }
116
142
  : null,
117
143
  saturated,
118
144
  };
@@ -165,6 +191,84 @@ export async function flushEventBatches(
165
191
  }
166
192
  }
167
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
+
168
272
  export async function loadSessionJourneyEvents(
169
273
  ctx: QueryCtx,
170
274
  input: {
@@ -255,19 +359,7 @@ export async function loadSessionJourneyEvents(
255
359
  ...event,
256
360
  _id: String(event._id),
257
361
  }));
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
- }
362
+ appendBatchEvents(rows, batches);
271
363
  return rows
272
364
  .filter((event) =>
273
365
  event.timestamp >= input.from &&
@@ -278,6 +370,77 @@ export async function loadSessionJourneyEvents(
278
370
  .slice(0, input.resultLimit);
279
371
  }
280
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
+
281
444
  export function compareJourneyEvents(
282
445
  left: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
283
446
  right: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
@@ -455,6 +618,62 @@ function eventForBatch(event: TrackerEvent): TrackerEvent {
455
618
  };
456
619
  }
457
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
+
458
677
  function compareJourneyEventToCursor(
459
678
  event: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
460
679
  cursor: JourneyCursor,
@@ -0,0 +1,52 @@
1
+ import { Migrations } from "@convex-dev/migrations";
2
+ import { v } from "convex/values";
3
+ import { components } from "./_generated/api.js";
4
+ import { internalMutation, internalQuery } from "./_generated/server.js";
5
+ import { migrateLegacyEventToBatch } from "./eventStore.js";
6
+ import schema from "./schema.js";
7
+
8
+ const migrations = new Migrations(components.migrations, {
9
+ internalMutation,
10
+ schema,
11
+ });
12
+
13
+ export const migrateLegacyEvents = migrations.define({
14
+ table: "events",
15
+ batchSize: 16,
16
+ migrateOne: migrateLegacyEventToBatch,
17
+ });
18
+
19
+ export const legacyEventsStatus = internalQuery({
20
+ args: {},
21
+ returns: v.object({
22
+ complete: v.boolean(),
23
+ sourceEmpty: v.boolean(),
24
+ migrationState: v.union(
25
+ v.literal("inProgress"),
26
+ v.literal("success"),
27
+ v.literal("failed"),
28
+ v.literal("canceled"),
29
+ v.literal("unknown"),
30
+ ),
31
+ processed: v.number(),
32
+ sampleRemainingId: v.union(v.id("events"), v.null()),
33
+ }),
34
+ handler: async (ctx) => {
35
+ const [remaining, statuses] = await Promise.all([
36
+ ctx.db.query("events").first(),
37
+ migrations.getStatus(ctx, {
38
+ migrations: ["migrations:migrateLegacyEvents"],
39
+ }),
40
+ ]);
41
+ const migrationStatus = statuses[0];
42
+ const migrationState = migrationStatus?.state ?? "unknown";
43
+ const sourceEmpty = remaining === null;
44
+ return {
45
+ complete: sourceEmpty && migrationState === "success",
46
+ sourceEmpty,
47
+ migrationState,
48
+ processed: migrationStatus?.processed ?? 0,
49
+ sampleRemainingId: remaining?._id ?? null,
50
+ };
51
+ },
52
+ });
@@ -25,6 +25,7 @@ import {
25
25
  hydrateEventContexts,
26
26
  type JourneyCursor,
27
27
  loadSessionJourneyEvents,
28
+ loadVisitorJourneyEvents,
28
29
  type RawJourneyEvent,
29
30
  } from "./eventStore.js";
30
31
  import { isPlainRecord } from "./guards.js";
@@ -450,47 +451,17 @@ export const visitorJourney = query({
450
451
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
451
452
  fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
452
453
  }
453
- const sessionGroups = await Promise.all(
454
- siteIds.map((siteId) =>
455
- ctx.db
456
- .query("sessions")
457
- .withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) =>
458
- range
459
- .eq("siteId", siteId)
460
- .eq("visitorId", args.visitorId)
461
- .gte("lastSeenAt", args.from),
462
- )
463
- .order("asc")
464
- .take(MAX_JOURNEY_EVENTS + 1),
465
- ),
466
- );
467
- const sessions = sessionGroups
468
- .flat()
469
- .filter((session) => session.startedAt <= args.to)
470
- .sort((left, right) => left.startedAt - right.startedAt)
471
- .slice(0, MAX_JOURNEY_EVENTS);
472
454
  const rows: RawJourneyEvent[] = [];
473
- let overlappingSessionsRead = 0;
474
- for (const session of sessions) {
475
- const resultIsFull = rows.length >= limit;
476
- const cutoff = resultIsFull
455
+ for (const siteId of siteIds) {
456
+ const cutoff = rows.length >= limit
477
457
  ? rows[limit - 1]!.timestamp
478
458
  : args.to;
479
- if (session.startedAt > cutoff) break;
480
- if (resultIsFull && ++overlappingSessionsRead > 8) {
481
- fail(
482
- "LIMIT_EXCEEDED",
483
- "visitor journey has too many overlapping sessions",
484
- { maximumOverlappingSessions: 8 },
485
- );
486
- }
487
- rows.push(...await loadSessionJourneyEvents(ctx, {
488
- siteId: session.siteId,
489
- sessionId: session.sessionId,
459
+ rows.push(...await loadVisitorJourneyEvents(ctx, {
460
+ siteId,
461
+ visitorId: args.visitorId,
490
462
  from: args.from,
491
- to: Math.min(args.to, cutoff),
492
- cursor: null,
493
- resultLimit: resultIsFull ? limit : limit - rows.length,
463
+ to: cutoff,
464
+ resultLimit: limit,
494
465
  }));
495
466
  rows.sort(compareJourneyEvents);
496
467
  if (rows.length > limit) rows.length = limit;
@@ -3,12 +3,12 @@ import { v } from "convex/values";
3
3
  import {
4
4
  affiliateFieldsValidator,
5
5
  aggregateFieldsValidator,
6
+ batchedEventValidator,
6
7
  storedEventFieldsValidator,
7
8
  funnelFieldsValidator,
8
9
  goalFieldsValidator,
9
10
  sessionFieldsValidator,
10
11
  siteFieldsValidator,
11
- trackerEventValidator,
12
12
  } from "./validators.js";
13
13
 
14
14
  export default defineSchema({
@@ -67,17 +67,19 @@ export default defineSchema({
67
67
  "sessionId",
68
68
  "timestamp",
69
69
  ])
70
- .index("by_siteId_and_visitorId_and_timestamp", {
71
- fields: ["siteId", "visitorId", "timestamp"],
72
- staged: true,
73
- }),
70
+ .index("by_siteId_and_visitorId_and_timestamp", [
71
+ "siteId",
72
+ "visitorId",
73
+ "timestamp",
74
+ ]),
74
75
 
75
76
  eventBatches: defineTable({
76
77
  siteId: v.id("sites"),
77
78
  sessionId: v.string(),
78
79
  visitorId: v.string(),
79
80
  bucketStart: v.number(),
80
- events: v.array(trackerEventValidator),
81
+ events: v.array(batchedEventValidator),
82
+ migrationVersion: v.optional(v.literal(1)),
81
83
  })
82
84
  .index("by_siteId_and_sessionId_and_bucketStart", [
83
85
  "siteId",
@@ -85,10 +87,11 @@ export default defineSchema({
85
87
  "bucketStart",
86
88
  ])
87
89
  .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"])
88
- .index("by_siteId_and_visitorId_and_bucketStart", {
89
- fields: ["siteId", "visitorId", "bucketStart"],
90
- staged: true,
91
- }),
90
+ .index("by_siteId_and_visitorId_and_bucketStart", [
91
+ "siteId",
92
+ "visitorId",
93
+ "bucketStart",
94
+ ]),
92
95
 
93
96
  aggregateBuckets: defineTable(aggregateFieldsValidator)
94
97
  .index("by_siteId_and_granularity_and_bucketStart_and_shard", [
@@ -49,6 +49,10 @@ export const ingestContextValidator = v.object({
49
49
  device: v.optional(v.string()),
50
50
  });
51
51
 
52
+ export const batchedEventValidator = trackerEventValidator.extend(
53
+ ingestContextValidator.fields,
54
+ );
55
+
52
56
  export const siteFieldsValidator = v.object({
53
57
  ownerId: v.string(),
54
58
  name: v.string(),
package/src/test.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  /// <reference types="vite/client" />
2
+ import migrationsComponent from "@convex-dev/migrations/test";
2
3
  import type { TestConvex } from "convex-test";
3
4
  import type { GenericSchema, SchemaDefinition } from "convex/server";
4
5
  import schema from "./component/schema.js";
@@ -15,5 +16,6 @@ export function register(
15
16
  name: string = "rastroAnalytics",
16
17
  ) {
17
18
  t.registerComponent(name, schema, modules);
19
+ migrationsComponent.register(t, `${name}/migrations`);
18
20
  }
19
21
  export default { register, schema, modules };