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

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 +85 -18
  2. package/dist/component/_generated/api.d.ts +2 -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/constants.d.ts +9 -6
  6. package/dist/component/constants.d.ts.map +1 -1
  7. package/dist/component/constants.js +9 -6
  8. package/dist/component/constants.js.map +1 -1
  9. package/dist/component/eventStore.d.ts +94 -0
  10. package/dist/component/eventStore.d.ts.map +1 -0
  11. package/dist/component/eventStore.js +304 -0
  12. package/dist/component/eventStore.js.map +1 -0
  13. package/dist/component/goals.d.ts.map +1 -1
  14. package/dist/component/goals.js +17 -4
  15. package/dist/component/goals.js.map +1 -1
  16. package/dist/component/http.d.ts.map +1 -1
  17. package/dist/component/http.js +39 -7
  18. package/dist/component/http.js.map +1 -1
  19. package/dist/component/ingest.d.ts.map +1 -1
  20. package/dist/component/ingest.js +520 -207
  21. package/dist/component/ingest.js.map +1 -1
  22. package/dist/component/live.d.ts +8 -0
  23. package/dist/component/live.d.ts.map +1 -1
  24. package/dist/component/live.js +72 -8
  25. package/dist/component/live.js.map +1 -1
  26. package/dist/component/reports.d.ts +16 -16
  27. package/dist/component/reports.d.ts.map +1 -1
  28. package/dist/component/reports.js +250 -66
  29. package/dist/component/reports.js.map +1 -1
  30. package/dist/component/retention.d.ts.map +1 -1
  31. package/dist/component/retention.js +15 -10
  32. package/dist/component/retention.js.map +1 -1
  33. package/dist/component/sanitize.d.ts.map +1 -1
  34. package/dist/component/sanitize.js +11 -3
  35. package/dist/component/sanitize.js.map +1 -1
  36. package/dist/component/schema.d.ts +101 -10
  37. package/dist/component/schema.js +30 -8
  38. package/dist/component/schema.js.map +1 -1
  39. package/dist/component/sites.d.ts.map +1 -1
  40. package/dist/component/sites.js +27 -18
  41. package/dist/component/sites.js.map +1 -1
  42. package/dist/component/validators.d.ts +49 -0
  43. package/dist/component/validators.d.ts.map +1 -1
  44. package/dist/component/validators.js +11 -0
  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 +33 -22
  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 +171 -0
  54. package/package.json +10 -4
  55. package/scripts/benchmark-ingest.mjs +499 -0
  56. package/src/component/_generated/api.ts +2 -0
  57. package/src/component/constants.ts +9 -6
  58. package/src/component/eventStore.ts +465 -0
  59. package/src/component/goals.ts +25 -4
  60. package/src/component/http.ts +49 -6
  61. package/src/component/ingest.ts +671 -215
  62. package/src/component/live.ts +83 -8
  63. package/src/component/reports.ts +320 -82
  64. package/src/component/retention.ts +15 -12
  65. package/src/component/sanitize.ts +10 -3
  66. package/src/component/schema.ts +33 -8
  67. package/src/component/sites.ts +31 -18
  68. package/src/component/validators.ts +12 -0
@@ -1,6 +1,9 @@
1
1
  import { v } from "convex/values";
2
2
  import { internal } from "./_generated/api.js";
3
3
  import { internalMutation } from "./_generated/server.js";
4
+ import { LIVE_SWEEP_INTERVAL_MS } from "./constants.js";
5
+
6
+ const SWEEP_BATCH_SIZE = 500;
4
7
 
