@iann29/rastro 0.1.0-alpha.4 → 0.1.0-alpha.6

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 (135) hide show
  1. package/README.md +203 -56
  2. package/agent/integration.md +17 -15
  3. package/agent/manifest.json +6 -4
  4. package/agent/manifest.schema.json +8 -5
  5. package/dist/client/federation.d.ts +34 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +17 -3
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +486 -31
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +209 -41
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +8 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +82 -8
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/affiliates.d.ts.map +1 -1
  19. package/dist/component/affiliates.js +6 -2
  20. package/dist/component/affiliates.js.map +1 -1
  21. package/dist/component/cardinality.d.ts.map +1 -1
  22. package/dist/component/cardinality.js.map +1 -1
  23. package/dist/component/constants.d.ts +6 -0
  24. package/dist/component/constants.d.ts.map +1 -1
  25. package/dist/component/constants.js +12 -0
  26. package/dist/component/constants.js.map +1 -1
  27. package/dist/component/diagnostics.d.ts.map +1 -1
  28. package/dist/component/diagnostics.js.map +1 -1
  29. package/dist/component/eventStore.d.ts +3 -1
  30. package/dist/component/eventStore.d.ts.map +1 -1
  31. package/dist/component/eventStore.js +13 -10
  32. package/dist/component/eventStore.js.map +1 -1
  33. package/dist/component/funnels.d.ts.map +1 -1
  34. package/dist/component/funnels.js +5 -3
  35. package/dist/component/funnels.js.map +1 -1
  36. package/dist/component/geo.d.ts.map +1 -1
  37. package/dist/component/geo.js +3 -2
  38. package/dist/component/geo.js.map +1 -1
  39. package/dist/component/goals.d.ts.map +1 -1
  40. package/dist/component/goals.js +8 -5
  41. package/dist/component/goals.js.map +1 -1
  42. package/dist/component/guards.d.ts.map +1 -1
  43. package/dist/component/guards.js.map +1 -1
  44. package/dist/component/http.d.ts.map +1 -1
  45. package/dist/component/http.js +71 -37
  46. package/dist/component/http.js.map +1 -1
  47. package/dist/component/identity.d.ts +13 -0
  48. package/dist/component/identity.d.ts.map +1 -0
  49. package/dist/component/identity.js +58 -0
  50. package/dist/component/identity.js.map +1 -0
  51. package/dist/component/ingest.d.ts +3 -1
  52. package/dist/component/ingest.d.ts.map +1 -1
  53. package/dist/component/ingest.js +168 -29
  54. package/dist/component/ingest.js.map +1 -1
  55. package/dist/component/live.d.ts.map +1 -1
  56. package/dist/component/live.js +1 -3
  57. package/dist/component/live.js.map +1 -1
  58. package/dist/component/reports.d.ts +70 -4
  59. package/dist/component/reports.d.ts.map +1 -1
  60. package/dist/component/reports.js +232 -59
  61. package/dist/component/reports.js.map +1 -1
  62. package/dist/component/retention.d.ts +4 -4
  63. package/dist/component/retention.d.ts.map +1 -1
  64. package/dist/component/retention.js +54 -22
  65. package/dist/component/retention.js.map +1 -1
  66. package/dist/component/sanitize.d.ts.map +1 -1
  67. package/dist/component/sanitize.js +42 -11
  68. package/dist/component/sanitize.js.map +1 -1
  69. package/dist/component/schema.d.ts +83 -10
  70. package/dist/component/schema.js +34 -1
  71. package/dist/component/schema.js.map +1 -1
  72. package/dist/component/sites.d.ts.map +1 -1
  73. package/dist/component/sites.js +6 -6
  74. package/dist/component/sites.js.map +1 -1
  75. package/dist/component/useragent.d.ts +9 -0
  76. package/dist/component/useragent.d.ts.map +1 -0
  77. package/dist/component/useragent.js +152 -0
  78. package/dist/component/useragent.js.map +1 -0
  79. package/dist/component/validators.d.ts +29 -18
  80. package/dist/component/validators.d.ts.map +1 -1
  81. package/dist/component/validators.js +8 -2
  82. package/dist/component/validators.js.map +1 -1
  83. package/dist/component/visitors.d.ts +19 -0
  84. package/dist/component/visitors.d.ts.map +1 -0
  85. package/dist/component/visitors.js +86 -0
  86. package/dist/component/visitors.js.map +1 -0
  87. package/dist/component/vitals.d.ts +41 -0
  88. package/dist/component/vitals.d.ts.map +1 -0
  89. package/dist/component/vitals.js +115 -0
  90. package/dist/component/vitals.js.map +1 -0
  91. package/dist/react/index.d.ts.map +1 -1
  92. package/dist/react/index.js.map +1 -1
  93. package/dist/tracker/generated.d.ts +8 -4
  94. package/dist/tracker/generated.d.ts.map +1 -1
  95. package/dist/tracker/generated.js +8 -4
  96. package/dist/tracker/generated.js.map +1 -1
  97. package/dist/tracker/tracker.d.ts.map +1 -1
  98. package/dist/tracker/tracker.js +23 -11
  99. package/dist/tracker/tracker.js.map +1 -1
  100. package/dist/tracker/vitals.d.ts +10 -0
  101. package/dist/tracker/vitals.d.ts.map +1 -0
  102. package/dist/tracker/vitals.js +137 -0
  103. package/dist/tracker/vitals.js.map +1 -0
  104. package/dist/tracker.min.js +1 -1
  105. package/dist/vitals.min.js +1 -0
  106. package/docs/benchmarks/2026-08-20-realistic.md +71 -71
  107. package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
  108. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  109. package/docs/federation.md +1 -1
  110. package/package.json +27 -9
  111. package/scripts/benchmark-ingest.mjs +101 -48
  112. package/src/component/_generated/api.ts +8 -0
  113. package/src/component/_generated/component.ts +87 -5
  114. package/src/component/affiliates.ts +20 -5
  115. package/src/component/cardinality.ts +8 -7
  116. package/src/component/constants.ts +12 -0
  117. package/src/component/diagnostics.ts +3 -2
  118. package/src/component/eventStore.ts +53 -46
  119. package/src/component/funnels.ts +19 -16
  120. package/src/component/geo.ts +16 -14
  121. package/src/component/goals.ts +29 -21
  122. package/src/component/guards.ts +3 -1
  123. package/src/component/http.ts +126 -78
  124. package/src/component/identity.ts +74 -0
  125. package/src/component/ingest.ts +349 -130
  126. package/src/component/live.ts +5 -4
  127. package/src/component/reports.ts +572 -221
  128. package/src/component/retention.ts +221 -98
  129. package/src/component/sanitize.ts +77 -27
  130. package/src/component/schema.ts +46 -5
  131. package/src/component/sites.ts +22 -11
  132. package/src/component/useragent.ts +171 -0
  133. package/src/component/validators.ts +19 -7
  134. package/src/component/visitors.ts +116 -0
  135. package/src/component/vitals.ts +146 -0
