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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +98 -18
  2. package/dist/component/_generated/api.d.ts +7 -1
  3. package/dist/component/_generated/api.d.ts.map +1 -1
  4. package/dist/component/_generated/api.js.map +1 -1
  5. package/dist/component/constants.d.ts +11 -6
  6. package/dist/component/constants.d.ts.map +1 -1
  7. package/dist/component/constants.js +11 -6
  8. package/dist/component/constants.js.map +1 -1
  9. package/dist/component/convex.config.d.ts +2 -2
  10. package/dist/component/convex.config.d.ts.map +1 -1
  11. package/dist/component/convex.config.js +4 -1
  12. package/dist/component/convex.config.js.map +1 -1
  13. package/dist/component/eventStore.d.ts +102 -0
  14. package/dist/component/eventStore.d.ts.map +1 -0
  15. package/dist/component/eventStore.js +463 -0
  16. package/dist/component/eventStore.js.map +1 -0
  17. package/dist/component/goals.d.ts.map +1 -1
  18. package/dist/component/goals.js +17 -4
  19. package/dist/component/goals.js.map +1 -1
  20. package/dist/component/http.d.ts.map +1 -1
  21. package/dist/component/http.js +39 -7
  22. package/dist/component/http.js.map +1 -1
  23. package/dist/component/ingest.d.ts.map +1 -1
  24. package/dist/component/ingest.js +520 -207
  25. package/dist/component/ingest.js.map +1 -1
  26. package/dist/component/live.d.ts +8 -0
  27. package/dist/component/live.d.ts.map +1 -1
  28. package/dist/component/live.js +72 -8
  29. package/dist/component/live.js.map +1 -1
  30. package/dist/component/migrations.d.ts +17 -0
  31. package/dist/component/migrations.d.ts.map +1 -0
  32. package/dist/component/migrations.js +44 -0
  33. package/dist/component/migrations.js.map +1 -0
  34. package/dist/component/reports.d.ts +16 -16
  35. package/dist/component/reports.d.ts.map +1 -1
  36. package/dist/component/reports.js +228 -65
  37. package/dist/component/reports.js.map +1 -1
  38. package/dist/component/retention.d.ts.map +1 -1
  39. package/dist/component/retention.js +15 -10
  40. package/dist/component/retention.js.map +1 -1
  41. package/dist/component/sanitize.d.ts.map +1 -1
  42. package/dist/component/sanitize.js +11 -3
  43. package/dist/component/sanitize.js.map +1 -1
  44. package/dist/component/schema.d.ts +132 -9
  45. package/dist/component/schema.js +32 -7
  46. package/dist/component/schema.js.map +1 -1
  47. package/dist/component/sites.d.ts.map +1 -1
  48. package/dist/component/sites.js +27 -18
  49. package/dist/component/sites.js.map +1 -1
  50. package/dist/component/validators.d.ts +96 -0
  51. package/dist/component/validators.d.ts.map +1 -1
  52. package/dist/component/validators.js +12 -0
  53. package/dist/component/validators.js.map +1 -1
  54. package/dist/tracker/generated.d.ts +4 -4
  55. package/dist/tracker/generated.d.ts.map +1 -1
  56. package/dist/tracker/generated.js +4 -4
  57. package/dist/tracker/generated.js.map +1 -1
  58. package/dist/tracker/tracker.js +33 -22
  59. package/dist/tracker/tracker.js.map +1 -1
  60. package/dist/tracker.min.js +1 -1
  61. package/docs/benchmarks/2026-08-20-realistic.md +171 -0
  62. package/package.json +15 -5
  63. package/scripts/benchmark-ingest.mjs +499 -0
  64. package/src/component/_generated/api.ts +7 -1
  65. package/src/component/constants.ts +11 -6
  66. package/src/component/convex.config.ts +5 -1
  67. package/src/component/eventStore.ts +684 -0
  68. package/src/component/goals.ts +25 -4
  69. package/src/component/http.ts +49 -6
  70. package/src/component/ingest.ts +671 -215
  71. package/src/component/live.ts +83 -8
  72. package/src/component/migrations.ts +52 -0
  73. package/src/component/reports.ts +295 -86
  74. package/src/component/retention.ts +15 -12
  75. package/src/component/sanitize.ts +10 -3
  76. package/src/component/schema.ts +35 -7
  77. package/src/component/sites.ts +31 -18
  78. package/src/component/validators.ts +16 -0
  79. package/src/test.ts +2 -0