5
8
  export const expire = internalMutation({
6
9
  args: {
@@ -21,16 +24,88 @@ export const expire = internalMutation({
21
24
  return { expired: false, rescheduled: false, stale: true };
22
25
  }
23
26
  if (liveSession.expiresAt > Date.now()) {
24
- await ctx.db.patch("liveSessions", liveSession._id, {
25
- expiryChainDeadline: liveSession.expiresAt,
26
- });
27
- await ctx.scheduler.runAt(liveSession.expiresAt, internal.live.expire, {
28
- liveSessionId: liveSession._id,
29
- scheduledDeadline: liveSession.expiresAt,
30
- });
31
- return { expired: false, rescheduled: true, stale: false };
27
+ const state = await ctx.db
28
+ .query("liveSweepStates")
29
+ .withIndex("by_siteId", (range) => range.eq("siteId", liveSession.siteId))
30
+ .unique();
31
+ const scheduledAt = Math.min(
32
+ liveSession.expiresAt,
33
+ Date.now() + LIVE_SWEEP_INTERVAL_MS,
34
+ );
35
+ if (!state || state.scheduledAt > scheduledAt) {
36
+ if (state) {
37
+ await ctx.db.patch("liveSweepStates", state._id, {
38
+ scheduledAt,
39
+ });
40
+ } else {
41
+ await ctx.db.insert("liveSweepStates", {
42
+ siteId: liveSession.siteId,
43
+ scheduledAt,
44
+ });
45
+ }
46
+ await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
47
+ siteId: liveSession.siteId,
48
+ scheduledAt,
49
+ });
50
+ return { expired: false, rescheduled: true, stale: false };
51
+ }
52
+ return { expired: false, rescheduled: false, stale: false };
32
53
  }
33
54
  await ctx.db.delete("liveSessions", liveSession._id);
34
55
  return { expired: true, rescheduled: false, stale: false };
35
56
  },
36
57
  });
58
+
59
+ export const sweep = internalMutation({
60
+ args: {
61
+ siteId: v.id("sites"),
62
+ scheduledAt: v.number(),
63
+ },
64
+ returns: v.object({
65
+ expired: v.number(),
66
+ rescheduled: v.boolean(),
67
+ stale: v.boolean(),
68
+ }),
69
+ handler: async (ctx, args) => {
70
+ const state = await ctx.db
71
+ .query("liveSweepStates")
72
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
73
+ .unique();
74
+ if (!state || state.scheduledAt !== args.scheduledAt) {
75
+ return { expired: 0, rescheduled: false, stale: true };
76
+ }
77
+
78
+ const now = Date.now();
79
+ const expired = await ctx.db
80
+ .query("liveSessions")
81
+ .withIndex("by_siteId_and_expiresAt", (range) =>
82
+ range.eq("siteId", args.siteId).lte("expiresAt", now),
83
+ )
84
+ .take(SWEEP_BATCH_SIZE);
85
+ for (const session of expired) {
86
+ await ctx.db.delete("liveSessions", session._id);
87
+ }
88
+
89
+ const next = await ctx.db
90
+ .query("liveSessions")
91
+ .withIndex("by_siteId_and_expiresAt", (range) =>
92
+ range.eq("siteId", args.siteId),
93
+ )
94
+ .order("asc")
95
+ .first();
96
+ if (!next) {
97
+ await ctx.db.delete("liveSweepStates", state._id);
98
+ return { expired: expired.length, rescheduled: false, stale: false };
99
+ }
100
+
101
+ const scheduledAt = expired.length === SWEEP_BATCH_SIZE
102
+ ? now
103
+ : now + LIVE_SWEEP_INTERVAL_MS;
104
+ await ctx.db.patch("liveSweepStates", state._id, { scheduledAt });
105
+ await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
106
+ siteId: args.siteId,
107
+ scheduledAt,
108
+ });
109
+ return { expired: expired.length, rescheduled: true, stale: false };
110
+ },
111
+ });
@@ -3,22 +3,30 @@ import {
3
3
  paginationResultValidator,
4
4
  } from "convex/server";
5
5
  import { v } from "convex/values";
6
+ import type { Doc } from "./_generated/dataModel.js";
6
7
  import { query } from "./_generated/server.js";
7
8
  import {
8
9
  AGGREGATE_SHARDS,
9
10
  DAY_MS,
10
11
  HOUR_MS,
12
+ HOURLY_AGGREGATE_SHARDS,
11
13
  MAX_AFFILIATES_PER_SITE,
12
14
  MAX_FUNNELS_PER_SITE,
13
15
  MAX_GOALS_PER_SITE,
14
16
  MAX_JOURNEY_EVENTS,
15
17
  MAX_LIVE_VISITORS,
16
- MAX_MANUAL_PAGINATION_OFFSET,
17
18
  MAX_OVERVIEW_AGGREGATE_ROWS,
18
19
  MAX_REPORT_RANGE_DAYS,
19
20
  MAX_REPORT_SITES,
20
21
  } from "./constants.js";
