@iann29/rastro 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (179) hide show
  1. package/README.md +693 -69
  2. package/agent/integration.md +801 -0
  3. package/agent/manifest.json +205 -0
  4. package/agent/manifest.schema.json +444 -0
  5. package/dist/client/federation.d.ts +381 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +274 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2673 -15
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +565 -33
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +22 -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 +315 -4
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +4 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/affiliates.d.ts.map +1 -1
  22. package/dist/component/affiliates.js +6 -2
  23. package/dist/component/affiliates.js.map +1 -1
  24. package/dist/component/cardinality.d.ts +12 -0
  25. package/dist/component/cardinality.d.ts.map +1 -0
  26. package/dist/component/cardinality.js +94 -0
  27. package/dist/component/cardinality.js.map +1 -0
  28. package/dist/component/constants.d.ts +28 -1
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +44 -1
  31. package/dist/component/constants.js.map +1 -1
  32. package/dist/component/convex.config.d.ts +6 -1
  33. package/dist/component/convex.config.js +9 -1
  34. package/dist/component/convex.config.js.map +1 -1
  35. package/dist/component/coverage.d.ts +27 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +56 -0
  38. package/dist/component/coverage.js.map +1 -0
  39. package/dist/component/diagnostics.d.ts +9 -0
  40. package/dist/component/diagnostics.d.ts.map +1 -0
  41. package/dist/component/diagnostics.js +47 -0
  42. package/dist/component/diagnostics.js.map +1 -0
  43. package/dist/component/errors.d.ts +1 -1
  44. package/dist/component/errors.d.ts.map +1 -1
  45. package/dist/component/errors.js.map +1 -1
  46. package/dist/component/eventStore.d.ts +21 -9
  47. package/dist/component/eventStore.d.ts.map +1 -1
  48. package/dist/component/eventStore.js +142 -152
  49. package/dist/component/eventStore.js.map +1 -1
  50. package/dist/component/funnels.d.ts.map +1 -1
  51. package/dist/component/funnels.js +5 -3
  52. package/dist/component/funnels.js.map +1 -1
  53. package/dist/component/geo.d.ts +73 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +648 -0
  56. package/dist/component/geo.js.map +1 -0
  57. package/dist/component/goals.d.ts.map +1 -1
  58. package/dist/component/goals.js +8 -5
  59. package/dist/component/goals.js.map +1 -1
  60. package/dist/component/guards.d.ts.map +1 -1
  61. package/dist/component/guards.js.map +1 -1
  62. package/dist/component/http.d.ts.map +1 -1
  63. package/dist/component/http.js +281 -62
  64. package/dist/component/http.js.map +1 -1
  65. package/dist/component/identity.d.ts +13 -0
  66. package/dist/component/identity.d.ts.map +1 -0
  67. package/dist/component/identity.js +58 -0
  68. package/dist/component/identity.js.map +1 -0
  69. package/dist/component/ingest.d.ts +3 -1
  70. package/dist/component/ingest.d.ts.map +1 -1
  71. package/dist/component/ingest.js +498 -95
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts.map +1 -1
  74. package/dist/component/live.js +7 -6
  75. package/dist/component/live.js.map +1 -1
  76. package/dist/component/localTime.d.ts +25 -0
  77. package/dist/component/localTime.d.ts.map +1 -0
  78. package/dist/component/localTime.js +126 -0
  79. package/dist/component/localTime.js.map +1 -0
  80. package/dist/component/reports.d.ts +266 -10
  81. package/dist/component/reports.d.ts.map +1 -1
  82. package/dist/component/reports.js +1243 -137
  83. package/dist/component/reports.js.map +1 -1
  84. package/dist/component/retention.d.ts +75 -1
  85. package/dist/component/retention.d.ts.map +1 -1
  86. package/dist/component/retention.js +561 -38
  87. package/dist/component/retention.js.map +1 -1
  88. package/dist/component/rollupStore.d.ts +320 -0
  89. package/dist/component/rollupStore.d.ts.map +1 -0
  90. package/dist/component/rollupStore.js +596 -0
  91. package/dist/component/rollupStore.js.map +1 -0
  92. package/dist/component/rollups.d.ts +20 -0
  93. package/dist/component/rollups.d.ts.map +1 -0
  94. package/dist/component/rollups.js +73 -0
  95. package/dist/component/rollups.js.map +1 -0
  96. package/dist/component/sanitize.d.ts +24 -1
  97. package/dist/component/sanitize.d.ts.map +1 -1
  98. package/dist/component/sanitize.js +96 -16
  99. package/dist/component/sanitize.js.map +1 -1
  100. package/dist/component/schema.d.ts +687 -65
  101. package/dist/component/schema.js +187 -20
  102. package/dist/component/schema.js.map +1 -1
  103. package/dist/component/sites.d.ts +12 -0
  104. package/dist/component/sites.d.ts.map +1 -1
  105. package/dist/component/sites.js +41 -7
  106. package/dist/component/sites.js.map +1 -1
  107. package/dist/component/useragent.d.ts +9 -0
  108. package/dist/component/useragent.d.ts.map +1 -0
  109. package/dist/component/useragent.js +152 -0
  110. package/dist/component/useragent.js.map +1 -0
  111. package/dist/component/validators.d.ts +124 -69
  112. package/dist/component/validators.d.ts.map +1 -1
  113. package/dist/component/validators.js +35 -14
  114. package/dist/component/validators.js.map +1 -1
  115. package/dist/component/visitors.d.ts +19 -0
  116. package/dist/component/visitors.d.ts.map +1 -0
  117. package/dist/component/visitors.js +86 -0
  118. package/dist/component/visitors.js.map +1 -0
  119. package/dist/component/vitals.d.ts +41 -0
  120. package/dist/component/vitals.d.ts.map +1 -0
  121. package/dist/component/vitals.js +115 -0
  122. package/dist/component/vitals.js.map +1 -0
  123. package/dist/react/index.d.ts.map +1 -1
  124. package/dist/react/index.js.map +1 -1
  125. package/dist/tracker/generated.d.ts +11 -4
  126. package/dist/tracker/generated.d.ts.map +1 -1
  127. package/dist/tracker/generated.js +11 -4
  128. package/dist/tracker/generated.js.map +1 -1
  129. package/dist/tracker/tracker.d.ts +1 -1
  130. package/dist/tracker/tracker.d.ts.map +1 -1
  131. package/dist/tracker/tracker.js +57 -20
  132. package/dist/tracker/tracker.js.map +1 -1
  133. package/dist/tracker/vitals.d.ts +10 -0
  134. package/dist/tracker/vitals.d.ts.map +1 -0
  135. package/dist/tracker/vitals.js +140 -0
  136. package/dist/tracker/vitals.js.map +1 -0
  137. package/dist/tracker.min.js +1 -1
  138. package/dist/vitals.min.js +1 -0
  139. package/docs/benchmarks/2026-08-20-realistic.md +76 -76
  140. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  141. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  142. package/docs/federation-setup.md +464 -0
  143. package/docs/federation.md +352 -0
  144. package/docs/upgrading.md +344 -0
  145. package/llms.txt +72 -0
  146. package/package.json +55 -11
  147. package/scripts/benchmark-ingest.mjs +175 -73
  148. package/scripts/generate-federation-keys.mjs +20 -0
  149. package/src/component/_generated/api.ts +22 -0
  150. package/src/component/_generated/component.ts +381 -4
  151. package/src/component/_generated/server.ts +4 -0
  152. package/src/component/affiliates.ts +20 -5
  153. package/src/component/cardinality.ts +117 -0
  154. package/src/component/constants.ts +44 -1
  155. package/src/component/convex.config.ts +11 -1
  156. package/src/component/coverage.ts +71 -0
  157. package/src/component/diagnostics.ts +65 -0
  158. package/src/component/errors.ts +2 -1
  159. package/src/component/eventStore.ts +217 -193
  160. package/src/component/funnels.ts +19 -16
  161. package/src/component/geo.ts +835 -0
  162. package/src/component/goals.ts +29 -21
  163. package/src/component/guards.ts +3 -1
  164. package/src/component/http.ts +404 -70
  165. package/src/component/identity.ts +74 -0
  166. package/src/component/ingest.ts +894 -188
  167. package/src/component/live.ts +13 -7
  168. package/src/component/localTime.ts +167 -0
  169. package/src/component/reports.ts +1872 -197
  170. package/src/component/retention.ts +788 -96
  171. package/src/component/rollupStore.ts +799 -0
  172. package/src/component/rollups.ts +82 -0
  173. package/src/component/sanitize.ts +144 -29
  174. package/src/component/schema.ts +217 -21
  175. package/src/component/sites.ts +59 -12
  176. package/src/component/useragent.ts +171 -0
  177. package/src/component/validators.ts +49 -14
  178. package/src/component/visitors.ts +116 -0
  179. package/src/component/vitals.ts +146 -0
