@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
@@ -8,6 +8,7 @@ import type {
8
8
  IngestContext,
9
9
  TrackerEvent,
10
10
  } from "./validators.js";
11
+ import { isValidVitalValue, isVitalMetric } from "./vitals.js";
11
12
 
12
13
  const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
13
14
  const SAFE_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/;
@@ -62,9 +63,10 @@ export function sanitizePath(value: string): string {
62
63
  const input = requireString(value, "path", 2048);
63
64
  let path: string;
64
65
  try {
65
- path = input.startsWith("http://") || input.startsWith("https://")
66
- ? new URL(input).pathname
67
- : input.split(/[?#]/, 1)[0];
66
+ path =
67
+ input.startsWith("http://") || input.startsWith("https://")
68
+ ? new URL(input).pathname
69
+ : input.split(/[?#]/, 1)[0];
68
70
  } catch {
69
71
  throw new Error("path is invalid");
70
72
  }
@@ -112,14 +114,19 @@ export function sanitizeProperties(
112
114
  }
113
115
 
114
116
  export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
115
- if (!Number.isFinite(event.timestamp)) throw new Error("timestamp is invalid");
117
+ if (!Number.isFinite(event.timestamp))
118
+ throw new Error("timestamp is invalid");
116
119
  if (
117
120
  event.timestamp < now - MAX_EVENT_AGE_MS ||
118
121
  event.timestamp > now + MAX_FUTURE_SKEW_MS
119
122
  ) {
120
123
  throw new Error("timestamp is outside the accepted window");
121
124
  }
122
- if (!Number.isSafeInteger(event.sequence) || event.sequence < 0 || event.sequence > 1_000_000_000) {
125
+ if (
126
+ !Number.isSafeInteger(event.sequence) ||
127
+ event.sequence < 0 ||
128
+ event.sequence > 1_000_000_000
129
+ ) {
123
130
  throw new Error("sequence is out of range");
124
131
  }
125
132
  if (event.revenueCents !== undefined && !isValidMoney(event.revenueCents)) {
@@ -130,9 +137,20 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
130
137
  throw new Error("name is required for custom events");
131
138
  }
132
139
  if (event.type === "conversion" && !name) name = "conversion";
133
- const currency = event.currency === undefined
134
- ? event.type === "conversion" ? "USD" : undefined
135
- : sanitizeCurrency(event.currency);
140
+ if (event.type === "vital") {
141
+ if (!name || !isVitalMetric(name)) {
142
+ throw new Error("vital name must be a Web Vitals metric");
143
+ }
144
+ if (event.value === undefined || !isValidVitalValue(event.value)) {
145
+ throw new Error("vital value is out of range");
146
+ }
147
+ }
148
+ const currency =
149
+ event.currency === undefined
150
+ ? event.type === "conversion"
151
+ ? "USD"
152
+ : undefined
153
+ : sanitizeCurrency(event.currency);
136
154
  return {
137
155
  eventId: sanitizeOpaqueId(event.eventId, "eventId"),
138
156
  sessionId: sanitizeOpaqueId(event.sessionId, "sessionId"),
@@ -140,19 +158,26 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
140
158
  type: event.type,
141
159
  name,
142
160
  path: sanitizePath(event.path),
143
- referrer: event.referrer === undefined
144
- ? undefined
145
- : sanitizePublicUrl(event.referrer),
161
+ referrer:
162
+ event.referrer === undefined
163
+ ? undefined
164
+ : sanitizePublicUrl(event.referrer),
146
165
  timestamp: Math.trunc(event.timestamp),
147
166
  sequence: event.sequence,
148
- target: event.target === undefined
149
- ? undefined
150
- : cleanString(event.target.replace(/\s+/g, " "), 160) || undefined,
167
+ target:
168
+ event.target === undefined
169
+ ? undefined
170
+ : cleanString(event.target.replace(/\s+/g, " "), 160) || undefined,
151
171
  href: event.href === undefined ? undefined : sanitizePublicUrl(event.href),
152
172
  properties: sanitizeProperties(event.properties),
153
173
  revenueCents: event.revenueCents,
154
174
  currency,
155
175
  affiliateSlug: optionalAffiliateSlug(event.affiliateSlug),
176
+ // Only measurements keep a value; a stray number on any other type is noise.
177
+ value:
178
+ event.type === "vital" && event.value !== undefined
179
+ ? Math.round(event.value)
180
+ : undefined,
156
181
  };
157
182
  }
158
183
 
@@ -167,7 +192,9 @@ function optionalAffiliateSlug(value: string | undefined): string | undefined {
167
192
 
168
193
  export function normalizeDomains(domains: string[]): string[] {
169
194
  if (domains.length === 0 || domains.length > MAX_SITE_DOMAINS) {
170
- throw new Error(`domains must contain between 1 and ${MAX_SITE_DOMAINS} entries`);
195
+ throw new Error(
196
+ `domains must contain between 1 and ${MAX_SITE_DOMAINS} entries`,
197
+ );
171
198
  }
172
199
  const normalized = domains.map(normalizeDomainPattern);
173
200
  return [...new Set(normalized)].sort();
@@ -185,7 +212,11 @@ export function normalizeDomainPattern(value: string): string {
185
212
  throw new Error("domains cannot include paths or query strings");
186
213
  }
187
214
  hostname = url.hostname;
188
- } else if (hostInput.includes("/") || hostInput.includes("?") || hostInput.includes("#")) {
215
+ } else if (
216
+ hostInput.includes("/") ||
217
+ hostInput.includes("?") ||
218
+ hostInput.includes("#")
219
+ ) {
189
220
  throw new Error("domain is invalid");
190
221
  }
191
222
  hostname = hostname.replace(/\.$/, "");
@@ -214,7 +245,9 @@ export function originAllowed(origin: string, domains: string[]): boolean {
214
245
  });
215
246
  }
216
247
 
217
- export function validateTimezone(value: string | undefined): string | undefined {
248
+ export function validateTimezone(
249
+ value: string | undefined,
250
+ ): string | undefined {
218
251
  if (value === undefined || value.trim() === "") return undefined;
219
252
  const timezone = cleanString(value, 80);
220
253
  try {
@@ -225,20 +258,33 @@ export function validateTimezone(value: string | undefined): string | undefined
225
258
  }
226
259
  }
227
260
 
228
- export function sanitizeContext(context: IngestContext | undefined): IngestContext {
261
+ export function sanitizeContext(
262
+ context: IngestContext | undefined,
263
+ ): IngestContext {
229
264
  const latitude = finiteCoordinate(context?.latitude, -90, 90);
230
265
  const longitude = finiteCoordinate(context?.longitude, -180, 180);
231
266
  return {
232
- country: context?.country ? cleanString(context.country.toUpperCase(), 2) || undefined : undefined,
233
- city: context?.city ? cleanString(context.city, 80) || undefined : undefined,
234
- latitude: latitude === undefined ? undefined : Math.round(latitude * 10) / 10,
235
- longitude: longitude === undefined ? undefined : Math.round(longitude * 10) / 10,
236
- browser: context?.browser ? cleanString(context.browser, 32) || undefined : undefined,
267
+ country: context?.country
268
+ ? cleanString(context.country.toUpperCase(), 2) || undefined
269
+ : undefined,
270
+ city: context?.city
271
+ ? cleanString(context.city, 80) || undefined
272
+ : undefined,
273
+ latitude:
274
+ latitude === undefined ? undefined : Math.round(latitude * 10) / 10,
275
+ longitude:
276
+ longitude === undefined ? undefined : Math.round(longitude * 10) / 10,
277
+ browser: context?.browser
278
+ ? cleanString(context.browser, 32) || undefined
279
+ : undefined,
237
280
  os: context?.os ? cleanString(context.os, 32) || undefined : undefined,
238
- device: context?.device ? cleanString(context.device, 24) || undefined : undefined,
239
- visitorKey: context?.visitorKey && SAFE_OPAQUE_ID.test(context.visitorKey)
240
- ? cleanString(context.visitorKey, 128) || undefined
281
+ device: context?.device
282
+ ? cleanString(context.device, 24) || undefined
241
283
  : undefined,
284
+ visitorKey:
285
+ context?.visitorKey && SAFE_OPAQUE_ID.test(context.visitorKey)
286
+ ? cleanString(context.visitorKey, 128) || undefined
287
+ : undefined,
242
288
  };
243
289
  }
244
290
 
@@ -257,8 +303,8 @@ export function measureBatchBytes(
257
303
  events: TrackerEvent[],
258
304
  ): { batchBytes: number; eventByteCounts: number[] } {
259
305
  const encoder = new TextEncoder();
260
- const eventByteCounts = events.map((event) =>
261
- encoder.encode(JSON.stringify(event)).byteLength
306
+ const eventByteCounts = events.map(
307
+ (event) => encoder.encode(JSON.stringify(event)).byteLength,
262
308
  );
263
309
  // The empty envelope already includes both array brackets; only commas remain.
264
310
  const batchBytes =
@@ -294,6 +340,7 @@ function finiteCoordinate(
294
340
  maximum: number,
295
341
  ): number | undefined {
296
342
  if (value === undefined) return undefined;
297
- if (!Number.isFinite(value) || value < minimum || value > maximum) return undefined;
343
+ if (!Number.isFinite(value) || value < minimum || value > maximum)
344
+ return undefined;
298
345
  return value;
299
346
  }
@@ -9,6 +9,7 @@ import {
9
9
  sessionFieldsValidator,
10
10
  siteFieldsValidator,
11
11
  visitorSketchValidator,
12
+ vitalMetricValidator,
12
13
  } from "./validators.js";
13
14
 
14
15
  export default defineSchema({
@@ -131,11 +132,13 @@ export default defineSchema({
131
132
  bucketStart: v.number(),
132
133
  shard: v.number(),
133
134
  generation: v.literal(1),
134
- steps: v.array(v.object({
135
- step: v.number(),
136
- entrants: v.number(),
137
- completions: v.number(),
138
- })),
135
+ steps: v.array(
136
+ v.object({
137
+ step: v.number(),
138
+ entrants: v.number(),
139
+ completions: v.number(),
140
+ }),
141
+ ),
139
142
  })
140
143
  .index("by_siteId_and_funnelId_and_bucketStart_and_shard", [
141
144
  "siteId",
@@ -150,6 +153,29 @@ export default defineSchema({
150
153
  ])
151
154
  .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
152
155
 
156
+ // Field Web Vitals. One row per site/day/shard and either a page (device
157
+ // "(all)") or a device class (page "(all)"); the row folds every metric's
158
+ // histogram together so a report reads five metrics for one document. Pages
159
+ // are bounded like dimension slots: at most VITAL_PAGE_SLOTS distinct pages
160
+ // per day, then "(other)".
161
+ vitalRollups: defineTable({
162
+ siteId: v.id("sites"),
163
+ bucketStart: v.number(),
164
+ device: v.string(),
165
+ page: v.string(),
166
+ shard: v.number(),
167
+ metrics: v.array(
168
+ v.object({
169
+ metric: vitalMetricValidator,
170
+ count: v.number(),
171
+ sum: v.number(),
172
+ histogram: v.array(v.number()),
173
+ }),
174
+ ),
175
+ })
176
+ .index("by_key", ["siteId", "bucketStart", "device", "page", "shard"])
177
+ .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
178
+
153
179
  affiliateDailyRollups: defineTable({
154
180
  siteId: v.id("sites"),
155
181
  affiliateId: v.id("affiliates"),
@@ -32,7 +32,8 @@ export const create = mutation({
32
32
  handler: async (ctx, args) => {
33
33
  const ownerId = cleanString(args.ownerId, 128);
34
34
  const name = cleanString(args.name, 120);
35
- if (!ownerId || !name) fail("INVALID_SITE", "ownerId and name are required");
35
+ if (!ownerId || !name)
36
+ fail("INVALID_SITE", "ownerId and name are required");
36
37
 
37
38
  let domains: string[];
38
39
  let timezone: string | undefined;
@@ -46,7 +47,10 @@ export const create = mutation({
46
47
  : undefined;
47
48
  currency = sanitizeCurrency(args.currency ?? "USD");
48
49
  } catch (error) {
49
- fail("INVALID_SITE", error instanceof Error ? error.message : "invalid site");
50
+ fail(
51
+ "INVALID_SITE",
52
+ error instanceof Error ? error.message : "invalid site",
53
+ );
50
54
  }
51
55
  const cookieless = args.cookieless ?? true;
52
56
 
@@ -65,7 +69,10 @@ export const create = mutation({
65
69
  (existing.currency ?? "USD") !== currency ||
66
70
  existing.cookieless !== cookieless
67
71
  ) {
68
- fail("CONFLICT", "site name is already associated with different settings");
72
+ fail(
73
+ "CONFLICT",
74
+ "site name is already associated with different settings",
75
+ );
69
76
  }
70
77
  await ensureAnalyticsControl(ctx, existing._id, Date.now());
71
78
  return existing._id;
@@ -137,22 +144,26 @@ export const update = mutation({
137
144
  patch.name = name;
138
145
  }
139
146
  try {
140
- if (args.domains !== undefined) patch.domains = normalizeDomains(args.domains);
147
+ if (args.domains !== undefined)
148
+ patch.domains = normalizeDomains(args.domains);
141
149
  if (args.timezone !== undefined) {
142
- patch.timezone = args.timezone === null
143
- ? undefined
144
- : validateTimezone(args.timezone);
150
+ patch.timezone =
151
+ args.timezone === null ? undefined : validateTimezone(args.timezone);
145
152
  }
146
153
  if (args.networkId !== undefined) {
147
- patch.networkId = args.networkId === null
148
- ? undefined
149
- : sanitizeOpaqueId(args.networkId, "networkId");
154
+ patch.networkId =
155
+ args.networkId === null
156
+ ? undefined
157
+ : sanitizeOpaqueId(args.networkId, "networkId");
150
158
  }
151
159
  if (args.currency !== undefined) {
152
160
  patch.currency = sanitizeCurrency(args.currency);
153
161
  }
154
162
  } catch (error) {
155
- fail("INVALID_SITE", error instanceof Error ? error.message : "invalid site");
163
+ fail(
164
+ "INVALID_SITE",
165
+ error instanceof Error ? error.message : "invalid site",
166
+ );
156
167
  }
157
168
  if (
158
169
  patch.currency !== undefined &&
@@ -7,6 +7,15 @@ export const eventTypeValidator = v.union(
7
7
  v.literal("conversion"),
8
8
  v.literal("heartbeat"),
9
9
  v.literal("outbound"),
10
+ v.literal("vital"),
11
+ );
12
+
13
+ export const vitalMetricValidator = v.union(
14
+ v.literal("LCP"),
15
+ v.literal("CLS"),
16
+ v.literal("INP"),
17
+ v.literal("FCP"),
18
+ v.literal("TTFB"),
10
19
  );
11
20
 
12
21
  export const propertyValueValidator = v.union(
@@ -37,6 +46,9 @@ export const trackerEventValidator = v.object({
37
46
  revenueCents: v.optional(v.number()),
38
47
  currency: v.optional(v.string()),
39
48
  affiliateSlug: v.optional(v.string()),
49
+ // Web Vitals measurement: milliseconds, or CLS scaled by 1000. Only "vital"
50
+ // events carry it; sanitization strips it from every other type.
51
+ value: v.optional(v.number()),
40
52
  });
41
53
 
42
54
  export const ingestContextValidator = v.object({
@@ -50,12 +62,10 @@ export const ingestContextValidator = v.object({
50
62
  visitorKey: v.optional(v.string()),
51
63
  });
52
64
 
53
- export const batchedEventValidator = trackerEventValidator.extend(
54
- {
55
- ...ingestContextValidator.fields,
56
- aggregateCountry: v.optional(v.string()),
57
- },
58
- );
65
+ export const batchedEventValidator = trackerEventValidator.extend({
66
+ ...ingestContextValidator.fields,
67
+ aggregateCountry: v.optional(v.string()),
68
+ });
59
69
 
60
70
  export const siteFieldsValidator = v.object({
61
71
  ownerId: v.string(),
@@ -236,7 +246,6 @@ export const trustedConversionValidator = v.object({
236
246
  properties: v.optional(eventPropertiesValidator),
237
247
  });
238
248
 
239
-
240
249
  export type TrackerEvent = Infer<typeof trackerEventValidator>;
241
250
  export type IngestContext = Infer<typeof ingestContextValidator>;
242
251
  export type EventProperties = Infer<typeof eventPropertiesValidator>;
@@ -59,14 +59,21 @@ export const link = mutation({
59
59
  const existing = await findAlias(ctx, args.siteId, previousVisitorId);
60
60
  if (existing) {
61
61
  if (existing.visitorId !== visitorId) {
62
- fail("CONFLICT", "previousVisitorId is already linked to a different visitor");
62
+ fail(
63
+ "CONFLICT",
64
+ "previousVisitorId is already linked to a different visitor",
65
+ );
63
66
  }
64
67
  return { linked: false, aliasCount: aliases.length };
65
68
  }
66
69
  if (aliases.length >= MAX_VISITOR_ALIASES) {
67
- fail("LIMIT_EXCEEDED", `a visitor can hold at most ${MAX_VISITOR_ALIASES} aliases`, {
68
- limit: MAX_VISITOR_ALIASES,
69
- });
70
+ fail(
71
+ "LIMIT_EXCEEDED",
72
+ `a visitor can hold at most ${MAX_VISITOR_ALIASES} aliases`,
73
+ {
74
+ limit: MAX_VISITOR_ALIASES,
75
+ },
76
+ );
70
77
  }
71
78
  await ctx.db.insert("visitorAliases", {
72
79
  siteId: args.siteId,
@@ -95,7 +102,11 @@ export async function resolveVisitorIdentities(
95
102
  return [identity, ...aliases.map((row) => row.previousVisitorId)];
96
103
  }
97
104
 
98
- function findAlias(ctx: QueryCtx, siteId: Id<"sites">, previousVisitorId: string) {
105
+ function findAlias(
106
+ ctx: QueryCtx,
107
+ siteId: Id<"sites">,
108
+ previousVisitorId: string,
109
+ ) {
99
110
  return ctx.db
100
111
  .query("visitorAliases")
101
112
  .withIndex("by_siteId_and_previousVisitorId", (range) =>
@@ -0,0 +1,146 @@
1
+ import { VITAL_HISTOGRAM_EDGES, MAX_VITAL_VALUE } from "./constants.js";
2
+
3
+ /**
4
+ * Field-measured Web Vitals. Values are integers: milliseconds for the time
5
+ * metrics, and CLS scaled by 1000 so one shared histogram covers every metric.
6
+ */
7
+ export const VITAL_METRICS = ["LCP", "CLS", "INP", "FCP", "TTFB"] as const;
8
+
9
+ export type VitalMetric = (typeof VITAL_METRICS)[number];
10
+
11
+ /**
12
+ * The Google-published rating thresholds. Every threshold is also a histogram
13
+ * edge, so good/needs-improvement/poor counts are exact, never interpolated.
14
+ */
15
+ export const VITAL_THRESHOLDS: Record<
16
+ VitalMetric,
17
+ { good: number; poor: number }
18
+ > = {
19
+ LCP: { good: 2_500, poor: 4_000 },
20
+ CLS: { good: 100, poor: 250 },
21
+ INP: { good: 200, poor: 500 },
22
+ FCP: { good: 1_800, poor: 3_000 },
23
+ TTFB: { good: 800, poor: 1_800 },
24
+ };
25
+
26
+ /** Sentinel row keys; sanitized paths always start with "/" so neither collides. */
27
+ export const VITAL_ALL = "(all)";
28
+ export const VITAL_OTHER_PAGES = "(other)";
29
+
30
+ /** Bounded device classes; anything else folds into "unknown". */
31
+ export const VITAL_DEVICES = ["desktop", "mobile", "tablet"] as const;
32
+
33
+ export function vitalDevice(device: string | undefined): string {
34
+ return device && (VITAL_DEVICES as readonly string[]).includes(device)
35
+ ? device
36
+ : "unknown";
37
+ }
38
+
39
+ export const VITAL_HISTOGRAM_BUCKETS = VITAL_HISTOGRAM_EDGES.length + 1;
40
+
41
+ export function isVitalMetric(value: string): value is VitalMetric {
42
+ return (VITAL_METRICS as readonly string[]).includes(value);
43
+ }
44
+
45
+ export function isValidVitalValue(value: number): boolean {
46
+ return Number.isFinite(value) && value >= 0 && value <= MAX_VITAL_VALUE;
47
+ }
48
+
49
+ export function emptyVitalHistogram(): number[] {
50
+ return Array.from({ length: VITAL_HISTOGRAM_BUCKETS }, () => 0);
51
+ }
52
+
53
+ /** Bucket i holds values in (edge[i-1], edge[i]]; the last bucket is open. */
54
+ export function vitalHistogramBucket(value: number): number {
55
+ for (let index = 0; index < VITAL_HISTOGRAM_EDGES.length; index += 1) {
56
+ if (value <= VITAL_HISTOGRAM_EDGES[index]) return index;
57
+ }
58
+ return VITAL_HISTOGRAM_EDGES.length;
59
+ }
60
+
61
+ export function addToVitalHistogram(
62
+ histogram: number[],
63
+ value: number,
64
+ ): number[] {
65
+ const next = normalizedVitalHistogram(histogram);
66
+ next[vitalHistogramBucket(value)] += 1;
67
+ return next;
68
+ }
69
+
70
+ export function mergeVitalHistograms(
71
+ left: number[],
72
+ right: number[],
73
+ ): number[] {
74
+ const result = normalizedVitalHistogram(left);
75
+ const addition = normalizedVitalHistogram(right);
76
+ for (let index = 0; index < result.length; index += 1) {
77
+ result[index] += addition[index];
78
+ }
79
+ return result;
80
+ }
81
+
82
+ /**
83
+ * Percentile estimated by linear interpolation inside the winning bucket. The
84
+ * top bucket is open-ended, so estimates saturate at the highest edge; a
85
+ * reported p75 equal to that edge means "at least this much".
86
+ */
87
+ export function vitalHistogramPercentile(
88
+ histogram: number[],
89
+ fraction: number,
90
+ ): number {
91
+ const buckets = normalizedVitalHistogram(histogram);
92
+ const total = buckets.reduce((sum, count) => sum + count, 0);
93
+ if (total === 0) return 0;
94
+ const target = Math.ceil(total * fraction);
95
+ let cumulative = 0;
96
+ for (let index = 0; index < buckets.length; index += 1) {
97
+ const count = buckets[index];
98
+ if (count === 0) continue;
99
+ if (cumulative + count >= target) {
100
+ const lower = index === 0 ? 0 : VITAL_HISTOGRAM_EDGES[index - 1];
101
+ const upper =
102
+ index < VITAL_HISTOGRAM_EDGES.length
103
+ ? VITAL_HISTOGRAM_EDGES[index]
104
+ : VITAL_HISTOGRAM_EDGES[VITAL_HISTOGRAM_EDGES.length - 1];
105
+ const position = (target - cumulative) / count;
106
+ return Math.round(Math.min(upper, lower + position * (upper - lower)));
107
+ }
108
+ cumulative += count;
109
+ }
110
+ return VITAL_HISTOGRAM_EDGES[VITAL_HISTOGRAM_EDGES.length - 1];
111
+ }
112
+
113
+ /** Exact rating counts; every threshold is a bucket edge by construction. */
114
+ export function vitalRatingCounts(
115
+ histogram: number[],
116
+ metric: VitalMetric,
117
+ ): { good: number; needsImprovement: number; poor: number } {
118
+ const buckets = normalizedVitalHistogram(histogram);
119
+ const thresholds = VITAL_THRESHOLDS[metric];
120
+ let good = 0;
121
+ let needsImprovement = 0;
122
+ let poor = 0;
123
+ for (let index = 0; index < buckets.length; index += 1) {
124
+ const upper =
125
+ index < VITAL_HISTOGRAM_EDGES.length
126
+ ? VITAL_HISTOGRAM_EDGES[index]
127
+ : Number.POSITIVE_INFINITY;
128
+ if (upper <= thresholds.good) good += buckets[index];
129
+ else if (upper <= thresholds.poor) needsImprovement += buckets[index];
130
+ else poor += buckets[index];
131
+ }
132
+ return { good, needsImprovement, poor };
133
+ }
134
+
135
+ function normalizedVitalHistogram(histogram: number[]): number[] {
136
+ const result = Array.from({ length: VITAL_HISTOGRAM_BUCKETS }, () => 0);
137
+ for (
138
+ let index = 0;
139
+ index < Math.min(histogram.length, result.length);
140
+ index += 1
141
+ ) {
142
+ const count = histogram[index];
143
+ result[index] = Number.isSafeInteger(count) && count > 0 ? count : 0;
144
+ }
145
+ return result;
146
+ }