lytx 0.3.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 (213) hide show
  1. package/.env.example +37 -0
  2. package/README.md +486 -0
  3. package/alchemy.run.ts +155 -0
  4. package/cli/bootstrap-admin.ts +284 -0
  5. package/cli/deploy-staging.ts +692 -0
  6. package/cli/import-events.ts +628 -0
  7. package/cli/import-sites.ts +518 -0
  8. package/cli/index.ts +609 -0
  9. package/cli/init-db.ts +269 -0
  10. package/cli/migrate-to-durable-objects.ts +564 -0
  11. package/cli/migration-worker.ts +300 -0
  12. package/cli/performance-test.ts +588 -0
  13. package/cli/pg/client.ts +4 -0
  14. package/cli/pg/new-site.ts +153 -0
  15. package/cli/rollback-durable-objects.ts +622 -0
  16. package/cli/seed-data.ts +459 -0
  17. package/cli/setup.js +18 -0
  18. package/cli/setup.ts +463 -0
  19. package/cli/validate-migration.ts +200 -0
  20. package/cli/wrangler-migration.jsonc +28 -0
  21. package/db/adapter.ts +166 -0
  22. package/db/analytics_engine/client.ts +0 -0
  23. package/db/analytics_engine/sites.ts +0 -0
  24. package/db/client.ts +16 -0
  25. package/db/d1/client.ts +8 -0
  26. package/db/d1/drizzle.config.ts +35 -0
  27. package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
  28. package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
  29. package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
  30. package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
  31. package/db/d1/migrations/0004_mute_stardust.sql +1 -0
  32. package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
  33. package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
  34. package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
  35. package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
  36. package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
  37. package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
  38. package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
  39. package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
  40. package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
  41. package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
  42. package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
  43. package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
  44. package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
  45. package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
  46. package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
  47. package/db/d1/migrations/meta/_journal.json +76 -0
  48. package/db/d1/schema.ts +407 -0
  49. package/db/d1/sites.ts +374 -0
  50. package/db/d1/teamAiUsage.ts +101 -0
  51. package/db/d1/teams.ts +127 -0
  52. package/db/durable/drizzle.config.ts +8 -0
  53. package/db/durable/durableObjectClient.ts +480 -0
  54. package/db/durable/events.ts +100 -0
  55. package/db/durable/migrations/0000_fair_bucky.sql +38 -0
  56. package/db/durable/migrations/meta/0000_snapshot.json +278 -0
  57. package/db/durable/migrations/meta/_journal.json +13 -0
  58. package/db/durable/migrations/migrations.js +10 -0
  59. package/db/durable/schema.ts +5 -0
  60. package/db/durable/siteDurableObject.ts +1352 -0
  61. package/db/durable/types.ts +53 -0
  62. package/db/postgres/client.ts +13 -0
  63. package/db/postgres/drizzle.config.ts +12 -0
  64. package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
  65. package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
  66. package/db/postgres/migrations/meta/_journal.json +13 -0
  67. package/db/postgres/schema.ts +145 -0
  68. package/db/postgres/sites.ts +118 -0
  69. package/db/tranformReports.ts +595 -0
  70. package/db/types.ts +55 -0
  71. package/endpoints/api_worker.tsx +1854 -0
  72. package/endpoints/site_do_worker.ts +11 -0
  73. package/index.d.ts +63 -0
  74. package/index.ts +83 -0
  75. package/lib/auth.ts +279 -0
  76. package/lib/geojson/world_countries.json +45307 -0
  77. package/lib/random_name.ts +41 -0
  78. package/lib/sendMail.ts +252 -0
  79. package/package.json +142 -0
  80. package/public/favicon.ico +0 -0
  81. package/public/images/android-chrome-192x192.png +0 -0
  82. package/public/images/android-chrome-512x512.png +0 -0
  83. package/public/images/apple-touch-icon.png +0 -0
  84. package/public/images/favicon-16x16.png +0 -0
  85. package/public/images/favicon-32x32.png +0 -0
  86. package/public/images/lytx_dark_dashboard.png +0 -0
  87. package/public/images/lytx_light_dashboard.png +0 -0
  88. package/public/images/safari-pinned-tab.svg +4 -0
  89. package/public/logo.png +0 -0
  90. package/public/site.webmanifest +26 -0
  91. package/public/sw.js +107 -0
  92. package/src/Document.tsx +86 -0
  93. package/src/api/ai_api.ts +1156 -0
  94. package/src/api/authMiddleware.ts +45 -0
  95. package/src/api/auth_api.ts +465 -0
  96. package/src/api/event_labels_api.ts +193 -0
  97. package/src/api/events_api.ts +210 -0
  98. package/src/api/queueWorker.ts +303 -0
  99. package/src/api/reports_api.ts +278 -0
  100. package/src/api/seed_api.ts +288 -0
  101. package/src/api/sites_api.ts +904 -0
  102. package/src/api/tag_api.ts +458 -0
  103. package/src/api/tag_api_v2.ts +289 -0
  104. package/src/api/team_api.ts +456 -0
  105. package/src/app/Dashboard.tsx +1339 -0
  106. package/src/app/Events.tsx +974 -0
  107. package/src/app/Explore.tsx +312 -0
  108. package/src/app/Layout.tsx +58 -0
  109. package/src/app/Settings.tsx +1302 -0
  110. package/src/app/components/DashboardCard.tsx +118 -0
  111. package/src/app/components/EditableCell.tsx +123 -0
  112. package/src/app/components/EventForm.tsx +93 -0
  113. package/src/app/components/MarketingFooter.tsx +49 -0
  114. package/src/app/components/MarketingNav.tsx +150 -0
  115. package/src/app/components/Nav.tsx +755 -0
  116. package/src/app/components/NewSiteSetup.tsx +298 -0
  117. package/src/app/components/SQLEditor.tsx +740 -0
  118. package/src/app/components/SiteSelector.tsx +126 -0
  119. package/src/app/components/SiteTag.tsx +42 -0
  120. package/src/app/components/SiteTagInstallCard.tsx +241 -0
  121. package/src/app/components/WorldMapCard.tsx +337 -0
  122. package/src/app/components/charts/ChartComponents.tsx +1481 -0
  123. package/src/app/components/charts/EventFunnel.tsx +45 -0
  124. package/src/app/components/charts/EventSummary.tsx +194 -0
  125. package/src/app/components/charts/SankeyFlows.tsx +72 -0
  126. package/src/app/components/marketing/CheckIcon.tsx +16 -0
  127. package/src/app/components/marketing/MarketingLayout.tsx +23 -0
  128. package/src/app/components/marketing/SectionHeading.tsx +35 -0
  129. package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
  130. package/src/app/components/reports/CreateReportStarter.tsx +74 -0
  131. package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
  132. package/src/app/components/reports/DashboardToolbar.tsx +154 -0
  133. package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
  134. package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
  135. package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
  136. package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
  137. package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
  138. package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
  139. package/src/app/components/reports/custom/chartPalettes.ts +18 -0
  140. package/src/app/components/reports/custom/types.ts +50 -0
  141. package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
  142. package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
  143. package/src/app/components/ui/AlertBanner.tsx +101 -0
  144. package/src/app/components/ui/Button.tsx +55 -0
  145. package/src/app/components/ui/Card.tsx +80 -0
  146. package/src/app/components/ui/Input.tsx +72 -0
  147. package/src/app/components/ui/Link.tsx +23 -0
  148. package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
  149. package/src/app/components/ui/ThemeToggle.tsx +54 -0
  150. package/src/app/constants.ts +6 -0
  151. package/src/app/headers.ts +33 -0
  152. package/src/app/providers/AuthProvider.tsx +189 -0
  153. package/src/app/providers/ClientProviders.tsx +18 -0
  154. package/src/app/providers/QueryProvider.tsx +23 -0
  155. package/src/app/providers/ThemeProvider.tsx +88 -0
  156. package/src/app/utils/chartThemes.ts +146 -0
  157. package/src/app/utils/keybinds.ts +96 -0
  158. package/src/app/utils/media.tsx +24 -0
  159. package/src/client.tsx +114 -0
  160. package/src/config/createLytxAppConfig.ts +252 -0
  161. package/src/config/resourceNames.ts +88 -0
  162. package/src/db/index.ts +67 -0
  163. package/src/index.css +285 -0
  164. package/src/lib/featureFlags.ts +69 -0
  165. package/src/pages/GetStarted.tsx +290 -0
  166. package/src/pages/Home.tsx +268 -0
  167. package/src/pages/Login.tsx +283 -0
  168. package/src/pages/PrivacyPolicy.tsx +120 -0
  169. package/src/pages/Signup.tsx +267 -0
  170. package/src/pages/TermsOfService.tsx +126 -0
  171. package/src/pages/VerifyEmail.tsx +56 -0
  172. package/src/session/durableObject.ts +7 -0
  173. package/src/session/siteSchema.ts +86 -0
  174. package/src/session/types.ts +36 -0
  175. package/src/templates/README.md +80 -0
  176. package/src/templates/cleanFunctions.js +44 -0
  177. package/src/templates/embedFunctions.js +52 -0
  178. package/src/templates/lytx-shared.ts +662 -0
  179. package/src/templates/lytxpixel-core.ts +144 -0
  180. package/src/templates/lytxpixel.ts +267 -0
  181. package/src/templates/lytxpixelBrowser.js +634 -0
  182. package/src/templates/lytxpixelBrowser.mjs +634 -0
  183. package/src/templates/parseData.js +12 -0
  184. package/src/templates/script.ts +31 -0
  185. package/src/templates/template.tsx +50 -0
  186. package/src/templates/test.js +3 -0
  187. package/src/templates/trackWebEvents.ts +177 -0
  188. package/src/templates/vendors/clickcease.ts +8 -0
  189. package/src/templates/vendors/google.ts +174 -0
  190. package/src/templates/vendors/linkedin.ts +23 -0
  191. package/src/templates/vendors/meta.ts +56 -0
  192. package/src/templates/vendors/quantcast.ts +22 -0
  193. package/src/templates/vendors/simplfi.ts +7 -0
  194. package/src/types/app-context.ts +16 -0
  195. package/src/utilities/dashboardParams.ts +188 -0
  196. package/src/utilities/dashboardQueries.ts +537 -0
  197. package/src/utilities/dashboardTransforms.ts +167 -0
  198. package/src/utilities/dataValidation.ts +414 -0
  199. package/src/utilities/detector.ts +73 -0
  200. package/src/utilities/encrypt.ts +103 -0
  201. package/src/utilities/index.ts +13 -0
  202. package/src/utilities/parser.ts +117 -0
  203. package/src/utilities/performanceMonitoring.ts +570 -0
  204. package/src/utilities/route_interuptors.ts +24 -0
  205. package/src/worker.tsx +675 -0
  206. package/tsconfig.json +78 -0
  207. package/types/env.d.ts +16 -0
  208. package/types/rw.d.ts +7 -0
  209. package/types/shims.d.ts +53 -0
  210. package/types/vite.d.ts +19 -0
  211. package/vite/vite-plugin-pixel-bundle.ts +126 -0
  212. package/vite.config.ts +53 -0
  213. package/worker-configuration.d.ts +8401 -0