@@ -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
+ });
@@ -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
+ });
@@ -3,22 +3,31 @@ 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
+ loadVisitorJourneyEvents,
29
+ type RawJourneyEvent,
30
+ } from "./eventStore.js";
22
31
  import { isPlainRecord } from "./guards.js";
23
32
  import {
24
33
  affiliateFieldsValidator,
@@ -33,7 +42,7 @@ const sessionDocumentValidator = sessionFieldsValidator.extend({
33
42
  _creationTime: v.number(),
34
43
  });
35
44
  const eventDocumentValidator = eventFieldsValidator.extend({
36
- _id: v.id("events"),
45
+ _id: v.string(),
37
46
  _creationTime: v.number(),
38
47
  });
39
48
  const liveSessionDocumentValidator = v.object({
@@ -131,15 +140,16 @@ export const overview = query({
131
140
  }
132
141
  const currency = [...currencies][0];
133
142
  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");
143
+ const interval = args.interval ?? (to - from <= DAY_MS ? "hour" : "day");
144
+ if (interval === "hour" && to - from > DAY_MS) {
145
+ fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
137
146
  }
138
147
  const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
139
148
  const firstBucket = Math.floor(from / intervalMs) * intervalMs;
140
149
  const lastBucket = Math.floor(to / intervalMs) * intervalMs;
141
150
  const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
142
- const perSiteLimit = bucketCount * AGGREGATE_SHARDS;
151
+ const perSiteLimit = bucketCount *
152
+ (interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
143
153
  if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
144
154
  fail(
145
155
  "LIMIT_EXCEEDED",
@@ -272,18 +282,59 @@ export const listSessions = query({
272
282
  returns: paginationResultValidator(sessionDocumentValidator),
273
283
  handler: async (ctx, args) => {
274
284
  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);
285
+ const pagination = parseKeysetPagination(args.paginationOpts, {
286
+ kind: "sessions",
287
+ siteId: String(args.siteId),
288
+ from: args.from,
289
+ to: args.to,
290
+ });
291
+ const rows: Doc<"sessions">[] = [];
292
+ const take = pagination.numItems + 1;
293
+ if (pagination.cursor) {
294
+ rows.push(
295
+ ...(await ctx.db
296
+ .query("sessions")
297
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
298
+ range
299
+ .eq("siteId", args.siteId)
300
+ .eq("lastSeenAt", pagination.cursor!.timestamp)
301
+ .lt("_creationTime", pagination.cursor!.creationTime),
302
+ )
303
+ .order("desc")
304
+ .take(take)),
305
+ );
306
+ if (rows.length < take) {
307
+ rows.push(
308
+ ...(await ctx.db
309
+ .query("sessions")
310
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
311
+ range
312
+ .eq("siteId", args.siteId)
313
+ .gte("lastSeenAt", args.from)
314
+ .lt("lastSeenAt", pagination.cursor!.timestamp),
315
+ )
316
+ .order("desc")
317
+ .take(take - rows.length)),
318
+ );
319
+ }
320
+ } else {
321
+ rows.push(
322
+ ...(await ctx.db
323
+ .query("sessions")
324
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
325
+ range
326
+ .eq("siteId", args.siteId)
327
+ .gte("lastSeenAt", args.from)
328
+ .lte("lastSeenAt", args.to),
329
+ )
330
+ .order("desc")
331
+ .take(take)),
332
+ );
333
+ }
334
+ return keysetPaginationResult(rows, pagination, (row) => ({
335
+ timestamp: row.lastSeenAt,
336
+ creationTime: row._creationTime,
337
+ }));
287
338
  },
288
339
  });
289
340
 
@@ -298,19 +349,23 @@ export const sessionJourney = query({
298
349
  returns: paginationResultValidator(eventDocumentValidator),
299
350
  handler: async (ctx, args) => {
300
351
  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);
352
+ const pagination = parseJourneyPagination(args.paginationOpts, {
353
+ kind: "events",
354
+ siteId: String(args.siteId),
355
+ sessionId: args.sessionId,
356
+ from: args.from,
357
+ to: args.to,
358
+ });
359
+ const rows = await loadSessionJourneyEvents(ctx, {
360
+ siteId: args.siteId,
361
+ sessionId: args.sessionId,
362
+ from: args.from,
363
+ to: args.to,
364
+ cursor: pagination.cursor,
365
+ resultLimit: pagination.numItems + 1,
366
+ });
367
+ const hydratedRows = await hydrateEventContexts(ctx, rows);
368
+ return journeyPaginationResult(hydratedRows, pagination);
314
369
  },
315
370
  });
316
371
 
@@ -324,18 +379,59 @@ export const listConversions = query({
324
379
  returns: paginationResultValidator(conversionDocumentValidator),
325
380
  handler: async (ctx, args) => {
326
381
  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);
382
+ const pagination = parseKeysetPagination(args.paginationOpts, {
383
+ kind: "conversions",
384
+ siteId: String(args.siteId),
385
+ from: args.from,
386
+ to: args.to,
387
+ });
388
+ const rows: Doc<"conversions">[] = [];
389
+ const take = pagination.numItems + 1;
390
+ if (pagination.cursor) {
391
+ rows.push(
392
+ ...(await ctx.db
393
+ .query("conversions")
394
+ .withIndex("by_siteId_and_timestamp", (range) =>
395
+ range
396
+ .eq("siteId", args.siteId)
397
+ .eq("timestamp", pagination.cursor!.timestamp)
398
+ .lt("_creationTime", pagination.cursor!.creationTime),
399
+ )
400
+ .order("desc")
401
+ .take(take)),
402
+ );
403
+ if (rows.length < take) {
404
+ rows.push(
405
+ ...(await ctx.db
406
+ .query("conversions")
407
+ .withIndex("by_siteId_and_timestamp", (range) =>
408
+ range
409
+ .eq("siteId", args.siteId)
410
+ .gte("timestamp", args.from)
411
+ .lt("timestamp", pagination.cursor!.timestamp),
412
+ )
413
+ .order("desc")
414
+ .take(take - rows.length)),
415
+ );
416
+ }
417
+ } else {
418
+ rows.push(
419
+ ...(await ctx.db
420
+ .query("conversions")
421
+ .withIndex("by_siteId_and_timestamp", (range) =>
422
+ range
423
+ .eq("siteId", args.siteId)
424
+ .gte("timestamp", args.from)
425
+ .lte("timestamp", args.to),
426
+ )
427
+ .order("desc")
428
+ .take(take)),
429
+ );
430
+ }
431
+ return keysetPaginationResult(rows, pagination, (row) => ({
432
+ timestamp: row.timestamp,
433
+ creationTime: row._creationTime,
434
+ }));
339
435
  },
340
436
  });
