@iann29/rastro 0.1.0-alpha.3 → 0.1.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +374 -62
  2. package/agent/integration.md +651 -0
  3. package/agent/manifest.json +183 -0
  4. package/agent/manifest.schema.json +405 -0
  5. package/dist/client/federation.d.ts +205 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +179 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +1658 -8
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +228 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +10 -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 +169 -1
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/cardinality.d.ts +12 -0
  19. package/dist/component/cardinality.d.ts.map +1 -0
  20. package/dist/component/cardinality.js +94 -0
  21. package/dist/component/cardinality.js.map +1 -0
  22. package/dist/component/constants.d.ts +11 -0
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +11 -0
  25. package/dist/component/constants.js.map +1 -1
  26. package/dist/component/coverage.d.ts +18 -0
  27. package/dist/component/coverage.d.ts.map +1 -0
  28. package/dist/component/coverage.js +19 -0
  29. package/dist/component/coverage.js.map +1 -0
  30. package/dist/component/errors.d.ts +1 -1
  31. package/dist/component/errors.d.ts.map +1 -1
  32. package/dist/component/errors.js.map +1 -1
  33. package/dist/component/eventStore.d.ts +1 -0
  34. package/dist/component/eventStore.d.ts.map +1 -1
  35. package/dist/component/http.d.ts.map +1 -1
  36. package/dist/component/http.js +85 -36
  37. package/dist/component/http.js.map +1 -1
  38. package/dist/component/identity.d.ts +13 -0
  39. package/dist/component/identity.d.ts.map +1 -0
  40. package/dist/component/identity.js +58 -0
  41. package/dist/component/identity.js.map +1 -0
  42. package/dist/component/ingest.d.ts +1 -0
  43. package/dist/component/ingest.d.ts.map +1 -1
  44. package/dist/component/ingest.js +258 -19
  45. package/dist/component/ingest.js.map +1 -1
  46. package/dist/component/live.d.ts.map +1 -1
  47. package/dist/component/live.js +6 -3
  48. package/dist/component/live.js.map +1 -1
  49. package/dist/component/reports.d.ts +120 -2
  50. package/dist/component/reports.d.ts.map +1 -1
  51. package/dist/component/reports.js +659 -91
  52. package/dist/component/reports.js.map +1 -1
  53. package/dist/component/retention.d.ts +75 -1
  54. package/dist/component/retention.d.ts.map +1 -1
  55. package/dist/component/retention.js +517 -54
  56. package/dist/component/retention.js.map +1 -1
  57. package/dist/component/sanitize.d.ts +4 -1
  58. package/dist/component/sanitize.d.ts.map +1 -1
  59. package/dist/component/sanitize.js +13 -5
  60. package/dist/component/sanitize.js.map +1 -1
  61. package/dist/component/schema.d.ts +224 -7
  62. package/dist/component/schema.js +113 -1
  63. package/dist/component/schema.js.map +1 -1
  64. package/dist/component/sites.d.ts.map +1 -1
  65. package/dist/component/sites.js +5 -1
  66. package/dist/component/sites.js.map +1 -1
  67. package/dist/component/useragent.d.ts +9 -0
  68. package/dist/component/useragent.d.ts.map +1 -0
  69. package/dist/component/useragent.js +152 -0
  70. package/dist/component/useragent.js.map +1 -0
  71. package/dist/component/validators.d.ts +42 -11
  72. package/dist/component/validators.d.ts.map +1 -1
  73. package/dist/component/validators.js +10 -1
  74. package/dist/component/validators.js.map +1 -1
  75. package/dist/component/visitors.d.ts +19 -0
  76. package/dist/component/visitors.d.ts.map +1 -0
  77. package/dist/component/visitors.js +86 -0
  78. package/dist/component/visitors.js.map +1 -0
  79. package/dist/tracker/generated.d.ts +4 -4
  80. package/dist/tracker/generated.d.ts.map +1 -1
  81. package/dist/tracker/generated.js +4 -4
  82. package/dist/tracker/generated.js.map +1 -1
  83. package/dist/tracker/tracker.js +23 -11
  84. package/dist/tracker/tracker.js.map +1 -1
  85. package/dist/tracker.min.js +1 -1
  86. package/docs/benchmarks/2026-08-20-realistic.md +4 -4
  87. package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
  88. package/docs/federation-setup.md +395 -0
  89. package/docs/federation.md +258 -0
  90. package/docs/upgrading.md +130 -0
  91. package/llms.txt +65 -0
  92. package/package.json +41 -11
  93. package/scripts/generate-federation-keys.mjs +20 -0
  94. package/src/component/_generated/api.ts +10 -0
  95. package/src/component/_generated/component.ts +228 -1
  96. package/src/component/cardinality.ts +116 -0
  97. package/src/component/constants.ts +11 -0
  98. package/src/component/coverage.ts +25 -0
  99. package/src/component/errors.ts +2 -1
  100. package/src/component/http.ts +129 -38
  101. package/src/component/identity.ts +74 -0
  102. package/src/component/ingest.ts +414 -13
  103. package/src/component/live.ts +8 -3
  104. package/src/component/reports.ts +910 -98
  105. package/src/component/retention.ts +624 -88
  106. package/src/component/sanitize.ts +19 -5
  107. package/src/component/schema.ts +125 -0
  108. package/src/component/sites.ts +5 -1
  109. package/src/component/useragent.ts +171 -0
  110. package/src/component/validators.ts +11 -0
  111. package/src/component/visitors.ts +105 -0