@@ -185,6 +185,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
185
185
  latitude?: number;
186
186
  longitude?: number;
187
187
  os?: string;
188
+ visitorKey?: string;
188
189
  };
189
190
  events: Array<{
190
191
  affiliateSlug?: string;
@@ -206,7 +207,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
206
207
  | "custom"
207
208
  | "conversion"
208
209
  | "heartbeat"
209
- | "outbound";
210
+ | "outbound"
211
+ | "vital";
212
+ value?: number;
210
213
  visitorId: string;
211
214
  }>;
212
215
  origin: string;
@@ -302,7 +305,8 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
302
305
  | "conversions"
303
306
  | "goals"
304
307
  | "funnels"
305
- | "affiliates";
308
+ | "affiliates"
309
+ | "vitals";
306
310
  }>;
307
311
  from: number;
308
312
  siteId: string;
@@ -488,6 +492,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
488
492
  utmMedium?: string;
489
493
  utmSource?: string;
490
494
  visitorId: string;
495
+ visitorKey?: string;
491
496
  }>;
492
497
  pageStatus?: "SplitRecommended" | "SplitRequired" | null;
493
498
  splitCursor?: string | null;
@@ -537,6 +542,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
537
542
  metadata: {
538
543
  breakdowns: {
539
544
  topBrowsers: "eventVolume";
545
+ topCampaigns: "eventVolume";
540
546
  topCountries: "eventVolume";
541
547
  topDevices: "eventVolume";
542
548
  topEvents: "eventVolume";
@@ -552,7 +558,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
552
558
  state: "complete" | "partial" | "retained" | "unavailable";
553
559
  };
554
560
  features: {
555
- botDetection: "unsupported";
561
+ botDetection: "userAgent";
556
562
  errorInsights: "unsupported";
557
563
  outboundLinks: "eventVolume";
558
564
  sessionReplay: "unsupported";
@@ -589,6 +595,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
589
595
  revenueCents: number;
590
596
  value: string;
591
597
  }>;
598
+ topCampaigns: Array<{
599
+ count: number;
600
+ revenueCents: number;
601
+ value: string;
602
+ }>;
592
603
  topCountries: Array<{
593
604
  count: number;
594
605
  revenueCents: number;
@@ -683,7 +694,8 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
683
694
  | "custom"
684
695
  | "conversion"
685
696
  | "heartbeat"
686
- | "outbound";
697
+ | "outbound"
698
+ | "vital";
687
699
  visitorId: string;
688
700
  }>;
689
701
  pageStatus?: "SplitRecommended" | "SplitRequired" | null;
@@ -731,11 +743,69 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
731
743
  | "custom"
732
744
  | "conversion"
733
745
  | "heartbeat"
734
- | "outbound";
746
+ | "outbound"
747
+ | "vital";
735
748
  visitorId: string;
736
749
  }>,
