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.
- package/.env.example +37 -0
- package/README.md +486 -0
- package/alchemy.run.ts +155 -0
- package/cli/bootstrap-admin.ts +284 -0
- package/cli/deploy-staging.ts +692 -0
- package/cli/import-events.ts +628 -0
- package/cli/import-sites.ts +518 -0
- package/cli/index.ts +609 -0
- package/cli/init-db.ts +269 -0
- package/cli/migrate-to-durable-objects.ts +564 -0
- package/cli/migration-worker.ts +300 -0
- package/cli/performance-test.ts +588 -0
- package/cli/pg/client.ts +4 -0
- package/cli/pg/new-site.ts +153 -0
- package/cli/rollback-durable-objects.ts +622 -0
- package/cli/seed-data.ts +459 -0
- package/cli/setup.js +18 -0
- package/cli/setup.ts +463 -0
- package/cli/validate-migration.ts +200 -0
- package/cli/wrangler-migration.jsonc +28 -0
- package/db/adapter.ts +166 -0
- package/db/analytics_engine/client.ts +0 -0
- package/db/analytics_engine/sites.ts +0 -0
- package/db/client.ts +16 -0
- package/db/d1/client.ts +8 -0
- package/db/d1/drizzle.config.ts +35 -0
- package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
- package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
- package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
- package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
- package/db/d1/migrations/0004_mute_stardust.sql +1 -0
- package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
- package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
- package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
- package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
- package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
- package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
- package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
- package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
- package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
- package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
- package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
- package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
- package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
- package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
- package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
- package/db/d1/migrations/meta/_journal.json +76 -0
- package/db/d1/schema.ts +407 -0
- package/db/d1/sites.ts +374 -0
- package/db/d1/teamAiUsage.ts +101 -0
- package/db/d1/teams.ts +127 -0
- package/db/durable/drizzle.config.ts +8 -0
- package/db/durable/durableObjectClient.ts +480 -0
- package/db/durable/events.ts +100 -0
- package/db/durable/migrations/0000_fair_bucky.sql +38 -0
- package/db/durable/migrations/meta/0000_snapshot.json +278 -0
- package/db/durable/migrations/meta/_journal.json +13 -0
- package/db/durable/migrations/migrations.js +10 -0
- package/db/durable/schema.ts +5 -0
- package/db/durable/siteDurableObject.ts +1352 -0
- package/db/durable/types.ts +53 -0
- package/db/postgres/client.ts +13 -0
- package/db/postgres/drizzle.config.ts +12 -0
- package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
- package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
- package/db/postgres/migrations/meta/_journal.json +13 -0
- package/db/postgres/schema.ts +145 -0
- package/db/postgres/sites.ts +118 -0
- package/db/tranformReports.ts +595 -0
- package/db/types.ts +55 -0
- package/endpoints/api_worker.tsx +1854 -0
- package/endpoints/site_do_worker.ts +11 -0
- package/index.d.ts +63 -0
- package/index.ts +83 -0
- package/lib/auth.ts +279 -0
- package/lib/geojson/world_countries.json +45307 -0
- package/lib/random_name.ts +41 -0
- package/lib/sendMail.ts +252 -0
- package/package.json +142 -0
- package/public/favicon.ico +0 -0
- package/public/images/android-chrome-192x192.png +0 -0
- package/public/images/android-chrome-512x512.png +0 -0
- package/public/images/apple-touch-icon.png +0 -0
- package/public/images/favicon-16x16.png +0 -0
- package/public/images/favicon-32x32.png +0 -0
- package/public/images/lytx_dark_dashboard.png +0 -0
- package/public/images/lytx_light_dashboard.png +0 -0
- package/public/images/safari-pinned-tab.svg +4 -0
- package/public/logo.png +0 -0
- package/public/site.webmanifest +26 -0
- package/public/sw.js +107 -0
- package/src/Document.tsx +86 -0
- package/src/api/ai_api.ts +1156 -0
- package/src/api/authMiddleware.ts +45 -0
- package/src/api/auth_api.ts +465 -0
- package/src/api/event_labels_api.ts +193 -0
- package/src/api/events_api.ts +210 -0
- package/src/api/queueWorker.ts +303 -0
- package/src/api/reports_api.ts +278 -0
- package/src/api/seed_api.ts +288 -0
- package/src/api/sites_api.ts +904 -0
- package/src/api/tag_api.ts +458 -0
- package/src/api/tag_api_v2.ts +289 -0
- package/src/api/team_api.ts +456 -0
- package/src/app/Dashboard.tsx +1339 -0
- package/src/app/Events.tsx +974 -0
- package/src/app/Explore.tsx +312 -0
- package/src/app/Layout.tsx +58 -0
- package/src/app/Settings.tsx +1302 -0
- package/src/app/components/DashboardCard.tsx +118 -0
- package/src/app/components/EditableCell.tsx +123 -0
- package/src/app/components/EventForm.tsx +93 -0
- package/src/app/components/MarketingFooter.tsx +49 -0
- package/src/app/components/MarketingNav.tsx +150 -0
- package/src/app/components/Nav.tsx +755 -0
- package/src/app/components/NewSiteSetup.tsx +298 -0
- package/src/app/components/SQLEditor.tsx +740 -0
- package/src/app/components/SiteSelector.tsx +126 -0
- package/src/app/components/SiteTag.tsx +42 -0
- package/src/app/components/SiteTagInstallCard.tsx +241 -0
- package/src/app/components/WorldMapCard.tsx +337 -0
- package/src/app/components/charts/ChartComponents.tsx +1481 -0
- package/src/app/components/charts/EventFunnel.tsx +45 -0
- package/src/app/components/charts/EventSummary.tsx +194 -0
- package/src/app/components/charts/SankeyFlows.tsx +72 -0
- package/src/app/components/marketing/CheckIcon.tsx +16 -0
- package/src/app/components/marketing/MarketingLayout.tsx +23 -0
- package/src/app/components/marketing/SectionHeading.tsx +35 -0
- package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
- package/src/app/components/reports/CreateReportStarter.tsx +74 -0
- package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
- package/src/app/components/reports/DashboardToolbar.tsx +154 -0
- package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
- package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
- package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
- package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
- package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
- package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
- package/src/app/components/reports/custom/chartPalettes.ts +18 -0
- package/src/app/components/reports/custom/types.ts +50 -0
- package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
- package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
- package/src/app/components/ui/AlertBanner.tsx +101 -0
- package/src/app/components/ui/Button.tsx +55 -0
- package/src/app/components/ui/Card.tsx +80 -0
- package/src/app/components/ui/Input.tsx +72 -0
- package/src/app/components/ui/Link.tsx +23 -0
- package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
- package/src/app/components/ui/ThemeToggle.tsx +54 -0
- package/src/app/constants.ts +6 -0
- package/src/app/headers.ts +33 -0
- package/src/app/providers/AuthProvider.tsx +189 -0
- package/src/app/providers/ClientProviders.tsx +18 -0
- package/src/app/providers/QueryProvider.tsx +23 -0
- package/src/app/providers/ThemeProvider.tsx +88 -0
- package/src/app/utils/chartThemes.ts +146 -0
- package/src/app/utils/keybinds.ts +96 -0
- package/src/app/utils/media.tsx +24 -0
- package/src/client.tsx +114 -0
- package/src/config/createLytxAppConfig.ts +252 -0
- package/src/config/resourceNames.ts +88 -0
- package/src/db/index.ts +67 -0
- package/src/index.css +285 -0
- package/src/lib/featureFlags.ts +69 -0
- package/src/pages/GetStarted.tsx +290 -0
- package/src/pages/Home.tsx +268 -0
- package/src/pages/Login.tsx +283 -0
- package/src/pages/PrivacyPolicy.tsx +120 -0
- package/src/pages/Signup.tsx +267 -0
- package/src/pages/TermsOfService.tsx +126 -0
- package/src/pages/VerifyEmail.tsx +56 -0
- package/src/session/durableObject.ts +7 -0
- package/src/session/siteSchema.ts +86 -0
- package/src/session/types.ts +36 -0
- package/src/templates/README.md +80 -0
- package/src/templates/cleanFunctions.js +44 -0
- package/src/templates/embedFunctions.js +52 -0
- package/src/templates/lytx-shared.ts +662 -0
- package/src/templates/lytxpixel-core.ts +144 -0
- package/src/templates/lytxpixel.ts +267 -0
- package/src/templates/lytxpixelBrowser.js +634 -0
- package/src/templates/lytxpixelBrowser.mjs +634 -0
- package/src/templates/parseData.js +12 -0
- package/src/templates/script.ts +31 -0
- package/src/templates/template.tsx +50 -0
- package/src/templates/test.js +3 -0
- package/src/templates/trackWebEvents.ts +177 -0
- package/src/templates/vendors/clickcease.ts +8 -0
- package/src/templates/vendors/google.ts +174 -0
- package/src/templates/vendors/linkedin.ts +23 -0
- package/src/templates/vendors/meta.ts +56 -0
- package/src/templates/vendors/quantcast.ts +22 -0
- package/src/templates/vendors/simplfi.ts +7 -0
- package/src/types/app-context.ts +16 -0
- package/src/utilities/dashboardParams.ts +188 -0
- package/src/utilities/dashboardQueries.ts +537 -0
- package/src/utilities/dashboardTransforms.ts +167 -0
- package/src/utilities/dataValidation.ts +414 -0
- package/src/utilities/detector.ts +73 -0
- package/src/utilities/encrypt.ts +103 -0
- package/src/utilities/index.ts +13 -0
- package/src/utilities/parser.ts +117 -0
- package/src/utilities/performanceMonitoring.ts +570 -0
- package/src/utilities/route_interuptors.ts +24 -0
- package/src/worker.tsx +675 -0
- package/tsconfig.json +78 -0
- package/types/env.d.ts +16 -0
- package/types/rw.d.ts +7 -0
- package/types/shims.d.ts +53 -0
- package/types/vite.d.ts +19 -0
- package/vite/vite-plugin-pixel-bundle.ts +126 -0
- package/vite.config.ts +53 -0
- package/worker-configuration.d.ts +8401 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// src/api/events_api.ts
|
|
2
|
+
|
|
3
|
+
import { route } from 'rwsdk/router';
|
|
4
|
+
import { env } from 'cloudflare:workers';
|
|
5
|
+
import { insertSiteEvents } from '@db/adapter';
|
|
6
|
+
import { getDashboardDataFromDurableObject } from '@db/durable/durableObjectClient';
|
|
7
|
+
import type { SiteEventInput } from '@/session/siteSchema';
|
|
8
|
+
import type { DBAdapter } from '@db/types';
|
|
9
|
+
import { d1_client } from '@db/d1/client';
|
|
10
|
+
import { sites } from '@db/d1/schema';
|
|
11
|
+
import { eq } from 'drizzle-orm';
|
|
12
|
+
import { IS_DEV } from 'rwsdk/constants';
|
|
13
|
+
|
|
14
|
+
const SEED_DATA_SECRET = (env as { SEED_DATA_SECRET?: string }).SEED_DATA_SECRET;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get events from site durable object
|
|
18
|
+
*/
|
|
19
|
+
export async function getEventsFromSite(
|
|
20
|
+
siteId: number,
|
|
21
|
+
siteUuid: string,
|
|
22
|
+
tagId?: string,
|
|
23
|
+
) {
|
|
24
|
+
try {
|
|
25
|
+
const dashboardData = await getDashboardDataFromDurableObject({
|
|
26
|
+
site_id: siteId,
|
|
27
|
+
site_uuid: siteUuid,
|
|
28
|
+
team_id: 1, // TODO: Get actual team_id
|
|
29
|
+
date: {
|
|
30
|
+
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // Last 30 days
|
|
31
|
+
end: new Date(),
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (dashboardData.query) {
|
|
36
|
+
// Filter by tagId if provided
|
|
37
|
+
let events = dashboardData.query.events || [];
|
|
38
|
+
if (tagId) {
|
|
39
|
+
events = events.filter((event: any) => event.tag_id === tagId);
|
|
40
|
+
}
|
|
41
|
+
return events;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return [];
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error(`Error fetching events for site ${siteId}:`, error);
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Failed to fetch events: ${error instanceof Error ? error.message : String(error)}`, { cause: error },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Save events to site durable object via insertSiteEvents
|
|
55
|
+
*/
|
|
56
|
+
export async function saveEventsToSite(
|
|
57
|
+
siteId: number,
|
|
58
|
+
eventsData: SiteEventInput[],
|
|
59
|
+
siteUuid: string,
|
|
60
|
+
siteAdapter: DBAdapter,
|
|
61
|
+
): Promise<{ success: boolean; inserted?: number; error?: string }> {
|
|
62
|
+
if (!Array.isArray(eventsData)) {
|
|
63
|
+
return { success: false, error: 'Events data must be an array' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (eventsData.length === 0) {
|
|
67
|
+
return { success: true, inserted: 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const result = await insertSiteEvents(siteId, siteUuid, eventsData, siteAdapter);
|
|
72
|
+
return result;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error(`Error saving events for site ${siteId}:`, error);
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
error: error instanceof Error ? error.message : String(error),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* GET/POST /api/events/:siteId/:tagId
|
|
85
|
+
*
|
|
86
|
+
* Updated events API endpoint that uses durable objects
|
|
87
|
+
* - GET: Fetch events from site durable object, optionally filtered by tagId
|
|
88
|
+
* - POST: Insert events into site durable object via insertSiteEvents
|
|
89
|
+
*/
|
|
90
|
+
export const eventsApi = route(
|
|
91
|
+
"/api/events/*",
|
|
92
|
+
async ({ params, request }) => {
|
|
93
|
+
if (!["GET", "POST"].includes(request.method)) {
|
|
94
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const seedSecretHeader = request.headers.get("x-seed-secret");
|
|
98
|
+
|
|
99
|
+
if (!IS_DEV) {
|
|
100
|
+
if (!seedSecretHeader) {
|
|
101
|
+
return new Response("Unauthorized", { status: 401 });
|
|
102
|
+
}
|
|
103
|
+
return new Response("Seed endpoint disabled outside dev.", { status: 403 });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!SEED_DATA_SECRET) {
|
|
107
|
+
return new Response("SEED_DATA_SECRET is not configured.", { status: 500 });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (seedSecretHeader !== SEED_DATA_SECRET) {
|
|
111
|
+
return new Response("Unauthorized", { status: 401 });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const pathPart = params.$0 ?? "";
|
|
115
|
+
const [siteIdStr, tagId] = pathPart.split("/");
|
|
116
|
+
|
|
117
|
+
if (!siteIdStr) {
|
|
118
|
+
return new Response("Site ID is required", { status: 400 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const siteId = parseInt(siteIdStr, 10);
|
|
122
|
+
if (isNaN(siteId)) {
|
|
123
|
+
return new Response("Invalid site ID format", { status: 400 });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const [siteDetails] = await d1_client
|
|
127
|
+
.select({ uuid: sites.uuid, site_db_adapter: sites.site_db_adapter })
|
|
128
|
+
.from(sites)
|
|
129
|
+
.where(eq(sites.site_id, siteId))
|
|
130
|
+
.limit(1);
|
|
131
|
+
|
|
132
|
+
if (!siteDetails) {
|
|
133
|
+
return new Response("Site not found", { status: 404 });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const siteAdapter = (siteDetails.site_db_adapter ?? "sqlite") as DBAdapter;
|
|
137
|
+
|
|
138
|
+
// GET: Fetch events from durable object
|
|
139
|
+
if (request.method === "GET") {
|
|
140
|
+
try {
|
|
141
|
+
const events = await getEventsFromSite(siteId, siteDetails.uuid, tagId);
|
|
142
|
+
return new Response(JSON.stringify(events), {
|
|
143
|
+
status: 200,
|
|
144
|
+
headers: { "Content-Type": "application/json" },
|
|
145
|
+
});
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(`Error in GET /api/events/${siteId}/${tagId || ''} route:`, error);
|
|
148
|
+
return new Response(error instanceof Error ? error.message : "Failed to fetch events", { status: 500 });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// POST: Insert events into durable object
|
|
153
|
+
if (request.method === "POST") {
|
|
154
|
+
try {
|
|
155
|
+
const eventsData = await request.json() as SiteEventInput[];
|
|
156
|
+
if (!Array.isArray(eventsData)) {
|
|
157
|
+
return new Response("Invalid events data format: expected an array.", { status: 400 });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Validate required fields
|
|
161
|
+
for (const event of eventsData) {
|
|
162
|
+
if (!event.event || !event.tag_id) {
|
|
163
|
+
return new Response("Each event must have 'event' and 'tag_id' fields", { status: 400 });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const normalizedEvents = eventsData.map((event) => ({
|
|
168
|
+
...event,
|
|
169
|
+
createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
|
|
170
|
+
updatedAt: event.updatedAt ? new Date(event.updatedAt) : undefined,
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
const result = await saveEventsToSite(siteId, normalizedEvents, siteDetails.uuid, siteAdapter);
|
|
174
|
+
|
|
175
|
+
if (result.success) {
|
|
176
|
+
return new Response(JSON.stringify({
|
|
177
|
+
message: "Events saved successfully",
|
|
178
|
+
inserted: result.inserted || 0
|
|
179
|
+
}), {
|
|
180
|
+
status: 200,
|
|
181
|
+
headers: { "Content-Type": "application/json" },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return new Response(JSON.stringify({
|
|
186
|
+
error: result.error || "Failed to save events"
|
|
187
|
+
}), {
|
|
188
|
+
status: 500,
|
|
189
|
+
headers: { "Content-Type": "application/json" }
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error(`Error in POST /api/events/${siteId} route:`, error);
|
|
193
|
+
const message = error instanceof Error ? error.message : "Failed to save events";
|
|
194
|
+
|
|
195
|
+
if (message.includes("Unexpected token") || message.includes("JSON at position")) {
|
|
196
|
+
return new Response("Invalid JSON format in request body.", { status: 400 });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
200
|
+
status: 500,
|
|
201
|
+
headers: { "Content-Type": "application/json" }
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type { SiteEventInput } from "@/session/siteSchema";
|
|
2
|
+
import type { DBAdapter } from "@db/types";
|
|
3
|
+
import { writeToDurableObject } from "@db/durable/durableObjectClient";
|
|
4
|
+
import { d1_client } from "@db/d1/client";
|
|
5
|
+
import { sites } from "@db/d1/schema";
|
|
6
|
+
import { eq } from "drizzle-orm";
|
|
7
|
+
import { env } from "cloudflare:workers";
|
|
8
|
+
import { IS_DEV } from "rwsdk/constants";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Queue message structure for site events
|
|
12
|
+
*/
|
|
13
|
+
export type QueueSiteEventInput = Omit<SiteEventInput, "createdAt" | "updatedAt"> & {
|
|
14
|
+
createdAt?: string;
|
|
15
|
+
updatedAt?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export interface QueueMessage {
|
|
19
|
+
type: "site_event";
|
|
20
|
+
siteId: number;
|
|
21
|
+
siteUuid?: string;
|
|
22
|
+
teamId?: number;
|
|
23
|
+
adapter: DBAdapter;
|
|
24
|
+
events: QueueSiteEventInput[];
|
|
25
|
+
timestamp: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type QueueMessageHandle = {
|
|
29
|
+
body: QueueMessage;
|
|
30
|
+
ack: () => void;
|
|
31
|
+
retry: (options?: { delaySeconds?: number }) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type QueueBatch = {
|
|
35
|
+
messages: QueueMessageHandle[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const MAX_DO_WRITE_EVENTS_PER_CALL = 200;
|
|
39
|
+
|
|
40
|
+
function chunkEvents<T>(events: T[], chunkSize: number): T[][] {
|
|
41
|
+
if (events.length <= chunkSize) return [events];
|
|
42
|
+
const chunks: T[][] = [];
|
|
43
|
+
for (let i = 0; i < events.length; i += chunkSize) {
|
|
44
|
+
chunks.push(events.slice(i, i + chunkSize));
|
|
45
|
+
}
|
|
46
|
+
return chunks;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeAdapter(adapter: string): DBAdapter {
|
|
50
|
+
if (
|
|
51
|
+
adapter === "postgres"
|
|
52
|
+
|| adapter === "singlestore"
|
|
53
|
+
|| adapter === "sqlite"
|
|
54
|
+
|| adapter === "analytics_engine"
|
|
55
|
+
) {
|
|
56
|
+
return adapter;
|
|
57
|
+
}
|
|
58
|
+
return "sqlite";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toQueueEvent(event: SiteEventInput): QueueSiteEventInput {
|
|
62
|
+
return {
|
|
63
|
+
...event,
|
|
64
|
+
createdAt: event.createdAt instanceof Date ? event.createdAt.toISOString() : undefined,
|
|
65
|
+
updatedAt: event.updatedAt instanceof Date ? event.updatedAt.toISOString() : undefined,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toSiteEventInput(event: QueueSiteEventInput): SiteEventInput {
|
|
70
|
+
const createdAt = event.createdAt ? new Date(event.createdAt) : undefined;
|
|
71
|
+
const updatedAt = event.updatedAt ? new Date(event.updatedAt) : undefined;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
...event,
|
|
75
|
+
createdAt: createdAt && !Number.isNaN(createdAt.getTime()) ? createdAt : undefined,
|
|
76
|
+
updatedAt: updatedAt && !Number.isNaN(updatedAt.getTime()) ? updatedAt : undefined,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function getSiteUuid(siteId: number): Promise<string | null> {
|
|
81
|
+
const [site] = await d1_client
|
|
82
|
+
.select({ uuid: sites.uuid })
|
|
83
|
+
.from(sites)
|
|
84
|
+
.where(eq(sites.site_id, siteId))
|
|
85
|
+
.limit(1);
|
|
86
|
+
|
|
87
|
+
return site?.uuid ?? null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Queue worker handler for processing site events
|
|
92
|
+
*
|
|
93
|
+
* This function processes batches of site events and writes them to
|
|
94
|
+
* the site-specific durable object used by dashboard queries.
|
|
95
|
+
*/
|
|
96
|
+
export async function handleQueueMessage(
|
|
97
|
+
batch: QueueBatch,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
if (IS_DEV) console.log(`Processing queue batch with ${batch.messages.length} messages`);
|
|
100
|
+
|
|
101
|
+
const messagesBySite = new Map<number, {
|
|
102
|
+
siteUuid?: string;
|
|
103
|
+
adapter: DBAdapter;
|
|
104
|
+
events: SiteEventInput[];
|
|
105
|
+
messages: QueueMessageHandle[];
|
|
106
|
+
}>();
|
|
107
|
+
|
|
108
|
+
for (const message of batch.messages) {
|
|
109
|
+
const { siteId, siteUuid, adapter, events } = message.body;
|
|
110
|
+
|
|
111
|
+
const existing = messagesBySite.get(siteId);
|
|
112
|
+
const normalizedEvents = events.map(toSiteEventInput);
|
|
113
|
+
|
|
114
|
+
if (existing) {
|
|
115
|
+
existing.events.push(...normalizedEvents);
|
|
116
|
+
existing.messages.push(message);
|
|
117
|
+
if (!existing.siteUuid && siteUuid) {
|
|
118
|
+
existing.siteUuid = siteUuid;
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
messagesBySite.set(siteId, {
|
|
122
|
+
siteUuid,
|
|
123
|
+
adapter,
|
|
124
|
+
events: normalizedEvents,
|
|
125
|
+
messages: [message],
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const [siteId, siteBatch] of messagesBySite.entries()) {
|
|
131
|
+
const { adapter, events, messages } = siteBatch;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
if (IS_DEV) console.log(`Processing site ${siteId} with ${events.length} events (adapter: ${adapter})`);
|
|
135
|
+
|
|
136
|
+
const resolvedSiteUuid = siteBatch.siteUuid ?? await getSiteUuid(siteId);
|
|
137
|
+
if (!resolvedSiteUuid) {
|
|
138
|
+
throw new Error(`Unable to resolve site UUID for site ${siteId}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let insertedTotal = 0;
|
|
142
|
+
const eventChunks = chunkEvents(events, MAX_DO_WRITE_EVENTS_PER_CALL);
|
|
143
|
+
|
|
144
|
+
for (const eventChunk of eventChunks) {
|
|
145
|
+
// Always write to durable object for fast dashboard access
|
|
146
|
+
const durableResult = await writeToDurableObject(siteId, resolvedSiteUuid, eventChunk);
|
|
147
|
+
|
|
148
|
+
if (!durableResult.success) {
|
|
149
|
+
throw new Error(`Durable object write failed: ${durableResult.error}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
insertedTotal += durableResult.inserted ?? eventChunk.length;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (IS_DEV) {
|
|
156
|
+
console.log(`✅ Wrote ${insertedTotal} events to durable object for site ${siteId} across ${eventChunks.length} chunk(s)`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const message of messages) {
|
|
160
|
+
message.ack();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.error(`❌ Queue processing failed for site ${siteId}:`, error);
|
|
165
|
+
|
|
166
|
+
// Consumer-level retry policy is configured in Alchemy eventSources settings
|
|
167
|
+
for (const message of messages) {
|
|
168
|
+
message.retry({ delaySeconds: 5 });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function batchProcessSiteEvents(
|
|
175
|
+
eventsBySite: Map<number, { siteUuid?: string; teamId?: number; adapter: string; events: SiteEventInput[] }>,
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
const messages: QueueMessage[] = [];
|
|
178
|
+
|
|
179
|
+
// Create queue messages for each site
|
|
180
|
+
for (const [siteId, { siteUuid, teamId, adapter, events }] of eventsBySite) {
|
|
181
|
+
messages.push({
|
|
182
|
+
type: "site_event",
|
|
183
|
+
siteId,
|
|
184
|
+
siteUuid,
|
|
185
|
+
teamId,
|
|
186
|
+
adapter: normalizeAdapter(adapter),
|
|
187
|
+
events: events.map(toQueueEvent),
|
|
188
|
+
timestamp: Date.now(),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Send all messages to queue in batch
|
|
193
|
+
if (messages.length > 0) {
|
|
194
|
+
await env.SITE_EVENTS_QUEUE.sendBatch(
|
|
195
|
+
messages.map(msg => ({ body: msg }))
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
if (IS_DEV) console.log(`📤 Sent ${messages.length} messages to queue for processing`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Direct processing for sqlite sites (bypass queue)
|
|
204
|
+
*/
|
|
205
|
+
export async function processDirectSiteEvents(
|
|
206
|
+
site_id: number,
|
|
207
|
+
site_uuid: string,
|
|
208
|
+
events: SiteEventInput[],
|
|
209
|
+
): Promise<{ success: boolean; inserted?: number; error?: string }> {
|
|
210
|
+
try {
|
|
211
|
+
// For sqlite sites, write directly to durable object
|
|
212
|
+
const result = await writeToDurableObject(site_id, site_uuid, events);
|
|
213
|
+
|
|
214
|
+
if (result.success) {
|
|
215
|
+
if (IS_DEV) console.log(`✅ Direct write: ${result.inserted} events to durable object for site ${site_uuid}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return result;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.error(`❌ Direct processing failed for site ${site_uuid}:`, error);
|
|
221
|
+
return {
|
|
222
|
+
success: false,
|
|
223
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function enqueueSiteEventsForProcessing(
|
|
229
|
+
input: {
|
|
230
|
+
siteId: number;
|
|
231
|
+
siteUuid: string;
|
|
232
|
+
teamId?: number;
|
|
233
|
+
adapter: DBAdapter;
|
|
234
|
+
events: SiteEventInput[];
|
|
235
|
+
},
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
if (input.events.length === 0) return;
|
|
238
|
+
|
|
239
|
+
const message: QueueMessage = {
|
|
240
|
+
type: "site_event",
|
|
241
|
+
siteId: input.siteId,
|
|
242
|
+
siteUuid: input.siteUuid,
|
|
243
|
+
teamId: input.teamId,
|
|
244
|
+
adapter: input.adapter,
|
|
245
|
+
events: input.events.map(toQueueEvent),
|
|
246
|
+
timestamp: Date.now(),
|
|
247
|
+
};
|
|
248
|
+
if (IS_DEV) console.log("🔥🔥🔥 Sending site event for processing");
|
|
249
|
+
await env.SITE_EVENTS_QUEUE.send(message);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Health check for queue system
|
|
254
|
+
*/
|
|
255
|
+
export async function checkQueueHealth(env: Env): Promise<{
|
|
256
|
+
status: 'healthy' | 'degraded' | 'unhealthy';
|
|
257
|
+
details: {
|
|
258
|
+
queueAvailable: boolean;
|
|
259
|
+
recentFailures: number;
|
|
260
|
+
lastProcessedAt?: string;
|
|
261
|
+
};
|
|
262
|
+
}> {
|
|
263
|
+
try {
|
|
264
|
+
// Check if queue is available by attempting to send a test message
|
|
265
|
+
const testMessage: QueueMessage = {
|
|
266
|
+
type: 'site_event',
|
|
267
|
+
siteId: -1, // Test site ID
|
|
268
|
+
teamId: -1,
|
|
269
|
+
adapter: 'sqlite',
|
|
270
|
+
events: [],
|
|
271
|
+
timestamp: Date.now(),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// This will throw if queue is not available
|
|
275
|
+
await env.SITE_EVENTS_QUEUE.send(testMessage);
|
|
276
|
+
|
|
277
|
+
// Check recent failures from KV
|
|
278
|
+
const failureKeys = await env.lytx_config.list({ prefix: 'failed_queue_message:' });
|
|
279
|
+
const recentFailures = failureKeys.keys.filter(key => {
|
|
280
|
+
const timestamp = parseInt(key.name.split(':').pop() || '0');
|
|
281
|
+
return Date.now() - timestamp < 3600000; // Last hour
|
|
282
|
+
}).length;
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
status: recentFailures > 10 ? 'degraded' : 'healthy',
|
|
286
|
+
details: {
|
|
287
|
+
queueAvailable: true,
|
|
288
|
+
recentFailures,
|
|
289
|
+
lastProcessedAt: new Date().toISOString(),
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
} catch (error) {
|
|
294
|
+
console.error('Queue health check failed:', error);
|
|
295
|
+
return {
|
|
296
|
+
status: 'unhealthy',
|
|
297
|
+
details: {
|
|
298
|
+
queueAvailable: false,
|
|
299
|
+
recentFailures: -1,
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|