@iann29/rastro 0.1.0-alpha.5 → 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 (120) hide show
  1. package/README.md +75 -27
  2. package/agent/integration.md +17 -15
  3. package/agent/manifest.json +5 -3
  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 +418 -21
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +191 -41
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +2 -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 +63 -7
  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 +5 -0
  24. package/dist/component/constants.d.ts.map +1 -1
  25. package/dist/component/constants.js +11 -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 +2 -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 +32 -9
  46. package/dist/component/http.js.map +1 -1
  47. package/dist/component/ingest.d.ts +2 -1
  48. package/dist/component/ingest.d.ts.map +1 -1
  49. package/dist/component/ingest.js +143 -23
  50. package/dist/component/ingest.js.map +1 -1
  51. package/dist/component/live.d.ts.map +1 -1
  52. package/dist/component/live.js +1 -3
  53. package/dist/component/live.js.map +1 -1
  54. package/dist/component/reports.d.ts +60 -3
  55. package/dist/component/reports.d.ts.map +1 -1
  56. package/dist/component/reports.js +215 -49
  57. package/dist/component/reports.js.map +1 -1
  58. package/dist/component/retention.d.ts +4 -4
  59. package/dist/component/retention.d.ts.map +1 -1
  60. package/dist/component/retention.js +54 -22
  61. package/dist/component/retention.js.map +1 -1
  62. package/dist/component/sanitize.d.ts.map +1 -1
  63. package/dist/component/sanitize.js +38 -10
  64. package/dist/component/sanitize.js.map +1 -1
  65. package/dist/component/schema.d.ts +47 -5
  66. package/dist/component/schema.js +21 -1
  67. package/dist/component/schema.js.map +1 -1
  68. package/dist/component/sites.d.ts.map +1 -1
  69. package/dist/component/sites.js +6 -6
  70. package/dist/component/sites.js.map +1 -1
  71. package/dist/component/validators.d.ts +14 -9
  72. package/dist/component/validators.d.ts.map +1 -1
  73. package/dist/component/validators.js +5 -1
  74. package/dist/component/validators.js.map +1 -1
  75. package/dist/component/visitors.d.ts.map +1 -1
  76. package/dist/component/visitors.js.map +1 -1
  77. package/dist/component/vitals.d.ts +41 -0
  78. package/dist/component/vitals.d.ts.map +1 -0
  79. package/dist/component/vitals.js +115 -0
  80. package/dist/component/vitals.js.map +1 -0
  81. package/dist/react/index.d.ts.map +1 -1
  82. package/dist/react/index.js.map +1 -1
  83. package/dist/tracker/generated.d.ts +4 -0
  84. package/dist/tracker/generated.d.ts.map +1 -1
  85. package/dist/tracker/generated.js +4 -0
  86. package/dist/tracker/generated.js.map +1 -1
  87. package/dist/tracker/tracker.d.ts.map +1 -1
  88. package/dist/tracker/tracker.js.map +1 -1
  89. package/dist/tracker/vitals.d.ts +10 -0
  90. package/dist/tracker/vitals.d.ts.map +1 -0
  91. package/dist/tracker/vitals.js +137 -0
  92. package/dist/tracker/vitals.js.map +1 -0
  93. package/dist/vitals.min.js +1 -0
  94. package/docs/benchmarks/2026-08-20-realistic.md +71 -71
  95. package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
  96. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  97. package/package.json +17 -3
  98. package/scripts/benchmark-ingest.mjs +101 -48
  99. package/src/component/_generated/api.ts +2 -0
  100. package/src/component/_generated/component.ts +69 -4
  101. package/src/component/affiliates.ts +20 -5
  102. package/src/component/cardinality.ts +8 -7
  103. package/src/component/constants.ts +11 -0
  104. package/src/component/diagnostics.ts +3 -2
  105. package/src/component/eventStore.ts +53 -46
  106. package/src/component/funnels.ts +19 -16
  107. package/src/component/geo.ts +16 -14
  108. package/src/component/goals.ts +29 -21
  109. package/src/component/guards.ts +3 -1
  110. package/src/component/http.ts +73 -46
  111. package/src/component/ingest.ts +320 -127
  112. package/src/component/live.ts +5 -4
  113. package/src/component/reports.ts +563 -219
  114. package/src/component/retention.ts +221 -98
  115. package/src/component/sanitize.ts +76 -29
  116. package/src/component/schema.ts +31 -5
  117. package/src/component/sites.ts +22 -11
  118. package/src/component/validators.ts +16 -7
  119. package/src/component/visitors.ts +16 -5
  120. package/src/component/vitals.ts +146 -0