737
750
  Name
738
751
  >;
752
+ vitalsReport: FunctionReference<
753
+ "query",
754
+ "internal",
755
+ { from: number; siteId: string; to: number },
756
+ {
757
+ metadata: {
758
+ basis: "dailyRollup" | "boundedLegacy";
759
+ coverage: {
760
+ availableFrom: number | null;
761
+ availableThrough: number | null;
762
+ generation: number | null;
763
+ retainedBefore: number | null;
764
+ state: "complete" | "partial" | "retained" | "unavailable";
765
+ };
766
+ range: {
767
+ boundary: "inclusive";
768
+ from: number;
769
+ timezone: "UTC";
770
+ to: number;
771
+ };
772
+ };
773
+ metrics: Array<{
774
+ average: number;
775
+ count: number;
776
+ devices: Array<{
777
+ average: number;
778
+ count: number;
779
+ device: string;
780
+ good: number;
781
+ needsImprovement: number;
782
+ p75: number;
783
+ poor: number;
784
+ }>;
785
+ good: number;
786
+ metric: "LCP" | "CLS" | "INP" | "FCP" | "TTFB";
787
+ needsImprovement: number;
788
+ p75: number;
789
+ pages: Array<{
790
+ average: number;
791
+ count: number;
792
+ good: number;
793
+ needsImprovement: number;
794
+ p75: number;
795
+ page: string;
796
+ poor: number;
797
+ }>;
798
+ poor: number;
799
+ timeSeries: Array<{
800
+ count: number;
801
+ p75: number;
802
+ timestamp: number;
803
+ }>;
804
+ }>;
805
+ truncated: boolean;
806
+ },
807
+ Name
808
+ >;
739
809
  };
740
810
  retention: {
741
811
  cleanup: FunctionReference<
@@ -757,6 +827,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
757
827
  | "goalRollups"
758
828
  | "funnelRollups"
759
829
  | "affiliateRollups"
830
+ | "vitalRollups"
760
831
  | "attributions"
761
832
  | "liveSessions"
762
833
  | "ingestWindows"
@@ -791,6 +862,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
791
862
  | "goalRollups"
792
863
  | "funnelRollups"
793
864
  | "affiliateRollups"
865
+ | "vitalRollups"
794
866
  | "attributions"
795
867
  | "liveSessions"
796
868
  | "ingestWindows"
@@ -836,6 +908,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
836
908
  | "goalRollups"
837
909
  | "funnelRollups"
838
910
  | "affiliateRollups"
911
+ | "vitalRollups"
839
912
  | "attributions"
840
913
  | "liveSessions"
841
914
  | "ingestWindows"
@@ -926,4 +999,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
926
999
  Name
927
1000
  >;
928
1001
  };
1002
+ visitors: {
1003
+ link: FunctionReference<
1004
+ "mutation",
1005
+ "internal",
1006
+ { previousVisitorId: string; siteId: string; visitorId: string },
1007
+ { aliasCount: number; linked: boolean },
1008
+ Name
1009
+ >;
1010
+ };
929
1011
  };