@@ -147,7 +147,7 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
147
147
  sequence: event.sequence,
148
148
  target: event.target === undefined
149
149
  ? undefined
150
- : cleanString(event.target, 160) || undefined,
150
+ : cleanString(event.target.replace(/\s+/g, " "), 160) || undefined,
151
151
  href: event.href === undefined ? undefined : sanitizePublicUrl(event.href),
152
152
  properties: sanitizeProperties(event.properties),
153
153
  revenueCents: event.revenueCents,
@@ -236,6 +236,9 @@ export function sanitizeContext(context: IngestContext | undefined): IngestConte
236
236
  browser: context?.browser ? cleanString(context.browser, 32) || undefined : undefined,
237
237
  os: context?.os ? cleanString(context.os, 32) || undefined : undefined,
238
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
241
+ : undefined,
239
242
  };
240
243
  }
241
244
 
@@ -249,10 +252,21 @@ export function isValidMoney(value: number): boolean {
249
252
  return Number.isSafeInteger(value) && value >= 0 && value <= 1_000_000_000;
250
253
  }
251
254
 
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;
255
+ export function measureBatchBytes(
256
+ siteId: string,
257
+ events: TrackerEvent[],
258
+ ): { batchBytes: number; eventByteCounts: number[] } {
259
+ const encoder = new TextEncoder();
260
+ const eventByteCounts = events.map((event) =>
261
+ encoder.encode(JSON.stringify(event)).byteLength
262
+ );
263
+ // The empty envelope already includes both array brackets; only commas remain.
264
+ const batchBytes =
265
+ encoder.encode(JSON.stringify({ siteId, events: [] })).byteLength +
266
+ eventByteCounts.reduce((total, byteCount) => total + byteCount, 0) +
267
+ Math.max(0, events.length - 1);
268
+ if (batchBytes > MAX_BATCH_BYTES) throw new Error("batch exceeds 64 KiB");
269
+ return { batchBytes, eventByteCounts };
256
270
  }
257
271
 