@@ -207,7 +207,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
207
207
  | "custom"
208
208
  | "conversion"
209
209
  | "heartbeat"
210
- | "outbound";
210
+ | "outbound"
211
+ | "vital";
212
+ value?: number;
211
213
  visitorId: string;
212
214
  }>;
213
215
  origin: string;
@@ -303,7 +305,8 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
303
305
  | "conversions"
304
306
  | "goals"
305
307
  | "funnels"
306
- | "affiliates";
308
+ | "affiliates"
309
+ | "vitals";
307
310
  }>;
308
311
  from: number;
309
312
  siteId: string;
@@ -691,7 +694,8 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
691
694
  | "custom"
692
695
  | "conversion"
693
696
  | "heartbeat"
694
- | "outbound";
697
+ | "outbound"
698
+ | "vital";
695
699
  visitorId: string;
696
700
  }>;
697
701
  pageStatus?: "SplitRecommended" | "SplitRequired" | null;
@@ -739,11 +743,69 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
739
743
  | "custom"
740
744
  | "conversion"
741
745
  | "heartbeat"
742
- | "outbound";
746
+ | "outbound"
747
+ | "vital";
743
748
  visitorId: string;
744
749
  }>,
745
750
  Name
746
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
+ >;
747
809
  };
748
810
  retention: {
749
811
  cleanup: FunctionReference<
@@ -765,6 +827,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
765
827
  | "goalRollups"
766
828
  | "funnelRollups"
767
829
  | "affiliateRollups"
830
+ | "vitalRollups"
768
831
  | "attributions"
769
832
  | "liveSessions"
770
833
  | "ingestWindows"
@@ -799,6 +862,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
799
862
  | "goalRollups"
800
863
  | "funnelRollups"
801
864
  | "affiliateRollups"
865
+ | "vitalRollups"
802
866
  | "attributions"
803
867
  | "liveSessions"
804
868
  | "ingestWindows"
@@ -844,6 +908,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
844
908
  | "goalRollups"
845
909
  | "funnelRollups"
846
910
  | "affiliateRollups"
911
+ | "vitalRollups"
847
912
  | "attributions"
848
913
  | "liveSessions"
849
914
  | "ingestWindows"
@@ -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,6 +36,17 @@ 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;
@@ -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) ?? {
@@ -8,10 +8,7 @@ import {
8
8
  sanitizePath,
9
9
  sanitizeSlug,
10
10
  } from "./sanitize.js";
11
- import {
12
- goalFieldsValidator,
13
- goalMatchTypeValidator,
14
- } from "./validators.js";
11
+ import { goalFieldsValidator, goalMatchTypeValidator } from "./validators.js";
15
12
 
16
13
  const goalDocumentValidator = goalFieldsValidator.extend({
17
14
  _id: v.id("goals"),
@@ -31,14 +28,16 @@ export const upsert = mutation({
31
28
  },
32
29
  returns: v.id("goals"),
33
30
  handler: async (ctx, args) => {
34
- if (!(await ctx.db.get("sites", args.siteId))) fail("NOT_FOUND", "site not found");
31
+ if (!(await ctx.db.get("sites", args.siteId)))
32
+ fail("NOT_FOUND", "site not found");
35
33
  let key: string;
36
34
  let matchValue: string;
37
35
  try {
38
36
  key = sanitizeSlug(args.key, "key");
39
- matchValue = args.matchType === "pageview"
40
- ? sanitizePath(args.matchValue)
41
- : cleanString(args.matchValue, 80);
37
+ matchValue =
38
+ args.matchType === "pageview"
39
+ ? sanitizePath(args.matchValue)
40
+ : cleanString(args.matchValue, 80);
42
41
  } catch (error) {
43
42
  fail(
44
43
  "INVALID_ARGUMENT",
@@ -49,7 +48,10 @@ export const upsert = mutation({
49
48
  if (!name) fail("INVALID_ARGUMENT", "goal name is required");
50
49
  if (!matchValue) fail("INVALID_ARGUMENT", "goal match value is required");
51
50
  if (args.valueCents !== undefined && !isValidMoney(args.valueCents)) {
52
- fail("INVALID_ARGUMENT", "valueCents must be an integer between 0 and 1000000000");
51
+ fail(
52
+ "INVALID_ARGUMENT",
53
+ "valueCents must be an integer between 0 and 1000000000",
54
+ );
53
55
  }
54
56
 
55
57
  const existingByKey = await ctx.db
@@ -69,14 +71,16 @@ export const upsert = mutation({
69
71
  const now = Date.now();
70
72
  if (goalId) {
71
73
  const goal = await ctx.db.get("goals", goalId);
72
- if (!goal || goal.siteId !== args.siteId) fail("NOT_FOUND", "goal not found");
74
+ if (!goal || goal.siteId !== args.siteId)
75
+ fail("NOT_FOUND", "goal not found");
73
76
  if (
74
77
  (args.active ?? goal.active) &&
75
- goals.some((candidate) =>
76
- candidate._id !== goalId &&
77
- candidate.active &&
78
- candidate.matchType === args.matchType &&
79
- candidate.matchValue === matchValue
78
+ goals.some(
79
+ (candidate) =>
80
+ candidate._id !== goalId &&
81
+ candidate.active &&
82
+ candidate.matchType === args.matchType &&
83
+ candidate.matchValue === matchValue,
80
84
  )
81
85
  ) {
82
86
  fail("CONFLICT", "another active goal already uses this matcher");
@@ -95,16 +99,19 @@ export const upsert = mutation({
95
99
 
96
100
  if (
97
101
  (args.active ?? true) &&
98
- goals.some((candidate) =>
99
- candidate.active &&
100
- candidate.matchType === args.matchType &&
101
- candidate.matchValue === matchValue
102
+ goals.some(
103
+ (candidate) =>
104
+ candidate.active &&
105
+ candidate.matchType === args.matchType &&
106
+ candidate.matchValue === matchValue,
102
107
  )
103
108
  ) {
104
109
  fail("CONFLICT", "another active goal already uses this matcher");
105
110
  }
106
111
  if (goals.length >= MAX_GOALS_PER_SITE) {
107
- fail("LIMIT_EXCEEDED", "goal limit reached", { limit: MAX_GOALS_PER_SITE });
112
+ fail("LIMIT_EXCEEDED", "goal limit reached", {
113
+ limit: MAX_GOALS_PER_SITE,
114
+ });
108
115
  }
109
116
  return await ctx.db.insert("goals", {
110
117
  siteId: args.siteId,
@@ -136,7 +143,8 @@ export const remove = mutation({
136
143
  returns: v.null(),
137
144
  handler: async (ctx, args) => {
138
145
  const goal = await ctx.db.get("goals", args.goalId);
139
- if (!goal || goal.siteId !== args.siteId) fail("NOT_FOUND", "goal not found");
146
+ if (!goal || goal.siteId !== args.siteId)
147
+ fail("NOT_FOUND", "goal not found");
140
148
  await ctx.db.delete("goals", args.goalId);
141
149
  return null;
142
150
  },