@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.
- package/README.md +85 -18
- package/dist/component/_generated/api.d.ts +2 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/constants.d.ts +9 -6
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +9 -6
- package/dist/component/constants.js.map +1 -1
- package/dist/component/eventStore.d.ts +94 -0
- package/dist/component/eventStore.d.ts.map +1 -0
- package/dist/component/eventStore.js +304 -0
- package/dist/component/eventStore.js.map +1 -0
- package/dist/component/goals.d.ts.map +1 -1
- package/dist/component/goals.js +17 -4
- package/dist/component/goals.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +39 -7
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +520 -207
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts +8 -0
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +72 -8
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +16 -16
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +250 -66
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +15 -10
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +11 -3
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +101 -10
- package/dist/component/schema.js +30 -8
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +27 -18
- package/dist/component/sites.js.map +1 -1
- package/dist/component/validators.d.ts +49 -0
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +11 -0
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +4 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +4 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.js +33 -22
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/benchmarks/2026-08-20-realistic.md +171 -0
- package/package.json +10 -4
- package/scripts/benchmark-ingest.mjs +499 -0
- package/src/component/_generated/api.ts +2 -0
- package/src/component/constants.ts +9 -6
- package/src/component/eventStore.ts +465 -0
- package/src/component/goals.ts +25 -4
- package/src/component/http.ts +49 -6
- package/src/component/ingest.ts +671 -215
- package/src/component/live.ts +83 -8
- package/src/component/reports.ts +320 -82
- package/src/component/retention.ts +15 -12
- package/src/component/sanitize.ts +10 -3
- package/src/component/schema.ts +33 -8
- package/src/component/sites.ts +31 -18
- package/src/component/validators.ts +12 -0
package/src/component/live.ts
CHANGED
|
@@ -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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
});
|
package/src/component/reports.ts
CHANGED
|
@@ -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.
|
|
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 <=
|
|
135
|
-
if (interval === "hour" && to - from >
|
|
136
|
-
fail("INVALID_ARGUMENT", "hourly reports are limited to
|
|
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 *
|
|
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 =
|
|
276
|
-
|
|
277
|
-
.
|
|
278
|
-
.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
.
|
|
285
|
-
|
|
286
|
-
|
|
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 =
|
|
302
|
-
|
|
303
|
-
.
|
|
304
|
-
.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
.
|
|
312
|
-
|
|
313
|
-
|
|
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 =
|
|
328
|
-
|
|
329
|
-
.
|
|
330
|
-
.
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
.
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
.
|
|
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("
|
|
368
|
-
.lte("timestamp", args.to),
|
|
461
|
+
.gte("lastSeenAt", args.from),
|
|
369
462
|
)
|
|
370
463
|
.order("asc")
|
|
371
|
-
.take(
|
|
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
|
|
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
|
|
644
|
-
|
|
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
|
|
649
|
-
options:
|
|
650
|
-
|
|
790
|
+
function parseKeysetPagination(
|
|
791
|
+
options: KeysetPaginationInput,
|
|
792
|
+
scope: PaginationScope,
|
|
793
|
+
): KeysetPagination {
|
|
651
794
|
validatePagination(options);
|
|
652
795
|
if (options.cursor === null) {
|
|
653
|
-
return {
|
|
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 !==
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
decoded.
|
|
667
|
-
decoded.
|
|
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
|
|
820
|
+
fail("INVALID_ARGUMENT", "pagination cursor is invalid for this query");
|
|
670
821
|
}
|
|
671
|
-
return {
|
|
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
|
|
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:
|
|
879
|
+
pagination: KeysetPagination,
|
|
880
|
+
cursorFor: (row: T) => KeysetCursor,
|
|
677
881
|
) {
|
|
678
|
-
const page = rows.slice(
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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:
|
|
691
|
-
|
|
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
|
-
|
|
56
|
+
effectiveBatchSize + 1,
|
|
46
57
|
);
|
|
47
|
-
for (const remove of deletions.slice(0,
|
|
58
|
+
for (const remove of deletions.slice(0, effectiveBatchSize)) {
|
|
48
59
|
await remove();
|
|
49
60
|
}
|
|
50
61
|
return {
|
|
51
|
-
deleted: Math.min(deletions.length,
|
|
52
|
-
hasMore: deletions.length >
|
|
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")
|