@iann29/rastro 0.1.0-alpha.3 → 0.1.0-alpha.5
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 +374 -62
- package/agent/integration.md +651 -0
- package/agent/manifest.json +183 -0
- package/agent/manifest.schema.json +405 -0
- package/dist/client/federation.d.ts +205 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +179 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/index.d.ts +1658 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +228 -2
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +10 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +169 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/cardinality.d.ts +12 -0
- package/dist/component/cardinality.d.ts.map +1 -0
- package/dist/component/cardinality.js +94 -0
- package/dist/component/cardinality.js.map +1 -0
- package/dist/component/constants.d.ts +11 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +11 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/coverage.d.ts +18 -0
- package/dist/component/coverage.d.ts.map +1 -0
- package/dist/component/coverage.js +19 -0
- package/dist/component/coverage.js.map +1 -0
- package/dist/component/errors.d.ts +1 -1
- package/dist/component/errors.d.ts.map +1 -1
- package/dist/component/errors.js.map +1 -1
- package/dist/component/eventStore.d.ts +1 -0
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +85 -36
- package/dist/component/http.js.map +1 -1
- package/dist/component/identity.d.ts +13 -0
- package/dist/component/identity.d.ts.map +1 -0
- package/dist/component/identity.js +58 -0
- package/dist/component/identity.js.map +1 -0
- package/dist/component/ingest.d.ts +1 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +258 -19
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +6 -3
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +120 -2
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +659 -91
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts +75 -1
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +517 -54
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts +4 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +13 -5
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +224 -7
- package/dist/component/schema.js +113 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +5 -1
- package/dist/component/sites.js.map +1 -1
- package/dist/component/useragent.d.ts +9 -0
- package/dist/component/useragent.d.ts.map +1 -0
- package/dist/component/useragent.js +152 -0
- package/dist/component/useragent.js.map +1 -0
- package/dist/component/validators.d.ts +42 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +10 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts +19 -0
- package/dist/component/visitors.d.ts.map +1 -0
- package/dist/component/visitors.js +86 -0
- package/dist/component/visitors.js.map +1 -0
- 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 +23 -11
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/benchmarks/2026-08-20-realistic.md +4 -4
- package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
- package/docs/federation-setup.md +395 -0
- package/docs/federation.md +258 -0
- package/docs/upgrading.md +130 -0
- package/llms.txt +65 -0
- package/package.json +41 -11
- package/scripts/generate-federation-keys.mjs +20 -0
- package/src/component/_generated/api.ts +10 -0
- package/src/component/_generated/component.ts +228 -1
- package/src/component/cardinality.ts +116 -0
- package/src/component/constants.ts +11 -0
- package/src/component/coverage.ts +25 -0
- package/src/component/errors.ts +2 -1
- package/src/component/http.ts +129 -38
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +414 -13
- package/src/component/live.ts +8 -3
- package/src/component/reports.ts +910 -98
- package/src/component/retention.ts +624 -88
- package/src/component/sanitize.ts +19 -5
- package/src/component/schema.ts +125 -0
- package/src/component/sites.ts +5 -1
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +11 -0
- package/src/component/visitors.ts +105 -0
package/src/component/reports.ts
CHANGED
|
@@ -3,22 +3,35 @@ 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
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
7
|
+
import type { QueryCtx } from "./_generated/server.js";
|
|
7
8
|
import { query } from "./_generated/server.js";
|
|
8
9
|
import {
|
|
9
10
|
AGGREGATE_SHARDS,
|
|
10
11
|
DAY_MS,
|
|
12
|
+
FEATURE_REPORT_BYTE_RESERVE,
|
|
13
|
+
FEATURE_REPORT_DOCUMENT_RESERVE,
|
|
11
14
|
HOUR_MS,
|
|
12
15
|
HOURLY_AGGREGATE_SHARDS,
|
|
13
16
|
MAX_AFFILIATES_PER_SITE,
|
|
14
17
|
MAX_FUNNELS_PER_SITE,
|
|
18
|
+
MAX_FEATURE_ROLLUP_DAYS,
|
|
19
|
+
MAX_FEATURE_ROLLUP_ROWS,
|
|
15
20
|
MAX_GOALS_PER_SITE,
|
|
16
21
|
MAX_JOURNEY_EVENTS,
|
|
17
22
|
MAX_LIVE_VISITORS,
|
|
18
23
|
MAX_OVERVIEW_AGGREGATE_ROWS,
|
|
24
|
+
MAX_REPORT_BUCKETS,
|
|
19
25
|
MAX_REPORT_RANGE_DAYS,
|
|
20
26
|
MAX_REPORT_SITES,
|
|
27
|
+
REPORT_ROLLUP_SHARDS,
|
|
21
28
|
} from "./constants.js";
|
|
29
|
+
import {
|
|
30
|
+
createVisitorSketch,
|
|
31
|
+
estimateVisitors,
|
|
32
|
+
unionVisitorSketches,
|
|
33
|
+
type VisitorSketch,
|
|
34
|
+
} from "./cardinality.js";
|
|
22
35
|
import { fail } from "./errors.js";
|
|
23
36
|
import {
|
|
24
37
|
compareJourneyEvents,
|
|
@@ -36,6 +49,7 @@ import {
|
|
|
36
49
|
goalFieldsValidator,
|
|
37
50
|
sessionFieldsValidator,
|
|
38
51
|
} from "./validators.js";
|
|
52
|
+
import { resolveVisitorIdentities } from "./visitors.js";
|
|
39
53
|
|
|
40
54
|
const sessionDocumentValidator = sessionFieldsValidator.extend({
|
|
41
55
|
_id: v.id("sessions"),
|
|
@@ -102,6 +116,30 @@ const topItemValidator = v.object({
|
|
|
102
116
|
count: v.number(),
|
|
103
117
|
revenueCents: v.number(),
|
|
104
118
|
});
|
|
119
|
+
const coverageStateValidator = v.union(
|
|
120
|
+
v.literal("complete"),
|
|
121
|
+
v.literal("partial"),
|
|
122
|
+
v.literal("retained"),
|
|
123
|
+
v.literal("unavailable"),
|
|
124
|
+
);
|
|
125
|
+
const resultCoverageValidator = v.object({
|
|
126
|
+
state: coverageStateValidator,
|
|
127
|
+
generation: v.union(v.number(), v.null()),
|
|
128
|
+
availableFrom: v.union(v.number(), v.null()),
|
|
129
|
+
availableThrough: v.union(v.number(), v.null()),
|
|
130
|
+
retainedBefore: v.union(v.number(), v.null()),
|
|
131
|
+
});
|
|
132
|
+
const rangeSemanticsValidator = v.object({
|
|
133
|
+
from: v.number(),
|
|
134
|
+
to: v.number(),
|
|
135
|
+
boundary: v.literal("inclusive"),
|
|
136
|
+
timezone: v.literal("UTC"),
|
|
137
|
+
});
|
|
138
|
+
const reportMetadataValidator = v.object({
|
|
139
|
+
basis: v.union(v.literal("dailyRollup"), v.literal("boundedLegacy")),
|
|
140
|
+
coverage: resultCoverageValidator,
|
|
141
|
+
range: rangeSemanticsValidator,
|
|
142
|
+
});
|
|
105
143
|
|
|
106
144
|
export const overview = query({
|
|
107
145
|
args: {
|
|
@@ -118,12 +156,43 @@ export const overview = query({
|
|
|
118
156
|
totals: metricTotalsValidator,
|
|
119
157
|
timeSeries: v.array(timePointValidator),
|
|
120
158
|
topSources: v.array(topItemValidator),
|
|
159
|
+
topCampaigns: v.array(topItemValidator),
|
|
121
160
|
topPages: v.array(topItemValidator),
|
|
122
161
|
topCountries: v.array(topItemValidator),
|
|
123
162
|
topDevices: v.array(topItemValidator),
|
|
124
163
|
topBrowsers: v.array(topItemValidator),
|
|
125
164
|
topEvents: v.array(topItemValidator),
|
|
126
165
|
topGoals: v.array(topItemValidator),
|
|
166
|
+
metadata: v.object({
|
|
167
|
+
visitors: v.object({
|
|
168
|
+
basis: v.union(
|
|
169
|
+
v.literal("estimatedUnique"),
|
|
170
|
+
v.literal("bucketOccurrences"),
|
|
171
|
+
),
|
|
172
|
+
exact: v.boolean(),
|
|
173
|
+
legacyIncomplete: v.boolean(),
|
|
174
|
+
sketchVersion: v.union(v.number(), v.null()),
|
|
175
|
+
precision: v.union(v.number(), v.null()),
|
|
176
|
+
}),
|
|
177
|
+
breakdowns: v.object({
|
|
178
|
+
topSources: v.literal("eventVolume"),
|
|
179
|
+
topCampaigns: v.literal("eventVolume"),
|
|
180
|
+
topPages: v.literal("eventVolume"),
|
|
181
|
+
topCountries: v.literal("eventVolume"),
|
|
182
|
+
topDevices: v.literal("eventVolume"),
|
|
183
|
+
topBrowsers: v.literal("eventVolume"),
|
|
184
|
+
topEvents: v.literal("eventVolume"),
|
|
185
|
+
topGoals: v.literal("goalCompletionVolume"),
|
|
186
|
+
}),
|
|
187
|
+
coverage: resultCoverageValidator,
|
|
188
|
+
range: rangeSemanticsValidator,
|
|
189
|
+
features: v.object({
|
|
190
|
+
sessionReplay: v.literal("unsupported"),
|
|
191
|
+
errorInsights: v.literal("unsupported"),
|
|
192
|
+
outboundLinks: v.literal("eventVolume"),
|
|
193
|
+
botDetection: v.literal("userAgent"),
|
|
194
|
+
}),
|
|
195
|
+
}),
|
|
127
196
|
}),
|
|
128
197
|
handler: async (ctx, args) => {
|
|
129
198
|
const siteIds = validateSiteIds(args.siteIds);
|
|
@@ -145,6 +214,7 @@ export const overview = query({
|
|
|
145
214
|
fail("INVALID_ARGUMENT", "hourly reports are limited to 24 hours");
|
|
146
215
|
}
|
|
147
216
|
const intervalMs = interval === "hour" ? HOUR_MS : DAY_MS;
|
|
217
|
+
requireAlignedRange(from, to, intervalMs, `${interval} overview`);
|
|
148
218
|
const firstBucket = Math.floor(from / intervalMs) * intervalMs;
|
|
149
219
|
const lastBucket = Math.floor(to / intervalMs) * intervalMs;
|
|
150
220
|
const bucketCount = Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
|
|
@@ -159,7 +229,16 @@ export const overview = query({
|
|
|
159
229
|
}
|
|
160
230
|
const totals = emptyTotals();
|
|
161
231
|
const points = new Map<number, MetricTotals>();
|
|
232
|
+
const visitorSketch = createVisitorSketch();
|
|
233
|
+
let mergedVisitorSketch = visitorSketch;
|
|
234
|
+
const pointVisitorSketches = new Map<number, VisitorSketch>();
|
|
235
|
+
let visitorsComplete = true;
|
|
162
236
|
const dimensions = new Map<string, { value: string; count: number; revenueCents: number }>();
|
|
237
|
+
const overviewDataset = interval === "hour"
|
|
238
|
+
? "overviewHour" as const
|
|
239
|
+
: "overviewDay" as const;
|
|
240
|
+
|
|
241
|
+
await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
|
|
163
242
|
|
|
164
243
|
for (const siteId of siteIds) {
|
|
165
244
|
const buckets = await ctx.db
|
|
@@ -171,12 +250,29 @@ export const overview = query({
|
|
|
171
250
|
.gte("bucketStart", firstBucket)
|
|
172
251
|
.lte("bucketStart", lastBucket),
|
|
173
252
|
)
|
|
174
|
-
.take(perSiteLimit);
|
|
253
|
+
.take(perSiteLimit + 1);
|
|
254
|
+
failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
|
|
175
255
|
for (const bucket of buckets) {
|
|
176
256
|
addMetrics(totals, bucket);
|
|
177
257
|
const point = points.get(bucket.bucketStart) ?? emptyTotals();
|
|
178
258
|
addMetrics(point, bucket);
|
|
179
259
|
points.set(bucket.bucketStart, point);
|
|
260
|
+
if (bucket.visitorSketch && bucket.visitorSketchComplete === true) {
|
|
261
|
+
mergedVisitorSketch = unionVisitorSketches(
|
|
262
|
+
mergedVisitorSketch,
|
|
263
|
+
bucket.visitorSketch,
|
|
264
|
+
);
|
|
265
|
+
pointVisitorSketches.set(
|
|
266
|
+
bucket.bucketStart,
|
|
267
|
+
unionVisitorSketches(
|
|
268
|
+
pointVisitorSketches.get(bucket.bucketStart) ??
|
|
269
|
+
createVisitorSketch(bucket.visitorSketch.precision),
|
|
270
|
+
bucket.visitorSketch,
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
} else if (bucket.events > 0) {
|
|
274
|
+
visitorsComplete = false;
|
|
275
|
+
}
|
|
180
276
|
for (const dimension of bucket.dimensions) {
|
|
181
277
|
const key = `${dimension.type}:${dimension.value}`;
|
|
182
278
|
const current = dimensions.get(key) ?? {
|
|
@@ -197,6 +293,20 @@ export const overview = query({
|
|
|
197
293
|
.map(([, value]) => value)
|
|
198
294
|
.sort((left, right) => right.count - left.count || left.value.localeCompare(right.value))
|
|
199
295
|
.slice(0, 10);
|
|
296
|
+
if (visitorsComplete) {
|
|
297
|
+
totals.visitors = estimateVisitors(mergedVisitorSketch);
|
|
298
|
+
for (const [timestamp, point] of points) {
|
|
299
|
+
point.visitors = estimateVisitors(
|
|
300
|
+
pointVisitorSketches.get(timestamp) ?? createVisitorSketch(),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const coverage = await coverageForRange(ctx, siteIds, from, to, {
|
|
305
|
+
datasets: [overviewDataset],
|
|
306
|
+
complete: visitorsComplete,
|
|
307
|
+
});
|
|
308
|
+
const visitorBasis: "estimatedUnique" | "bucketOccurrences" =
|
|
309
|
+
visitorsComplete ? "estimatedUnique" : "bucketOccurrences";
|
|
200
310
|
return {
|
|
201
311
|
from,
|
|
202
312
|
to,
|
|
@@ -207,12 +317,40 @@ export const overview = query({
|
|
|
207
317
|
.sort(([left], [right]) => left - right)
|
|
208
318
|
.map(([timestamp, metrics]) => ({ timestamp, ...metrics })),
|
|
209
319
|
topSources: top("source"),
|
|
320
|
+
topCampaigns: top("campaign"),
|
|
210
321
|
topPages: top("page"),
|
|
211
322
|
topCountries: top("country"),
|
|
212
323
|
topDevices: top("device"),
|
|
213
324
|
topBrowsers: top("browser"),
|
|
214
325
|
topEvents: top("event"),
|
|
215
326
|
topGoals: top("goal"),
|
|
327
|
+
metadata: {
|
|
328
|
+
visitors: {
|
|
329
|
+
basis: visitorBasis,
|
|
330
|
+
exact: false,
|
|
331
|
+
legacyIncomplete: !visitorsComplete,
|
|
332
|
+
sketchVersion: visitorsComplete ? 1 : null,
|
|
333
|
+
precision: visitorsComplete ? 6 : null,
|
|
334
|
+
},
|
|
335
|
+
breakdowns: {
|
|
336
|
+
topSources: "eventVolume" as const,
|
|
337
|
+
topCampaigns: "eventVolume" as const,
|
|
338
|
+
topPages: "eventVolume" as const,
|
|
339
|
+
topCountries: "eventVolume" as const,
|
|
340
|
+
topDevices: "eventVolume" as const,
|
|
341
|
+
topBrowsers: "eventVolume" as const,
|
|
342
|
+
topEvents: "eventVolume" as const,
|
|
343
|
+
topGoals: "goalCompletionVolume" as const,
|
|
344
|
+
},
|
|
345
|
+
coverage,
|
|
346
|
+
range: rangeSemantics(from, to),
|
|
347
|
+
features: {
|
|
348
|
+
sessionReplay: "unsupported" as const,
|
|
349
|
+
errorInsights: "unsupported" as const,
|
|
350
|
+
outboundLinks: "eventVolume" as const,
|
|
351
|
+
botDetection: "userAgent" as const,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
216
354
|
};
|
|
217
355
|
},
|
|
218
356
|
});
|
|
@@ -282,6 +420,7 @@ export const listSessions = query({
|
|
|
282
420
|
returns: paginationResultValidator(sessionDocumentValidator),
|
|
283
421
|
handler: async (ctx, args) => {
|
|
284
422
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
423
|
+
await assertSourceAvailable(ctx, [args.siteId], ["sessions"], args.from);
|
|
285
424
|
const pagination = parseKeysetPagination(args.paginationOpts, {
|
|
286
425
|
kind: "sessions",
|
|
287
426
|
siteId: String(args.siteId),
|
|
@@ -352,6 +491,7 @@ export const sessionJourney = query({
|
|
|
352
491
|
returns: paginationResultValidator(eventDocumentValidator),
|
|
353
492
|
handler: async (ctx, args) => {
|
|
354
493
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
494
|
+
await assertSourceAvailable(ctx, [args.siteId], ["events"], args.from);
|
|
355
495
|
const pagination = parseJourneyPagination(args.paginationOpts, {
|
|
356
496
|
kind: "events",
|
|
357
497
|
siteId: String(args.siteId),
|
|
@@ -382,6 +522,7 @@ export const listConversions = query({
|
|
|
382
522
|
returns: paginationResultValidator(conversionDocumentValidator),
|
|
383
523
|
handler: async (ctx, args) => {
|
|
384
524
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
525
|
+
await assertSourceAvailable(ctx, [args.siteId], ["conversions"], args.from);
|
|
385
526
|
const pagination = parseKeysetPagination(args.paginationOpts, {
|
|
386
527
|
kind: "conversions",
|
|
387
528
|
siteId: String(args.siteId),
|
|
@@ -450,24 +591,27 @@ export const visitorJourney = query({
|
|
|
450
591
|
handler: async (ctx, args) => {
|
|
451
592
|
const siteIds = validateSiteIds(args.siteIds);
|
|
452
593
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
594
|
+
await assertSourceAvailable(ctx, siteIds, ["events"], args.from);
|
|
453
595
|
const limit = args.limit ?? 200;
|
|
454
596
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_JOURNEY_EVENTS) {
|
|
455
597
|
fail("INVALID_ARGUMENT", `limit must be an integer between 1 and ${MAX_JOURNEY_EVENTS}`);
|
|
456
598
|
}
|
|
457
599
|
const rows: RawJourneyEvent[] = [];
|
|
458
600
|
for (const siteId of siteIds) {
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
601
|
+
for (const visitorId of await resolveVisitorIdentities(ctx, siteId, args.visitorId)) {
|
|
602
|
+
const cutoff = rows.length >= limit
|
|
603
|
+
? rows[limit - 1]!.timestamp
|
|
604
|
+
: args.to;
|
|
605
|
+
rows.push(...await loadVisitorJourneyEvents(ctx, {
|
|
606
|
+
siteId,
|
|
607
|
+
visitorId,
|
|
608
|
+
from: args.from,
|
|
609
|
+
to: cutoff,
|
|
610
|
+
resultLimit: limit,
|
|
611
|
+
}));
|
|
612
|
+
rows.sort(compareJourneyEvents);
|
|
613
|
+
if (rows.length > limit) rows.length = limit;
|
|
614
|
+
}
|
|
471
615
|
}
|
|
472
616
|
return hydrateEventContexts(ctx, rows);
|
|
473
617
|
},
|
|
@@ -485,36 +629,81 @@ export const goalsReport = query({
|
|
|
485
629
|
returns: v.object({
|
|
486
630
|
goals: v.array(goalReportItemValidator),
|
|
487
631
|
truncated: v.boolean(),
|
|
632
|
+
metadata: reportMetadataValidator,
|
|
488
633
|
}),
|
|
489
634
|
handler: async (ctx, args) => {
|
|
490
635
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
491
|
-
const
|
|
636
|
+
const goalRows = await ctx.db
|
|
492
637
|
.query("goals")
|
|
493
638
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
494
|
-
.take(MAX_GOALS_PER_SITE);
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
498
|
-
range
|
|
499
|
-
.eq("siteId", args.siteId)
|
|
500
|
-
.gte("timestamp", args.from)
|
|
501
|
-
.lte("timestamp", args.to),
|
|
502
|
-
)
|
|
503
|
-
.take(5_001);
|
|
639
|
+
.take(MAX_GOALS_PER_SITE + 1);
|
|
640
|
+
failIfIncomplete(goalRows, MAX_GOALS_PER_SITE, "goal definitions");
|
|
641
|
+
const goals = goalRows;
|
|
504
642
|
const totals = new Map<string, { completions: number; valueCents: number }>();
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
643
|
+
const useRollups = await dailyRollupsAvailable(
|
|
644
|
+
ctx,
|
|
645
|
+
args.siteId,
|
|
646
|
+
args.from,
|
|
647
|
+
args.to,
|
|
648
|
+
"goalRollups",
|
|
649
|
+
);
|
|
650
|
+
const rollups = useRollups
|
|
651
|
+
? await loadGoalDailyRollups(
|
|
652
|
+
ctx,
|
|
653
|
+
args.siteId,
|
|
654
|
+
goals.map((goal) => goal._id),
|
|
655
|
+
args.from,
|
|
656
|
+
args.to,
|
|
657
|
+
)
|
|
658
|
+
: null;
|
|
659
|
+
if (rollups) {
|
|
660
|
+
for (const row of rollups) {
|
|
661
|
+
const key = String(row.goalId);
|
|
662
|
+
const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
|
|
663
|
+
total.completions += row.completions;
|
|
664
|
+
total.valueCents += row.valueCents;
|
|
665
|
+
totals.set(key, total);
|
|
666
|
+
}
|
|
667
|
+
} else {
|
|
668
|
+
await assertSourceAvailable(
|
|
669
|
+
ctx,
|
|
670
|
+
[args.siteId],
|
|
671
|
+
["goalCompletions"],
|
|
672
|
+
args.from,
|
|
673
|
+
);
|
|
674
|
+
const completions = await ctx.db
|
|
675
|
+
.query("goalCompletions")
|
|
676
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
677
|
+
range
|
|
678
|
+
.eq("siteId", args.siteId)
|
|
679
|
+
.gte("timestamp", args.from)
|
|
680
|
+
.lte("timestamp", args.to),
|
|
681
|
+
)
|
|
682
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
683
|
+
failIfIncomplete(completions, MAX_REPORT_BUCKETS, "goal completions");
|
|
684
|
+
for (const completion of completions) {
|
|
685
|
+
const key = String(completion.goalId);
|
|
686
|
+
const total = totals.get(key) ?? { completions: 0, valueCents: 0 };
|
|
687
|
+
total.completions += 1;
|
|
688
|
+
total.valueCents += completion.valueCents;
|
|
689
|
+
totals.set(key, total);
|
|
690
|
+
}
|
|
511
691
|
}
|
|
512
692
|
return {
|
|
513
693
|
goals: goals.map((goal) => ({
|
|
514
694
|
...goal,
|
|
515
695
|
...(totals.get(String(goal._id)) ?? { completions: 0, valueCents: 0 }),
|
|
516
696
|
})),
|
|
517
|
-
truncated:
|
|
697
|
+
truncated: false,
|
|
698
|
+
metadata: {
|
|
699
|
+
basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
|
|
700
|
+
coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
701
|
+
datasets: [rollups ? "goalRollups" : "goalCompletions"],
|
|
702
|
+
complete: true,
|
|
703
|
+
generation: rollups ? 1 : null,
|
|
704
|
+
}),
|
|
705
|
+
range: rangeSemantics(args.from, args.to),
|
|
706
|
+
},
|
|
518
707
|
};
|
|
519
708
|
},
|
|
520
709
|
});
|
|
@@ -526,6 +715,7 @@ const funnelReportItemValidator = funnelFieldsValidator.extend({
|
|
|
526
715
|
v.object({ step: v.number(), name: v.string(), entrants: v.number(), completions: v.number() }),
|
|
527
716
|
),
|
|
528
717
|
truncated: v.boolean(),
|
|
718
|
+
metadata: reportMetadataValidator,
|
|
529
719
|
});
|
|
530
720
|
|
|
531
721
|
export const funnelsReport = query({
|
|
@@ -533,29 +723,74 @@ export const funnelsReport = query({
|
|
|
533
723
|
returns: v.array(funnelReportItemValidator),
|
|
534
724
|
handler: async (ctx, args) => {
|
|
535
725
|
validateRange(args.from, args.to, 90);
|
|
536
|
-
|
|
726
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "funnel report");
|
|
727
|
+
const funnelRows = await ctx.db
|
|
537
728
|
.query("funnels")
|
|
538
729
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
539
|
-
.take(MAX_FUNNELS_PER_SITE);
|
|
730
|
+
.take(MAX_FUNNELS_PER_SITE + 1);
|
|
731
|
+
failIfIncomplete(funnelRows, MAX_FUNNELS_PER_SITE, "funnel definitions");
|
|
732
|
+
const funnels = funnelRows;
|
|
733
|
+
const useRollups = await dailyRollupsAvailable(
|
|
734
|
+
ctx,
|
|
735
|
+
args.siteId,
|
|
736
|
+
args.from,
|
|
737
|
+
args.to,
|
|
738
|
+
"funnelRollups",
|
|
739
|
+
);
|
|
740
|
+
const rollups = useRollups
|
|
741
|
+
? await loadFunnelDailyRollups(
|
|
742
|
+
ctx,
|
|
743
|
+
args.siteId,
|
|
744
|
+
funnels.map((funnel) => funnel._id),
|
|
745
|
+
args.from,
|
|
746
|
+
args.to,
|
|
747
|
+
)
|
|
748
|
+
: null;
|
|
749
|
+
if (!rollups) {
|
|
750
|
+
await assertSourceAvailable(
|
|
751
|
+
ctx,
|
|
752
|
+
[args.siteId],
|
|
753
|
+
["funnelSteps"],
|
|
754
|
+
args.from,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
const reportCoverage = await coverageForRange(
|
|
758
|
+
ctx,
|
|
759
|
+
[args.siteId],
|
|
760
|
+
args.from,
|
|
761
|
+
args.to,
|
|
762
|
+
{
|
|
763
|
+
datasets: [rollups ? "funnelRollups" : "funnelSteps"],
|
|
764
|
+
complete: true,
|
|
765
|
+
generation: rollups ? 1 : null,
|
|
766
|
+
},
|
|
767
|
+
);
|
|
540
768
|
const reports = [];
|
|
541
769
|
for (const funnel of funnels) {
|
|
542
|
-
const buckets = await ctx.db
|
|
543
|
-
.query("funnelStepBuckets")
|
|
544
|
-
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
|
|
545
|
-
range
|
|
546
|
-
.eq("siteId", args.siteId)
|
|
547
|
-
.eq("funnelId", funnel._id)
|
|
548
|
-
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
549
|
-
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
|
|
550
|
-
)
|
|
551
|
-
.take(601);
|
|
552
770
|
const stepsReport = funnel.steps.map((step, index) => ({
|
|
553
771
|
step: index,
|
|
554
772
|
name: step.name,
|
|
555
773
|
entrants: 0,
|
|
556
774
|
completions: 0,
|
|
557
775
|
}));
|
|
558
|
-
|
|
776
|
+
const buckets = rollups
|
|
777
|
+
? rollups
|
|
778
|
+
.filter((row) => row.funnelId === funnel._id)
|
|
779
|
+
.flatMap((row) => row.steps)
|
|
780
|
+
: await ctx.db
|
|
781
|
+
.query("funnelStepBuckets")
|
|
782
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
|
|
783
|
+
range
|
|
784
|
+
.eq("siteId", args.siteId)
|
|
785
|
+
.eq("funnelId", funnel._id)
|
|
786
|
+
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
787
|
+
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
|
|
788
|
+
)
|
|
789
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
790
|
+
if (!rollups) {
|
|
791
|
+
failIfIncomplete(buckets, MAX_REPORT_BUCKETS, "funnel step buckets");
|
|
792
|
+
}
|
|
793
|
+
for (const bucket of buckets) {
|
|
559
794
|
const step = stepsReport[bucket.step];
|
|
560
795
|
if (step) {
|
|
561
796
|
step.entrants += bucket.entrants;
|
|
@@ -565,7 +800,12 @@ export const funnelsReport = query({
|
|
|
565
800
|
reports.push({
|
|
566
801
|
...funnel,
|
|
567
802
|
stepsReport,
|
|
568
|
-
truncated:
|
|
803
|
+
truncated: false,
|
|
804
|
+
metadata: {
|
|
805
|
+
basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
|
|
806
|
+
coverage: reportCoverage,
|
|
807
|
+
range: rangeSemantics(args.from, args.to),
|
|
808
|
+
},
|
|
569
809
|
});
|
|
570
810
|
}
|
|
571
811
|
return reports;
|
|
@@ -587,64 +827,124 @@ export const affiliatesReport = query({
|
|
|
587
827
|
returns: v.object({
|
|
588
828
|
affiliates: v.array(affiliateReportItemValidator),
|
|
589
829
|
truncated: v.boolean(),
|
|
830
|
+
metadata: reportMetadataValidator.extend({
|
|
831
|
+
uniqueVisitors: v.object({
|
|
832
|
+
basis: v.union(v.literal("estimatedUnique"), v.literal("exactLegacy")),
|
|
833
|
+
exact: v.boolean(),
|
|
834
|
+
}),
|
|
835
|
+
}),
|
|
590
836
|
}),
|
|
591
837
|
handler: async (ctx, args) => {
|
|
592
838
|
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
839
|
+
requireAlignedRange(args.from, args.to, DAY_MS, "affiliate report");
|
|
593
840
|
const affiliateRows = await ctx.db
|
|
594
841
|
.query("affiliates")
|
|
595
842
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
596
843
|
.order("desc")
|
|
597
844
|
.take(MAX_AFFILIATES_PER_SITE + 1);
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
.
|
|
613
|
-
.
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
.take(5_001);
|
|
845
|
+
failIfIncomplete(affiliateRows, MAX_AFFILIATES_PER_SITE, "affiliate definitions");
|
|
846
|
+
const affiliates = affiliateRows;
|
|
847
|
+
const useRollups = await dailyRollupsAvailable(
|
|
848
|
+
ctx,
|
|
849
|
+
args.siteId,
|
|
850
|
+
args.from,
|
|
851
|
+
args.to,
|
|
852
|
+
"affiliateRollups",
|
|
853
|
+
);
|
|
854
|
+
const rollups = useRollups
|
|
855
|
+
? await loadAffiliateDailyRollups(
|
|
856
|
+
ctx,
|
|
857
|
+
args.siteId,
|
|
858
|
+
affiliates.map((affiliate) => affiliate._id),
|
|
859
|
+
args.from,
|
|
860
|
+
args.to,
|
|
861
|
+
)
|
|
862
|
+
: null;
|
|
617
863
|
const visits = new Map<string, {
|
|
618
864
|
visits: number;
|
|
619
865
|
visitors: Set<string>;
|
|
866
|
+
sketch: VisitorSketch;
|
|
620
867
|
}>();
|
|
621
|
-
for (const session of sessions.slice(0, 5_000)) {
|
|
622
|
-
if (!session.affiliateId) continue;
|
|
623
|
-
const key = String(session.affiliateId);
|
|
624
|
-
const total = visits.get(key) ?? {
|
|
625
|
-
visits: 0,
|
|
626
|
-
visitors: new Set<string>(),
|
|
627
|
-
};
|
|
628
|
-
total.visits += 1;
|
|
629
|
-
total.visitors.add(session.visitorId);
|
|
630
|
-
visits.set(key, total);
|
|
631
|
-
}
|
|
632
868
|
const totals = new Map<string, {
|
|
633
869
|
conversions: number;
|
|
634
870
|
revenueCents: number;
|
|
635
871
|
commissionCents: number;
|
|
636
872
|
}>();
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
873
|
+
if (rollups) {
|
|
874
|
+
for (const row of rollups) {
|
|
875
|
+
const key = String(row.affiliateId);
|
|
876
|
+
const traffic = visits.get(key) ?? {
|
|
877
|
+
visits: 0,
|
|
878
|
+
visitors: new Set<string>(),
|
|
879
|
+
sketch: createVisitorSketch(),
|
|
880
|
+
};
|
|
881
|
+
traffic.visits += row.visits;
|
|
882
|
+
traffic.sketch = unionVisitorSketches(
|
|
883
|
+
traffic.sketch,
|
|
884
|
+
row.visitorSketch,
|
|
885
|
+
);
|
|
886
|
+
visits.set(key, traffic);
|
|
887
|
+
const total = totals.get(key) ?? {
|
|
888
|
+
conversions: 0,
|
|
889
|
+
revenueCents: 0,
|
|
890
|
+
commissionCents: 0,
|
|
891
|
+
};
|
|
892
|
+
total.conversions += row.conversions;
|
|
893
|
+
total.revenueCents += row.revenueCents;
|
|
894
|
+
total.commissionCents += row.commissionCents;
|
|
895
|
+
totals.set(key, total);
|
|
896
|
+
}
|
|
897
|
+
} else {
|
|
898
|
+
await assertSourceAvailable(
|
|
899
|
+
ctx,
|
|
900
|
+
[args.siteId],
|
|
901
|
+
["affiliateStats", "sessions"],
|
|
902
|
+
args.from,
|
|
903
|
+
);
|
|
904
|
+
const stats = await ctx.db
|
|
905
|
+
.query("affiliateDailyStats")
|
|
906
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
907
|
+
range
|
|
908
|
+
.eq("siteId", args.siteId)
|
|
909
|
+
.gte("bucketStart", Math.floor(args.from / DAY_MS) * DAY_MS)
|
|
910
|
+
.lte("bucketStart", Math.floor(args.to / DAY_MS) * DAY_MS),
|
|
911
|
+
)
|
|
912
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
913
|
+
const sessions = await ctx.db
|
|
914
|
+
.query("sessions")
|
|
915
|
+
.withIndex("by_siteId_and_startedAt", (range) =>
|
|
916
|
+
range
|
|
917
|
+
.eq("siteId", args.siteId)
|
|
918
|
+
.gte("startedAt", args.from)
|
|
919
|
+
.lte("startedAt", args.to),
|
|
920
|
+
)
|
|
921
|
+
.take(MAX_REPORT_BUCKETS + 1);
|
|
922
|
+
failIfIncomplete(stats, MAX_REPORT_BUCKETS, "affiliate stat buckets");
|
|
923
|
+
failIfIncomplete(sessions, MAX_REPORT_BUCKETS, "affiliate sessions");
|
|
924
|
+
for (const session of sessions) {
|
|
925
|
+
if (!session.affiliateId) continue;
|
|
926
|
+
const key = String(session.affiliateId);
|
|
927
|
+
const total = visits.get(key) ?? {
|
|
928
|
+
visits: 0,
|
|
929
|
+
visitors: new Set<string>(),
|
|
930
|
+
sketch: createVisitorSketch(),
|
|
931
|
+
};
|
|
932
|
+
total.visits += 1;
|
|
933
|
+
total.visitors.add(session.visitorId);
|
|
934
|
+
visits.set(key, total);
|
|
935
|
+
}
|
|
936
|
+
for (const row of stats) {
|
|
937
|
+
const key = String(row.affiliateId);
|
|
938
|
+
const total = totals.get(key) ?? {
|
|
939
|
+
conversions: 0,
|
|
940
|
+
revenueCents: 0,
|
|
941
|
+
commissionCents: 0,
|
|
942
|
+
};
|
|
943
|
+
total.conversions += row.conversions;
|
|
944
|
+
total.revenueCents += row.revenueCents;
|
|
945
|
+
total.commissionCents += row.commissionCents;
|
|
946
|
+
totals.set(key, total);
|
|
947
|
+
}
|
|
648
948
|
}
|
|
649
949
|
return {
|
|
650
950
|
affiliates: affiliates.map((affiliate) => {
|
|
@@ -658,17 +958,498 @@ export const affiliatesReport = query({
|
|
|
658
958
|
...affiliate,
|
|
659
959
|
...performance,
|
|
660
960
|
visits: traffic?.visits ?? 0,
|
|
661
|
-
uniqueVisitors:
|
|
961
|
+
uniqueVisitors: rollups
|
|
962
|
+
? estimateVisitors(traffic?.sketch ?? createVisitorSketch())
|
|
963
|
+
: traffic?.visitors.size ?? 0,
|
|
662
964
|
};
|
|
663
965
|
}),
|
|
664
|
-
truncated:
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
966
|
+
truncated: false,
|
|
967
|
+
metadata: {
|
|
968
|
+
basis: rollups ? "dailyRollup" as const : "boundedLegacy" as const,
|
|
969
|
+
coverage: await coverageForRange(ctx, [args.siteId], args.from, args.to, {
|
|
970
|
+
datasets: rollups
|
|
971
|
+
? ["affiliateRollups"]
|
|
972
|
+
: ["affiliateStats", "sessions"],
|
|
973
|
+
complete: true,
|
|
974
|
+
generation: rollups ? 1 : null,
|
|
975
|
+
}),
|
|
976
|
+
range: rangeSemantics(args.from, args.to),
|
|
977
|
+
uniqueVisitors: {
|
|
978
|
+
basis: rollups ? "estimatedUnique" as const : "exactLegacy" as const,
|
|
979
|
+
exact: !rollups,
|
|
980
|
+
},
|
|
981
|
+
},
|
|
668
982
|
};
|
|
669
983
|
},
|
|
670
984
|
});
|
|
671
985
|
|
|
986
|
+
const coverageDatasetValidator = v.union(
|
|
987
|
+
v.literal("overviewHour"),
|
|
988
|
+
v.literal("overviewDay"),
|
|
989
|
+
v.literal("events"),
|
|
990
|
+
v.literal("sessions"),
|
|
991
|
+
v.literal("conversions"),
|
|
992
|
+
v.literal("goals"),
|
|
993
|
+
v.literal("funnels"),
|
|
994
|
+
v.literal("affiliates"),
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
export const dataCoverage = query({
|
|
998
|
+
args: {
|
|
999
|
+
siteId: v.id("sites"),
|
|
1000
|
+
from: v.number(),
|
|
1001
|
+
to: v.number(),
|
|
1002
|
+
},
|
|
1003
|
+
returns: v.object({
|
|
1004
|
+
siteId: v.id("sites"),
|
|
1005
|
+
from: v.number(),
|
|
1006
|
+
to: v.number(),
|
|
1007
|
+
datasets: v.array(v.object({
|
|
1008
|
+
dataset: coverageDatasetValidator,
|
|
1009
|
+
coverage: resultCoverageValidator,
|
|
1010
|
+
})),
|
|
1011
|
+
}),
|
|
1012
|
+
handler: async (ctx, args) => {
|
|
1013
|
+
validateRange(args.from, args.to, MAX_REPORT_RANGE_DAYS);
|
|
1014
|
+
const datasets = [
|
|
1015
|
+
"overviewHour",
|
|
1016
|
+
"overviewDay",
|
|
1017
|
+
"events",
|
|
1018
|
+
"sessions",
|
|
1019
|
+
"conversions",
|
|
1020
|
+
"goals",
|
|
1021
|
+
"funnels",
|
|
1022
|
+
"affiliates",
|
|
1023
|
+
] as const;
|
|
1024
|
+
return {
|
|
1025
|
+
siteId: args.siteId,
|
|
1026
|
+
from: args.from,
|
|
1027
|
+
to: args.to,
|
|
1028
|
+
datasets: await Promise.all(datasets.map(async (dataset) => ({
|
|
1029
|
+
dataset,
|
|
1030
|
+
coverage: await datasetCoverageForRange(
|
|
1031
|
+
ctx,
|
|
1032
|
+
args.siteId,
|
|
1033
|
+
dataset,
|
|
1034
|
+
args.from,
|
|
1035
|
+
args.to,
|
|
1036
|
+
),
|
|
1037
|
+
}))),
|
|
1038
|
+
};
|
|
1039
|
+
},
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
async function dailyRollupsAvailable(
|
|
1043
|
+
ctx: QueryCtx,
|
|
1044
|
+
siteId: Id<"sites">,
|
|
1045
|
+
from: number,
|
|
1046
|
+
to: number,
|
|
1047
|
+
dataset: "goalRollups" | "funnelRollups" | "affiliateRollups",
|
|
1048
|
+
) {
|
|
1049
|
+
if (!isAlignedRange(from, to, DAY_MS)) return false;
|
|
1050
|
+
const coverage = await ctx.db
|
|
1051
|
+
.query("analyticsCoverage")
|
|
1052
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1053
|
+
.unique();
|
|
1054
|
+
if (!coverage || coverage.rollupCompleteFrom > from) {
|
|
1055
|
+
return false;
|
|
1056
|
+
}
|
|
1057
|
+
const retainedBefore = coverage.retained.find(
|
|
1058
|
+
(item) => item.dataset === dataset,
|
|
1059
|
+
)?.before;
|
|
1060
|
+
return retainedBefore === undefined || from >= retainedBefore;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async function loadGoalDailyRollups(
|
|
1064
|
+
ctx: QueryCtx,
|
|
1065
|
+
siteId: Id<"sites">,
|
|
1066
|
+
goalIds: Id<"goals">[],
|
|
1067
|
+
from: number,
|
|
1068
|
+
to: number,
|
|
1069
|
+
) {
|
|
1070
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1071
|
+
requireFeatureRollupBudget(goalIds.length, days, "goal daily rollups");
|
|
1072
|
+
const rows: Doc<"goalDailyRollups">[] = [];
|
|
1073
|
+
for (const goalId of goalIds) {
|
|
1074
|
+
const perGoalLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1075
|
+
const goalRows = await ctx.db
|
|
1076
|
+
.query("goalDailyRollups")
|
|
1077
|
+
.withIndex("by_siteId_and_goalId_and_bucketStart", (range) =>
|
|
1078
|
+
range
|
|
1079
|
+
.eq("siteId", siteId)
|
|
1080
|
+
.eq("goalId", goalId)
|
|
1081
|
+
.gte("bucketStart", from)
|
|
1082
|
+
.lte("bucketStart", to),
|
|
1083
|
+
)
|
|
1084
|
+
.take(perGoalLimit + 1);
|
|
1085
|
+
failIfIncomplete(goalRows, perGoalLimit, "goal daily rollups");
|
|
1086
|
+
rows.push(...goalRows);
|
|
1087
|
+
await requireFeatureReadHeadroom(ctx, "goal daily rollups");
|
|
1088
|
+
}
|
|
1089
|
+
return rows;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
async function loadFunnelDailyRollups(
|
|
1093
|
+
ctx: QueryCtx,
|
|
1094
|
+
siteId: Id<"sites">,
|
|
1095
|
+
funnelIds: Id<"funnels">[],
|
|
1096
|
+
from: number,
|
|
1097
|
+
to: number,
|
|
1098
|
+
) {
|
|
1099
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1100
|
+
requireFeatureRollupBudget(funnelIds.length, days, "funnel daily rollups");
|
|
1101
|
+
const rows: Doc<"funnelDailyRollups">[] = [];
|
|
1102
|
+
for (const funnelId of funnelIds) {
|
|
1103
|
+
const perFunnelLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1104
|
+
const funnelRows = await ctx.db
|
|
1105
|
+
.query("funnelDailyRollups")
|
|
1106
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart", (range) =>
|
|
1107
|
+
range
|
|
1108
|
+
.eq("siteId", siteId)
|
|
1109
|
+
.eq("funnelId", funnelId)
|
|
1110
|
+
.gte("bucketStart", from)
|
|
1111
|
+
.lte("bucketStart", to),
|
|
1112
|
+
)
|
|
1113
|
+
.take(perFunnelLimit + 1);
|
|
1114
|
+
failIfIncomplete(funnelRows, perFunnelLimit, "funnel daily rollups");
|
|
1115
|
+
rows.push(...funnelRows);
|
|
1116
|
+
await requireFeatureReadHeadroom(ctx, "funnel daily rollups");
|
|
1117
|
+
}
|
|
1118
|
+
return rows;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
async function loadAffiliateDailyRollups(
|
|
1122
|
+
ctx: QueryCtx,
|
|
1123
|
+
siteId: Id<"sites">,
|
|
1124
|
+
affiliateIds: Id<"affiliates">[],
|
|
1125
|
+
from: number,
|
|
1126
|
+
to: number,
|
|
1127
|
+
) {
|
|
1128
|
+
const days = Math.floor((to - from) / DAY_MS) + 1;
|
|
1129
|
+
requireFeatureRollupBudget(
|
|
1130
|
+
affiliateIds.length,
|
|
1131
|
+
days,
|
|
1132
|
+
"affiliate daily rollups",
|
|
1133
|
+
);
|
|
1134
|
+
const rows: Doc<"affiliateDailyRollups">[] = [];
|
|
1135
|
+
for (const affiliateId of affiliateIds) {
|
|
1136
|
+
const perAffiliateLimit = days * REPORT_ROLLUP_SHARDS;
|
|
1137
|
+
const affiliateRows = await ctx.db
|
|
1138
|
+
.query("affiliateDailyRollups")
|
|
1139
|
+
.withIndex("by_siteId_and_affiliateId_and_bucketStart", (range) =>
|
|
1140
|
+
range
|
|
1141
|
+
.eq("siteId", siteId)
|
|
1142
|
+
.eq("affiliateId", affiliateId)
|
|
1143
|
+
.gte("bucketStart", from)
|
|
1144
|
+
.lte("bucketStart", to),
|
|
1145
|
+
)
|
|
1146
|
+
.take(perAffiliateLimit + 1);
|
|
1147
|
+
failIfIncomplete(affiliateRows, perAffiliateLimit, "affiliate daily rollups");
|
|
1148
|
+
rows.push(...affiliateRows);
|
|
1149
|
+
await requireFeatureReadHeadroom(ctx, "affiliate daily rollups");
|
|
1150
|
+
}
|
|
1151
|
+
return rows;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function requireFeatureRollupBudget(
|
|
1155
|
+
entities: number,
|
|
1156
|
+
days: number,
|
|
1157
|
+
dataset: string,
|
|
1158
|
+
) {
|
|
1159
|
+
const maximumRows = entities * days * REPORT_ROLLUP_SHARDS;
|
|
1160
|
+
if (days > MAX_FEATURE_ROLLUP_DAYS || maximumRows > MAX_FEATURE_ROLLUP_ROWS) {
|
|
1161
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds its complete read budget`, {
|
|
1162
|
+
dataset,
|
|
1163
|
+
maximumDays: MAX_FEATURE_ROLLUP_DAYS,
|
|
1164
|
+
maximumRows: MAX_FEATURE_ROLLUP_ROWS,
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async function requireFeatureReadHeadroom(ctx: QueryCtx, dataset: string) {
|
|
1170
|
+
const metrics = await ctx.meta.getTransactionMetrics();
|
|
1171
|
+
if (
|
|
1172
|
+
metrics.bytesRead.remaining < FEATURE_REPORT_BYTE_RESERVE ||
|
|
1173
|
+
metrics.documentsRead.remaining < FEATURE_REPORT_DOCUMENT_RESERVE
|
|
1174
|
+
) {
|
|
1175
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds its byte or document budget`, {
|
|
1176
|
+
dataset,
|
|
1177
|
+
bytesRead: metrics.bytesRead.used,
|
|
1178
|
+
documentsRead: metrics.documentsRead.used,
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function failIfIncomplete<T>(rows: T[], maximum: number, dataset: string) {
|
|
1184
|
+
if (rows.length > maximum) {
|
|
1185
|
+
fail("REPORT_INCOMPLETE", `${dataset} exceeds the complete report read budget`, {
|
|
1186
|
+
maximumRows: maximum,
|
|
1187
|
+
dataset,
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
async function coverageForRange(
|
|
1193
|
+
ctx: QueryCtx,
|
|
1194
|
+
siteIds: Id<"sites">[],
|
|
1195
|
+
from: number,
|
|
1196
|
+
to: number,
|
|
1197
|
+
options: {
|
|
1198
|
+
datasets: CoverageSource[];
|
|
1199
|
+
complete?: boolean;
|
|
1200
|
+
generation?: 1 | null;
|
|
1201
|
+
},
|
|
1202
|
+
) {
|
|
1203
|
+
const controls = await Promise.all(siteIds.map((siteId) =>
|
|
1204
|
+
ctx.db
|
|
1205
|
+
.query("analyticsCoverage")
|
|
1206
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1207
|
+
.unique()
|
|
1208
|
+
));
|
|
1209
|
+
const sourceRanges = await Promise.all(siteIds.map((siteId) =>
|
|
1210
|
+
sourceSetRange(ctx, siteId, options.datasets)
|
|
1211
|
+
));
|
|
1212
|
+
const retainedBefore = controls.reduce<number | null>((latest, control) => {
|
|
1213
|
+
const before = control?.retained
|
|
1214
|
+
.filter((item) => options.datasets.some((dataset) => dataset === item.dataset))
|
|
1215
|
+
.reduce<number | undefined>(
|
|
1216
|
+
(latest, item) => Math.max(latest ?? 0, item.before),
|
|
1217
|
+
undefined,
|
|
1218
|
+
);
|
|
1219
|
+
return before === undefined ? latest : Math.max(latest ?? 0, before);
|
|
1220
|
+
}, null);
|
|
1221
|
+
const availableFrom = options.generation === 1
|
|
1222
|
+
? controls.length > 0 && controls.every(Boolean)
|
|
1223
|
+
? Math.max(...controls.map((control) => control!.rollupCompleteFrom))
|
|
1224
|
+
: null
|
|
1225
|
+
: sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1226
|
+
? Math.max(...sourceRanges.map((range) => range!.from))
|
|
1227
|
+
: null;
|
|
1228
|
+
const availableThrough = sourceRanges.length > 0 && sourceRanges.every(Boolean)
|
|
1229
|
+
? Math.min(...sourceRanges.map((range) => range!.through))
|
|
1230
|
+
: null;
|
|
1231
|
+
let state: "complete" | "partial" | "retained" | "unavailable";
|
|
1232
|
+
if (retainedBefore !== null && to < retainedBefore) {
|
|
1233
|
+
state = "unavailable";
|
|
1234
|
+
} else if (retainedBefore !== null && from < retainedBefore) {
|
|
1235
|
+
state = "partial";
|
|
1236
|
+
} else if (options.complete || (
|
|
1237
|
+
availableFrom !== null &&
|
|
1238
|
+
availableThrough !== null &&
|
|
1239
|
+
from >= availableFrom &&
|
|
1240
|
+
to <= availableThrough
|
|
1241
|
+
)) {
|
|
1242
|
+
state = "complete";
|
|
1243
|
+
} else if (sourceRanges.some(Boolean)) {
|
|
1244
|
+
state = "retained";
|
|
1245
|
+
} else {
|
|
1246
|
+
state = "unavailable";
|
|
1247
|
+
}
|
|
1248
|
+
return {
|
|
1249
|
+
state,
|
|
1250
|
+
generation: options.generation ?? null,
|
|
1251
|
+
availableFrom,
|
|
1252
|
+
availableThrough,
|
|
1253
|
+
retainedBefore,
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async function assertSourceAvailable(
|
|
1258
|
+
ctx: QueryCtx,
|
|
1259
|
+
siteIds: Id<"sites">[],
|
|
1260
|
+
sources: CoverageSource[],
|
|
1261
|
+
from: number,
|
|
1262
|
+
) {
|
|
1263
|
+
const rows = await Promise.all(siteIds.map((siteId) =>
|
|
1264
|
+
ctx.db
|
|
1265
|
+
.query("analyticsCoverage")
|
|
1266
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1267
|
+
.unique()
|
|
1268
|
+
));
|
|
1269
|
+
const cutoff = rows.reduce<number | null>((latest, row) => {
|
|
1270
|
+
for (const retained of row?.retained ?? []) {
|
|
1271
|
+
if (sources.some((source) => source === retained.dataset)) {
|
|
1272
|
+
latest = Math.max(latest ?? 0, retained.before);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return latest;
|
|
1276
|
+
}, null);
|
|
1277
|
+
if (cutoff !== null && from < cutoff) {
|
|
1278
|
+
fail("REPORT_INCOMPLETE", "requested report range intersects retained data", {
|
|
1279
|
+
retainedBefore: cutoff,
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
async function datasetCoverageForRange(
|
|
1285
|
+
ctx: QueryCtx,
|
|
1286
|
+
siteId: Id<"sites">,
|
|
1287
|
+
dataset: PublicCoverageDataset,
|
|
1288
|
+
from: number,
|
|
1289
|
+
to: number,
|
|
1290
|
+
) {
|
|
1291
|
+
const sourceOptions: CoverageSource[][] = dataset === "goals"
|
|
1292
|
+
? [["goalRollups"], ["goalCompletions"]]
|
|
1293
|
+
: dataset === "funnels"
|
|
1294
|
+
? [["funnelRollups"], ["funnelSteps"]]
|
|
1295
|
+
: dataset === "affiliates"
|
|
1296
|
+
? [["affiliateRollups"], ["affiliateStats", "sessions"]]
|
|
1297
|
+
: [[dataset]];
|
|
1298
|
+
const options = (await Promise.all(sourceOptions.map(async (sources) => {
|
|
1299
|
+
const rollupDataset = sources[0] as
|
|
1300
|
+
| "goalRollups"
|
|
1301
|
+
| "funnelRollups"
|
|
1302
|
+
| "affiliateRollups";
|
|
1303
|
+
const isRollup = rollupDataset.endsWith("Rollups");
|
|
1304
|
+
const complete = isRollup
|
|
1305
|
+
? await dailyRollupsAvailable(ctx, siteId, from, to, rollupDataset)
|
|
1306
|
+
: false;
|
|
1307
|
+
if (isRollup && !complete) return null;
|
|
1308
|
+
return coverageForRange(ctx, [siteId], from, to, {
|
|
1309
|
+
datasets: sources,
|
|
1310
|
+
complete,
|
|
1311
|
+
generation: complete ? 1 : null,
|
|
1312
|
+
});
|
|
1313
|
+
}))).filter((option): option is NonNullable<typeof option> => option !== null);
|
|
1314
|
+
return options.sort((left, right) =>
|
|
1315
|
+
coverageRank(right.state) - coverageRank(left.state)
|
|
1316
|
+
)[0]!;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
type PublicCoverageDataset =
|
|
1320
|
+
| "overviewHour"
|
|
1321
|
+
| "overviewDay"
|
|
1322
|
+
| "events"
|
|
1323
|
+
| "sessions"
|
|
1324
|
+
| "conversions"
|
|
1325
|
+
| "goals"
|
|
1326
|
+
| "funnels"
|
|
1327
|
+
| "affiliates";
|
|
1328
|
+
|
|
1329
|
+
type CoverageSource =
|
|
1330
|
+
| "overviewHour"
|
|
1331
|
+
| "overviewDay"
|
|
1332
|
+
| "events"
|
|
1333
|
+
| "sessions"
|
|
1334
|
+
| "conversions"
|
|
1335
|
+
| "goalCompletions"
|
|
1336
|
+
| "funnelSteps"
|
|
1337
|
+
| "affiliateStats"
|
|
1338
|
+
| "goalRollups"
|
|
1339
|
+
| "funnelRollups"
|
|
1340
|
+
| "affiliateRollups";
|
|
1341
|
+
|
|
1342
|
+
type SourceRange = { from: number; through: number };
|
|
1343
|
+
|
|
1344
|
+
async function sourceSetRange(
|
|
1345
|
+
ctx: QueryCtx,
|
|
1346
|
+
siteId: Id<"sites">,
|
|
1347
|
+
sources: CoverageSource[],
|
|
1348
|
+
): Promise<SourceRange | null> {
|
|
1349
|
+
const ranges = (await Promise.all(
|
|
1350
|
+
sources.map((source) => sourceRange(ctx, siteId, source)),
|
|
1351
|
+
)).filter((range): range is SourceRange => range !== null);
|
|
1352
|
+
if (ranges.length === 0) return null;
|
|
1353
|
+
return {
|
|
1354
|
+
from: Math.min(...ranges.map((range) => range.from)),
|
|
1355
|
+
through: Math.max(...ranges.map((range) => range.through)),
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
async function sourceRange(
|
|
1360
|
+
ctx: QueryCtx,
|
|
1361
|
+
siteId: Id<"sites">,
|
|
1362
|
+
source: CoverageSource,
|
|
1363
|
+
): Promise<SourceRange | null> {
|
|
1364
|
+
if (source === "overviewHour" || source === "overviewDay") {
|
|
1365
|
+
const granularity = source === "overviewHour" ? "hour" as const : "day" as const;
|
|
1366
|
+
const [first, last] = await Promise.all([
|
|
1367
|
+
ctx.db.query("aggregateBuckets")
|
|
1368
|
+
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
|
|
1369
|
+
range.eq("siteId", siteId).eq("granularity", granularity),
|
|
1370
|
+
).order("asc").take(1),
|
|
1371
|
+
ctx.db.query("aggregateBuckets")
|
|
1372
|
+
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
|
|
1373
|
+
range.eq("siteId", siteId).eq("granularity", granularity),
|
|
1374
|
+
).order("desc").take(1),
|
|
1375
|
+
]);
|
|
1376
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1377
|
+
}
|
|
1378
|
+
if (source === "events") {
|
|
1379
|
+
const [first, last] = await Promise.all([
|
|
1380
|
+
ctx.db.query("eventBatches")
|
|
1381
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1382
|
+
.order("asc").take(1),
|
|
1383
|
+
ctx.db.query("eventBatches")
|
|
1384
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1385
|
+
.order("desc").take(1),
|
|
1386
|
+
]);
|
|
1387
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1388
|
+
}
|
|
1389
|
+
if (source === "sessions") {
|
|
1390
|
+
const [first, last] = await Promise.all([
|
|
1391
|
+
ctx.db.query("sessions")
|
|
1392
|
+
.withIndex("by_siteId_and_startedAt", (range) => range.eq("siteId", siteId))
|
|
1393
|
+
.order("asc").take(1),
|
|
1394
|
+
ctx.db.query("sessions")
|
|
1395
|
+
.withIndex("by_siteId_and_lastSeenAt", (range) => range.eq("siteId", siteId))
|
|
1396
|
+
.order("desc").take(1),
|
|
1397
|
+
]);
|
|
1398
|
+
return bounds(first[0]?.startedAt, last[0]?.lastSeenAt);
|
|
1399
|
+
}
|
|
1400
|
+
if (source === "conversions") {
|
|
1401
|
+
const [first, last] = await Promise.all([
|
|
1402
|
+
ctx.db.query("conversions")
|
|
1403
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1404
|
+
.order("asc").take(1),
|
|
1405
|
+
ctx.db.query("conversions")
|
|
1406
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1407
|
+
.order("desc").take(1),
|
|
1408
|
+
]);
|
|
1409
|
+
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
1410
|
+
}
|
|
1411
|
+
if (source === "goalCompletions") {
|
|
1412
|
+
const [first, last] = await Promise.all([
|
|
1413
|
+
ctx.db.query("goalCompletions")
|
|
1414
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1415
|
+
.order("asc").take(1),
|
|
1416
|
+
ctx.db.query("goalCompletions")
|
|
1417
|
+
.withIndex("by_siteId_and_timestamp", (range) => range.eq("siteId", siteId))
|
|
1418
|
+
.order("desc").take(1),
|
|
1419
|
+
]);
|
|
1420
|
+
return bounds(first[0]?.timestamp, last[0]?.timestamp);
|
|
1421
|
+
}
|
|
1422
|
+
const table = source === "funnelSteps"
|
|
1423
|
+
? "funnelStepBuckets" as const
|
|
1424
|
+
: source === "affiliateStats"
|
|
1425
|
+
? "affiliateDailyStats" as const
|
|
1426
|
+
: source === "goalRollups"
|
|
1427
|
+
? "goalDailyRollups" as const
|
|
1428
|
+
: source === "funnelRollups"
|
|
1429
|
+
? "funnelDailyRollups" as const
|
|
1430
|
+
: "affiliateDailyRollups" as const;
|
|
1431
|
+
const [first, last] = await Promise.all([
|
|
1432
|
+
ctx.db.query(table)
|
|
1433
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1434
|
+
.order("asc").take(1),
|
|
1435
|
+
ctx.db.query(table)
|
|
1436
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range.eq("siteId", siteId))
|
|
1437
|
+
.order("desc").take(1),
|
|
1438
|
+
]);
|
|
1439
|
+
return bounds(first[0]?.bucketStart, last[0]?.bucketStart);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function bounds(from: number | undefined, through: number | undefined) {
|
|
1443
|
+
return from === undefined || through === undefined ? null : { from, through };
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function coverageRank(state: "complete" | "partial" | "retained" | "unavailable") {
|
|
1447
|
+
if (state === "complete") return 3;
|
|
1448
|
+
if (state === "retained") return 2;
|
|
1449
|
+
if (state === "partial") return 1;
|
|
1450
|
+
return 0;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
672
1453
|
|
|
673
1454
|
type MetricTotals = {
|
|
674
1455
|
events: number;
|
|
@@ -718,15 +1499,46 @@ function validateSiteIds<T>(siteIds: T[]): T[] {
|
|
|
718
1499
|
|
|
719
1500
|
function validateRange(from: number, to: number, maxDays: number) {
|
|
720
1501
|
if (
|
|
721
|
-
!Number.
|
|
722
|
-
!Number.
|
|
1502
|
+
!Number.isSafeInteger(from) ||
|
|
1503
|
+
!Number.isSafeInteger(to) ||
|
|
723
1504
|
from < 0 ||
|
|
724
|
-
to
|
|
725
|
-
to - from
|
|
1505
|
+
to < from ||
|
|
1506
|
+
to - from >= maxDays * DAY_MS
|
|
726
1507
|
) {
|
|
727
|
-
fail(
|
|
1508
|
+
fail(
|
|
1509
|
+
"INVALID_ARGUMENT",
|
|
1510
|
+
`range must use inclusive integer milliseconds and span at most ${maxDays} UTC days`,
|
|
1511
|
+
);
|
|
728
1512
|
}
|
|
729
|
-
return { from
|
|
1513
|
+
return { from, to };
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function isAlignedRange(from: number, to: number, intervalMs: number) {
|
|
1517
|
+
return from % intervalMs === 0 && (to + 1) % intervalMs === 0;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function requireAlignedRange(
|
|
1521
|
+
from: number,
|
|
1522
|
+
to: number,
|
|
1523
|
+
intervalMs: number,
|
|
1524
|
+
report: string,
|
|
1525
|
+
) {
|
|
1526
|
+
if (!isAlignedRange(from, to, intervalMs)) {
|
|
1527
|
+
fail(
|
|
1528
|
+
"REPORT_INCOMPLETE",
|
|
1529
|
+
`${report} requires complete inclusive UTC bucket boundaries`,
|
|
1530
|
+
{ from, to, intervalMs, timezone: "UTC" },
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function rangeSemantics(from: number, to: number) {
|
|
1536
|
+
return {
|
|
1537
|
+
from,
|
|
1538
|
+
to,
|
|
1539
|
+
boundary: "inclusive" as const,
|
|
1540
|
+
timezone: "UTC" as const,
|
|
1541
|
+
};
|
|
730
1542
|
}
|
|
731
1543
|
|
|
732
1544
|
type KeysetPaginationInput = {
|