@@ -0,0 +1,167 @@
1
+ export type DateRangeFilter = {
2
+ start?: Date;
3
+ end?: Date;
4
+ };
5
+
6
+ export function isWithinDateRange(date: Date, range?: DateRangeFilter): boolean {
7
+ if (range?.start && date < range.start) return false;
8
+ if (range?.end && date > range.end) return false;
9
+ return true;
10
+ }
11
+
12
+ export function filterByDateRange<T>(
13
+ items: T[],
14
+ getDate: (item: T) => Date | null | undefined,
15
+ range?: DateRangeFilter,
16
+ ): T[] {
17
+ if (!range?.start && !range?.end) return items;
18
+ return items.filter((item) => {
19
+ const date = getDate(item);
20
+ if (!date) return false;
21
+ return isWithinDateRange(date, range);
22
+ });
23
+ }
24
+
25
+ export function toDateKey(date: Date): string {
26
+ return date.toISOString().split("T")[0];
27
+ }
28
+
29
+ export function countBy<T, K>(
30
+ items: T[],
31
+ getKey: (item: T) => K | null | undefined,
32
+ ): Map<K, number> {
33
+ const map = new Map<K, number>();
34
+
35
+ for (const item of items) {
36
+ const key = getKey(item);
37
+ if (key === null || key === undefined) continue;
38
+ map.set(key, (map.get(key) ?? 0) + 1);
39
+ }
40
+
41
+ return map;
42
+ }
43
+
44
+ export function countDistinctBy<T, K>(
45
+ items: T[],
46
+ getKey: (item: T) => K | null | undefined,
47
+ ): number {
48
+ const set = new Set<K>();
49
+ for (const item of items) {
50
+ const key = getKey(item);
51
+ if (key === null || key === undefined) continue;
52
+ set.add(key);
53
+ }
54
+ return set.size;
55
+ }
56
+
57
+ export function mapToSortedEntries<K>(
58
+ map: Map<K, number>,
59
+ options?: { direction?: "asc" | "desc"; limit?: number },
60
+ ): Array<[K, number]> {
61
+ const direction = options?.direction ?? "desc";
62
+ const entries = Array.from(map.entries()).toSorted((a, b) =>
63
+ direction === "desc" ? b[1] - a[1] : a[1] - b[1],
64
+ );
65
+ return typeof options?.limit === "number" ? entries.slice(0, options.limit) : entries;
66
+ }
67
+
68
+ export function formatPercent(value: number, decimals: number): string {
69
+ if (!Number.isFinite(value)) return `0.${"0".repeat(decimals)}%`;
70
+ return `${value.toFixed(decimals)}%`;
71
+ }
72
+
73
+ export function formatDurationSeconds(totalSeconds: number): string {
74
+ if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return "0s";
75
+
76
+ const wholeSeconds = Math.floor(totalSeconds);
77
+ const minutes = Math.floor(wholeSeconds / 60);
78
+ const seconds = wholeSeconds % 60;
79
+
80
+ if (minutes > 0) {
81
+ return `${minutes}m ${seconds}s`;
82
+ }
83
+
84
+ return `${seconds}s`;
85
+ }
86
+
87
+ export function cleanReferer(referer: unknown): string {
88
+ if (!referer || referer === "" || referer === "null") {
89
+ return "Direct";
90
+ }
91
+
92
+ const value = String(referer);
93
+
94
+ try {
95
+ const url = new URL(value);
96
+ return url.hostname;
97
+ } catch {
98
+ const hostname = value.replace(/^https?:\/\//, "").replace(/\/.*/, "");
99
+ if (hostname.length === 0) return "Direct";
100
+ return hostname.length > 50 ? `${hostname.substring(0, 47)}...` : hostname;
101
+ }
102
+ }
103
+
104
+ export function cleanPageUrl(url: unknown): string {
105
+ if (!url || url === "" || url === "null") {
106
+ return "Unknown";
107
+ }
108
+
109
+ const value = String(url);
110
+
111
+ try {
112
+ const urlObj = new URL(value);
113
+ return urlObj.pathname + (urlObj.search ? urlObj.search : "");
114
+ } catch {
115
+ return value.length > 50 ? `${value.substring(0, 47)}...` : value;
116
+ }
117
+ }
118
+
119
+ export function serializeForClient<T>(obj: T): T {
120
+ return JSON.parse(
121
+ JSON.stringify(obj, (_key, value) => {
122
+ if (value instanceof Date) {
123
+ return value.toISOString();
124
+ }
125
+ if (value === undefined) {
126
+ return null;
127
+ }
128
+ return value;
129
+ }),
130
+ );
131
+ }
132
+
133
+ export function calculateAverageSessionDurationSeconds<T>(
134
+ events: T[],
135
+ selectors: {
136
+ getSessionId: (event: T) => string | null | undefined;
137
+ getTimestamp: (event: T) => Date | null | undefined;
138
+ },
139
+ ): number {
140
+ const sessionTimes = new Map<string, { first: Date; last: Date }>();
141
+
142
+ for (const event of events) {
143
+ const sessionId = selectors.getSessionId(event);
144
+ const timestamp = selectors.getTimestamp(event);
145
+ if (!sessionId || !timestamp) continue;
146
+
147
+ const existing = sessionTimes.get(sessionId);
148
+ if (!existing) {
149
+ sessionTimes.set(sessionId, { first: timestamp, last: timestamp });
150
+ continue;
151
+ }
152
+
153
+ if (timestamp < existing.first) existing.first = timestamp;
154
+ if (timestamp > existing.last) existing.last = timestamp;
155
+ }
156
+
157
+ if (sessionTimes.size === 0) return 0;
158
+
159
+ let durationSumSeconds = 0;
160
+ for (const { first, last } of sessionTimes.values()) {
161
+ durationSumSeconds += (last.getTime() - first.getTime()) / 1000;
162
+ }
163
+
164
+ return durationSumSeconds / sessionTimes.size;
165
+ }
166
+
167
+
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Data Validation Functions for Durable Object Migration
3
+ *
4
+ * These functions validate data integrity during migration from original databases
5
+ * to site-specific durable objects, ensuring consistency and completeness.
6
+ */
7
+
8
+ import type { SiteEventInput } from "@/session/siteSchema";
9
+ import { getDashboardDataFromDurableObject } from "@db/durable/durableObjectClient";
10
+ import type { DashboardOptions } from "@db/types";
11
+ import { IS_DEV } from "rwsdk/constants";
12
+
13
+ /**
14
+ * Validation result interface
15
+ */
16
+ export interface ValidationResult {
17
+ isValid: boolean;
18
+ errors: string[];
19
+ warnings: string[];
20
+ recordCount?: number;
21
+ validRecords?: number;
22
+ invalidRecords?: number;
23
+ }
24
+
25
+ /**
26
+ * Site event validation configuration
27
+ */
28
+ export interface ValidationConfig {
29
+ strictMode?: boolean; // If true, warnings become errors
30
+ allowEmptyFields?: string[]; // Fields that can be empty/null
31
+ maxStringLength?: number; // Maximum string field length
32
+ dateRange?: {
33
+ minDate?: Date;
34
+ maxDate?: Date;
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Default validation configuration
40
+ */
41
+ const DEFAULT_CONFIG: ValidationConfig = {
42
+ strictMode: false,
43
+ allowEmptyFields: ['bot_data', 'custom_data', 'query_params', 'rid', 'postal', 'region', 'city', 'country'],
44
+ maxStringLength: 2000,
45
+ dateRange: {
46
+ minDate: new Date('2020-01-01'), // Reasonable minimum date
47
+ maxDate: new Date(Date.now() + 24 * 60 * 60 * 1000) // Allow up to 1 day in future
48
+ }
49
+ };
50
+
51
+ /**
52
+ * Validate a single site event record
53
+ */
54
+ export function validateSiteEvent(
55
+ event: SiteEventInput,
56
+ config: ValidationConfig = DEFAULT_CONFIG
57
+ ): ValidationResult {
58
+ const errors: string[] = [];
59
+ const warnings: string[] = [];
60
+
61
+ // Required field validation
62
+ if (!event.event || typeof event.event !== 'string') {
63
+ errors.push('Field "event" is required and must be a string');
64
+ }
65
+
66
+ if (!event.tag_id || typeof event.tag_id !== 'string') {
67
+ errors.push('Field "tag_id" is required and must be a string');
68
+ }
69
+
70
+ // String length validation
71
+ const stringFields = ['event', 'tag_id', 'browser', 'city', 'client_page_url', 'country',
72
+ 'device_type', 'operating_system', 'page_url', 'postal', 'referer',
73
+ 'region', 'rid'];
74
+
75
+ for (const field of stringFields) {
76
+ const value = event[field as keyof SiteEventInput];
77
+ if (value && typeof value === 'string' && value.length > (config.maxStringLength || 2000)) {
78
+ errors.push(`Field "${field}" exceeds maximum length of ${config.maxStringLength}`);
79
+ }
80
+ }
81
+
82
+ // Numeric field validation
83
+ if (event.screen_height !== undefined && (typeof event.screen_height !== 'number' || event.screen_height < 0)) {
84
+ errors.push('Field "screen_height" must be a positive number');
85
+ }
86
+
87
+ if (event.screen_width !== undefined && (typeof event.screen_width !== 'number' || event.screen_width < 0)) {
88
+ errors.push('Field "screen_width" must be a positive number');
89
+ }
90
+
91
+ // Date validation
92
+ if (event.createdAt) {
93
+ const date = new Date(event.createdAt);
94
+ if (isNaN(date.getTime())) {
95
+ errors.push('Field "createdAt" must be a valid date');
96
+ } else {
97
+ const { minDate, maxDate } = config.dateRange || {};
98
+ if (minDate && date < minDate) {
99
+ warnings.push(`Field "createdAt" is before minimum date ${minDate.toISOString()}`);
100
+ }
101
+ if (maxDate && date > maxDate) {
102
+ warnings.push(`Field "createdAt" is after maximum date ${maxDate.toISOString()}`);
103
+ }
104
+ }
105
+ }
106
+
107
+ // JSON field validation
108
+ const jsonFields = ['bot_data', 'custom_data', 'query_params'];
109
+ for (const field of jsonFields) {
110
+ const value = event[field as keyof SiteEventInput];
111
+ if (value !== undefined && value !== null) {
112
+ try {
113
+ if (typeof value === 'string') {
114
+ JSON.parse(value);
115
+ } else if (typeof value !== 'object') {
116
+ errors.push(`Field "${field}" must be a valid JSON object or string`);
117
+ }
118
+ } catch (e) {
119
+ errors.push(`Field "${field}" contains invalid JSON`);
120
+ }
121
+ }
122
+ }
123
+
124
+ // URL validation (basic)
125
+ const urlFields = ['client_page_url', 'page_url', 'referer'];
126
+ for (const field of urlFields) {
127
+ const value = event[field as keyof SiteEventInput];
128
+ if (value && typeof value === 'string') {
129
+ try {
130
+ new URL(value);
131
+ } catch (e) {
132
+ warnings.push(`Field "${field}" does not appear to be a valid URL: ${value}`);
133
+ }
134
+ }
135
+ }
136
+
137
+ // Convert warnings to errors in strict mode
138
+ if (config.strictMode) {
139
+ errors.push(...warnings);
140
+ warnings.length = 0;
141
+ }
142
+
143
+ return {
144
+ isValid: errors.length === 0,
145
+ errors,
146
+ warnings,
147
+ recordCount: 1,
148
+ validRecords: errors.length === 0 ? 1 : 0,
149
+ invalidRecords: errors.length > 0 ? 1 : 0
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Validate an array of site events
155
+ */
156
+ export function validateSiteEvents(
157
+ events: SiteEventInput[],
158
+ config: ValidationConfig = DEFAULT_CONFIG
159
+ ): ValidationResult {
160
+ const allErrors: string[] = [];
161
+ const allWarnings: string[] = [];
162
+ let validRecords = 0;
163
+ let invalidRecords = 0;
164
+
165
+ if (!Array.isArray(events)) {
166
+ return {
167
+ isValid: false,
168
+ errors: ['Input must be an array of events'],
169
+ warnings: [],
170
+ recordCount: 0,
171
+ validRecords: 0,
172
+ invalidRecords: 0
173
+ };
174
+ }
175
+
176
+ events.forEach((event, index) => {
177
+ const result = validateSiteEvent(event, config);
178
+
179
+ if (result.isValid) {
180
+ validRecords++;
181
+ } else {
182
+ invalidRecords++;
183
+ }
184
+
185
+ // Prefix errors and warnings with record index
186
+ result.errors.forEach(error => {
187
+ allErrors.push(`Record ${index}: ${error}`);
188
+ });
189
+
190
+ result.warnings.forEach(warning => {
191
+ allWarnings.push(`Record ${index}: ${warning}`);
192
+ });
193
+ });
194
+
195
+ return {
196
+ isValid: invalidRecords === 0,
197
+ errors: allErrors,
198
+ warnings: allWarnings,
199
+ recordCount: events.length,
200
+ validRecords,
201
+ invalidRecords
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Compare record counts between original database and durable object
207
+ */
208
+ export async function validateRecordCounts(
209
+ siteId: number,
210
+ originalCount: number,
211
+ env: Env,
212
+ dateRange?: { start: Date; end: Date }
213
+ ): Promise<ValidationResult> {
214
+ const errors: string[] = [];
215
+ const warnings: string[] = [];
216
+
217
+ try {
218
+ // Get count from durable object
219
+ const options: DashboardOptions = {
220
+ site_id: siteId,
221
+ site_uuid: `site-${siteId}`,
222
+ team_id: 1, // TODO: Get actual team_id
223
+ date: dateRange
224
+ };
225
+
226
+ const dashboardData = await getDashboardDataFromDurableObject(options);
227
+ const durableObjectCount = dashboardData.query?.events?.length || 0;
228
+
229
+ // Compare counts
230
+ if (durableObjectCount !== originalCount) {
231
+ const difference = Math.abs(durableObjectCount - originalCount);
232
+ const percentageDiff = originalCount > 0 ? (difference / originalCount) * 100 : 100;
233
+
234
+ if (percentageDiff > 5) { // More than 5% difference is an error
235
+ errors.push(
236
+ `Significant count mismatch for site ${siteId}: ` +
237
+ `Original DB: ${originalCount}, Durable Object: ${durableObjectCount} ` +
238
+ `(${percentageDiff.toFixed(2)}% difference)`
239
+ );
240
+ } else {
241
+ warnings.push(
242
+ `Minor count mismatch for site ${siteId}: ` +
243
+ `Original DB: ${originalCount}, Durable Object: ${durableObjectCount} ` +
244
+ `(${percentageDiff.toFixed(2)}% difference)`
245
+ );
246
+ }
247
+ }
248
+
249
+ return {
250
+ isValid: errors.length === 0,
251
+ errors,
252
+ warnings,
253
+ recordCount: durableObjectCount,
254
+ validRecords: durableObjectCount,
255
+ invalidRecords: 0
256
+ };
257
+
258
+ } catch (error) {
259
+ return {
260
+ isValid: false,
261
+ errors: [`Failed to validate record counts for site ${siteId}: ${error instanceof Error ? error.message : String(error)}`],
262
+ warnings: [],
263
+ recordCount: 0,
264
+ validRecords: 0,
265
+ invalidRecords: 0
266
+ };
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Validate data consistency between original database and durable object
272
+ * Compares a sample of records to ensure data integrity
273
+ */
274
+ export async function validateDataConsistency(
275
+ siteId: number,
276
+ originalEvents: SiteEventInput[],
277
+ env: Env,
278
+ sampleSize: number = 100
279
+ ): Promise<ValidationResult> {
280
+ const errors: string[] = [];
281
+ const warnings: string[] = [];
282
+
283
+ try {
284
+ // Get events from durable object
285
+ const options: DashboardOptions = {
286
+ site_id: siteId,
287
+ site_uuid: `site-${siteId}`,
288
+ team_id: 1, // TODO: Get actual team_id
289
+ };
290
+
291
+ const dashboardData = await getDashboardDataFromDurableObject(options);
292
+ const durableObjectEvents = dashboardData.query?.events || [];
293
+
294
+ if (durableObjectEvents.length === 0) {
295
+ errors.push(`No events found in durable object for site ${siteId}`);
296
+ return { isValid: false, errors, warnings };
297
+ }
298
+
299
+ // Sample events for comparison (take first N events)
300
+ const sampleOriginal = originalEvents.slice(0, Math.min(sampleSize, originalEvents.length));
301
+ const sampleDurableObject = durableObjectEvents.slice(0, Math.min(sampleSize, durableObjectEvents.length));
302
+
303
+ // Compare key fields for sampled records
304
+ const keyFields = ['event', 'tag_id', 'country', 'device_type', 'browser'];
305
+
306
+ for (let i = 0; i < Math.min(sampleOriginal.length, sampleDurableObject.length); i++) {
307
+ const original = sampleOriginal[i];
308
+ const durableObj = sampleDurableObject[i];
309
+
310
+ for (const field of keyFields) {
311
+ const originalValue = original[field as keyof SiteEventInput];
312
+ const durableObjValue = durableObj[field as keyof SiteEventInput];
313
+
314
+ if (originalValue !== durableObjValue) {
315
+ warnings.push(
316
+ `Data mismatch in record ${i}, field "${field}": ` +
317
+ `Original: "${originalValue}", Durable Object: "${durableObjValue}"`
318
+ );
319
+ }
320
+ }
321
+ }
322
+
323
+ return {
324
+ isValid: errors.length === 0,
325
+ errors,
326
+ warnings,
327
+ recordCount: Math.min(sampleOriginal.length, sampleDurableObject.length),
328
+ validRecords: Math.min(sampleOriginal.length, sampleDurableObject.length) - warnings.length,
329
+ invalidRecords: warnings.length
330
+ };
331
+
332
+ } catch (error) {
333
+ return {
334
+ isValid: false,
335
+ errors: [`Failed to validate data consistency for site ${siteId}: ${error instanceof Error ? error.message : String(error)}`],
336
+ warnings: [],
337
+ recordCount: 0,
338
+ validRecords: 0,
339
+ invalidRecords: 0
340
+ };
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Comprehensive validation suite for site migration
346
+ */
347
+ export async function validateSiteMigration(
348
+ siteId: number,
349
+ originalEvents: SiteEventInput[],
350
+ originalCount: number,
351
+ env: Env,
352
+ config: ValidationConfig = DEFAULT_CONFIG
353
+ ): Promise<ValidationResult> {
354
+ const allErrors: string[] = [];
355
+ const allWarnings: string[] = [];
356
+
357
+ // 1. Validate event data structure
358
+ if (IS_DEV) console.log(`Validating ${originalEvents.length} events for site ${siteId}...`);
359
+ const structureValidation = validateSiteEvents(originalEvents, config);
360
+ allErrors.push(...structureValidation.errors);
361
+ allWarnings.push(...structureValidation.warnings);
362
+
363
+ // 2. Validate record counts
364
+ if (IS_DEV) console.log(`Validating record counts for site ${siteId}...`);
365
+ const countValidation = await validateRecordCounts(siteId, originalCount, env);
366
+ allErrors.push(...countValidation.errors);
367
+ allWarnings.push(...countValidation.warnings);
368
+
369
+ // 3. Validate data consistency (sample)
370
+ if (IS_DEV) console.log(`Validating data consistency for site ${siteId}...`);
371
+ const consistencyValidation = await validateDataConsistency(siteId, originalEvents, env);
372
+ allErrors.push(...consistencyValidation.errors);
373
+ allWarnings.push(...consistencyValidation.warnings);
374
+
375
+ return {
376
+ isValid: allErrors.length === 0,
377
+ errors: allErrors,
378
+ warnings: allWarnings,
379
+ recordCount: originalEvents.length,
380
+ validRecords: structureValidation.validRecords || 0,
381
+ invalidRecords: structureValidation.invalidRecords || 0
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Generate validation report
387
+ */
388
+ export function generateValidationReport(result: ValidationResult, siteId?: number): string {
389
+ const lines: string[] = [];
390
+
391
+ if (siteId) {
392
+ lines.push(`=== Validation Report for Site ${siteId} ===`);
393
+ } else {
394
+ lines.push(`=== Validation Report ===`);
395
+ }
396
+
397
+ lines.push(`Status: ${result.isValid ? 'PASSED' : 'FAILED'}`);
398
+ lines.push(`Total Records: ${result.recordCount || 0}`);
399
+ lines.push(`Valid Records: ${result.validRecords || 0}`);
400
+ lines.push(`Invalid Records: ${result.invalidRecords || 0}`);
401
+
402
+ if (result.errors.length > 0) {
403
+ lines.push(`\nErrors (${result.errors.length}):`);
404
+ result.errors.forEach(error => lines.push(` - ${error}`));
405
+ }
406
+
407
+ if (result.warnings.length > 0) {
408
+ lines.push(`\nWarnings (${result.warnings.length}):`);
409
+ result.warnings.forEach(warning => lines.push(` - ${warning}`));
410
+ }
411
+
412
+ lines.push('');
413
+ return lines.join('\n');
414
+ }
@@ -0,0 +1,73 @@
1
+ import DeviceDetector from "device-detector-js";
2
+
3
+ enum BROWSER_ENUM {
4
+ EDGE,
5
+ INTERNET_EXPLORER,
6
+ FIRE_FOX,
7
+ OPERA,
8
+ UC_BROWSER,
9
+ SAMSUNG_BROWSER,
10
+ CHROME,
11
+ SAFARI,
12
+ UNKNOWN,
13
+ }
14
+ export function parseUserAgent(userAgent: string) {
15
+ const deviceDetector = new DeviceDetector();
16
+ const device = deviceDetector.parse(userAgent);
17
+
18
+ return device;
19
+ }
20
+
21
+ export function parseBrowser(
22
+ device: DeviceDetector.DeviceDetectorResult,
23
+ rawUserAgent: string
24
+ ) {
25
+ if (device.client && device.client.name) {
26
+ return device.client.name;
27
+ } else return BROWSER_ENUM[detectBrowser(rawUserAgent)];
28
+ }
29
+
30
+ export function detectBrowser(userAgent: string): BROWSER_ENUM {
31
+ const testUserAgent = (regexp: RegExp): boolean => regexp.test(userAgent);
32
+ switch (true) {
33
+ case testUserAgent(/edg/i):
34
+ return BROWSER_ENUM.EDGE;
35
+ case testUserAgent(/trident/i):
36
+ return BROWSER_ENUM.INTERNET_EXPLORER;
37
+ case testUserAgent(/firefox|fxios/i):
38
+ return BROWSER_ENUM.FIRE_FOX;
39
+ case testUserAgent(/opr\//i):
40
+ return BROWSER_ENUM.OPERA;
41
+ case testUserAgent(/ucbrowser/i):
42
+ return BROWSER_ENUM.UC_BROWSER;
43
+ case testUserAgent(/samsungbrowser/i):
44
+ return BROWSER_ENUM.SAMSUNG_BROWSER;
45
+ case testUserAgent(/chrome|chromium|crios/i):
46
+ return BROWSER_ENUM.CHROME;
47
+ case testUserAgent(/safari/i):
48
+ return BROWSER_ENUM.SAFARI;
49
+ default:
50
+ return BROWSER_ENUM.UNKNOWN;
51
+ }
52
+ }
53
+
54
+ export function parseDeviceType(
55
+ device: DeviceDetector.DeviceDetectorResult,
56
+ rawHeader: string | null
57
+ ) {
58
+ if (device.device && device.device.type) {
59
+ return device.device.type;
60
+ }
61
+ return rawHeader ?? "Unknown";
62
+ }
63
+
64
+
65
+
66
+ export function parseOs(
67
+ device: DeviceDetector.DeviceDetectorResult,
68
+ rawHeader: string | null
69
+ ) {
70
+ if (device.os && device.os.name) {
71
+ return device.os.name;
72
+ } else return rawHeader ?? "Unknown";
73
+ }