21
22
  import { fail } from "./errors.js";
23
+ import {
24
+ compareJourneyEvents,
25
+ hydrateEventContexts,
26
+ type JourneyCursor,
27
+ loadSessionJourneyEvents,
28
+ type RawJourneyEvent,
29
+ } from "./eventStore.js";
22
30
  import { isPlainRecord } from "./guards.js";
23
31
  import {
24
32
  affiliateFieldsValidator,
@@ -33,7 +41,7 @@ const sessionDocumentValidator = sessionFieldsValidator.extend({
33
41
  _creationTime: v.number(),
34
42
  });
35
43
  const eventDocumentValidator = eventFieldsValidator.extend({
36
- _id: v.id("events"),
44
+ _id: v.string(),
37
45
  _creationTime: v.number(),
38
46
  });
39
47
  const liveSessionDocumentValidator = v.object({
@@ -131,15 +139,16 @@ export const overview = query({
131
139
  }
132
140
  const currency = [...currencies][0];
133
141
  const { from, to } = validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
134
- const interval = args.interval ?? (to - from <= 7 * DAY_MS ? "hour" : "day");
135
- if (interval === "hour" && to - from > 7 * DAY_MS) {
136
- fail("INVALID_ARGUMENT", "hourly reports are limited to seven days");
142
+ const interval = args.interval ?? (to - from <= DAY_MS ? "hour" : "day");
143
+ if (interval === "hour" && to - from > DAY_MS) {
144
+ fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
137
145
  }
138
146
  const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
139
147
  const firstBucket = Math.floor(from / intervalMs) * intervalMs;
140
148
  const lastBucket = Math.floor(to / intervalMs) * intervalMs;
141
149
  const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
142
- const perSiteLimit = bucketCount * AGGREGATE_SHARDS;
150
+ const perSiteLimit = bucketCount *
151
+ (interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
143
152
  if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
144
153
  fail(
145
154
  "LIMIT_EXCEEDED",
@@ -272,18 +281,59 @@ export const listSessions = query({
272
281
  returns: paginationResultValidator(sessionDocumentValidator),
273
282
  handler: async (ctx, args) => {
274
283
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
275
- const pagination = parseManualPagination(args.paginationOpts);
276
- const rows = await ctx.db
277
- .query("sessions")
278
- .withIndex("by_siteId_and_lastSeenAt", (range) =>
279
- range
280
- .eq("siteId", args.siteId)
281
- .gte("lastSeenAt", args.from)
282
- .lte("lastSeenAt", args.to),
283
- )
284
- .order("desc")
285
- .take(pagination.offset + pagination.numItems + 1);
286
- return manualPaginationResult(rows, pagination);
284
+ const pagination = parseKeysetPagination(args.paginationOpts, {
285
+ kind: "sessions",
286
+ siteId: String(args.siteId),
287
+ from: args.from,
288
+ to: args.to,
289
+ });
290
+ const rows: Doc<"sessions">[] = [];
291
+ const take = pagination.numItems + 1;
292
+ if (pagination.cursor) {
293
+ rows.push(
294
+ ...(await ctx.db
295
+ .query("sessions")
296
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
297
+ range
298
+ .eq("siteId", args.siteId)
299
+ .eq("lastSeenAt", pagination.cursor!.timestamp)
300
+ .lt("_creationTime", pagination.cursor!.creationTime),
301
+ )
302
+ .order("desc")
303
+ .take(take)),
304
+ );
305
+ if (rows.length < take) {
306
+ rows.push(
307
+ ...(await ctx.db
308
+ .query("sessions")
309
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
310
+ range
311
+ .eq("siteId", args.siteId)
312
+ .gte("lastSeenAt", args.from)
313
+ .lt("lastSeenAt", pagination.cursor!.timestamp),
314
+ )
315
+ .order("desc")
316
+ .take(take - rows.length)),
317
+ );
318
+ }
319
+ } else {
320
+ rows.push(
321
+ ...(await ctx.db
322
+ .query("sessions")
323
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
324
+ range
325
+ .eq("siteId", args.siteId)
326
+ .gte("lastSeenAt", args.from)
327
+ .lte("lastSeenAt", args.to),
328
+ )
329
+ .order("desc")
330
+ .take(take)),
331
+ );
332
+ }
333
+ return keysetPaginationResult(rows, pagination, (row) => ({
334
+ timestamp: row.lastSeenAt,
335
+ creationTime: row._creationTime,
336
+ }));
287
337
  },
288
338
  });
289
339
 
@@ -298,19 +348,23 @@ export const sessionJourney = query({
298
348
  returns: paginationResultValidator(eventDocumentValidator),
299
349
  handler: async (ctx, args) => {
300
350
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
301
- const pagination = parseManualPagination(args.paginationOpts);
302
- const rows = await ctx.db
303
- .query("events")
304
- .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
305
- range
306
- .eq("siteId", args.siteId)
307
- .eq("sessionId", args.sessionId)
308
- .gte("timestamp", args.from)
309
- .lte("timestamp", args.to),
310
- )
311
- .order("asc")
312
- .take(pagination.offset + pagination.numItems + 1);
313
- return manualPaginationResult(rows, pagination);
351
+ const pagination = parseJourneyPagination(args.paginationOpts, {
352
+ kind: "events",
353
+ siteId: String(args.siteId),
354
+ sessionId: args.sessionId,
355
+ from: args.from,
356
+ to: args.to,
357
+ });
358
+ const rows = await loadSessionJourneyEvents(ctx, {
359
+ siteId: args.siteId,
360
+ sessionId: args.sessionId,
361
+ from: args.from,
362
+ to: args.to,
363
+ cursor: pagination.cursor,
364
+ resultLimit: pagination.numItems + 1,
365
+ });
366
+ const hydratedRows = await hydrateEventContexts(ctx, rows);
367
+ return journeyPaginationResult(hydratedRows, pagination);
314
368
  },
315
369
  });
316
370
 
@@ -324,18 +378,59 @@ export const listConversions = query({
324
378
  returns: paginationResultValidator(conversionDocumentValidator),
325
379
  handler: async (ctx, args) => {
326
380
  validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
327
- const pagination = parseManualPagination(args.paginationOpts);
328
- const rows = await ctx.db
329
- .query("conversions")
330
- .withIndex("by_siteId_and_timestamp", (range) =>
331
- range
332
- .eq("siteId", args.siteId)
333
- .gte("timestamp", args.from)
334
- .lte("timestamp", args.to),
335
- )
336
- .order("desc")
337
- .take(pagination.offset + pagination.numItems + 1);
338
- return manualPaginationResult(rows, pagination);
381
+ const pagination = parseKeysetPagination(args.paginationOpts, {
382
+ kind: "conversions",
383
+ siteId: String(args.siteId),
384
+ from: args.from,
385
+ to: args.to,
386
+ });
387
+ const rows: Doc<"conversions">[] = [];
388
+ const take = pagination.numItems + 1;
389
+ if (pagination.cursor) {
390
+ rows.push(
391
+ ...(await ctx.db
392
+ .query("conversions")
393
+ .withIndex("by_siteId_and_timestamp", (range) =>
394
+ range
395
+ .eq("siteId", args.siteId)
396
+ .eq("timestamp", pagination.cursor!.timestamp)
397
+ .lt("_creationTime", pagination.cursor!.creationTime),
398
+ )
399
+ .order("desc")
400
+ .take(take)),
401
+ );
402
+ if (rows.length < take) {
403
+ rows.push(
404
+ ...(await ctx.db
405
+ .query("conversions")
406
+ .withIndex("by_siteId_and_timestamp", (range) =>
407
+ range
408
+ .eq("siteId", args.siteId)
409
+ .gte("timestamp", args.from)
410
+ .lt("timestamp", pagination.cursor!.timestamp),
411
+ )
412
+ .order("desc")
413
+ .take(take - rows.length)),
414
+ );
415
+ }
416
+ } else {
417
+ rows.push(
418
+ ...(await ctx.db
419
+ .query("conversions")
420
+ .withIndex("by_siteId_and_timestamp", (range) =>
421
+ range
422
+ .eq("siteId", args.siteId)
423
+ .gte("timestamp", args.from)
424
+ .lte("timestamp", args.to),
425
+ )
426
+ .order("desc")
427
+ .take(take)),
428
+ );
429
+ }
430
+ return keysetPaginationResult(rows, pagination, (row) => ({
431
+ timestamp: row.timestamp,
432
+ creationTime: row._creationTime,
433
+ }));
339
434
  },
340
435
  });
341
436
 
@@ -355,25 +450,52 @@ export const visitorJourney = query({
355
450
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
356
451
  fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
357
452
  }
358
- const rows = [];
359
- for (const siteId of siteIds) {
360
- rows.push(
361
- ...(await ctx.db
362
- .query("events")
363
- .withIndex("by_siteId_and_visitorId_and_timestamp", (range) =>
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) =>
364
458
  range
365
459
  .eq("siteId", siteId)
366
460
  .eq("visitorId", args.visitorId)
367
- .gte("timestamp", args.from)
368
- .lte("timestamp", args.to),
461
+ .gte("lastSeenAt", args.from),
369
462
  )
370
463
  .order("asc")
371
- .take(limit)),
372
- );
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
+ const rows: RawJourneyEvent[] = [];
473
+ let overlappingSessionsRead = 0;
474
+ for (const session of sessions) {
475
+ const resultIsFull = rows.length >= limit;
476
+ const cutoff = resultIsFull
477
+ ? rows[limit - 1]!.timestamp
478
+ : 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,
490
+ from: args.from,
491
+ to: Math.min(args.to, cutoff),
492
+ cursor: null,
493
+ resultLimit: resultIsFull ? limit : limit - rows.length,
494
+ }));
495
+ rows.sort(compareJourneyEvents);
496
+ if (rows.length > limit) rows.length = limit;
373
497
  }
374
- return rows
375
- .sort((left, right) => left.timestamp - right.timestamp || left.sequence - right.sequence)
376
- .slice(0, limit);
498
+ return hydrateEventContexts(ctx, rows);
377
499
  },