@@ -22,16 +22,27 @@ export const upsert = mutation({
22
22
  },
23
23
  returns: v.id("affiliates"),
24
24
  handler: async (ctx, args) => {
25
- if (!(await ctx.db.get("sites", args.siteId))) fail("NOT_FOUND", "site not found");
26
- if (!Number.isSafeInteger(args.commissionBps) || args.commissionBps < 0 || args.commissionBps > 10_000) {
27
- fail("INVALID_ARGUMENT", "commissionBps must be an integer between 0 and 10000");
25
+ if (!(await ctx.db.get("sites", args.siteId)))
26
+ fail("NOT_FOUND", "site not found");
27
+ if (
28
+ !Number.isSafeInteger(args.commissionBps) ||
29
+ args.commissionBps < 0 ||
30
+ args.commissionBps > 10_000
31
+ ) {
32
+ fail(
33
+ "INVALID_ARGUMENT",
34
+ "commissionBps must be an integer between 0 and 10000",
35
+ );
28
36
  }
29
37
  if (
30
38
  !Number.isSafeInteger(args.attributionWindowDays) ||
31
39
  args.attributionWindowDays < 1 ||
32
40
  args.attributionWindowDays > 365
33
41
  ) {
34
- fail("INVALID_ARGUMENT", "attributionWindowDays must be an integer between 1 and 365");
42
+ fail(
43
+ "INVALID_ARGUMENT",
44
+ "attributionWindowDays must be an integer between 1 and 365",
45
+ );
35
46
  }
36
47
  let slug: string;
37
48
  try {
@@ -52,7 +63,11 @@ export const upsert = mutation({
52
63
  )
53
64
  .unique();
54
65
  const affiliateId = args.affiliateId ?? existingBySlug?._id;
55
- if (args.affiliateId && existingBySlug && existingBySlug._id !== args.affiliateId) {
66
+ if (
67
+ args.affiliateId &&
68
+ existingBySlug &&
69
+ existingBySlug._id !== args.affiliateId
70
+ ) {
56
71
  fail("CONFLICT", "another affiliate already uses this slug");
57
72
  }
58
73
  const now = Date.now();
@@ -60,13 +60,14 @@ export function unionVisitorSketches(
60
60
  export function estimateVisitors(sketch: VisitorSketch): number {
61
61
  const registers = checkedRegisters(sketch);
62
62
  const count = registers.length;
63
- const alpha = count === 16
64
- ? 0.673
65
- : count === 32
66
- ? 0.697
67
- : count === 64
68
- ? 0.709
69
- : 0.7213 / (1 + 1.079 / count);
63
+ const alpha =
64
+ count === 16
65
+ ? 0.673
66
+ : count === 32
67
+ ? 0.697
68
+ : count === 64
69
+ ? 0.709
70
+ : 0.7213 / (1 + 1.079 / count);
70
71
  let denominator = 0;
71
72
  let empty = 0;
72
73
  for (const register of registers) {
@@ -36,8 +36,20 @@ export const FEATURE_REPORT_DOCUMENT_RESERVE = 500;
36
36
  export const REPORT_ROLLUP_SHARDS = 2;
37
37
  export const ROLLUP_SAFETY_DELAY_MS = 5 * 60_000;
38
38
  export const RETENTION_LEASE_MS = 60_000;
39
+ // Shared Web Vitals histogram edges. Every Google rating threshold (LCP
40
+ // 2500/4000, CLS×1000 100/250, INP 200/500, FCP 1800/3000, TTFB 800/1800) is
41
+ // an edge, so rating counts stay exact while one layout serves all metrics.
42
+ export const VITAL_HISTOGRAM_EDGES = [
43
+ 100, 200, 250, 300, 500, 800, 1_000, 1_200, 1_800, 2_500, 3_000, 4_000, 5_000,
44
+ 7_500, 10_000,
45
+ ];
46
+ export const MAX_VITAL_VALUE = 600_000;
47
+ export const VITAL_PAGE_SLOTS = 48;
48
+ export const MAX_VITALS_REPORT_ROWS = 5_000;
49
+ export const VITALS_REPORT_PAGE_LIMIT = 12;
39
50
 
40
51
  export const DAY_MS = 86_400_000;
41
52
  export const HOUR_MS = 3_600_000;
42
53
  export const MINUTE_MS = 60_000;
43
54
  export const RETENTION_POLICY_INTERVAL_MS = DAY_MS;
55
+ export const MAX_VISITOR_ALIASES = 10;
@@ -29,7 +29,8 @@ export const sessionEventTotals = query({
29
29
  try {
30
30
  sessionIds = args.sessionIds.map((sessionId) => {
31
31
  const sanitized = sanitizeOpaqueId(sessionId, "sessionId");
32
- if (sanitized !== sessionId) throw new Error("sessionId is not canonical");
32
+ if (sanitized !== sessionId)
33
+ throw new Error("sessionId is not canonical");
33
34
  return sanitized;
34
35
  });
35
36
  } catch (error) {
@@ -49,7 +50,7 @@ export const sessionEventTotals = query({
49
50
  .withIndex("by_siteId_and_sessionId", (range) =>
50
51
  range.eq("siteId", args.siteId).eq("sessionId", sessionId),
51
52
  )
52
- .unique()
53
+ .unique(),
53
54
  ),
54
55
  );
55
56
  return {
@@ -90,8 +90,8 @@ export async function findTelemetry(
90
90
  let batch: Doc<"eventBatches"> | undefined;
91
91
  let nestedEvent: Doc<"eventBatches">["events"][number] | undefined;
92
92
  for (const candidate of batches) {
93
- nestedEvent = candidate.events.find((nested) =>
94
- nested.eventId === event.eventId
93
+ nestedEvent = candidate.events.find(
94
+ (nested) => nested.eventId === event.eventId,
95
95
  );
96
96
  if (nestedEvent) {
97
97
  batch = candidate;
@@ -99,13 +99,14 @@ export async function findTelemetry(
99
99
  }
100
100
  }
101
101
  return {
102
- match: batch && nestedEvent
103
- ? {
104
- sessionId: batch.sessionId,
105
- visitorId: batch.visitorId,
106
- ...eventContext(nestedEvent),
107
- }
108
- : null,
102
+ match:
103
+ batch && nestedEvent
104
+ ? {
105
+ sessionId: batch.sessionId,
106
+ visitorId: batch.visitorId,
107
+ ...eventContext(nestedEvent),
108
+ }
109
+ : null,
109
110
  saturated,
110
111
  };
111
112
  }
@@ -190,17 +191,23 @@ export async function loadSessionJourneyEvents(
190
191
  )
191
192
  .order("asc");
192
193
  for await (const batch of query) {
193
- if (completedBucket !== undefined && batch.bucketStart !== completedBucket) {
194
+ if (
195
+ completedBucket !== undefined &&
196
+ batch.bucketStart !== completedBucket
197
+ ) {
194
198
  break;
195
199
  }
196
200
  batches.push(batch);
197
201
  qualifyingEvents += batch.events.reduce((count, _event, index) => {
198
202
  const event = eventFromBatch(batch, index);
199
- return count + Number(
200
- event.timestamp >= input.from &&
201
- event.timestamp <= input.to &&
202
- (!input.cursor ||
203
- compareJourneyEventToCursor(event, input.cursor) > 0),
203
+ return (
204
+ count +
205
+ Number(
206
+ event.timestamp >= input.from &&
207
+ event.timestamp <= input.to &&
208
+ (!input.cursor ||
209
+ compareJourneyEventToCursor(event, input.cursor) > 0),
210
+ )
204
211
  );
205
212
  }, 0);
206
213
  if (qualifyingEvents >= input.resultLimit) {
@@ -213,10 +220,11 @@ export async function loadSessionJourneyEvents(
213
220
  const rows: RawJourneyEvent[] = [];
214
221
  appendBatchEvents(rows, batches);
215
222
  return rows
216
- .filter((event) =>
217
- event.timestamp >= input.from &&
218
- event.timestamp <= input.to &&
219
- (!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0)
223
+ .filter(
224
+ (event) =>
225
+ event.timestamp >= input.from &&
226
+ event.timestamp <= input.to &&
227
+ (!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0),
220
228
  )
221
229
  .sort(compareJourneyEvents)
222
230
  .slice(0, input.resultLimit);
@@ -249,13 +257,16 @@ export async function loadVisitorJourneyEvents(
249
257
  )
250
258
  .order("asc");
251
259
  for await (const batch of query) {
252
- if (completedBucket !== undefined && batch.bucketStart !== completedBucket) {
260
+ if (
261
+ completedBucket !== undefined &&
262
+ batch.bucketStart !== completedBucket
263
+ ) {
253
264
  break;
254
265
  }
255
266
  batches.push(batch);
256
267
  await requireJourneyReadHeadroom(ctx);
257
- qualifyingEvents += batch.events.filter((event) =>
258
- event.timestamp >= input.from && event.timestamp <= input.to
268
+ qualifyingEvents += batch.events.filter(
269
+ (event) => event.timestamp >= input.from && event.timestamp <= input.to,
259
270
  ).length;
260
271
  if (qualifyingEvents >= input.resultLimit) {
261
272
  completedBucket = batch.bucketStart;
@@ -267,8 +278,8 @@ export async function loadVisitorJourneyEvents(
267
278
  const rows: RawJourneyEvent[] = [];
268
279
  appendBatchEvents(rows, batches);
269
280
  return rows
270
- .filter((event) =>
271
- event.timestamp >= input.from && event.timestamp <= input.to
281
+ .filter(
282
+ (event) => event.timestamp >= input.from && event.timestamp <= input.to,
272
283
  )
273
284
  .sort(compareJourneyEvents)
274
285
  .slice(0, input.resultLimit);
@@ -278,9 +289,11 @@ export function compareJourneyEvents(
278
289
  left: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
279
290
  right: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
280
291
  ): number {
281
- return left.timestamp - right.timestamp ||
292
+ return (
293
+ left.timestamp - right.timestamp ||
282
294
  left._creationTime - right._creationTime ||
283
- left._id.localeCompare(right._id);
295
+ left._id.localeCompare(right._id)
296
+ );
284
297
  }
285
298
 
286
299
  export async function hydrateEventContexts(
@@ -304,8 +317,8 @@ export async function hydrateEventContexts(
304
317
  }
305
318
  const sessions = new Map(
306
319
  await Promise.all(
307
- [...sessionPromises].map(async ([key, session]) =>
308
- [key, await session] as const
320
+ [...sessionPromises].map(
321
+ async ([key, session]) => [key, await session] as const,
309
322
  ),
310
323
  ),
311
324
  );
@@ -373,13 +386,8 @@ async function loadTelemetryMinute(
373
386
  .take(MAX_EVENTS_PER_SESSION_WINDOW + 1);
374
387
  }
375
388
 
376
- function telemetryMinuteCount(
377
- batches: Array<{ events: TrackerEvent[] }>,
378
- ) {
379
- return batches.reduce(
380
- (total, batch) => total + batch.events.length,
381
- 0,
382
- );
389
+ function telemetryMinuteCount(batches: Array<{ events: TrackerEvent[] }>) {
390
+ return batches.reduce((total, batch) => total + batch.events.length, 0);
383
391
  }
384
392
 
385
393
  function eventForBatch(
@@ -423,7 +431,8 @@ function eventFromBatch(
423
431
  batch: Doc<"eventBatches">,
424
432
  index: number,
425
433
  ): RawJourneyEvent {
426
- const { aggregateCountry: _aggregateCountry, ...event } = batch.events[index]!;
434
+ const { aggregateCountry: _aggregateCountry, ...event } =
435
+ batch.events[index]!;
427
436
  void _aggregateCountry;
428
437
  return {
429
438
  ...event,
@@ -461,14 +470,10 @@ async function requireJourneyReadHeadroom(ctx: QueryCtx) {
461
470
  metrics.bytesRead.remaining < JOURNEY_READ_BYTE_RESERVE ||
462
471
  metrics.documentsRead.remaining < JOURNEY_READ_DOCUMENT_RESERVE
463
472
  ) {
464
- fail(
465
- "LIMIT_EXCEEDED",
466
- "visitor journey exceeds the safe read budget",
467
- {
468
- bytesRead: metrics.bytesRead.used,
469
- documentsRead: metrics.documentsRead.used,
470
- },
471
- );
473
+ fail("LIMIT_EXCEEDED", "visitor journey exceeds the safe read budget", {
474
+ bytesRead: metrics.bytesRead.used,
475
+ documentsRead: metrics.documentsRead.used,
476
+ });
472
477
  }
473
478
  }
474
479
 
@@ -476,7 +481,9 @@ function compareJourneyEventToCursor(
476
481
  event: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
477
482
  cursor: JourneyCursor,
478
483
  ) {
479
- return event.timestamp - cursor.timestamp ||
484
+ return (
485
+ event.timestamp - cursor.timestamp ||
480
486
  event._creationTime - cursor.creationTime ||
481
- event._id.localeCompare(cursor.id);
487
+ event._id.localeCompare(cursor.id)
488
+ );
482
489
  }
@@ -1,15 +1,9 @@
1
1
  import { v } from "convex/values";
2
2
  import { mutation, query } from "./_generated/server.js";
3
- import {
4
- MAX_FUNNELS_PER_SITE,
5
- MAX_FUNNEL_STEPS,
6
- } from "./constants.js";
3
+ import { MAX_FUNNELS_PER_SITE, MAX_FUNNEL_STEPS } from "./constants.js";
7
4
  import { fail } from "./errors.js";
8
5
  import { cleanString, sanitizePath, sanitizeSlug } from "./sanitize.js";
9
- import {
10
- funnelFieldsValidator,
11
- funnelStepValidator,
12
- } from "./validators.js";
6
+ import { funnelFieldsValidator, funnelStepValidator } from "./validators.js";
13
7
 
14
8
  const funnelDocumentValidator = funnelFieldsValidator.extend({
15
9
  _id: v.id("funnels"),
@@ -28,7 +22,8 @@ export const upsert = mutation({
28
22
  },
29
23
  returns: v.id("funnels"),
30
24
  handler: async (ctx, args) => {
31
- if (!(await ctx.db.get("sites", args.siteId))) fail("NOT_FOUND", "site not found");
25
+ if (!(await ctx.db.get("sites", args.siteId)))
26
+ fail("NOT_FOUND", "site not found");
32
27
  if (args.steps.length < 2 || args.steps.length > MAX_FUNNEL_STEPS) {
33
28
  fail("INVALID_ARGUMENT", `funnels require 2-${MAX_FUNNEL_STEPS} steps`);
34
29
  }
@@ -37,7 +32,10 @@ export const upsert = mutation({
37
32
  args.conversionWindowMs < 60_000 ||
38
33
  args.conversionWindowMs > 90 * 86_400_000
39
34
  ) {
40
- fail("INVALID_ARGUMENT", "conversionWindowMs must be between one minute and 90 days");
35
+ fail(
36
+ "INVALID_ARGUMENT",
37
+ "conversionWindowMs must be between one minute and 90 days",
38
+ );
41
39
  }
42
40
  let key: string;
43
41
  let steps: Array<{
@@ -49,9 +47,10 @@ export const upsert = mutation({
49
47
  key = sanitizeSlug(args.key, "key");
50
48
  steps = args.steps.map((step) => {
51
49
  const stepName = cleanString(step.name, 80);
52
- const value = step.type === "pageview"
53
- ? sanitizePath(step.value)
54
- : cleanString(step.value, 80);
50
+ const value =
51
+ step.type === "pageview"
52
+ ? sanitizePath(step.value)
53
+ : cleanString(step.value, 80);
55
54
  if (!stepName || !value) {
56
55
  throw new Error("funnel steps require name and value");
57
56
  }
@@ -79,7 +78,8 @@ export const upsert = mutation({
79
78
  const now = Date.now();
80
79
  if (funnelId) {
81
80
  const funnel = await ctx.db.get("funnels", funnelId);
82
- if (!funnel || funnel.siteId !== args.siteId) fail("NOT_FOUND", "funnel not found");
81
+ if (!funnel || funnel.siteId !== args.siteId)
82
+ fail("NOT_FOUND", "funnel not found");
83
83
  await ctx.db.patch("funnels", funnelId, {
84
84
  key,
85
85
  name,
@@ -96,7 +96,9 @@ export const upsert = mutation({
96
96
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
97
97
  .take(MAX_FUNNELS_PER_SITE + 1);
98
98
  if (funnels.length >= MAX_FUNNELS_PER_SITE) {
99
- fail("LIMIT_EXCEEDED", "funnel limit reached", { limit: MAX_FUNNELS_PER_SITE });
99
+ fail("LIMIT_EXCEEDED", "funnel limit reached", {
100
+ limit: MAX_FUNNELS_PER_SITE,
101
+ });
100
102
  }
101
103
  return await ctx.db.insert("funnels", {
102
104
  siteId: args.siteId,
@@ -127,7 +129,8 @@ export const remove = mutation({
127
129
  returns: v.null(),
128
130
  handler: async (ctx, args) => {
129
131
  const funnel = await ctx.db.get("funnels", args.funnelId);
130
- if (!funnel || funnel.siteId !== args.siteId) fail("NOT_FOUND", "funnel not found");
132
+ if (!funnel || funnel.siteId !== args.siteId)
133
+ fail("NOT_FOUND", "funnel not found");
131
134
  await ctx.db.delete("funnels", args.funnelId);
132
135
  return null;
133
136
  },
@@ -15,10 +15,7 @@ import {
15
15
  import { eventBatchKey, eventBucketStart } from "./eventStore.js";
16
16
  import { isPlainRecord } from "./guards.js";
17
17
  import { sanitizeContext, stableHash } from "./sanitize.js";
18
- import {
19
- trackerEventValidator,
20
- type IngestContext,
21
- } from "./validators.js";
18
+ import { trackerEventValidator, type IngestContext } from "./validators.js";
22
19
 
23
20
  export type GeoIpProvider = "ipinfo" | "ipwhois";
24
21
  export type GeoIpStatus = "disabled" | "ready" | "missingToken";
@@ -153,10 +150,13 @@ export const claimEnrichment = internalMutation({
153
150
  batches.flatMap((batch) =>
154
151
  batch.events
155
152
  .filter((event) => group.eventIds.has(event.eventId))
156
- .map((event) => [
157
- event.eventId,
158
- event.aggregateCountry ?? event.country,
159
- ] as const),
153
+ .map(
154
+ (event) =>
155
+ [
156
+ event.eventId,
157
+ event.aggregateCountry ?? event.country,
158
+ ] as const,
159
+ ),
160
160
  ),
161
161
  ),
162
162
  );
@@ -237,16 +237,16 @@ export const claimEnrichment = internalMutation({
237
237
  seenEvents.has(key) ||
238
238
  !claimed.has(event.sessionId) ||
239
239
  !persistedEvents
240
- .get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
240
+ .get(
241
+ eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
242
+ )
241
243
  ?.has(event.eventId)
242
244
  ) {
243
245
  continue;
244
246
  }
245
247
  seenEvents.add(key);
246
248
  const priorCountry = persistedEvents
247
- .get(
248
- eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
249
- )
249
+ .get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
250
250
  ?.get(event.eventId);
251
251
  if (priorCountry === undefined) continue;
252
252
  correctionEvents.push({
@@ -469,7 +469,8 @@ function classifyIpv6(ip: string): Exclude<IpKind, "unavailable"> {
469
469
  (groups[0]! & 0xffc0) === 0xfe80 ||
470
470
  (groups[0]! & 0xffc0) === 0xfec0 ||
471
471
  (groups[0]! & 0xff00) === 0xff00 ||
472
- (groups[0] === 0x0100 && groups.slice(1, 4).every((group) => group === 0)) ||
472
+ (groups[0] === 0x0100 &&
473
+ groups.slice(1, 4).every((group) => group === 0)) ||
473
474
  (groups[0] === 0x0064 && groups[1] === 0xff9b && groups[2] === 1) ||
474
475
  (groups[0] === 0x2001 && (groups[1]! & 0xfe00) === 0) ||
475
476
  (groups[0] === 0x2001 && groups[1] === 0x0db8) ||
@@ -563,7 +564,8 @@ async function correctCountryAggregates(
563
564
  for (const granularity of ["hour", "day"] as const) {
564
565
  const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
565
566
  const bucketStart = Math.floor(event.timestamp / interval) * interval;
566
- const shard = stableHash(event.sessionId) %
567
+ const shard =
568
+ stableHash(event.sessionId) %
567
569
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
568
570
  const key = `${granularity}:${bucketStart}:${shard}`;
569
571
  const correction = corrections.get(key) ?? {