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,740 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { init } from "modern-monaco";
5
+
6
+ /**
7
+ * Example queries to help users get started.
8
+ * These are static since they're documentation, not schema-derived.
9
+ */
10
+ const EXAMPLE_QUERIES = [
11
+ {
12
+ name: "Recent Events",
13
+ query: `SELECT event, page_url, created_at
14
+ FROM site_events
15
+ ORDER BY created_at DESC
16
+ LIMIT 50`,
17
+ },
18
+ {
19
+ name: "Events by Type",
20
+ query: `SELECT event, COUNT(*) as count
21
+ FROM site_events
22
+ GROUP BY event
23
+ ORDER BY count DESC`,
24
+ },
25
+ {
26
+ name: "Find Unnamed Events",
27
+ query: `SELECT event, COUNT(*) as count,
28
+ datetime(MIN(created_at), 'unixepoch') as first_seen_utc,
29
+ datetime(MAX(created_at), 'unixepoch') as last_seen_utc
30
+ FROM site_events
31
+ WHERE lower(event) LIKE '%unnamed%'
32
+ GROUP BY event
33
+ ORDER BY count DESC
34
+ LIMIT 100`,
35
+ },
36
+ {
37
+ name: "Top Pages",
38
+ query: `SELECT page_url, COUNT(*) as views
39
+ FROM site_events
40
+ WHERE event = 'pageview'
41
+ GROUP BY page_url
42
+ ORDER BY views DESC
43
+ LIMIT 20`,
44
+ },
45
+ {
46
+ name: "Traffic by Country",
47
+ query: `SELECT country, COUNT(*) as visits
48
+ FROM site_events
49
+ WHERE country IS NOT NULL
50
+ GROUP BY country
51
+ ORDER BY visits DESC
52
+ LIMIT 10`,
53
+ },
54
+ {
55
+ name: "Device Breakdown",
56
+ query: `SELECT device_type, COUNT(*) as count
57
+ FROM site_events
58
+ WHERE device_type IS NOT NULL
59
+ GROUP BY device_type`,
60
+ },
61
+ {
62
+ name: "Browser Usage",
63
+ query: `SELECT browser, COUNT(*) as count
64
+ FROM site_events
65
+ WHERE browser IS NOT NULL
66
+ GROUP BY browser
67
+ ORDER BY count DESC`,
68
+ },
69
+ ];
70
+
71
+ /** SQL keywords for autocomplete */
72
+ const SQL_KEYWORDS = [
73
+ "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN",
74
+ "IS", "NULL", "AS", "ORDER", "BY", "ASC", "DESC", "LIMIT", "OFFSET",
75
+ "GROUP", "HAVING", "DISTINCT", "COUNT", "SUM", "AVG", "MIN", "MAX",
76
+ "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", "UNION", "ALL",
77
+ "CASE", "WHEN", "THEN", "ELSE", "END", "CAST", "COALESCE", "IFNULL",
78
+ "STRFTIME", "DATE", "TIME", "DATETIME", "EXISTS", "WITH"
79
+ ];
80
+
81
+ /** Column info returned from PRAGMA table_info */
82
+ interface ColumnInfo {
83
+ name: string;
84
+ type: string;
85
+ nullable: boolean;
86
+ primaryKey: boolean;
87
+ defaultValue: string | null;
88
+ }
89
+
90
+ /** Index info returned from PRAGMA index_list + index_info */
91
+ interface IndexInfo {
92
+ name: string;
93
+ columns: string[];
94
+ unique: boolean;
95
+ }
96
+
97
+ /** Table schema returned from the API */
98
+ interface TableSchema {
99
+ name: string;
100
+ columns: ColumnInfo[];
101
+ indexes: IndexInfo[];
102
+ }
103
+
104
+ /** API response shape */
105
+ interface SchemaResponse {
106
+ tables: TableSchema[];
107
+ siteId: number | null;
108
+ error?: string;
109
+ }
110
+
111
+ export interface SQLEditorProps {
112
+ /** The SQL query value */
113
+ value: string;
114
+ /** Callback when the query changes */
115
+ onChange?: (value: string) => void;
116
+ /** Callback when user presses Ctrl+Enter to submit */
117
+ onSubmit?: () => void;
118
+ /** Placeholder text when editor is empty */
119
+ placeholder?: string;
120
+ /** Whether the editor is disabled/read-only */
121
+ disabled?: boolean;
122
+ /** Height of the editor (default: "200px") */
123
+ height?: string;
124
+ /** Monaco theme (default: "github-dark") */
125
+ theme?: string;
126
+ /** Additional class names for the container */
127
+ className?: string;
128
+ /** Whether to show schema tab (default: true) */
129
+ showSchema?: boolean;
130
+ /** Site ID to fetch schema for */
131
+ siteId?: number | null;
132
+ }
133
+
134
+ /**
135
+ * Schema viewer component showing table structure and example queries.
136
+ * Fetches schema from the durable object at runtime.
137
+ */
138
+ function SchemaViewer({
139
+ onSelectQuery,
140
+ height,
141
+ siteId,
142
+ }: {
143
+ onSelectQuery?: (query: string) => void;
144
+ height: string;
145
+ siteId?: number | null;
146
+ }) {
147
+ const [expandedSection, setExpandedSection] = useState<"columns" | "indexes" | "examples">("columns");
148
+ const [schema, setSchema] = useState<TableSchema | null>(null);
149
+ const [loading, setLoading] = useState(false);
150
+ const [error, setError] = useState<string | null>(null);
151
+
152
+ useEffect(() => {
153
+ if (!siteId) {
154
+ setError("No site selected");
155
+ return;
156
+ }
157
+
158
+ const fetchSchema = async () => {
159
+ setLoading(true);
160
+ setError(null);
161
+ try {
162
+ const response = await fetch(`/api/site-events/schema?site_id=${siteId}`);
163
+ const data = await response.json() as SchemaResponse;
164
+
165
+ if (!response.ok) {
166
+ setError(data.error || "Failed to fetch schema");
167
+ return;
168
+ }
169
+
170
+ if (data.tables && data.tables.length > 0) {
171
+ setSchema(data.tables[0]);
172
+ } else {
173
+ setError("No tables found");
174
+ }
175
+ } catch (err) {
176
+ setError("Failed to fetch schema");
177
+ console.error("Schema fetch error:", err);
178
+ } finally {
179
+ setLoading(false);
180
+ }
181
+ };
182
+
183
+ fetchSchema();
184
+ }, [siteId]);
185
+
186
+ if (loading) {
187
+ return (
188
+ <div
189
+ className="flex items-center justify-center bg-[var(--theme-input-bg)] rounded-lg border border-[var(--theme-input-border)]"
190
+ style={{ height, minHeight: "120px" }}
191
+ >
192
+ <div className="text-sm text-[var(--theme-text-secondary)]">Loading schema...</div>
193
+ </div>
194
+ );
195
+ }
196
+
197
+ if (error || !schema) {
198
+ return (
199
+ <div
200
+ className="overflow-y-auto bg-[var(--theme-input-bg)] rounded-lg border border-[var(--theme-input-border)]"
201
+ style={{ height, minHeight: "120px" }}
202
+ >
203
+ {/* Show error but still allow access to examples */}
204
+ <div className="p-3 border-b border-[var(--theme-input-border)]">
205
+ {error && (
206
+ <div className="text-xs text-amber-400 mb-2">
207
+ {error}
208
+ </div>
209
+ )}
210
+ <div className="flex items-center gap-2">
211
+ <span className="text-xs font-mono px-2 py-1 bg-blue-500/20 text-blue-400 rounded">
212
+ TABLE
213
+ </span>
214
+ <code className="text-sm font-semibold text-[var(--theme-text-primary)]">
215
+ site_events
216
+ </code>
217
+ </div>
218
+ </div>
219
+
220
+ {/* Still show example queries */}
221
+ <div className="p-3 space-y-3">
222
+ <p className="text-xs text-[var(--theme-text-secondary)] mb-2">
223
+ Example queries:
224
+ </p>
225
+ {EXAMPLE_QUERIES.map((example) => (
226
+ <div key={example.name} className="space-y-1">
227
+ <div className="flex items-center justify-between">
228
+ <span className="text-xs font-medium text-[var(--theme-text-primary)]">
229
+ {example.name}
230
+ </span>
231
+ {onSelectQuery && (
232
+ <button
233
+ onClick={() => onSelectQuery(example.query)}
234
+ className="text-[10px] px-2 py-1 rounded bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
235
+ >
236
+ Use Query
237
+ </button>
238
+ )}
239
+ </div>
240
+ <pre className="text-[11px] font-mono p-2 rounded bg-[var(--theme-bg-secondary)] text-[var(--theme-text-secondary)] overflow-x-auto whitespace-pre-wrap">
241
+ {example.query}
242
+ </pre>
243
+ </div>
244
+ ))}
245
+ </div>
246
+ </div>
247
+ );
248
+ }
249
+
250
+ return (
251
+ <div
252
+ className="overflow-y-auto bg-[var(--theme-input-bg)] rounded-lg border border-[var(--theme-input-border)]"
253
+ style={{ height, minHeight: "120px" }}
254
+ >
255
+ <div className="p-3 border-b border-[var(--theme-input-border)]">
256
+ <div className="flex items-center gap-2">
257
+ <span className="text-xs font-mono px-2 py-1 bg-blue-500/20 text-blue-400 rounded">
258
+ TABLE
259
+ </span>
260
+ <code className="text-sm font-semibold text-[var(--theme-text-primary)]">
261
+ {schema.name}
262
+ </code>
263
+ </div>
264
+ <p className="text-xs text-[var(--theme-text-secondary)] mt-1">
265
+ Stores all tracked events for a site
266
+ </p>
267
+ </div>
268
+
269
+ {/* Section Tabs */}
270
+ <div className="flex border-b border-[var(--theme-input-border)]">
271
+ {(["columns", "indexes", "examples"] as const).map((section) => (
272
+ <button
273
+ key={section}
274
+ onClick={() => setExpandedSection(section)}
275
+ className={`flex-1 px-3 py-2 text-xs font-medium transition-colors ${
276
+ expandedSection === section
277
+ ? "bg-[var(--theme-bg-secondary)] text-[var(--theme-text-primary)] border-b-2 border-blue-500"
278
+ : "text-[var(--theme-text-secondary)] hover:text-[var(--theme-text-primary)]"
279
+ }`}
280
+ >
281
+ {section === "columns" && `Columns (${schema.columns.length})`}
282
+ {section === "indexes" && `Indexes (${schema.indexes.length})`}
283
+ {section === "examples" && `Examples (${EXAMPLE_QUERIES.length})`}
284
+ </button>
285
+ ))}
286
+ </div>
287
+
288
+ {/* Columns Section */}
289
+ {expandedSection === "columns" && (
290
+ <div className="divide-y divide-[var(--theme-input-border)]">
291
+ {schema.columns.map((col) => (
292
+ <div key={col.name} className="px-3 py-2 hover:bg-[var(--theme-bg-secondary)]">
293
+ <div className="flex items-center gap-2 flex-wrap">
294
+ <code className="text-xs font-mono text-[var(--theme-text-primary)]">
295
+ {col.name}
296
+ </code>
297
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--theme-bg-secondary)] text-[var(--theme-text-secondary)]">
298
+ {col.type || "TEXT"}
299
+ </span>
300
+ {col.primaryKey && (
301
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">
302
+ PRIMARY KEY
303
+ </span>
304
+ )}
305
+ {!col.nullable && !col.primaryKey && (
306
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">
307
+ NOT NULL
308
+ </span>
309
+ )}
310
+ </div>
311
+ </div>
312
+ ))}
313
+ </div>
314
+ )}
315
+
316
+ {/* Indexes Section */}
317
+ {expandedSection === "indexes" && (
318
+ <div className="p-3 space-y-2">
319
+ <p className="text-xs text-[var(--theme-text-secondary)] mb-2">
320
+ Indexed columns for optimized queries:
321
+ </p>
322
+ {schema.indexes.map((idx) => (
323
+ <div
324
+ key={idx.name}
325
+ className="px-2 py-1.5 bg-[var(--theme-bg-secondary)] rounded text-xs font-mono text-[var(--theme-text-secondary)]"
326
+ >
327
+ <span className="text-[var(--theme-text-primary)]">{idx.name}</span>
328
+ {" "}({idx.columns.join(", ")})
329
+ {idx.unique && (
330
+ <span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
331
+ UNIQUE
332
+ </span>
333
+ )}
334
+ </div>
335
+ ))}
336
+ </div>
337
+ )}
338
+
339
+ {/* Example Queries Section */}
340
+ {expandedSection === "examples" && (
341
+ <div className="p-3 space-y-3">
342
+ {EXAMPLE_QUERIES.map((example) => (
343
+ <div key={example.name} className="space-y-1">
344
+ <div className="flex items-center justify-between">
345
+ <span className="text-xs font-medium text-[var(--theme-text-primary)]">
346
+ {example.name}
347
+ </span>
348
+ {onSelectQuery && (
349
+ <button
350
+ onClick={() => onSelectQuery(example.query)}
351
+ className="text-[10px] px-2 py-1 rounded bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
352
+ >
353
+ Use Query
354
+ </button>
355
+ )}
356
+ </div>
357
+ <pre className="text-[11px] font-mono p-2 rounded bg-[var(--theme-bg-secondary)] text-[var(--theme-text-secondary)] overflow-x-auto whitespace-pre-wrap">
358
+ {example.query}
359
+ </pre>
360
+ </div>
361
+ ))}
362
+ </div>
363
+ )}
364
+ </div>
365
+ );
366
+ }
367
+
368
+ /**
369
+ * SQL Editor component using Monaco editor with SQL/SQLite syntax highlighting.
370
+ * Includes a schema tab that fetches table structure from the durable object at runtime.
371
+ * Provides autocomplete for table names, column names, and SQL keywords.
372
+ *
373
+ * Autocomplete: Press Ctrl+Space (or Cmd+Space on Mac) to trigger suggestions.
374
+ *
375
+ * Reusable across different parts of the application.
376
+ */
377
+ export function SQLEditor({
378
+ value,
379
+ onChange,
380
+ onSubmit,
381
+ placeholder = "SELECT * FROM site_events LIMIT 10",
382
+ disabled = false,
383
+ height = "200px",
384
+ theme = "github-dark",
385
+ className = "",
386
+ showSchema = true,
387
+ siteId,
388
+ }: SQLEditorProps) {
389
+ const containerRef = useRef<HTMLDivElement>(null);
390
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
391
+ const editorRef = useRef<any>(null);
392
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
393
+ const monacoRef = useRef<any>(null);
394
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
395
+ const completionProviderRef = useRef<any>(null);
396
+ const isInitialized = useRef(false);
397
+ const [activeTab, setActiveTab] = useState<"editor" | "schema">("editor");
398
+ const [schema, setSchema] = useState<TableSchema | null>(null);
399
+ // Keep schema in a ref so completion provider can access latest value
400
+ const schemaRef = useRef<TableSchema | null>(null);
401
+ schemaRef.current = schema;
402
+
403
+ // Stable callbacks
404
+ const onChangeRef = useRef(onChange);
405
+ onChangeRef.current = onChange;
406
+ const onSubmitRef = useRef(onSubmit);
407
+ onSubmitRef.current = onSubmit;
408
+
409
+ // Fetch schema for autocomplete
410
+ useEffect(() => {
411
+ if (!siteId) return;
412
+
413
+ const fetchSchema = async () => {
414
+ try {
415
+ const response = await fetch(`/api/site-events/schema?site_id=${siteId}`);
416
+ const data = await response.json() as SchemaResponse;
417
+
418
+ if (response.ok && data.tables && data.tables.length > 0) {
419
+ setSchema(data.tables[0]);
420
+ }
421
+ } catch (err) {
422
+ console.error("Failed to fetch schema for autocomplete:", err);
423
+ }
424
+ };
425
+
426
+ fetchSchema();
427
+ }, [siteId]);
428
+
429
+ useEffect(() => {
430
+ if (!containerRef.current || isInitialized.current) return;
431
+ isInitialized.current = true;
432
+
433
+ const initEditor = async () => {
434
+ try {
435
+ const monaco = await init({
436
+ defaultTheme: theme,
437
+ // Pre-load SQL grammar for syntax highlighting
438
+ langs: ["sql"],
439
+ });
440
+
441
+ if (!containerRef.current) return;
442
+
443
+ // Create a model with the SQL language explicitly set
444
+ const model = monaco.editor.createModel(value, "sql");
445
+
446
+ const editor = monaco.editor.create(containerRef.current, {
447
+ model,
448
+ theme: theme,
449
+ automaticLayout: true,
450
+ minimap: { enabled: false },
451
+ scrollBeyondLastLine: false,
452
+ fontSize: 14,
453
+ fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace",
454
+ lineNumbers: "on",
455
+ renderLineHighlight: "line",
456
+ wordWrap: "on",
457
+ padding: { top: 12, bottom: 12 },
458
+ readOnly: disabled,
459
+ tabSize: 2,
460
+ bracketPairColorization: { enabled: true },
461
+ folding: true,
462
+ glyphMargin: false,
463
+ lineNumbersMinChars: 3,
464
+ overviewRulerBorder: false,
465
+ // Autocomplete settings - press Ctrl+Space to trigger suggestions
466
+ quickSuggestions: true,
467
+ suggestOnTriggerCharacters: true,
468
+ wordBasedSuggestions: "off",
469
+ scrollbar: {
470
+ vertical: "auto",
471
+ horizontal: "auto",
472
+ verticalScrollbarSize: 10,
473
+ horizontalScrollbarSize: 10,
474
+ },
475
+ });
476
+
477
+ editorRef.current = editor;
478
+ monacoRef.current = monaco;
479
+
480
+ // Register completion provider
481
+ registerCompletionProvider(monaco);
482
+
483
+ // Listen for content changes
484
+ editor.onDidChangeModelContent(() => {
485
+ const newValue = editor.getValue();
486
+ onChangeRef.current?.(newValue);
487
+ });
488
+
489
+ // Ctrl+Enter to submit query
490
+ editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
491
+ onSubmitRef.current?.();
492
+ });
493
+
494
+ // Handle focus/blur for accessibility
495
+ editor.onDidFocusEditorText(() => {
496
+ containerRef.current?.classList.add("editor-focused");
497
+ });
498
+
499
+ editor.onDidBlurEditorText(() => {
500
+ containerRef.current?.classList.remove("editor-focused");
501
+ });
502
+ } catch (error) {
503
+ console.error("Failed to initialize Monaco editor:", error);
504
+ }
505
+ };
506
+
507
+ initEditor();
508
+
509
+ return () => {
510
+ if (completionProviderRef.current) {
511
+ completionProviderRef.current.dispose();
512
+ completionProviderRef.current = null;
513
+ }
514
+ if (editorRef.current) {
515
+ editorRef.current.dispose();
516
+ editorRef.current = null;
517
+ }
518
+ monacoRef.current = null;
519
+ isInitialized.current = false;
520
+ };
521
+ // Only run once on mount - value updates handled separately
522
+ // eslint-disable-next-line react-hooks/exhaustive-deps
523
+ }, []);
524
+
525
+ // Helper function to register completion provider
526
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
527
+ const registerCompletionProvider = (monaco: any) => {
528
+ // Dispose previous provider if exists
529
+ if (completionProviderRef.current) {
530
+ completionProviderRef.current.dispose();
531
+ }
532
+
533
+ // Register new completion provider
534
+ completionProviderRef.current = monaco.languages.registerCompletionItemProvider("sql", {
535
+ triggerCharacters: [" ", ".", ",", "("],
536
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
537
+ provideCompletionItems: (model: any, position: any) => {
538
+ const word = model.getWordUntilPosition(position);
539
+ const range = {
540
+ startLineNumber: position.lineNumber,
541
+ endLineNumber: position.lineNumber,
542
+ startColumn: word.startColumn,
543
+ endColumn: word.endColumn,
544
+ };
545
+
546
+ const suggestions: Array<{
547
+ label: string;
548
+ kind: number;
549
+ insertText: string;
550
+ detail?: string;
551
+ range: typeof range;
552
+ sortText?: string;
553
+ }> = [];
554
+
555
+ // Add SQL keywords
556
+ for (const keyword of SQL_KEYWORDS) {
557
+ suggestions.push({
558
+ label: keyword,
559
+ kind: monaco.languages.CompletionItemKind.Keyword,
560
+ insertText: keyword,
561
+ detail: "SQL keyword",
562
+ range,
563
+ sortText: `2_${keyword}`, // Keywords sort after columns/tables
564
+ });
565
+ }
566
+
567
+ // Add table name - use schemaRef to get latest value
568
+ const currentSchema = schemaRef.current;
569
+ if (currentSchema) {
570
+ suggestions.push({
571
+ label: currentSchema.name,
572
+ kind: monaco.languages.CompletionItemKind.Class,
573
+ insertText: currentSchema.name,
574
+ detail: "Table",
575
+ range,
576
+ sortText: `0_${currentSchema.name}`, // Tables sort first
577
+ });
578
+
579
+ // Add column names
580
+ for (const col of currentSchema.columns) {
581
+ suggestions.push({
582
+ label: col.name,
583
+ kind: monaco.languages.CompletionItemKind.Field,
584
+ insertText: col.name,
585
+ detail: `${col.type || "TEXT"}${col.primaryKey ? " (PK)" : ""}${!col.nullable ? " NOT NULL" : ""}`,
586
+ range,
587
+ sortText: `1_${col.name}`, // Columns sort after tables, before keywords
588
+ });
589
+ }
590
+ } else {
591
+ // Fallback if schema not loaded - add site_events as table
592
+ suggestions.push({
593
+ label: "site_events",
594
+ kind: monaco.languages.CompletionItemKind.Class,
595
+ insertText: "site_events",
596
+ detail: "Table",
597
+ range,
598
+ sortText: "0_site_events",
599
+ });
600
+ }
601
+
602
+ // Add common SQL functions
603
+ const functions = [
604
+ { name: "COUNT", detail: "Count rows" },
605
+ { name: "SUM", detail: "Sum values" },
606
+ { name: "AVG", detail: "Average value" },
607
+ { name: "MIN", detail: "Minimum value" },
608
+ { name: "MAX", detail: "Maximum value" },
609
+ { name: "STRFTIME", detail: "Format date/time" },
610
+ { name: "COALESCE", detail: "Return first non-null" },
611
+ { name: "IFNULL", detail: "Replace null with value" },
612
+ { name: "LENGTH", detail: "String length" },
613
+ { name: "LOWER", detail: "Lowercase string" },
614
+ { name: "UPPER", detail: "Uppercase string" },
615
+ { name: "TRIM", detail: "Remove whitespace" },
616
+ { name: "SUBSTR", detail: "Substring" },
617
+ { name: "REPLACE", detail: "Replace text" },
618
+ { name: "INSTR", detail: "Find position" },
619
+ { name: "ABS", detail: "Absolute value" },
620
+ { name: "ROUND", detail: "Round number" },
621
+ ];
622
+
623
+ for (const fn of functions) {
624
+ suggestions.push({
625
+ label: fn.name,
626
+ kind: monaco.languages.CompletionItemKind.Function,
627
+ insertText: `${fn.name}()`,
628
+ detail: fn.detail,
629
+ range,
630
+ sortText: `3_${fn.name}`, // Functions sort after keywords
631
+ });
632
+ }
633
+
634
+ return { suggestions };
635
+ },
636
+ });
637
+ };
638
+
639
+ // Update editor value when prop changes (from external source)
640
+ useEffect(() => {
641
+ if (editorRef.current) {
642
+ const currentValue = editorRef.current.getValue();
643
+ if (currentValue !== value) {
644
+ editorRef.current.setValue(value);
645
+ }
646
+ }
647
+ }, [value]);
648
+
649
+ // Update read-only state when disabled changes
650
+ useEffect(() => {
651
+ if (editorRef.current) {
652
+ editorRef.current.updateOptions({ readOnly: disabled });
653
+ }
654
+ }, [disabled]);
655
+
656
+ // Update theme when it changes
657
+ useEffect(() => {
658
+ if (editorRef.current) {
659
+ init().then((monaco) => {
660
+ monaco.editor.setTheme(theme);
661
+ });
662
+ }
663
+ }, [theme]);
664
+
665
+ const handleSelectQuery = (query: string) => {
666
+ if (editorRef.current) {
667
+ editorRef.current.setValue(query);
668
+ onChangeRef.current?.(query);
669
+ }
670
+ setActiveTab("editor");
671
+ };
672
+
673
+ // If schema is disabled, just render the editor
674
+ if (!showSchema) {
675
+ return (
676
+ <div
677
+ ref={containerRef}
678
+ className={`sql-editor-container rounded-lg border border-[var(--theme-input-border)] bg-[var(--theme-input-bg)] overflow-hidden transition-all ${
679
+ disabled ? "opacity-60 cursor-not-allowed" : ""
680
+ } ${className}`}
681
+ style={{ height, minHeight: "120px" }}
682
+ />
683
+ );
684
+ }
685
+
686
+ return (
687
+ <div className={`sql-editor-wrapper ${className}`}>
688
+ {/* Tabs */}
689
+ <div className="flex mb-2 bg-[var(--theme-bg-secondary)] rounded-lg p-1 w-fit">
690
+ <button
691
+ onClick={() => setActiveTab("editor")}
692
+ className={`px-4 py-1.5 text-xs font-medium rounded-md transition-colors ${
693
+ activeTab === "editor"
694
+ ? "bg-[var(--theme-bg-primary)] text-[var(--theme-text-primary)] shadow-sm"
695
+ : "text-[var(--theme-text-secondary)] hover:text-[var(--theme-text-primary)]"
696
+ }`}
697
+ >
698
+ Editor
699
+ </button>
700
+ <button
701
+ onClick={() => setActiveTab("schema")}
702
+ className={`px-4 py-1.5 text-xs font-medium rounded-md transition-colors ${
703
+ activeTab === "schema"
704
+ ? "bg-[var(--theme-bg-primary)] text-[var(--theme-text-primary)] shadow-sm"
705
+ : "text-[var(--theme-text-secondary)] hover:text-[var(--theme-text-primary)]"
706
+ }`}
707
+ >
708
+ Schema
709
+ </button>
710
+ </div>
711
+
712
+ {/* Editor Tab */}
713
+ <div style={{ display: activeTab === "editor" ? "block" : "none" }}>
714
+ <div
715
+ ref={containerRef}
716
+ className={`sql-editor-container rounded-lg border border-[var(--theme-input-border)] bg-[var(--theme-input-bg)] overflow-hidden transition-all ${
717
+ disabled ? "opacity-60 cursor-not-allowed" : ""
718
+ }`}
719
+ style={{ height, minHeight: "120px" }}
720
+ />
721
+ <p className="text-xs text-[var(--theme-text-secondary)] mt-1.5 opacity-70">
722
+ <kbd className="px-1.5 py-0.5 rounded bg-[var(--theme-bg-secondary)] font-mono text-[10px]">Ctrl</kbd>+<kbd className="px-1.5 py-0.5 rounded bg-[var(--theme-bg-secondary)] font-mono text-[10px]">Space</kbd> autocomplete
723
+ <span className="mx-2">·</span>
724
+ <kbd className="px-1.5 py-0.5 rounded bg-[var(--theme-bg-secondary)] font-mono text-[10px]">Ctrl</kbd>+<kbd className="px-1.5 py-0.5 rounded bg-[var(--theme-bg-secondary)] font-mono text-[10px]">Enter</kbd> run query
725
+ </p>
726
+ </div>
727
+
728
+ {/* Schema Tab */}
729
+ {activeTab === "schema" && (
730
+ <SchemaViewer
731
+ onSelectQuery={handleSelectQuery}
732
+ height={height}
733
+ siteId={siteId}
734
+ />
735
+ )}
736
+ </div>
737
+ );
738
+ }
739
+
740
+ export default SQLEditor;