378
500
  });
379
501
 
@@ -633,24 +755,45 @@ function validateRange(from: number, to: number, maxDays: number) {
633
755
  return { from: Math.trunc(from), to: Math.trunc(to) };
634
756
  }
635
757
 
636
- type ManualPaginationInput = {
758
+ type KeysetPaginationInput = {
637
759
  cursor: string | null;
638
760
  numItems: number;
639
761
  maximumRowsRead?: number;
640
762
  maximumBytesRead?: number;
641
763
  };
642
764
 
643
- type ManualPagination = {
644
- offset: number;
765
+ type PaginationScope = {
766
+ kind: "sessions" | "events" | "conversions";
767
+ siteId: string;
768
+ sessionId?: string;
769
+ from: number;
770
+ to: number;
771
+ };
772
+
773
+ type KeysetCursor = {
774
+ timestamp: number;
775
+ creationTime: number;
776
+ };
777
+
778
+ type KeysetPagination = {
779
+ numItems: number;
780
+ scope: PaginationScope;
781
+ cursor: KeysetCursor | null;
782
+ };
783
+
784
+ type JourneyPagination = {
645
785
  numItems: number;
786
+ scope: PaginationScope & { kind: "events"; sessionId: string };
787
+ cursor: JourneyCursor | null;
646
788
  };
647
789
 
648
- function parseManualPagination(
649
- options: ManualPaginationInput,
650
- ): ManualPagination {
790
+ function parseKeysetPagination(
791
+ options: KeysetPaginationInput,
792
+ scope: PaginationScope,
793
+ ): KeysetPagination {
651
794
  validatePagination(options);
652
795
  if (options.cursor === null) {
653
- return { offset: 0, numItems: options.numItems };
796
+ return { numItems: options.numItems, scope, cursor: null };
654
797
  }
655
798
  let decoded: unknown;
656
799
  try {
@@ -660,35 +803,130 @@ function parseManualPagination(
660
803
  }
661
804
  if (
662
805
  !isPlainRecord(decoded) ||
663
- decoded.v !== 1 ||
664
- typeof decoded.o !== "number" ||
665
- !Number.isSafeInteger(decoded.o) ||
666
- decoded.o < 0 ||
667
- decoded.o > MAX_MANUAL_PAGINATION_OFFSET
806
+ decoded.v !== 2 ||
807
+ decoded.k !== scope.kind ||
808
+ decoded.s !== scope.siteId ||
809
+ decoded.n !== (scope.sessionId ?? null) ||
810
+ decoded.f !== scope.from ||
811
+ decoded.t !== scope.to ||
812
+ typeof decoded.x !== "number" ||
813
+ !Number.isFinite(decoded.x) ||
814
+ decoded.x < scope.from ||
815
+ decoded.x > scope.to ||
816
+ typeof decoded.c !== "number" ||
817
+ !Number.isFinite(decoded.c) ||
818
+ decoded.c < 0
668
819
  ) {
669
- fail("INVALID_ARGUMENT", "pagination cursor is invalid or exceeds the offset limit");
820
+ fail("INVALID_ARGUMENT", "pagination cursor is invalid for this query");
670
821
  }
671
- return { offset: decoded.o, numItems: options.numItems };
822
+ return {
823
+ numItems: options.numItems,
824
+ scope,
825
+ cursor: {
826
+ timestamp: decoded.x,
827
+ creationTime: decoded.c,
828
+ },
829
+ };
672
830
  }
673
831
 
674
- function manualPaginationResult<T>(
832
+ function parseJourneyPagination(
833
+ options: KeysetPaginationInput,
834
+ scope: PaginationScope & { kind: "events"; sessionId: string },
835
+ ): JourneyPagination {
836
+ validatePagination(options);
837
+ if (options.cursor === null) {
838
+ return { numItems: options.numItems, scope, cursor: null };
839
+ }
840
+ let decoded: unknown;
841
+ try {
842
+ decoded = JSON.parse(atob(options.cursor));
843
+ } catch {
844
+ fail("INVALID_ARGUMENT", "pagination cursor is malformed");
845
+ }
846
+ if (
847
+ !isPlainRecord(decoded) ||
848
+ decoded.v !== 4 ||
849
+ decoded.k !== scope.kind ||
850
+ decoded.s !== scope.siteId ||
851
+ decoded.n !== scope.sessionId ||
852
+ decoded.f !== scope.from ||
853
+ decoded.t !== scope.to ||
854
+ typeof decoded.x !== "number" ||
855
+ !Number.isFinite(decoded.x) ||
856
+ decoded.x < scope.from ||
857
+ decoded.x > scope.to ||
858
+ typeof decoded.c !== "number" ||
859
+ !Number.isFinite(decoded.c) ||
860
+ decoded.c < 0 ||
861
+ typeof decoded.i !== "string" ||
862
+ decoded.i.length === 0
863
+ ) {
864
+ fail("INVALID_ARGUMENT", "pagination cursor is invalid for this query");
865
+ }
866
+ return {
867
+ numItems: options.numItems,
868
+ scope,
869
+ cursor: {
870
+ timestamp: decoded.x,
871
+ creationTime: decoded.c,
872
+ id: decoded.i,
873
+ },
874
+ };
875
+ }
876
+
877
+ function keysetPaginationResult<T>(
675
878
  rows: T[],
676
- pagination: ManualPagination,
879
+ pagination: KeysetPagination,
880
+ cursorFor: (row: T) => KeysetCursor,
677
881
  ) {
678
- const page = rows.slice(
679
- pagination.offset,
680
- pagination.offset + pagination.numItems,
681
- );
682
- const nextOffset = pagination.offset + page.length;
683
- const isDone =
684
- rows.length <= pagination.offset + pagination.numItems ||
685
- nextOffset >= MAX_MANUAL_PAGINATION_OFFSET;
882
+ const page = rows.slice(0, pagination.numItems);
883
+ const isDone = rows.length <= pagination.numItems;
884
+ const next = page.length > 0
885
+ ? cursorFor(page[page.length - 1])
886
+ : pagination.cursor;
887
+ return {
888
+ page,
889
+ isDone,
890
+ continueCursor: btoa(JSON.stringify({
891
+ v: 2,
892
+ k: pagination.scope.kind,
893
+ s: pagination.scope.siteId,
894
+ n: pagination.scope.sessionId ?? null,
895
+ f: pagination.scope.from,
896
+ t: pagination.scope.to,
897
+ x: next?.timestamp ?? pagination.scope.to,
898
+ c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
899
+ })),
900
+ };
901
+ }
902
+
903
+ function journeyPaginationResult(
904
+ rows: Awaited<ReturnType<typeof hydrateEventContexts>>,
905
+ pagination: JourneyPagination,
906
+ ) {
907
+ const page = rows.slice(0, pagination.numItems);
908
+ const isDone = rows.length <= pagination.numItems;
909
+ const last = page.at(-1);
910
+ const next = last
911
+ ? {
912
+ timestamp: last.timestamp,
913
+ creationTime: last._creationTime,
914
+ id: last._id,
915
+ }
916
+ : pagination.cursor;
686
917
  return {
687
918
  page,
688
919
  isDone,
689
920
  continueCursor: btoa(JSON.stringify({
690
- v: 1,
691
- o: Math.min(nextOffset, MAX_MANUAL_PAGINATION_OFFSET),
921
+ v: 4,
922
+ k: pagination.scope.kind,
923
+ s: pagination.scope.siteId,
924
+ n: pagination.scope.sessionId,
925
+ f: pagination.scope.from,
926
+ t: pagination.scope.to,
927
+ x: next?.timestamp ?? pagination.scope.to,
928
+ c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
929
+ i: next?.id ?? "~",
692
930
  })),
693
931
  };
694
932
  }
@@ -4,6 +4,7 @@ import type { MutationCtx } from "./_generated/server.js";
4
4
  import { mutation } from "./_generated/server.js";
5
5
  import { MAX_RETENTION_BATCH } from "./constants.js";
6
6
  import { fail } from "./errors.js";
7
+ import { deleteTelemetryBefore } from "./eventStore.js";
7
8
 
8
9
  const cleanupKindValidator = v.union(
9
10
  v.literal("events"),
@@ -37,19 +38,29 @@ export const cleanup = mutation({
37
38
  if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > MAX_RETENTION_BATCH) {
38
39
  fail("INVALID_ARGUMENT", `batchSize must be an integer between 1 and ${MAX_RETENTION_BATCH}`);
39
40
  }
41
+ const effectiveBatchSize = args.kind === "events"
42
+ ? Math.min(batchSize, 32)
43
+ : batchSize;
44
+ if (args.kind === "events") {
45
+ return await deleteTelemetryBefore(ctx, {
46
+ siteId: args.siteId,
47
+ before: args.before,
48
+ batchSize: effectiveBatchSize,
49
+ });
50
+ }
40
51
  const deletions = await deletionsForCleanup(
41
52
  ctx,
42
53
  args.siteId,
43
54
  args.kind,
44
55
  args.before,
45
- batchSize + 1,
56
+ effectiveBatchSize + 1,
46
57
  );
47
- for (const remove of deletions.slice(0, batchSize)) {
58
+ for (const remove of deletions.slice(0, effectiveBatchSize)) {
48
59
  await remove();
49
60
  }
50
61
  return {
51
- deleted: Math.min(deletions.length, batchSize),
52
- hasMore: deletions.length > batchSize,
62
+ deleted: Math.min(deletions.length, effectiveBatchSize),
63
+ hasMore: deletions.length > effectiveBatchSize,
53
64
  };
54
65
  },
55
66
  });
@@ -78,14 +89,6 @@ async function deletionsForCleanup(
78
89
  before: number,
79
90
  limit: number,
80
91
  ): Promise<CleanupDelete[]> {
81
- if (kind === "events") {
82
- return (await ctx.db
83
- .query("events")
84
- .withIndex("by_siteId_and_timestamp", (range) =>
85
- range.eq("siteId", siteId).lt("timestamp", before),
86
- )
87
- .take(limit)).map((row) => () => ctx.db.delete("events", row._id));
88
- }
89
92
  if (kind === "sessions") {
90
93
  return (await ctx.db
91
94
  .query("sessions")