341
437
 
@@ -355,25 +451,22 @@ export const visitorJourney = query({
355
451
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
356
452
  fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
357
453
  }
358
- const rows = [];
454
+ const rows: RawJourneyEvent[] = [];
359
455
  for (const siteId of siteIds) {
360
- rows.push(
361
- ...(await ctx.db
362
- .query("events")
363
- .withIndex("by_siteId_and_visitorId_and_timestamp", (range) =>
364
- range
365
- .eq("siteId", siteId)
366
- .eq("visitorId", args.visitorId)
367
- .gte("timestamp", args.from)
368
- .lte("timestamp", args.to),
369
- )
370
- .order("asc")
371
- .take(limit)),
372
- );
456
+ const cutoff = rows.length >= limit
457
+ ? rows[limit - 1]!.timestamp
458
+ : args.to;
459
+ rows.push(...await loadVisitorJourneyEvents(ctx, {
460
+ siteId,
461
+ visitorId: args.visitorId,
462
+ from: args.from,
463
+ to: cutoff,
464
+ resultLimit: limit,
465
+ }));
466
+ rows.sort(compareJourneyEvents);
467
+ if (rows.length > limit) rows.length = limit;
373
468
  }
374
- return rows
375
- .sort((left, right) => left.timestamp - right.timestamp || left.sequence - right.sequence)
376
- .slice(0, limit);
469
+ return hydrateEventContexts(ctx, rows);
377
470
  },
