@iann29/rastro 0.1.0-alpha.0

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 (126) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +352 -0
  3. package/assets/rastro-live-atlas.png +0 -0
  4. package/dist/client/_generated/_ignore.d.ts +1 -0
  5. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  6. package/dist/client/_generated/_ignore.js +3 -0
  7. package/dist/client/_generated/_ignore.js.map +1 -0
  8. package/dist/client/index.d.ts +960 -0
  9. package/dist/client/index.d.ts.map +1 -0
  10. package/dist/client/index.js +591 -0
  11. package/dist/client/index.js.map +1 -0
  12. package/dist/component/_generated/api.d.ts +60 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -0
  14. package/dist/component/_generated/api.js +31 -0
  15. package/dist/component/_generated/api.js.map +1 -0
  16. package/dist/component/_generated/component.d.ts +569 -0
  17. package/dist/component/_generated/component.d.ts.map +1 -0
  18. package/dist/component/_generated/component.js +11 -0
  19. package/dist/component/_generated/component.js.map +1 -0
  20. package/dist/component/_generated/dataModel.d.ts +46 -0
  21. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  22. package/dist/component/_generated/dataModel.js +11 -0
  23. package/dist/component/_generated/dataModel.js.map +1 -0
  24. package/dist/component/_generated/server.d.ts +133 -0
  25. package/dist/component/_generated/server.d.ts.map +1 -0
  26. package/dist/component/_generated/server.js +80 -0
  27. package/dist/component/_generated/server.js.map +1 -0
  28. package/dist/component/affiliates.d.ts +28 -0
  29. package/dist/component/affiliates.d.ts.map +1 -0
  30. package/dist/component/affiliates.js +110 -0
  31. package/dist/component/affiliates.js.map +1 -0
  32. package/dist/component/constants.d.ts +28 -0
  33. package/dist/component/constants.d.ts.map +1 -0
  34. package/dist/component/constants.js +28 -0
  35. package/dist/component/constants.js.map +1 -0
  36. package/dist/component/convex.config.d.ts +3 -0
  37. package/dist/component/convex.config.d.ts.map +1 -0
  38. package/dist/component/convex.config.js +3 -0
  39. package/dist/component/convex.config.js.map +1 -0
  40. package/dist/component/errors.d.ts +3 -0
  41. package/dist/component/errors.d.ts.map +1 -0
  42. package/dist/component/errors.js +5 -0
  43. package/dist/component/errors.js.map +1 -0
  44. package/dist/component/funnels.d.ts +36 -0
  45. package/dist/component/funnels.d.ts.map +1 -0
  46. package/dist/component/funnels.js +117 -0
  47. package/dist/component/funnels.js.map +1 -0
  48. package/dist/component/goals.d.ts +30 -0
  49. package/dist/component/goals.d.ts.map +1 -0
  50. package/dist/component/goals.js +110 -0
  51. package/dist/component/goals.js.map +1 -0
  52. package/dist/component/guards.d.ts +2 -0
  53. package/dist/component/guards.d.ts.map +1 -0
  54. package/dist/component/guards.js +4 -0
  55. package/dist/component/guards.js.map +1 -0
  56. package/dist/component/http.d.ts +3 -0
  57. package/dist/component/http.d.ts.map +1 -0
  58. package/dist/component/http.js +270 -0
  59. package/dist/component/http.js.map +1 -0
  60. package/dist/component/ingest.d.ts +51 -0
  61. package/dist/component/ingest.d.ts.map +1 -0
  62. package/dist/component/ingest.js +991 -0
  63. package/dist/component/ingest.js.map +1 -0
  64. package/dist/component/live.d.ts +9 -0
  65. package/dist/component/live.d.ts.map +1 -0
  66. package/dist/component/live.js +36 -0
  67. package/dist/component/live.js.map +1 -0
  68. package/dist/component/reports.d.ts +330 -0
  69. package/dist/component/reports.d.ts.map +1 -0
  70. package/dist/component/reports.js +595 -0
  71. package/dist/component/reports.js.map +1 -0
  72. package/dist/component/retention.d.ts +10 -0
  73. package/dist/component/retention.d.ts.map +1 -0
  74. package/dist/component/retention.js +107 -0
  75. package/dist/component/retention.js.map +1 -0
  76. package/dist/component/sanitize.d.ts +20 -0
  77. package/dist/component/sanitize.d.ts.map +1 -0
  78. package/dist/component/sanitize.js +269 -0
  79. package/dist/component/sanitize.js.map +1 -0
  80. package/dist/component/schema.d.ts +517 -0
  81. package/dist/component/schema.d.ts.map +1 -0
  82. package/dist/component/schema.js +209 -0
  83. package/dist/component/schema.js.map +1 -0
  84. package/dist/component/sites.d.ts +54 -0
  85. package/dist/component/sites.d.ts.map +1 -0
  86. package/dist/component/sites.js +164 -0
  87. package/dist/component/sites.js.map +1 -0
  88. package/dist/component/validators.d.ts +377 -0
  89. package/dist/component/validators.d.ts.map +1 -0
  90. package/dist/component/validators.js +176 -0
  91. package/dist/component/validators.js.map +1 -0
  92. package/dist/react/index.d.ts +40 -0
  93. package/dist/react/index.d.ts.map +1 -0
  94. package/dist/react/index.js +31 -0
  95. package/dist/react/index.js.map +1 -0
  96. package/dist/tracker/generated.d.ts +5 -0
  97. package/dist/tracker/generated.d.ts.map +1 -0
  98. package/dist/tracker/generated.js +6 -0
  99. package/dist/tracker/generated.js.map +1 -0
  100. package/dist/tracker/tracker.d.ts +27 -0
  101. package/dist/tracker/tracker.d.ts.map +1 -0
  102. package/dist/tracker/tracker.js +108 -0
  103. package/dist/tracker/tracker.js.map +1 -0
  104. package/dist/tracker.min.js +1 -0
  105. package/package.json +121 -0
  106. package/src/component/_generated/api.ts +76 -0
  107. package/src/component/_generated/component.ts +706 -0
  108. package/src/component/_generated/dataModel.ts +60 -0
  109. package/src/component/_generated/server.ts +169 -0
  110. package/src/component/affiliates.ts +119 -0
  111. package/src/component/constants.ts +28 -0
  112. package/src/component/convex.config.ts +3 -0
  113. package/src/component/errors.ts +20 -0
  114. package/src/component/funnels.ts +134 -0
  115. package/src/component/goals.ts +122 -0
  116. package/src/component/guards.ts +3 -0
  117. package/src/component/http.ts +301 -0
  118. package/src/component/ingest.ts +1364 -0
  119. package/src/component/live.ts +36 -0
  120. package/src/component/reports.ts +720 -0
  121. package/src/component/retention.ts +180 -0
  122. package/src/component/sanitize.ts +278 -0
  123. package/src/component/schema.ts +232 -0
  124. package/src/component/sites.ts +188 -0
  125. package/src/component/validators.ts +226 -0
  126. package/src/test.ts +19 -0