@@ -0,0 +1,82 @@
1
+ import { v } from "convex/values";
2
+ import { internal } from "./_generated/api.js";
3
+ import { internalMutation } from "./_generated/server.js";
4
+ import {
5
+ ROLLUP_FOLD_BATCH_DOCS,
6
+ ROLLUP_FOLD_LEASE_MS,
7
+ ROLLUP_FOLD_MAX_ROWS,
8
+ } from "./constants.js";
9
+ import {
10
+ countReportRollupRows,
11
+ createReportRollupDeltas,
12
+ flushReportRollups,
13
+ mergeQueuedRollups,
14
+ } from "./rollupStore.js";
15
+
16
+ /**
17
+ * Folds a site's queued report rollup deltas into the rollup rows. Exactly
18
+ * one chain runs per site: ingestion starts one when no lease is held, every
19
+ * run carries the token of the lease it belongs to, and a run whose token is
20
+ * no longer current exits without touching anything. A run that finds more
21
+ * queued documents than it could take continues at once; a run that drains
22
+ * the queue releases the lease. A batch that lands while a run is draining
23
+ * is never stranded: the run read the queue range the batch inserted into
24
+ * and the batch read the lease the run released, so one of the two retries
25
+ * and sees the other's work.
26
+ */
27
+ export const fold = internalMutation({
28
+ args: { siteId: v.id("sites"), token: v.string() },
29
+ returns: v.object({
30
+ folded: v.number(),
31
+ remaining: v.boolean(),
32
+ stale: v.boolean(),
33
+ }),
34
+ handler: async (ctx, args) => {
35
+ const state = await ctx.db
36
+ .query("rollupFoldStates")
37
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
38
+ .unique();
39
+ if (!state || state.token !== args.token) {
40
+ return { folded: 0, remaining: false, stale: true };
41
+ }
42
+ const now = Date.now();
43
+ const queued = await ctx.db
44
+ .query("reportRollupQueue")
45
+ .withIndex("by_siteId_and_createdAt", (range) =>
46
+ range.eq("siteId", args.siteId),
47
+ )
48
+ .order("asc")
49
+ .take(ROLLUP_FOLD_BATCH_DOCS + 1);
50
+ if (queued.length === 0) {
51
+ if (state.leaseUntil > now) {
52
+ await ctx.db.patch("rollupFoldStates", state._id, { leaseUntil: now });
53
+ }
54
+ return { folded: 0, remaining: false, stale: false };
55
+ }
56
+ const deltas = createReportRollupDeltas();
57
+ let taken = 0;
58
+ for (const document of queued.slice(0, ROLLUP_FOLD_BATCH_DOCS)) {
59
+ mergeQueuedRollups(deltas, document);
60
+ taken += 1;
61
+ if (countReportRollupRows(deltas) >= ROLLUP_FOLD_MAX_ROWS) break;
62
+ }
63
+ await flushReportRollups(ctx, deltas);
64
+ for (const document of queued.slice(0, taken)) {
65
+ await ctx.db.delete("reportRollupQueue", document._id);
66
+ }
67
+ const remaining = queued.length > taken;
68
+ if (remaining) {
69
+ // Renew only when the lease is halfway gone, so the state document is
70
+ // rewritten about twice a minute under load rather than on every run.
71
+ if (state.leaseUntil - now < ROLLUP_FOLD_LEASE_MS / 2) {
72
+ await ctx.db.patch("rollupFoldStates", state._id, {
73
+ leaseUntil: now + ROLLUP_FOLD_LEASE_MS,
74
+ });
75
+ }
76
+ await ctx.scheduler.runAfter(0, internal.rollups.fold, args);
77
+ } else {
78
+ await ctx.db.patch("rollupFoldStates", state._id, { leaseUntil: now });
79
+ }
80
+ return { folded: taken, remaining, stale: false };
81
+ },
82
+ });
@@ -1,4 +1,5 @@
1
1
  import {
2
+ CLOCK_SKEW_TOLERANCE_MS,
2
3
  MAX_BATCH_BYTES,
3
4
  MAX_PROPERTIES,
4
5
  MAX_SITE_DOMAINS,
@@ -8,6 +9,7 @@ import type {
8
9
  IngestContext,
9
10
  TrackerEvent,
10
11
  } from "./validators.js";
12
+ import { isValidVitalValue, isVitalMetric } from "./vitals.js";
11
13
 
12
14
  const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
13
15
  const SAFE_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/;
@@ -62,9 +64,10 @@ export function sanitizePath(value: string): string {
62
64
  const input = requireString(value, "path", 2048);
63
65
  let path: string;
64
66
  try {
65
- path = input.startsWith("http://") || input.startsWith("https://")
66
- ? new URL(input).pathname
67
- : input.split(/[?#]/, 1)[0];
67
+ path =
68
+ input.startsWith("http://") || input.startsWith("https://")
69
+ ? new URL(input).pathname
70
+ : input.split(/[?#]/, 1)[0];
68
71
  } catch {
69
72
  throw new Error("path is invalid");
70
73
  }
@@ -73,6 +76,37 @@ export function sanitizePath(value: string): string {
73
76
  return cleaned || "/";
74
77
  }
75
78
 
79
+ const ROUTE_ID = ":id";
80
+ const NUMERIC_SEGMENT = /^\d+$/;
81
+ const UUID_SEGMENT =
82
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
83
+ const HEX_SEGMENT = /^[0-9a-f]{16,}$/i;
84
+
85
+ /**
86
+ * Collapses the identifiers that unmistakably vary per record — numbers,
87
+ * UUIDs, and long hexadecimal tokens — into `:id`, so `/blog/42` and
88
+ * `/blog/43` roll up as one route in the site map. Slugs are pages of their
89
+ * own and stay as they are. A trailing slash is dropped because both
90
+ * spellings serve the same page. Only the map uses this; every other report
91
+ * keeps the sanitized path exactly as the tracker sent it.
92
+ */
93
+ export function normalizeRoute(path: string): string {
94
+ const trimmed =
95
+ path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
96
+ const segments = trimmed.split("/");
97
+ for (let index = 1; index < segments.length; index += 1) {
98
+ const segment = segments[index] ?? "";
99
+ if (
100
+ NUMERIC_SEGMENT.test(segment) ||
101
+ UUID_SEGMENT.test(segment) ||
102
+ HEX_SEGMENT.test(segment)
103
+ ) {
104
+ segments[index] = ROUTE_ID;
105
+ }
106
+ }
107
+ return segments.join("/") || "/";
108
+ }
109
+
76
110
  export function sanitizePublicUrl(value: string): string | undefined {
77
111
  const input = cleanString(value, 2048);
78
112
  if (!input) return undefined;
@@ -111,15 +145,42 @@ export function sanitizeProperties(
111
145
  return Object.keys(result).length > 0 ? result : undefined;
112
146
  }
113
147
 
148
+ /**
149
+ * Re-anchors a batch whose sender's clock disagrees with ours. `sentAt` is the
150
+ * sender's clock at flush time, so `now - sentAt` is the clock offset alone: an
151
+ * event queued long before the flush keeps its age, and nothing is inferred
152
+ * from the events themselves. Offsets inside the tolerance are latency or a
153
+ * slightly drifted clock and leave the batch untouched, so a well-set client's
154
+ * timestamps stay exactly as sent.
155
+ */
156
+ export function alignClock<T extends { timestamp: number }>(
157
+ events: T[],
158
+ sentAt: number | undefined,
159
+ now: number,
160
+ ): T[] {
161
+ if (sentAt === undefined || !Number.isFinite(sentAt)) return events;
162
+ const offset = now - sentAt;
163
+ if (Math.abs(offset) <= CLOCK_SKEW_TOLERANCE_MS) return events;
164
+ return events.map((event) => ({
165
+ ...event,
166
+ timestamp: event.timestamp + offset,
167
+ }));
168
+ }
169
+
114
170
  export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
115
- if (!Number.isFinite(event.timestamp)) throw new Error("timestamp is invalid");
171
+ if (!Number.isFinite(event.timestamp))
172
+ throw new Error("timestamp is invalid");
116
173
  if (
117
174
  event.timestamp < now - MAX_EVENT_AGE_MS ||
118
175
  event.timestamp > now + MAX_FUTURE_SKEW_MS
119
176
  ) {
120
177
  throw new Error("timestamp is outside the accepted window");
121
178
  }
122
- if (!Number.isSafeInteger(event.sequence) || event.sequence < 0 || event.sequence > 1_000_000_000) {
179
+ if (
180
+ !Number.isSafeInteger(event.sequence) ||
181
+ event.sequence < 0 ||
182
+ event.sequence > 1_000_000_000
183
+ ) {
123
184
  throw new Error("sequence is out of range");
124
185
  }
125
186
  if (event.revenueCents !== undefined && !isValidMoney(event.revenueCents)) {
@@ -130,9 +191,20 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
130
191
  throw new Error("name is required for custom events");
131
192
  }
132
193
  if (event.type === "conversion" && !name) name = "conversion";
133
- const currency = event.currency === undefined
134
- ? event.type === "conversion" ? "USD" : undefined
135
- : sanitizeCurrency(event.currency);
194
+ if (event.type === "vital") {
195
+ if (!name || !isVitalMetric(name)) {
196
+ throw new Error("vital name must be a Web Vitals metric");
197
+ }
198
+ if (event.value === undefined || !isValidVitalValue(event.value)) {
199
+ throw new Error("vital value is out of range");
200
+ }
201
+ }
202
+ const currency =
203
+ event.currency === undefined
204
+ ? event.type === "conversion"
205
+ ? "USD"
206
+ : undefined
207
+ : sanitizeCurrency(event.currency);
136
208
  return {
137
209
  eventId: sanitizeOpaqueId(event.eventId, "eventId"),
138
210
  sessionId: sanitizeOpaqueId(event.sessionId, "sessionId"),
@@ -140,19 +212,26 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
140
212
  type: event.type,
141
213
  name,
142
214
  path: sanitizePath(event.path),
143
- referrer: event.referrer === undefined
144
- ? undefined
145
- : sanitizePublicUrl(event.referrer),
215
+ referrer:
216
+ event.referrer === undefined
217
+ ? undefined
218
+ : sanitizePublicUrl(event.referrer),
146
219
  timestamp: Math.trunc(event.timestamp),
147
220
  sequence: event.sequence,
148
- target: event.target === undefined
149
- ? undefined
150
- : cleanString(event.target, 160) || undefined,
221
+ target:
222
+ event.target === undefined
223
+ ? undefined
224
+ : cleanString(event.target.replace(/\s+/g, " "), 160) || undefined,
151
225
  href: event.href === undefined ? undefined : sanitizePublicUrl(event.href),
152
226
  properties: sanitizeProperties(event.properties),
153
227
  revenueCents: event.revenueCents,
154
228
  currency,
155
229
  affiliateSlug: optionalAffiliateSlug(event.affiliateSlug),
230
+ // Only measurements keep a value; a stray number on any other type is noise.
231
+ value:
232
+ event.type === "vital" && event.value !== undefined
233
+ ? Math.round(event.value)
234
+ : undefined,
156
235
  };
157
236
  }
158
237
 
@@ -167,7 +246,9 @@ function optionalAffiliateSlug(value: string | undefined): string | undefined {
167
246
 
168
247
  export function normalizeDomains(domains: string[]): string[] {
169
248
  if (domains.length === 0 || domains.length > MAX_SITE_DOMAINS) {
170
- throw new Error(`domains must contain between 1 and ${MAX_SITE_DOMAINS} entries`);
249
+ throw new Error(
250
+ `domains must contain between 1 and ${MAX_SITE_DOMAINS} entries`,
251
+ );
171
252
  }
172
253
  const normalized = domains.map(normalizeDomainPattern);
173
254
  return [...new Set(normalized)].sort();
@@ -185,7 +266,11 @@ export function normalizeDomainPattern(value: string): string {
185
266
  throw new Error("domains cannot include paths or query strings");
186
267
  }
187
268
  hostname = url.hostname;
188
- } else if (hostInput.includes("/") || hostInput.includes("?") || hostInput.includes("#")) {
269
+ } else if (
270
+ hostInput.includes("/") ||
271
+ hostInput.includes("?") ||
272
+ hostInput.includes("#")
273
+ ) {
189
274
  throw new Error("domain is invalid");
190
275
  }
191
276
  hostname = hostname.replace(/\.$/, "");
@@ -214,7 +299,9 @@ export function originAllowed(origin: string, domains: string[]): boolean {
214
299
  });
215
300
  }
216
301
 
217
- export function validateTimezone(value: string | undefined): string | undefined {
302
+ export function validateTimezone(
303
+ value: string | undefined,
304
+ ): string | undefined {
218
305
  if (value === undefined || value.trim() === "") return undefined;
219
306
  const timezone = cleanString(value, 80);
220
307
  try {
@@ -225,17 +312,33 @@ export function validateTimezone(value: string | undefined): string | undefined
225
312
  }
226
313
  }
227
314
 
228
- export function sanitizeContext(context: IngestContext | undefined): IngestContext {
315
+ export function sanitizeContext(
316
+ context: IngestContext | undefined,
317
+ ): IngestContext {
229
318
  const latitude = finiteCoordinate(context?.latitude, -90, 90);
230
319
  const longitude = finiteCoordinate(context?.longitude, -180, 180);
231
320
  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,
321
+ country: context?.country
322
+ ? cleanString(context.country.toUpperCase(), 2) || undefined
323
+ : undefined,
324
+ city: context?.city
325
+ ? cleanString(context.city, 80) || undefined
326
+ : undefined,
327
+ latitude:
328
+ latitude === undefined ? undefined : Math.round(latitude * 10) / 10,
329
+ longitude:
330
+ longitude === undefined ? undefined : Math.round(longitude * 10) / 10,
331
+ browser: context?.browser
332
+ ? cleanString(context.browser, 32) || undefined
333
+ : undefined,
237
334
  os: context?.os ? cleanString(context.os, 32) || undefined : undefined,
238
- device: context?.device ? cleanString(context.device, 24) || undefined : undefined,
335
+ device: context?.device
336
+ ? cleanString(context.device, 24) || undefined
337
+ : undefined,
338
+ visitorKey:
339
+ context?.visitorKey && SAFE_OPAQUE_ID.test(context.visitorKey)
340
+ ? cleanString(context.visitorKey, 128) || undefined
341
+ : undefined,
239
342
  };
240
343
  }
241
344
 
@@ -249,10 +352,21 @@ export function isValidMoney(value: number): boolean {
249
352
  return Number.isSafeInteger(value) && value >= 0 && value <= 1_000_000_000;
250
353
  }
251
354
 
252
- export function ensureBatchBytes(value: unknown): number {
253
- const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength;
254
- if (bytes > MAX_BATCH_BYTES) throw new Error("batch exceeds 64 KiB");
255
- return bytes;
355
+ export function measureBatchBytes(
356
+ siteId: string,
357
+ events: TrackerEvent[],
358
+ ): { batchBytes: number; eventByteCounts: number[] } {
359
+ const encoder = new TextEncoder();
360
+ const eventByteCounts = events.map(
361
+ (event) => encoder.encode(JSON.stringify(event)).byteLength,
362
+ );
363
+ // The empty envelope already includes both array brackets; only commas remain.
364
+ const batchBytes =
365
+ encoder.encode(JSON.stringify({ siteId, events: [] })).byteLength +
366
+ eventByteCounts.reduce((total, byteCount) => total + byteCount, 0) +
367
+ Math.max(0, events.length - 1);
368
+ if (batchBytes > MAX_BATCH_BYTES) throw new Error("batch exceeds 64 KiB");
369
+ return { batchBytes, eventByteCounts };
256
370
  }
257
371
 
258
372
  export function sourceFromReferrer(referrer: string | undefined): string {
@@ -280,6 +394,7 @@ function finiteCoordinate(
280
394
  maximum: number,
281
395
  ): number | undefined {
282
396
  if (value === undefined) return undefined;
283
- if (!Number.isFinite(value) || value < minimum || value > maximum) return undefined;
397
+ if (!Number.isFinite(value) || value < minimum || value > maximum)
398
+ return undefined;
284
399
  return value;
285
400
  }
@@ -3,13 +3,18 @@ import { v } from "convex/values";
3
3
  import {
4
4
  affiliateFieldsValidator,
5
5
  aggregateFieldsValidator,
6
- storedEventFieldsValidator,
6
+ batchedEventValidator,
7
7
  funnelFieldsValidator,
8
8
  goalFieldsValidator,
9
9
  sessionFieldsValidator,
10
10
  siteFieldsValidator,
11
- trackerEventValidator,
11
+ visitorSketchValidator,
12
+ vitalMetricValidator,
12
13
  } from "./validators.js";
14
+ import {
15
+ reportRollupQueueFields,
16
+ rollupFoldStateFields,
17
+ } from "./rollupStore.js";
13
18
 
14
19
  export default defineSchema({
15
20
  sites: defineTable(siteFieldsValidator)
@@ -17,7 +22,12 @@ export default defineSchema({
17
22
  .index("by_ownerId_and_name", ["ownerId", "name"])
18
23
  .index("by_networkId", ["networkId"]),
19
24
 
20
- sessions: defineTable(sessionFieldsValidator)
25
+ sessions: defineTable(
26
+ sessionFieldsValidator.extend({
27
+ geoLookupAttemptedAt: v.optional(v.number()),
28
+ lastPageviewAt: v.optional(v.number()),
29
+ }),
30
+ )
21
31
  .index("by_siteId_and_sessionId", ["siteId", "sessionId"])
22
32
  .index("by_siteId_and_lastSeenAt", ["siteId", "lastSeenAt"])
23
33
  .index("by_siteId_and_startedAt", ["siteId", "startedAt"])
@@ -48,6 +58,8 @@ export default defineSchema({
48
58
  lastHeartbeatEventId: v.optional(v.string()),
49
59
  recentHeartbeatEventIds: v.optional(v.array(v.string())),
50
60
  eventCount: v.number(),
61
+ previousPath: v.optional(v.string()),
62
+ movedAt: v.optional(v.number()),
51
63
  })
52
64
  .index("by_siteId_and_sessionId", ["siteId", "sessionId"])
53
65
  .index("by_siteId_and_expiresAt", ["siteId", "expiresAt"])
@@ -59,25 +71,19 @@ export default defineSchema({
59
71
  scheduledAt: v.number(),
60
72
  }).index("by_siteId", ["siteId"]),
61
73
 
62
- events: defineTable(storedEventFieldsValidator)
63
- .index("by_siteId_and_eventId", ["siteId", "eventId"])
64
- .index("by_siteId_and_timestamp", ["siteId", "timestamp"])
65
- .index("by_siteId_and_sessionId_and_timestamp", [
66
- "siteId",
67
- "sessionId",
68
- "timestamp",
69
- ])
70
- .index("by_siteId_and_visitorId_and_timestamp", {
71
- fields: ["siteId", "visitorId", "timestamp"],
72
- staged: true,
73
- }),
74
+ geoLookupBudgets: defineTable({
75
+ siteId: v.id("sites"),
76
+ dayStart: v.number(),
77
+ count: v.number(),
78
+ }).index("by_siteId", ["siteId"]),
74
79
 
75
80
  eventBatches: defineTable({
76
81
  siteId: v.id("sites"),
77
82
  sessionId: v.string(),
78
83
  visitorId: v.string(),
79
84
  bucketStart: v.number(),
80
- events: v.array(trackerEventValidator),
85
+ events: v.array(batchedEventValidator),
86
+ migrationVersion: v.optional(v.literal(1)),
81
87
  })
82
88
  .index("by_siteId_and_sessionId_and_bucketStart", [
83
89
  "siteId",
@@ -85,10 +91,11 @@ export default defineSchema({
85
91
  "bucketStart",
86
92
  ])
87
93
  .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"])
88
- .index("by_siteId_and_visitorId_and_bucketStart", {
89
- fields: ["siteId", "visitorId", "bucketStart"],
90
- staged: true,
91
- }),
94
+ .index("by_siteId_and_visitorId_and_bucketStart", [
95
+ "siteId",
96
+ "visitorId",
97
+ "bucketStart",
98
+ ]),
92
99
 
93
100
  aggregateBuckets: defineTable(aggregateFieldsValidator)
94
101
  .index("by_siteId_and_granularity_and_bucketStart_and_shard", [
@@ -103,6 +110,176 @@ export default defineSchema({
103
110
  "bucketStart",
104
111
  ]),
105
112
 
113
+ routeDailyRollups: defineTable({
114
+ siteId: v.id("sites"),
115
+ bucketStart: v.number(),
116
+ shard: v.number(),
117
+ generation: v.literal(1),
118
+ route: v.string(),
119
+ pageviews: v.number(),
120
+ entries: v.number(),
121
+ continued: v.number(),
122
+ dwellMs: v.number(),
123
+ dwellSamples: v.number(),
124
+ visitorSketch: visitorSketchValidator,
125
+ outgoing: v.array(v.object({ route: v.string(), count: v.number() })),
126
+ })
127
+ .index("by_siteId_and_route_and_bucketStart_and_shard", [
128
+ "siteId",
129
+ "route",
130
+ "bucketStart",
131
+ "shard",
132
+ ])
133
+ .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
134
+
135
+ goalDailyRollups: defineTable({
136
+ siteId: v.id("sites"),
137
+ goalId: v.id("goals"),
138
+ bucketStart: v.number(),
139
+ shard: v.number(),
140
+ generation: v.literal(1),
141
+ goalKey: v.string(),
142
+ completions: v.number(),
143
+ valueCents: v.number(),
144
+ })
145
+ .index("by_siteId_and_goalId_and_bucketStart_and_shard", [
146
+ "siteId",
147
+ "goalId",
148
+ "bucketStart",
149
+ "shard",
150
+ ])
151
+ .index("by_siteId_and_goalId_and_bucketStart", [
152
+ "siteId",
153
+ "goalId",
154
+ "bucketStart",
155
+ ])
156
+ .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
157
+
158
+ funnelDailyRollups: defineTable({
159
+ siteId: v.id("sites"),
160
+ funnelId: v.id("funnels"),
161
+ bucketStart: v.number(),
162
+ shard: v.number(),
163
+ generation: v.literal(1),
164
+ steps: v.array(
165
+ v.object({
166
+ step: v.number(),
167
+ entrants: v.number(),
168
+ completions: v.number(),
169
+ }),
170
+ ),
171
+ })
172
+ .index("by_siteId_and_funnelId_and_bucketStart_and_shard", [
173
+ "siteId",
174
+ "funnelId",
175
+ "bucketStart",
176
+ "shard",
177
+ ])
178
+ .index("by_siteId_and_funnelId_and_bucketStart", [
179
+ "siteId",
180
+ "funnelId",
181
+ "bucketStart",
182
+ ])
183
+ .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
184
+
185
+ // Field Web Vitals. One row per site/day/shard and either a page (device
186
+ // "(all)") or a device class (page "(all)"); the row folds every metric's
187
+ // histogram together so a report reads five metrics for one document. Pages
188
+ // are bounded like dimension slots: at most VITAL_PAGE_SLOTS distinct pages
189
+ // per day, then "(other)".
190
+ vitalRollups: defineTable({
191
+ siteId: v.id("sites"),
192
+ bucketStart: v.number(),
193
+ device: v.string(),
194
+ page: v.string(),
195
+ shard: v.number(),
196
+ metrics: v.array(
197
+ v.object({
198
+ metric: vitalMetricValidator,
199
+ count: v.number(),
200
+ sum: v.number(),
201
+ histogram: v.array(v.number()),
202
+ }),
203
+ ),
204
+ })
205
+ .index("by_key", ["siteId", "bucketStart", "device", "page", "shard"])
206
+ .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
207
+
208
+ affiliateDailyRollups: defineTable({
209
+ siteId: v.id("sites"),
210
+ affiliateId: v.id("affiliates"),
211
+ bucketStart: v.number(),
212
+ shard: v.number(),
213
+ generation: v.literal(1),
214
+ affiliateSlug: v.string(),
215
+ visits: v.number(),
216
+ visitorSketch: visitorSketchValidator,
217
+ conversions: v.number(),
218
+ revenueCents: v.number(),
219
+ commissionCents: v.number(),
220
+ })
221
+ .index("by_siteId_and_affiliateId_and_bucketStart_and_shard", [
222
+ "siteId",
223
+ "affiliateId",
224
+ "bucketStart",
225
+ "shard",
226
+ ])
227
+ .index("by_siteId_and_affiliateId_and_bucketStart", [
228
+ "siteId",
229
+ "affiliateId",
230
+ "bucketStart",
231
+ ])
232
+ .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
233
+
234
+ // Rollup deltas wait here between ingestion and the fold job; see
235
+ // rollupStore.ts. Rows are deleted as they are folded.
236
+ reportRollupQueue: defineTable(reportRollupQueueFields).index(
237
+ "by_siteId_and_createdAt",
238
+ ["siteId", "createdAt"],
239
+ ),
240
+
241
+ rollupFoldStates: defineTable(rollupFoldStateFields).index("by_siteId", [
242
+ "siteId",
243
+ ]),
244
+
245
+ analyticsCoverage: defineTable({
246
+ siteId: v.id("sites"),
247
+ generation: v.literal(1),
248
+ rollupCompleteFrom: v.number(),
249
+ siteMapSince: v.optional(v.number()),
250
+ countersSince: v.optional(v.number()),
251
+ localDaySince: v.optional(v.number()),
252
+ retained: v.array(v.object({ dataset: v.string(), before: v.number() })),
253
+ updatedAt: v.number(),
254
+ }).index("by_siteId", ["siteId"]),
255
+
256
+ retentionPolicies: defineTable({
257
+ siteId: v.id("sites"),
258
+ eventsDays: v.number(),
259
+ sessionsDays: v.number(),
260
+ conversionsDays: v.number(),
261
+ aggregatesDays: v.number(),
262
+ reportsDays: v.number(),
263
+ updatedAt: v.number(),
264
+ }).index("by_siteId", ["siteId"]),
265
+
266
+ retentionStates: defineTable({
267
+ siteId: v.id("sites"),
268
+ kind: v.string(),
269
+ before: v.number(),
270
+ status: v.union(
271
+ v.literal("pending"),
272
+ v.literal("running"),
273
+ v.literal("complete"),
274
+ ),
275
+ generation: v.number(),
276
+ attempt: v.number(),
277
+ leaseExpiresAt: v.optional(v.number()),
278
+ scheduledAt: v.optional(v.number()),
279
+ deleted: v.number(),
280
+ updatedAt: v.number(),
281
+ }).index("by_siteId_and_kind", ["siteId", "kind"]),
282
+
106
283
  goals: defineTable(goalFieldsValidator)
107
284
  .index("by_siteId", ["siteId"])
108
285
  .index("by_siteId_and_key", ["siteId", "key"]),
@@ -119,7 +296,11 @@ export default defineSchema({
119
296
  valueCents: v.number(),
120
297
  })
121
298
  .index("by_siteId_and_goalId_and_eventId", ["siteId", "goalId", "eventId"])
122
- .index("by_siteId_and_goalId_and_timestamp", ["siteId", "goalId", "timestamp"])
299
+ .index("by_siteId_and_goalId_and_timestamp", [
300
+ "siteId",
301
+ "goalId",
302
+ "timestamp",
303
+ ])
123
304
  .index("by_siteId_and_timestamp", ["siteId", "timestamp"]),
124
305
 
125
306
  funnels: defineTable(funnelFieldsValidator)
@@ -171,6 +352,21 @@ export default defineSchema({
171
352
  .index("by_siteId", ["siteId"])
172
353
  .index("by_siteId_and_slug", ["siteId", "slug"]),
173
354
 
355
+ visitorSecrets: defineTable({
356
+ key: v.literal("current"),
357
+ value: v.string(),
358
+ createdAt: v.number(),
359
+ }).index("by_key", ["key"]),
360
+
361
+ visitorAliases: defineTable({
362
+ siteId: v.id("sites"),
363
+ visitorId: v.string(),
364
+ previousVisitorId: v.string(),
365
+ linkedAt: v.number(),
366
+ })
367
+ .index("by_siteId_and_visitorId", ["siteId", "visitorId"])
368
+ .index("by_siteId_and_previousVisitorId", ["siteId", "previousVisitorId"]),
369
+
174
370
  visitorAttributions: defineTable({
175
371
  siteId: v.id("sites"),
176
372
  visitorId: v.string(),