378
471
  });
379
472
 
@@ -633,24 +726,45 @@ function validateRange(from: number, to: number, maxDays: number) {
633
726
  return { from: Math.trunc(from), to: Math.trunc(to) };
634
727
  }
635
728
 
636
- type ManualPaginationInput = {
729
+ type KeysetPaginationInput = {
637
730
  cursor: string | null;
638
731
  numItems: number;
639
732
  maximumRowsRead?: number;
640
733
  maximumBytesRead?: number;
641
734
  };
642
735
 
643
- type ManualPagination = {
644
- offset: number;
736
+ type PaginationScope = {
737
+ kind: "sessions" | "events" | "conversions";
738
+ siteId: string;
739
+ sessionId?: string;
740
+ from: number;
741
+ to: number;
742
+ };
743
+
744
+ type KeysetCursor = {
745
+ timestamp: number;
746
+ creationTime: number;
747
+ };
748
+
749
+ type KeysetPagination = {
750
+ numItems: number;
751
+ scope: PaginationScope;
752
+ cursor: KeysetCursor | null;
753
+ };
754
+
755
+ type JourneyPagination = {
645
756
  numItems: number;
757
+ scope: PaginationScope & { kind: "events"; sessionId: string };
758
+ cursor: JourneyCursor | null;
646
759
  };
647
760
 
648
- function parseManualPagination(
649
- options: ManualPaginationInput,
650
- ): ManualPagination {
761
+ function parseKeysetPagination(
762
+ options: KeysetPaginationInput,
763
+ scope: PaginationScope,
764
+ ): KeysetPagination {
651
765
  validatePagination(options);
652
766
  if (options.cursor === null) {
653
- return { offset: 0, numItems: options.numItems };
767
+ return { numItems: options.numItems, scope, cursor: null };
654
768
  }
655
769
  let decoded: unknown;
656
770
  try {
@@ -660,35 +774,130 @@ function parseManualPagination(
660
774
  }
661
775
  if (
662
776
  !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
777
+ decoded.v !== 2 ||
778
+ decoded.k !== scope.kind ||
779
+ decoded.s !== scope.siteId ||
780
+ decoded.n !== (scope.sessionId ?? null) ||
781
+ decoded.f !== scope.from ||
782
+ decoded.t !== scope.to ||
783
+ typeof decoded.x !== "number" ||
784
+ !Number.isFinite(decoded.x) ||
785
+ decoded.x < scope.from ||
786
+ decoded.x > scope.to ||
787
+ typeof decoded.c !== "number" ||
788
+ !Number.isFinite(decoded.c) ||
789
+ decoded.c < 0
668
790
  ) {
669
- fail("INVALID_ARGUMENT", "pagination cursor is invalid or exceeds the offset limit");
791
+ fail("INVALID_ARGUMENT", "pagination cursor is invalid for this query");
670
792
  }
671
- return { offset: decoded.o, numItems: options.numItems };
793
+ return {
794
+ numItems: options.numItems,
795
+ scope,
796
+ cursor: {
797
+ timestamp: decoded.x,
798
+ creationTime: decoded.c,
799
+ },
800
+ };
672
801
  }
673
802
 
674
- function manualPaginationResult<T>(
803
+ function parseJourneyPagination(
804
+ options: KeysetPaginationInput,
805
+ scope: PaginationScope & { kind: "events"; sessionId: string },
806
+ ): JourneyPagination {
807
+ validatePagination(options);
808
+ if (options.cursor === null) {
809
+ return { numItems: options.numItems, scope, cursor: null };
810
+ }
811
+ let decoded: unknown;
812
+ try {
813
+ decoded = JSON.parse(atob(options.cursor));
814
+ } catch {
815
+ fail("INVALID_ARGUMENT", "pagination cursor is malformed");
816
+ }
817
+ if (
818
+ !isPlainRecord(decoded) ||
819
+ decoded.v !== 4 ||
820
+ decoded.k !== scope.kind ||
821
+ decoded.s !== scope.siteId ||
822
+ decoded.n !== scope.sessionId ||
823
+ decoded.f !== scope.from ||
824
+ decoded.t !== scope.to ||
825
+ typeof decoded.x !== "number" ||
826
+ !Number.isFinite(decoded.x) ||
827
+ decoded.x < scope.from ||
828
+ decoded.x > scope.to ||
829
+ typeof decoded.c !== "number" ||
830
+ !Number.isFinite(decoded.c) ||
831
+ decoded.c < 0 ||
832
+ typeof decoded.i !== "string" ||
833
+ decoded.i.length === 0
834
+ ) {
835
+ fail("INVALID_ARGUMENT", "pagination cursor is invalid for this query");
836
+ }
837
+ return {
838
+ numItems: options.numItems,
839
+ scope,
840
+ cursor: {
841
+ timestamp: decoded.x,
842
+ creationTime: decoded.c,
843
+ id: decoded.i,
844
+ },
845
+ };
846
+ }
847
+
848
+ function keysetPaginationResult<T>(
675
849
  rows: T[],
676
- pagination: ManualPagination,
850
+ pagination: KeysetPagination,
851
+ cursorFor: (row: T) => KeysetCursor,
677
852
  ) {
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;
853
+ const page = rows.slice(0, pagination.numItems);
854
+ const isDone = rows.length <= pagination.numItems;
855
+ const next = page.length > 0
856
+ ? cursorFor(page[page.length - 1])
857
+ : pagination.cursor;
858
+ return {
859
+ page,
860
+ isDone,
861
+ continueCursor: btoa(JSON.stringify({
862
+ v: 2,
863
+ k: pagination.scope.kind,
864
+ s: pagination.scope.siteId,
865
+ n: pagination.scope.sessionId ?? null,
866
+ f: pagination.scope.from,
867
+ t: pagination.scope.to,
868
+ x: next?.timestamp ?? pagination.scope.to,
869
+ c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
870
+ })),
871
+ };
872
+ }
873
+
874
+ function journeyPaginationResult(
875
+ rows: Awaited<ReturnType<typeof hydrateEventContexts>>,
876
+ pagination: JourneyPagination,
877
+ ) {
878
+ const page = rows.slice(0, pagination.numItems);
879
+ const isDone = rows.length <= pagination.numItems;
880
+ const last = page.at(-1);
881
+ const next = last
882
+ ? {
883
+ timestamp: last.timestamp,
884
+ creationTime: last._creationTime,
885
+ id: last._id,
886
+ }
887
+ : pagination.cursor;
686
888
  return {
687
889
  page,
688
890
  isDone,
689
891
  continueCursor: btoa(JSON.stringify({
690
- v: 1,
691
- o: Math.min(nextOffset, MAX_MANUAL_PAGINATION_OFFSET),
892
+ v: 4,
893
+ k: pagination.scope.kind,
894
+ s: pagination.scope.siteId,
895
+ n: pagination.scope.sessionId,
896
+ f: pagination.scope.from,
897
+ t: pagination.scope.to,
898
+ x: next?.timestamp ?? pagination.scope.to,
899
+ c: next?.creationTime ?? Number.MAX_SAFE_INTEGER,
900
+ i: next?.id ?? "~",
692
901
  })),
693
902
  };
694
903
  }