@@ -0,0 +1,180 @@
1
+ import { v } from "convex/values";
2
+ import type { Id } from "./_generated/dataModel.js";
3
+ import type { MutationCtx } from "./_generated/server.js";
4
+ import { mutation } from "./_generated/server.js";
5
+ import { MAX_RETENTION_BATCH } from "./constants.js";
6
+ import { fail } from "./errors.js";
7
+
8
+ const cleanupKindValidator = v.union(
9
+ v.literal("events"),
10
+ v.literal("sessions"),
11
+ v.literal("conversions"),
12
+ v.literal("goalCompletions"),
13
+ v.literal("aggregateHour"),
14
+ v.literal("aggregateDay"),
15
+ v.literal("funnelProgress"),
16
+ v.literal("funnelSteps"),
17
+ v.literal("affiliateStats"),
18
+ v.literal("attributions"),
19
+ v.literal("liveSessions"),
20
+ v.literal("ingestWindows"),
21
+ v.literal("siteIngestWindows"),
22
+ );
23
+
24
+ export const cleanup = mutation({
25
+ args: {
26
+ siteId: v.id("sites"),
27
+ kind: cleanupKindValidator,
28
+ before: v.number(),
29
+ batchSize: v.optional(v.number()),
30
+ },
31
+ returns: v.object({ deleted: v.number(), hasMore: v.boolean() }),
32
+ handler: async (ctx, args) => {
33
+ if (!Number.isFinite(args.before) || args.before < 0) {
34
+ fail("INVALID_ARGUMENT", "before must be a positive timestamp");
35
+ }
36
+ const batchSize = args.batchSize ?? 100;
37
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > MAX_RETENTION_BATCH) {
38
+ fail("INVALID_ARGUMENT", `batchSize must be an integer between 1 and ${MAX_RETENTION_BATCH}`);
39
+ }
40
+ const deletions = await deletionsForCleanup(
41
+ ctx,
42
+ args.siteId,
43
+ args.kind,
44
+ args.before,
45
+ batchSize + 1,
46
+ );
47
+ for (const remove of deletions.slice(0, batchSize)) {
48
+ await remove();
49
+ }
50
+ return {
51
+ deleted: Math.min(deletions.length, batchSize),
52
+ hasMore: deletions.length > batchSize,
53
+ };
54
+ },
55
+ });
56
+
57
+ type CleanupKind =
58
+ | "events"
59
+ | "sessions"
60
+ | "conversions"
61
+ | "goalCompletions"
62
+ | "aggregateHour"
63
+ | "aggregateDay"
64
+ | "funnelProgress"
65
+ | "funnelSteps"
66
+ | "affiliateStats"
67
+ | "attributions"
68
+ | "liveSessions"
69
+ | "ingestWindows"
70
+ | "siteIngestWindows";
71
+
72
+ type CleanupDelete = () => Promise<void>;
73
+
74
+ async function deletionsForCleanup(
75
+ ctx: MutationCtx,
76
+ siteId: Id<"sites">,
77
+ kind: CleanupKind,
78
+ before: number,
79
+ limit: number,
80
+ ): Promise<CleanupDelete[]> {
81
+ if (kind === "events") {
82
+ return (await ctx.db
83
+ .query("events")
84
+ .withIndex("by_siteId_and_timestamp", (range) =>
85
+ range.eq("siteId", siteId).lt("timestamp", before),
86
+ )
87
+ .take(limit)).map((row) => () => ctx.db.delete("events", row._id));
88
+ }
89
+ if (kind === "sessions") {
90
+ return (await ctx.db
91
+ .query("sessions")
92
+ .withIndex("by_siteId_and_lastSeenAt", (range) =>
93
+ range.eq("siteId", siteId).lt("lastSeenAt", before),
94
+ )
95
+ .take(limit)).map((row) => () => ctx.db.delete("sessions", row._id));
96
+ }
97
+ if (kind === "conversions") {
98
+ return (await ctx.db
99
+ .query("conversions")
100
+ .withIndex("by_siteId_and_timestamp", (range) =>
101
+ range.eq("siteId", siteId).lt("timestamp", before),
102
+ )
103
+ .take(limit)).map((row) => () => ctx.db.delete("conversions", row._id));
104
+ }
105
+ if (kind === "goalCompletions") {
106
+ return (await ctx.db
107
+ .query("goalCompletions")
108
+ .withIndex("by_siteId_and_timestamp", (range) =>
109
+ range.eq("siteId", siteId).lt("timestamp", before),
110
+ )
111
+ .take(limit)).map((row) => () => ctx.db.delete("goalCompletions", row._id));
112
+ }
113
+ if (kind === "aggregateHour" || kind === "aggregateDay") {
114
+ return (await ctx.db
115
+ .query("aggregateBuckets")
116
+ .withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
117
+ range
118
+ .eq("siteId", siteId)
119
+ .eq("granularity", kind === "aggregateHour" ? "hour" : "day")
120
+ .lt("bucketStart", before),
121
+ )
122
+ .take(limit)).map((row) => () => ctx.db.delete("aggregateBuckets", row._id));
123
+ }
124
+ if (kind === "funnelProgress") {
125
+ return (await ctx.db
126
+ .query("funnelProgress")
127
+ .withIndex("by_siteId_and_lastStepAt", (range) =>
128
+ range.eq("siteId", siteId).lt("lastStepAt", before),
129
+ )
130
+ .take(limit)).map((row) => () => ctx.db.delete("funnelProgress", row._id));
131
+ }
132
+ if (kind === "funnelSteps") {
133
+ return (await ctx.db
134
+ .query("funnelStepBuckets")
135
+ .withIndex("by_siteId_and_bucketStart", (range) =>
136
+ range.eq("siteId", siteId).lt("bucketStart", before),
137
+ )
138
+ .take(limit)).map((row) => () => ctx.db.delete("funnelStepBuckets", row._id));
139
+ }
140
+ if (kind === "affiliateStats") {
141
+ return (await ctx.db
142
+ .query("affiliateDailyStats")
143
+ .withIndex("by_siteId_and_bucketStart", (range) =>
144
+ range.eq("siteId", siteId).lt("bucketStart", before),
145
+ )
146
+ .take(limit)).map((row) => () => ctx.db.delete("affiliateDailyStats", row._id));
147
+ }
148
+ if (kind === "attributions") {
149
+ return (await ctx.db
150
+ .query("visitorAttributions")
151
+ .withIndex("by_siteId_and_expiresAt", (range) =>
152
+ range.eq("siteId", siteId).lt("expiresAt", before),
153
+ )
154
+ .take(limit)).map((row) => () =>
155
+ ctx.db.delete("visitorAttributions", row._id),
156
+ );
157
+ }
158
+ if (kind === "liveSessions") {
159
+ return (await ctx.db
160
+ .query("liveSessions")
161
+ .withIndex("by_siteId_and_expiresAt", (range) =>
162
+ range.eq("siteId", siteId).lt("expiresAt", before),
163
+ )
164
+ .take(limit)).map((row) => () => ctx.db.delete("liveSessions", row._id));
165
+ }
166
+ if (kind === "siteIngestWindows") {
167
+ return (await ctx.db
168
+ .query("siteIngestWindows")
169
+ .withIndex("by_siteId_and_updatedAt", (range) =>
170
+ range.eq("siteId", siteId).lt("updatedAt", before),
171
+ )
172
+ .take(limit)).map((row) => () => ctx.db.delete("siteIngestWindows", row._id));
173
+ }
174
+ return (await ctx.db
175
+ .query("ingestWindows")
176
+ .withIndex("by_siteId_and_updatedAt", (range) =>
177
+ range.eq("siteId", siteId).lt("updatedAt", before),
178
+ )
179
+ .take(limit)).map((row) => () => ctx.db.delete("ingestWindows", row._id));
180
+ }
@@ -0,0 +1,278 @@
1
+ import {
2
+ MAX_BATCH_BYTES,
3
+ MAX_PROPERTIES,
4
+ MAX_SITE_DOMAINS,
5
+ } from "./constants.js";
6
+ import type {
7
+ EventProperties,
8
+ IngestContext,
9
+ TrackerEvent,
10
+ } from "./validators.js";
11
+
12
+ const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
13
+ const SAFE_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/;
14
+ const SAFE_OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/;
15
+ const SAFE_SLUG = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
16
+ const MAX_EVENT_AGE_MS = 400 * 86_400_000;
17
+ const MAX_FUTURE_SKEW_MS = 5 * 60_000;
18
+
19
+ export function cleanString(value: string, maxLength: number): string {
20
+ let safe = "";
21
+ for (const character of value) {
22
+ const codePoint = character.codePointAt(0) ?? 0;
23
+ if (codePoint <= 0x1f || codePoint === 0x7f) continue;
24
+ safe += character;
25
+ }
26
+ const normalized = safe.trim();
27
+ let end = 0;
28
+ let length = 0;
29
+ for (const character of normalized) {
30
+ if (length === maxLength) break;
31
+ end += character.length;
32
+ length += 1;
33
+ }
34
+ return normalized.slice(0, end);
35
+ }
36
+
37
+ export function requireString(
38
+ value: string,
39
+ field: string,
40
+ maxLength: number,
41
+ ): string {
42
+ const cleaned = cleanString(value, maxLength);
43
+ if (!cleaned) throw new Error(`${field} is required`);
44
+ return cleaned;
45
+ }
46
+
47
+ export function sanitizeOpaqueId(value: string, field: string): string {
48
+ const cleaned = requireString(value, field, 128);
49
+ if (!SAFE_OPAQUE_ID.test(cleaned)) {
50
+ throw new Error(`${field} contains unsupported characters`);
51
+ }
52
+ return cleaned;
53
+ }
54
+
55
+ export function sanitizeSlug(value: string, field = "slug"): string {
56
+ const cleaned = cleanString(value.toLowerCase(), 64);
57
+ if (!SAFE_SLUG.test(cleaned)) throw new Error(`${field} is invalid`);
58
+ return cleaned;
59
+ }
60
+
61
+ export function sanitizePath(value: string): string {
62
+ const input = requireString(value, "path", 2048);
63
+ let path: string;
64
+ try {
65
+ path = input.startsWith("http://") || input.startsWith("https://")
66
+ ? new URL(input).pathname
67
+ : input.split(/[?#]/, 1)[0];
68
+ } catch {
69
+ throw new Error("path is invalid");
70
+ }
71
+ if (!path.startsWith("/")) path = `/${path}`;
72
+ const cleaned = cleanString(path, 512);
73
+ return cleaned || "/";
74
+ }
75
+
76
+ export function sanitizePublicUrl(value: string): string | undefined {
77
+ const input = cleanString(value, 2048);
78
+ if (!input) return undefined;
79
+ try {
80
+ if (input.startsWith("/")) return sanitizePath(input);
81
+ const url = new URL(input);
82
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
83
+ const port = url.port ? `:${url.port}` : "";
84
+ return `${url.protocol}//${url.hostname.toLowerCase()}${port}${sanitizePath(url.pathname)}`;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ export function sanitizeProperties(
91
+ properties: EventProperties | undefined,
92
+ ): EventProperties | undefined {
93
+ if (!properties) return undefined;
94
+ const entries = Object.entries(properties);
95
+ if (entries.length > MAX_PROPERTIES) throw new Error("too many properties");
96
+ const result: EventProperties = {};
97
+ for (const [rawKey, rawValue] of entries) {
98
+ const key = cleanString(rawKey, 40);
99
+ if (!SAFE_KEY.test(key)) throw new Error("property key is invalid");
100
+ if (typeof rawValue === "string") {
101
+ result[key] = cleanString(rawValue, 160);
102
+ } else if (typeof rawValue === "number") {
103
+ if (!Number.isFinite(rawValue) || Math.abs(rawValue) > 1_000_000_000) {
104
+ throw new Error("property number is out of range");
105
+ }
106
+ result[key] = rawValue;
107
+ } else if (typeof rawValue === "boolean" || rawValue === null) {
108
+ result[key] = rawValue;
109
+ }
110
+ }
111
+ return Object.keys(result).length > 0 ? result : undefined;
112
+ }
113
+
114
+ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
115
+ if (!Number.isFinite(event.timestamp)) throw new Error("timestamp is invalid");
116
+ if (
117
+ event.timestamp < now - MAX_EVENT_AGE_MS ||
118
+ event.timestamp > now + MAX_FUTURE_SKEW_MS
119
+ ) {
120
+ throw new Error("timestamp is outside the accepted window");
121
+ }
122
+ if (!Number.isSafeInteger(event.sequence) || event.sequence < 0 || event.sequence > 1_000_000_000) {
123
+ throw new Error("sequence is out of range");
124
+ }
125
+ if (event.revenueCents !== undefined && !isValidMoney(event.revenueCents)) {
126
+ throw new Error("revenueCents is out of range");
127
+ }
128
+ let name = event.name === undefined ? undefined : cleanString(event.name, 80);
129
+ if (event.type === "custom" && !name) {
130
+ throw new Error("name is required for custom events");
131
+ }
132
+ if (event.type === "conversion" && !name) name = "conversion";
133
+ const currency = event.currency === undefined
134
+ ? event.type === "conversion" ? "USD" : undefined
135
+ : sanitizeCurrency(event.currency);
136
+ return {
137
+ eventId: sanitizeOpaqueId(event.eventId, "eventId"),
138
+ sessionId: sanitizeOpaqueId(event.sessionId, "sessionId"),
139
+ visitorId: sanitizeOpaqueId(event.visitorId, "visitorId"),
140
+ type: event.type,
141
+ name,
142
+ path: sanitizePath(event.path),
143
+ referrer: event.referrer === undefined
144
+ ? undefined
145
+ : sanitizePublicUrl(event.referrer),
146
+ timestamp: Math.trunc(event.timestamp),
147
+ sequence: event.sequence,
148
+ target: event.target === undefined
149
+ ? undefined
150
+ : cleanString(event.target, 160) || undefined,
151
+ href: event.href === undefined ? undefined : sanitizePublicUrl(event.href),
152
+ properties: sanitizeProperties(event.properties),
153
+ revenueCents: event.revenueCents,
154
+ currency,
155
+ affiliateSlug: event.affiliateSlug === undefined
156
+ ? undefined
157
+ : sanitizeSlug(event.affiliateSlug, "affiliateSlug"),
158
+ };
159
+ }
160
+
161
+ export function normalizeDomains(domains: string[]): string[] {
162
+ if (domains.length === 0 || domains.length > MAX_SITE_DOMAINS) {
163
+ throw new Error(`domains must contain between 1 and ${MAX_SITE_DOMAINS} entries`);
164
+ }
165
+ const normalized = domains.map(normalizeDomainPattern);
166
+ return [...new Set(normalized)].sort();
167
+ }
168
+
169
+ export function normalizeDomainPattern(value: string): string {
170
+ const raw = cleanString(value.toLowerCase(), 255);
171
+ const wildcard = raw.startsWith("*.");
172
+ const hostInput = wildcard ? raw.slice(2) : raw;
173
+ let hostname = hostInput;
174
+ if (hostInput.includes("://")) {
175
+ if (wildcard) throw new Error("wildcard domains cannot include a scheme");
176
+ const url = new URL(hostInput);
177
+ if (url.pathname !== "/" || url.search || url.hash) {
178
+ throw new Error("domains cannot include paths or query strings");
179
+ }
180
+ hostname = url.hostname;
181
+ } else if (hostInput.includes("/") || hostInput.includes("?") || hostInput.includes("#")) {
182
+ throw new Error("domain is invalid");
183
+ }
184
+ hostname = hostname.replace(/\.$/, "");
185
+ if (hostname !== "localhost") {
186
+ const labels = hostname.split(".");
187
+ if (labels.length < 2 || labels.some((label) => !HOST_LABEL.test(label))) {
188
+ throw new Error("domain is invalid");
189
+ }
190
+ }
191
+ return wildcard ? `*.${hostname}` : hostname;
192
+ }
193
+
194
+ export function originAllowed(origin: string, domains: string[]): boolean {
195
+ let hostname: string;
196
+ try {
197
+ const url = new URL(origin);
198
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
199
+ hostname = url.hostname.toLowerCase().replace(/\.$/, "");
200
+ } catch {
201
+ return false;
202
+ }
203
+ return domains.some((pattern) => {
204
+ if (!pattern.startsWith("*.")) return hostname === pattern;
205
+ const suffix = pattern.slice(2);
206
+ return hostname !== suffix && hostname.endsWith(`.${suffix}`);
207
+ });
208
+ }
209
+
210
+ export function validateTimezone(value: string | undefined): string | undefined {
211
+ if (value === undefined || value.trim() === "") return undefined;
212
+ const timezone = cleanString(value, 80);
213
+ try {
214
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(0);
215
+ return timezone;
216
+ } catch {
217
+ throw new Error("timezone is invalid");
218
+ }
219
+ }
220
+
221
+ export function sanitizeContext(context: IngestContext | undefined): IngestContext {
222
+ const latitude = finiteCoordinate(context?.latitude, -90, 90);
223
+ const longitude = finiteCoordinate(context?.longitude, -180, 180);
224
+ return {
225
+ country: context?.country ? cleanString(context.country.toUpperCase(), 2) || undefined : undefined,
226
+ city: context?.city ? cleanString(context.city, 80) || undefined : undefined,
227
+ latitude: latitude === undefined ? undefined : Math.round(latitude * 10) / 10,
228
+ longitude: longitude === undefined ? undefined : Math.round(longitude * 10) / 10,
229
+ browser: context?.browser ? cleanString(context.browser, 32) || undefined : undefined,
230
+ os: context?.os ? cleanString(context.os, 32) || undefined : undefined,
231
+ device: context?.device ? cleanString(context.device, 24) || undefined : undefined,
232
+ };
233
+ }
234
+
235
+ export function sanitizeCurrency(value: string): string {
236
+ const currency = cleanString(value.toUpperCase(), 3);
237
+ if (!/^[A-Z]{3}$/.test(currency)) throw new Error("currency is invalid");
238
+ return currency;
239
+ }
240
+
241
+ export function isValidMoney(value: number): boolean {
242
+ return Number.isSafeInteger(value) && value >= 0 && value <= 1_000_000_000;
243
+ }
244
+
245
+ export function ensureBatchBytes(value: unknown): number {
246
+ const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength;
247
+ if (bytes > MAX_BATCH_BYTES) throw new Error("batch exceeds 64 KiB");
248
+ return bytes;
249
+ }
250
+
251
+ export function sourceFromReferrer(referrer: string | undefined): string {
252
+ if (!referrer) return "direct";
253
+ if (referrer.startsWith("/")) return "internal";
254
+ try {
255
+ return new URL(referrer).hostname.toLowerCase().slice(0, 120) || "direct";
256
+ } catch {
257
+ return "direct";
258
+ }
259
+ }
260
+
261
+ export function stableHash(value: string): number {
262
+ let hash = 2_166_136_261;
263
+ for (let index = 0; index < value.length; index += 1) {
264
+ hash ^= value.charCodeAt(index);
265
+ hash = Math.imul(hash, 16_777_619);
266
+ }
267
+ return hash >>> 0;
268
+ }
269
+
270
+ function finiteCoordinate(
271
+ value: number | undefined,
272
+ minimum: number,
273
+ maximum: number,
274
+ ): number | undefined {
275
+ if (value === undefined) return undefined;
276
+ if (!Number.isFinite(value) || value < minimum || value > maximum) return undefined;
277
+ return value;
278
+ }
@@ -0,0 +1,232 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import {
4
+ affiliateFieldsValidator,
5
+ aggregateFieldsValidator,
6
+ eventFieldsValidator,
7
+ funnelFieldsValidator,
8
+ goalFieldsValidator,
9
+ sessionFieldsValidator,
10
+ siteFieldsValidator,
11
+ } from "./validators.js";
12
+
13
+ export default defineSchema({
14
+ sites: defineTable(siteFieldsValidator)
15
+ .index("by_ownerId", ["ownerId"])
16
+ .index("by_ownerId_and_name", ["ownerId", "name"])
17
+ .index("by_networkId", ["networkId"]),
18
+
19
+ sessions: defineTable(sessionFieldsValidator)
20
+ .index("by_siteId_and_sessionId", ["siteId", "sessionId"])
21
+ .index("by_siteId_and_lastSeenAt", ["siteId", "lastSeenAt"])
22
+ .index("by_siteId_and_startedAt", ["siteId", "startedAt"])
23
+ .index("by_siteId_and_visitorId_and_lastSeenAt", [
24
+ "siteId",
25
+ "visitorId",
26
+ "lastSeenAt",
27
+ ]),
28
+
29
+ liveSessions: defineTable({
30
+ siteId: v.id("sites"),
31
+ sessionId: v.string(),
32
+ visitorId: v.string(),
33
+ path: v.string(),
34
+ startedAt: v.number(),
35
+ lastSeenAt: v.number(),
36
+ expiresAt: v.number(),
37
+ expiryChainDeadline: v.optional(v.number()),
38
+ country: v.optional(v.string()),
39
+ city: v.optional(v.string()),
40
+ latitude: v.optional(v.number()),
41
+ longitude: v.optional(v.number()),
42
+ browser: v.string(),
43
+ os: v.string(),
44
+ source: v.string(),
45
+ device: v.string(),
46
+ pageviewCount: v.number(),
47
+ lastHeartbeatEventId: v.optional(v.string()),
48
+ eventCount: v.number(),
49
+ })
50
+ .index("by_siteId_and_sessionId", ["siteId", "sessionId"])
51
+ .index("by_siteId_and_expiresAt", ["siteId", "expiresAt"])
52
+ .index("by_siteId_and_lastSeenAt", ["siteId", "lastSeenAt"]),
53
+
54
+ events: defineTable(eventFieldsValidator)
55
+ .index("by_siteId_and_eventId", ["siteId", "eventId"])
56
+ .index("by_siteId_and_timestamp", ["siteId", "timestamp"])
57
+ .index("by_siteId_and_visitorId_and_timestamp", [
58
+ "siteId",
59
+ "visitorId",
60
+ "timestamp",
61
+ ])
62
+ .index("by_siteId_and_sessionId_and_timestamp", [
63
+ "siteId",
64
+ "sessionId",
65
+ "timestamp",
66
+ ])
67
+ .index("by_siteId_and_type_and_timestamp", ["siteId", "type", "timestamp"]),
68
+
69
+ aggregateBuckets: defineTable(aggregateFieldsValidator)
70
+ .index("by_siteId_and_granularity_and_bucketStart_and_shard", [
71
+ "siteId",
72
+ "granularity",
73
+ "bucketStart",
74
+ "shard",
75
+ ])
76
+ .index("by_siteId_and_granularity_and_bucketStart", [
77
+ "siteId",
78
+ "granularity",
79
+ "bucketStart",
80
+ ]),
81
+
82
+ goals: defineTable(goalFieldsValidator)
83
+ .index("by_siteId", ["siteId"])
84
+ .index("by_siteId_and_key", ["siteId", "key"]),
85
+
86
+ goalCompletions: defineTable({
87
+ siteId: v.id("sites"),
88
+ goalId: v.id("goals"),
89
+ goalKey: v.string(),
90
+ eventId: v.string(),
91
+ sessionId: v.string(),
92
+ visitorId: v.string(),
93
+ timestamp: v.number(),
94
+ valueCents: v.number(),
95
+ })
96
+ .index("by_siteId_and_goalId_and_eventId", ["siteId", "goalId", "eventId"])
97
+ .index("by_siteId_and_goalId_and_timestamp", ["siteId", "goalId", "timestamp"])
98
+ .index("by_siteId_and_timestamp", ["siteId", "timestamp"]),
99
+
100
+ funnels: defineTable(funnelFieldsValidator)
101
+ .index("by_siteId", ["siteId"])
102
+ .index("by_siteId_and_key", ["siteId", "key"]),
103
+
104
+ funnelProgress: defineTable({
105
+ siteId: v.id("sites"),
106
+ funnelId: v.id("funnels"),
107
+ visitorId: v.string(),
108
+ sessionId: v.string(),
109
+ currentStep: v.number(),
110
+ startedAt: v.number(),
111
+ lastStepAt: v.number(),
112
+ completedAt: v.optional(v.number()),
113
+ })
114
+ .index("by_siteId_and_funnelId_and_visitorId", [
115
+ "siteId",
116
+ "funnelId",
117
+ "visitorId",
118
+ ])
119
+ .index("by_siteId_and_visitorId", ["siteId", "visitorId"])
120
+ .index("by_siteId_and_lastStepAt", ["siteId", "lastStepAt"]),
121
+
122
+ funnelStepBuckets: defineTable({
123
+ siteId: v.id("sites"),
124
+ funnelId: v.id("funnels"),
125
+ bucketStart: v.number(),
126
+ step: v.number(),
127
+ shard: v.number(),
128
+ entrants: v.number(),
129
+ completions: v.number(),
130
+ })
131
+ .index("by_siteId_and_funnelId_and_bucketStart_and_step_and_shard", [
132
+ "siteId",
133
+ "funnelId",
134
+ "bucketStart",
135
+ "step",
136
+ "shard",
137
+ ])
138
+ .index("by_siteId_and_funnelId_and_bucketStart", [
139
+ "siteId",
140
+ "funnelId",
141
+ "bucketStart",
142
+ ])
143
+ .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
144
+
145
+ affiliates: defineTable(affiliateFieldsValidator)
146
+ .index("by_siteId", ["siteId"])
147
+ .index("by_siteId_and_slug", ["siteId", "slug"]),
148
+
149
+ visitorAttributions: defineTable({
150
+ siteId: v.id("sites"),
151
+ visitorId: v.string(),
152
+ affiliateId: v.id("affiliates"),
153
+ affiliateSlug: v.string(),
154
+ attributedAt: v.number(),
155
+ expiresAt: v.number(),
156
+ })
157
+ .index("by_siteId_and_visitorId", ["siteId", "visitorId"])
158
+ .index("by_siteId_and_expiresAt", ["siteId", "expiresAt"]),
159
+
160
+ affiliateDailyStats: defineTable({
161
+ siteId: v.id("sites"),
162
+ affiliateId: v.id("affiliates"),
163
+ affiliateSlug: v.string(),
164
+ bucketStart: v.number(),
165
+ shard: v.number(),
166
+ conversions: v.number(),
167
+ revenueCents: v.number(),
168
+ commissionCents: v.number(),
169
+ })
170
+ .index("by_siteId_and_affiliateId_and_bucketStart_and_shard", [
171
+ "siteId",
172
+ "affiliateId",
173
+ "bucketStart",
174
+ "shard",
175
+ ])
176
+ .index("by_siteId_and_bucketStart", ["siteId", "bucketStart"]),
177
+
178
+ conversions: defineTable({
179
+ siteId: v.id("sites"),
180
+ eventId: v.string(),
181
+ sessionId: v.string(),
182
+ visitorId: v.string(),
183
+ timestamp: v.number(),
184
+ path: v.string(),
185
+ revenueCents: v.number(),
186
+ currency: v.string(),
187
+ source: v.union(v.literal("tracker"), v.literal("trusted")),
188
+ goalKey: v.optional(v.string()),
189
+ affiliateId: v.optional(v.id("affiliates")),
190
+ affiliateAttributedAt: v.optional(v.number()),
191
+ affiliateExpiresAt: v.optional(v.number()),
192
+ affiliateSlug: v.optional(v.string()),
193
+ commissionCents: v.number(),
194
+ })
195
+ .index("by_siteId_and_eventId", ["siteId", "eventId"])
196
+ .index("by_siteId_and_timestamp", ["siteId", "timestamp"])
197
+ .index("by_siteId_and_visitorId_and_timestamp", [
198
+ "siteId",
199
+ "visitorId",
200
+ "timestamp",
201
+ ]),
202
+
203
+ ingestWindows: defineTable({
204
+ siteId: v.id("sites"),
205
+ sessionId: v.string(),
206
+ windowStart: v.number(),
207
+ eventCount: v.number(),
208
+ byteCount: v.number(),
209
+ updatedAt: v.number(),
210
+ })
211
+ .index("by_siteId_and_sessionId_and_windowStart", [
212
+ "siteId",
213
+ "sessionId",
214
+ "windowStart",
215
+ ])
216
+ .index("by_siteId_and_updatedAt", ["siteId", "updatedAt"]),
217
+
218
+ siteIngestWindows: defineTable({
219
+ siteId: v.id("sites"),
220
+ shard: v.number(),
221
+ windowStart: v.number(),
222
+ eventCount: v.number(),
223
+ byteCount: v.number(),
224
+ updatedAt: v.number(),
225
+ })
226
+ .index("by_siteId_and_shard_and_windowStart", [
227
+ "siteId",
228
+ "shard",
229
+ "windowStart",
230
+ ])
231
+ .index("by_siteId_and_updatedAt", ["siteId", "updatedAt"]),
232
+ });