258
272
  export function sourceFromReferrer(referrer: string | undefined): string {
@@ -8,6 +8,7 @@ import {
8
8
  goalFieldsValidator,
9
9
  sessionFieldsValidator,
10
10
  siteFieldsValidator,
11
+ visitorSketchValidator,
11
12
  } from "./validators.js";
12
13
 
13
14
  export default defineSchema({
@@ -101,6 +102,115 @@ export default defineSchema({
101
102
  "bucketStart",
102
103
  ]),
103
104
 
105
+ goalDailyRollups: defineTable({
106
+ siteId: v.id("sites"),
107
+ goalId: v.id("goals"),
108
+ bucketStart: v.number(),
109
+ shard: v.number(),
110
+ generation: v.literal(1),
111
+ goalKey: v.string(),
112
+ completions: v.number(),
113
+ valueCents: v.number(),
114
+ })
115
+ .index("by_siteId_and_goalId_and_bucketStart_and_shard", [
116
+ "siteId",
117
+ "goalId",
118
+ "bucketStart",
119
+ "shard",
120
+ ])
121
+ .index("by_siteId_and_goalId_and_bucketStart", [
122
+ "siteId",
123
+ "goalId",
124
+ "bucketStart",
125
+ ])
126
+ .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
127
+
128
+ funnelDailyRollups: defineTable({
129
+ siteId: v.id("sites"),
130
+ funnelId: v.id("funnels"),
131
+ bucketStart: v.number(),
132
+ shard: v.number(),
133
+ generation: v.literal(1),
134
+ steps: v.array(v.object({
135
+ step: v.number(),
136
+ entrants: v.number(),
137
+ completions: v.number(),
138
+ })),
139
+ })
140
+ .index("by_siteId_and_funnelId_and_bucketStart_and_shard", [
141
+ "siteId",
142
+ "funnelId",
143
+ "bucketStart",
144
+ "shard",
145
+ ])
146
+ .index("by_siteId_and_funnelId_and_bucketStart", [
147
+ "siteId",
148
+ "funnelId",
149
+ "bucketStart",
150
+ ])
151
+ .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
152
+
153
+ affiliateDailyRollups: defineTable({
154
+ siteId: v.id("sites"),
155
+ affiliateId: v.id("affiliates"),
156
+ bucketStart: v.number(),
157
+ shard: v.number(),
158
+ generation: v.literal(1),
159
+ affiliateSlug: v.string(),
160
+ visits: v.number(),
161
+ visitorSketch: visitorSketchValidator,
162
+ conversions: v.number(),
163
+ revenueCents: v.number(),
164
+ commissionCents: v.number(),
165
+ })
166
+ .index("by_siteId_and_affiliateId_and_bucketStart_and_shard", [
167
+ "siteId",
168
+ "affiliateId",
169
+ "bucketStart",
170
+ "shard",
171
+ ])
172
+ .index("by_siteId_and_affiliateId_and_bucketStart", [
173
+ "siteId",
174
+ "affiliateId",
175
+ "bucketStart",
176
+ ])
177
+ .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
178
+
179
+ analyticsCoverage: defineTable({
180
+ siteId: v.id("sites"),
181
+ generation: v.literal(1),
182
+ rollupCompleteFrom: v.number(),
183
+ retained: v.array(v.object({ dataset: v.string(), before: v.number() })),
184
+ updatedAt: v.number(),
185
+ }).index("by_siteId", ["siteId"]),
186
+
187
+ retentionPolicies: defineTable({
188
+ siteId: v.id("sites"),
189
+ eventsDays: v.number(),
190
+ sessionsDays: v.number(),
191
+ conversionsDays: v.number(),
192
+ aggregatesDays: v.number(),
193
+ reportsDays: v.number(),
194
+ updatedAt: v.number(),
195
+ }).index("by_siteId", ["siteId"]),
196
+
197
+ retentionStates: defineTable({
198
+ siteId: v.id("sites"),
199
+ kind: v.string(),
200
+ before: v.number(),
201
+ status: v.union(
202
+ v.literal("pending"),
203
+ v.literal("running"),
204
+ v.literal("complete"),
205
+ ),
206
+ generation: v.number(),
207
+ attempt: v.number(),
208
+ leaseExpiresAt: v.optional(v.number()),
209
+ scheduledAt: v.optional(v.number()),
210
+ deleted: v.number(),
211
+ updatedAt: v.number(),
212
+ }).index("by_siteId_and_kind", ["siteId", "kind"]),
213
+
104
214
  goals: defineTable(goalFieldsValidator)
105
215
  .index("by_siteId", ["siteId"])
106
216
  .index("by_siteId_and_key", ["siteId", "key"]),
@@ -173,6 +283,21 @@ export default defineSchema({
173
283
  .index("by_siteId", ["siteId"])
174
284
  .index("by_siteId_and_slug", ["siteId", "slug"]),
175
285
 
286
+ visitorSecrets: defineTable({
287
+ key: v.literal("current"),
288
+ value: v.string(),
289
+ createdAt: v.number(),
290
+ }).index("by_key", ["key"]),
291
+
292
+ visitorAliases: defineTable({
293
+ siteId: v.id("sites"),
294
+ visitorId: v.string(),
295
+ previousVisitorId: v.string(),
296
+ linkedAt: v.number(),
297
+ })
298
+ .index("by_siteId_and_visitorId", ["siteId", "visitorId"])
299
+ .index("by_siteId_and_previousVisitorId", ["siteId", "previousVisitorId"]),
300
+
176
301
  visitorAttributions: defineTable({
177
302
  siteId: v.id("sites"),
178
303
  visitorId: v.string(),
@@ -1,6 +1,7 @@
1
1
  import { v } from "convex/values";
2
2
  import { internalQuery, mutation, query } from "./_generated/server.js";
3
3
  import { MAX_SITES_PER_OWNER } from "./constants.js";
4
+ import { ensureAnalyticsControl } from "./coverage.js";
4
5
  import { fail } from "./errors.js";
5
6
  import {
6
7
  cleanString,
@@ -66,6 +67,7 @@ export const create = mutation({
66
67
  ) {
67
68
  fail("CONFLICT", "site name is already associated with different settings");
68
69
  }
70
+ await ensureAnalyticsControl(ctx, existing._id, Date.now());
69
71
  return existing._id;
70
72
  }
71
73
 
@@ -80,7 +82,7 @@ export const create = mutation({
80
82
  }
81
83
 
82
84
  const now = Date.now();
83
- return await ctx.db.insert("sites", {
85
+ const siteId = await ctx.db.insert("sites", {
84
86
  ownerId,
85
87
  name,
86
88
  domains,
@@ -91,6 +93,8 @@ export const create = mutation({
91
93
  createdAt: now,
92
94
  updatedAt: now,
93
95
  });
96
+ await ensureAnalyticsControl(ctx, siteId, now);
97
+ return siteId;
94
98
  },
95
99
  });
96
100
 
@@ -0,0 +1,171 @@
1
+ export type ClientClassification = {
2
+ browser: string;
3
+ os: string;
4
+ device: string;
5
+ };
6
+
7
+ // The tracker never reports the client: everything here is derived from headers
8
+ // the browser sets itself. Chromium sends the low-entropy `Sec-CH-UA` hints on
9
+ // cross-origin requests without an `Accept-CH` opt-in, which is the only way to
10
+ // tell Brave apart from Chrome — Brave deliberately ships Chrome's User-Agent.
11
+ // Firefox and Safari implement no client hints, so the User-Agent stays the
12
+ // fallback for them and for insecure origins, where no hint is sent at all.
13
+ const MAX_HINT_LENGTH = 512;
14
+ const MAX_HINT_BRANDS = 16;
15
+
16
+ const BRAND_PATTERN =
17
+ /"((?:[^"\\]|\\.)*)"(?:\s*;\s*v\s*=\s*"(?:[^"\\]|\\.)*")?/g;
18
+ const PLATFORM_PATTERN = /^\s*"((?:[^"\\]|\\.)*)"\s*$/;
19
+
20
+ // `Sec-CH-UA` is attacker-controlled on a public ingestion route, so brands are
21
+ // allowlisted: anything unrecognized (including the GREASE entries Chromium
22
+ // injects, such as `Not.A/Brand`) falls back to the User-Agent instead of
23
+ // entering the browser dimension verbatim.
24
+ const HINT_BRANDS = new Map<string, string>([
25
+ ["brave", "Brave"],
26
+ ["chromium", "Chromium"],
27
+ ["google chrome", "Chrome"],
28
+ ["microsoft edge", "Edge"],
29
+ ["opera", "Opera"],
30
+ ["opera gx", "Opera"],
31
+ ["samsung internet", "Samsung Internet"],
32
+ ["vivaldi", "Vivaldi"],
33
+ ["yandex", "Yandex"],
34
+ ]);
35
+
36
+ // Every Chromium brand list contains "Chromium", and Chrome adds "Google
37
+ // Chrome" beside it; both only win when no fork identifies itself.
38
+ const GENERIC_BRANDS = new Set(["Chrome", "Chromium"]);
39
+
40
+ const HINT_PLATFORMS = new Map<string, string>([
41
+ ["android", "Android"],
42
+ ["chrome os", "ChromeOS"],
43
+ ["chromium os", "ChromeOS"],
44
+ ["ios", "iOS"],
45
+ ["linux", "Linux"],
46
+ ["macos", "macOS"],
47
+ ["windows", "Windows"],
48
+ ]);
49
+
50
+ // Ordered: every Chromium fork carries `Chrome/`, and every Chromium and Gecko
51
+ // browser on iOS carries `Safari/`, so the specific tokens must be tested
52
+ // first. Brave has no token at all and is only detectable through the hints.
53
+ const AGENT_BROWSERS: readonly (readonly [RegExp, string])[] = [
54
+ [/Edg(?:A|iOS)?\//, "Edge"],
55
+ [/(?:Firefox|FxiOS)\//, "Firefox"],
56
+ [/OPR\//, "Opera"],
57
+ [/SamsungBrowser\//, "Samsung Internet"],
58
+ [/Vivaldi\//, "Vivaldi"],
59
+ [/YaBrowser\//, "Yandex"],
60
+ [/(?:Chrome|CriOS)\//, "Chrome"],
61
+ [/Safari\//, "Safari"],
62
+ ];
63
+
64
+ // Ordered: Android and ChromeOS user agents also carry the `Linux` token.
65
+ const AGENT_PLATFORMS: readonly (readonly [RegExp, string])[] = [
66
+ [/Android/, "Android"],
67
+ [/iPhone|iPad|iPod/, "iOS"],
68
+ [/CrOS/, "ChromeOS"],
69
+ [/Windows/, "Windows"],
70
+ [/Mac OS X/, "macOS"],
71
+ [/Linux/, "Linux"],
72
+ ];
73
+
74
+ // Ordered: tablet user agents that carry `Android` must not be read as mobile.
75
+ const AGENT_DEVICES: readonly (readonly [RegExp, string])[] = [
76
+ [/iPad|Tablet/, "tablet"],
77
+ [/Mobile|Android|iPhone/, "mobile"],
78
+ ];
79
+
80
+ export function classifyClient(
81
+ userAgent: string,
82
+ headers: Headers,
83
+ ): ClientClassification {
84
+ const agent = classifyUserAgent(userAgent);
85
+ const brand = brandFromClientHints(headers.get("sec-ch-ua"));
86
+ const platform = platformFromClientHints(headers.get("sec-ch-ua-platform"));
87
+ const mobile = mobileFromClientHints(headers.get("sec-ch-ua-mobile"));
88
+ return {
89
+ browser: brand ?? agent.browser,
90
+ os: platform ?? agent.os,
91
+ device: deviceFromClientHints(mobile, platform) ?? agent.device,
92
+ };
93
+ }
94
+
95
+ export function classifyUserAgent(userAgent: string): ClientClassification {
96
+ return {
97
+ browser: matchAgent(AGENT_BROWSERS, userAgent) ?? "Other",
98
+ os: matchAgent(AGENT_PLATFORMS, userAgent) ?? "Other",
99
+ device: matchAgent(AGENT_DEVICES, userAgent) ?? "desktop",
100
+ };
101
+ }
102
+
103
+ function matchAgent(
104
+ table: readonly (readonly [RegExp, string])[],
105
+ userAgent: string,
106
+ ): string | undefined {
107
+ for (const [pattern, value] of table) {
108
+ if (pattern.test(userAgent)) return value;
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ function brandFromClientHints(header: string | null): string | undefined {
114
+ if (!header || header.length > MAX_HINT_LENGTH) return undefined;
115
+ let generic: string | undefined;
116
+ let seen = 0;
117
+ for (const match of header.matchAll(BRAND_PATTERN)) {
118
+ if (seen >= MAX_HINT_BRANDS) break;
119
+ seen += 1;
120
+ const brand = HINT_BRANDS.get(unquote(match[1]).trim().toLowerCase());
121
+ if (!brand) continue;
122
+ if (!GENERIC_BRANDS.has(brand)) return brand;
123
+ if (brand === "Chrome" || generic === undefined) generic = brand;
124
+ }
125
+ return generic;
126
+ }
127
+
128
+ function platformFromClientHints(header: string | null): string | undefined {
129
+ if (!header || header.length > MAX_HINT_LENGTH) return undefined;
130
+ const match = PLATFORM_PATTERN.exec(header);
131
+ if (!match) return undefined;
132
+ return HINT_PLATFORMS.get(unquote(match[1]).trim().toLowerCase());
133
+ }
134
+
135
+ function mobileFromClientHints(header: string | null): boolean | undefined {
136
+ if (header === null) return undefined;
137
+ const value = header.trim();
138
+ if (value === "?1") return true;
139
+ if (value === "?0") return false;
140
+ return undefined;
141
+ }
142
+
143
+ function deviceFromClientHints(
144
+ mobile: boolean | undefined,
145
+ platform: string | undefined,
146
+ ): string | undefined {
147
+ if (mobile === undefined) return undefined;
148
+ if (mobile) return "mobile";
149
+ // Chrome on Android tablets reports `Sec-CH-UA-Mobile: ?0`. The explicit
150
+ // `Sec-CH-UA-Form-Factors` hint is high entropy, so it would need an
151
+ // `Accept-CH` opt-in and a permissions-policy delegation from every tracked
152
+ // site; "Android but not mobile" is the only tablet signal available here.
153
+ if (platform === "Android") return "tablet";
154
+ return "desktop";
155
+ }
156
+
157
+ function unquote(value: string): string {
158
+ return value.replace(/\\(.)/g, "$1");
159
+ }
160
+
161
+ // Self-declared crawlers, link unfurlers, uptime monitors, HTTP libraries, and
162
+ // headless browsers. A URL inside the User-Agent is the crawler convention
163
+ // ("+https://…/bot.html"); no browser ships one. An empty User-Agent is left
164
+ // alone because some runtimes do not expose it at all, and a scripted browser
165
+ // that spoofs a stock User-Agent is indistinguishable from a person here.
166
+ const BOT_PATTERN =
167
+ /bot|crawl|spider|slurp|headless|phantomjs|lighthouse|pingdom|gtmetrix|uptime|monitor|scrap|fetch|curl\/|wget\/|python|java\/|go-http-client|okhttp|axios|libwww|httpclient|facebookexternalhit|whatsapp|embedly|quora link preview|preview|mediapartners|feedfetcher|validator|https?:\/\//i;
168
+
169
+ export function isKnownBot(userAgent: string): boolean {
170
+ return BOT_PATTERN.test(userAgent);
171
+ }
@@ -47,6 +47,7 @@ export const ingestContextValidator = v.object({
47
47
  browser: v.optional(v.string()),
48
48
  os: v.optional(v.string()),
49
49
  device: v.optional(v.string()),
50
+ visitorKey: v.optional(v.string()),
50
51
  });
51
52
 
52
53
  export const batchedEventValidator = trackerEventValidator.extend(
@@ -72,6 +73,7 @@ export const sessionFieldsValidator = v.object({
72
73
  siteId: v.id("sites"),
73
74
  sessionId: v.string(),
74
75
  visitorId: v.string(),
76
+ visitorKey: v.optional(v.string()),
75
77
  startedAt: v.number(),
76
78
  lastSeenAt: v.number(),
77
79
  entryPath: v.string(),
@@ -128,6 +130,7 @@ export const eventFieldsValidator = v.object({
128
130
 
129
131
  export const dimensionTypeValidator = v.union(
130
132
  v.literal("source"),
133
+ v.literal("campaign"),
131
134
  v.literal("page"),
132
135
  v.literal("country"),
133
136
  v.literal("device"),
@@ -144,6 +147,12 @@ export const dimensionSlotValidator = v.object({
144
147
  revenueCents: v.number(),
145
148
  });
146
149
 
150
+ export const visitorSketchValidator = v.object({
151
+ version: v.literal(1),
152
+ precision: v.number(),
153
+ registers: v.bytes(),
154
+ });
155
+
147
156
  export const aggregateFieldsValidator = v.object({
148
157
  siteId: v.id("sites"),
149
158
  granularity: v.union(v.literal("hour"), v.literal("day")),
@@ -156,6 +165,8 @@ export const aggregateFieldsValidator = v.object({
156
165
  outboundClicks: v.number(),
157
166
  sessions: v.number(),
158
167
  visitors: v.number(),
168
+ visitorSketch: v.optional(visitorSketchValidator),
169
+ visitorSketchComplete: v.optional(v.boolean()),
159
170
  conversions: v.number(),
160
171
  revenueCents: v.number(),
161
172
  dimensions: v.array(dimensionSlotValidator),
@@ -0,0 +1,105 @@
1
+ import { v } from "convex/values";
2
+ import { MAX_VISITOR_ALIASES } from "./constants.js";
3
+ import { fail } from "./errors.js";
4
+ import { sanitizeOpaqueId } from "./sanitize.js";
5
+ import type { Id } from "./_generated/dataModel.js";
6
+ import { mutation, type QueryCtx } from "./_generated/server.js";
7
+
8
+ /**
9
+ * Folds an anonymous visitor id into the pseudonymous identity the host
10
+ * supplies after signup or sign-in. Links are one level deep: an alias can
11
+ * belong to one identity, an identity can never become an alias, and an
12
+ * alias can never own aliases, so journeys stay bounded and unambiguous.
13
+ */
14
+ export const link = mutation({
15
+ args: {
16
+ siteId: v.id("sites"),
17
+ visitorId: v.string(),
18
+ previousVisitorId: v.string(),
19
+ },
20
+ returns: v.object({ linked: v.boolean(), aliasCount: v.number() }),
21
+ handler: async (ctx, args) => {
22
+ const site = await ctx.db.get("sites", args.siteId);
23
+ if (!site) fail("NOT_FOUND", "site not found");
24
+ let visitorId: string;
25
+ let previousVisitorId: string;
26
+ try {
27
+ visitorId = sanitizeOpaqueId(args.visitorId, "visitorId");
28
+ previousVisitorId = sanitizeOpaqueId(
29
+ args.previousVisitorId,
30
+ "previousVisitorId",
31
+ );
32
+ } catch (error) {
33
+ fail(
34
+ "INVALID_ARGUMENT",
35
+ error instanceof Error ? error.message : "invalid visitor id",
36
+ );
37
+ }
38
+ if (visitorId === previousVisitorId) {
39
+ fail("INVALID_ARGUMENT", "previousVisitorId must differ from visitorId");
40
+ }
41
+ if (await findAlias(ctx, args.siteId, visitorId)) {
42
+ fail("CONFLICT", "visitorId is already linked to another visitor");
43
+ }
44
+ const previousOwnsAliases = await ctx.db
45
+ .query("visitorAliases")
46
+ .withIndex("by_siteId_and_visitorId", (range) =>
47
+ range.eq("siteId", args.siteId).eq("visitorId", previousVisitorId),
48
+ )
49
+ .first();
50
+ if (previousOwnsAliases) {
51
+ fail("CONFLICT", "previousVisitorId already owns linked visitors");
52
+ }
53
+ const aliases = await ctx.db
54
+ .query("visitorAliases")
55
+ .withIndex("by_siteId_and_visitorId", (range) =>
56
+ range.eq("siteId", args.siteId).eq("visitorId", visitorId),
57
+ )
58
+ .take(MAX_VISITOR_ALIASES);
59
+ const existing = await findAlias(ctx, args.siteId, previousVisitorId);
60
+ if (existing) {
61
+ if (existing.visitorId !== visitorId) {
62
+ fail("CONFLICT", "previousVisitorId is already linked to a different visitor");
63
+ }
64
+ return { linked: false, aliasCount: aliases.length };
65
+ }
66
+ 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
+ }
71
+ await ctx.db.insert("visitorAliases", {
72
+ siteId: args.siteId,
73
+ visitorId,
74
+ previousVisitorId,
75
+ linkedAt: Date.now(),
76
+ });
77
+ return { linked: true, aliasCount: aliases.length + 1 };
78
+ },
79
+ });
80
+
81
+ /** The identity a visitor id resolves to, followed by every alias it owns. */
82
+ export async function resolveVisitorIdentities(
83
+ ctx: QueryCtx,
84
+ siteId: Id<"sites">,
85
+ visitorId: string,
86
+ ): Promise<string[]> {
87
+ const alias = await findAlias(ctx, siteId, visitorId);
88
+ const identity = alias?.visitorId ?? visitorId;
89
+ const aliases = await ctx.db
90
+ .query("visitorAliases")
91
+ .withIndex("by_siteId_and_visitorId", (range) =>
92
+ range.eq("siteId", siteId).eq("visitorId", identity),
93
+ )
94
+ .take(MAX_VISITOR_ALIASES);
95
+ return [identity, ...aliases.map((row) => row.previousVisitorId)];
96
+ }
97
+
98
+ function findAlias(ctx: QueryCtx, siteId: Id<"sites">, previousVisitorId: string) {
99
+ return ctx.db
100
+ .query("visitorAliases")
101
+ .withIndex("by_siteId_and_previousVisitorId", (range) =>
102
+ range.eq("siteId", siteId).eq("previousVisitorId", previousVisitorId),
103
+ )
104
+ .unique();
105
+ }