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
package/cli/setup.ts
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
//
|
|
3
|
+
// Lytx Kit – Interactive Setup
|
|
4
|
+
//
|
|
5
|
+
// Generates alchemy.run.ts and .env for deploying Lytx on your own
|
|
6
|
+
// Cloudflare account. Run with:
|
|
7
|
+
//
|
|
8
|
+
// bun run cli/setup.ts
|
|
9
|
+
// bun run cli/setup.ts --non-interactive (use all defaults)
|
|
10
|
+
//
|
|
11
|
+
|
|
12
|
+
import { existsSync, writeFileSync, readFileSync } from "fs";
|
|
13
|
+
import { resolve } from "path";
|
|
14
|
+
import { createInterface } from "readline";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Defaults — match what the reference alchemy.run.ts uses
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
interface SetupConfig {
|
|
21
|
+
appName: string;
|
|
22
|
+
workerName: string;
|
|
23
|
+
domains: string[];
|
|
24
|
+
apiWorkerEnabled: boolean;
|
|
25
|
+
apiWorkerName: string;
|
|
26
|
+
apiWorkerDomain: string;
|
|
27
|
+
apiWorkerPort: number;
|
|
28
|
+
d1Name: string;
|
|
29
|
+
kvEvents: string;
|
|
30
|
+
kvConfig: string;
|
|
31
|
+
kvSessions: string;
|
|
32
|
+
queueName: string;
|
|
33
|
+
doName: string;
|
|
34
|
+
adoptMode: boolean;
|
|
35
|
+
queueBatchSize: number;
|
|
36
|
+
queueMaxConcurrency: number;
|
|
37
|
+
queueMaxRetries: number;
|
|
38
|
+
queueMaxWaitTimeMs: number;
|
|
39
|
+
queueRetryDelay: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULTS: SetupConfig = {
|
|
43
|
+
appName: "lytx",
|
|
44
|
+
workerName: "lytx-app",
|
|
45
|
+
domains: [],
|
|
46
|
+
apiWorkerEnabled: false,
|
|
47
|
+
apiWorkerName: "lytx-api",
|
|
48
|
+
apiWorkerDomain: "",
|
|
49
|
+
apiWorkerPort: 8788,
|
|
50
|
+
d1Name: "lytx-core-db",
|
|
51
|
+
kvEvents: "LYTX_EVENTS",
|
|
52
|
+
kvConfig: "lytx_config",
|
|
53
|
+
kvSessions: "lytx_sessions",
|
|
54
|
+
queueName: "site-events-queue",
|
|
55
|
+
doName: "site-durable-object",
|
|
56
|
+
adoptMode: false,
|
|
57
|
+
queueBatchSize: 100,
|
|
58
|
+
queueMaxConcurrency: 4,
|
|
59
|
+
queueMaxRetries: 3,
|
|
60
|
+
queueMaxWaitTimeMs: 20_000,
|
|
61
|
+
queueRetryDelay: 30,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Prompt helper
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
69
|
+
|
|
70
|
+
function ask(question: string, fallback: string): Promise<string> {
|
|
71
|
+
const label = fallback ? ` (${fallback})` : "";
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
rl.question(`${question}${label}: `, (answer) => {
|
|
74
|
+
resolve(answer.trim() || fallback);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function askBool(question: string, fallback: boolean): Promise<boolean> {
|
|
80
|
+
const hint = fallback ? "Y/n" : "y/N";
|
|
81
|
+
const answer = await ask(`${question} [${hint}]`, "");
|
|
82
|
+
if (!answer) return fallback;
|
|
83
|
+
return answer.toLowerCase().startsWith("y");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function askNumber(question: string, fallback: number): Promise<number> {
|
|
87
|
+
const raw = await ask(question, String(fallback));
|
|
88
|
+
const parsed = Number(raw);
|
|
89
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function askList(question: string, hint: string): Promise<string[]> {
|
|
93
|
+
const raw = await ask(`${question} (${hint})`, "");
|
|
94
|
+
if (!raw) return [];
|
|
95
|
+
return raw
|
|
96
|
+
.split(",")
|
|
97
|
+
.map((s) => s.trim())
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Interactive prompts
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async function promptConfig(): Promise<SetupConfig> {
|
|
106
|
+
const config = { ...DEFAULTS };
|
|
107
|
+
|
|
108
|
+
console.log("\n╔══════════════════════════════════════════╗");
|
|
109
|
+
console.log("║ Lytx Kit – Setup Wizard ║");
|
|
110
|
+
console.log("╚══════════════════════════════════════════╝\n");
|
|
111
|
+
|
|
112
|
+
// -- Core identity --
|
|
113
|
+
console.log("── Project ────────────────────────────────\n");
|
|
114
|
+
config.appName = await ask("Alchemy app name", config.appName);
|
|
115
|
+
config.workerName = await ask("Main worker name", config.workerName);
|
|
116
|
+
|
|
117
|
+
// -- Domains --
|
|
118
|
+
console.log("\n── Domains ────────────────────────────────\n");
|
|
119
|
+
console.log(" Add custom domains Cloudflare will route to this worker.");
|
|
120
|
+
console.log(" Leave blank for workers.dev only (you can add domains later).\n");
|
|
121
|
+
config.domains = await askList("Custom domains", "comma-separated, e.g. analytics.example.com");
|
|
122
|
+
|
|
123
|
+
// -- API Worker --
|
|
124
|
+
console.log("\n── API Worker (optional) ──────────────────\n");
|
|
125
|
+
console.log(" A separate Worker that exposes the Lytx REST API on its own");
|
|
126
|
+
console.log(" domain/subdomain. Skip this if you only need the main app.\n");
|
|
127
|
+
config.apiWorkerEnabled = await askBool("Deploy a separate API worker?", config.apiWorkerEnabled);
|
|
128
|
+
if (config.apiWorkerEnabled) {
|
|
129
|
+
config.apiWorkerName = await ask(" API worker name", config.apiWorkerName);
|
|
130
|
+
config.apiWorkerDomain = await ask(" API worker domain", "api.example.com");
|
|
131
|
+
config.apiWorkerPort = await askNumber(" Local dev port", config.apiWorkerPort);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// -- Cloudflare resources --
|
|
135
|
+
console.log("\n── Cloudflare Resources ───────────────────\n");
|
|
136
|
+
config.d1Name = await ask("D1 database name", config.d1Name);
|
|
137
|
+
config.kvEvents = await ask("KV namespace – events", config.kvEvents);
|
|
138
|
+
config.kvConfig = await ask("KV namespace – config", config.kvConfig);
|
|
139
|
+
config.kvSessions = await ask("KV namespace – sessions", config.kvSessions);
|
|
140
|
+
config.queueName = await ask("Queue name", config.queueName);
|
|
141
|
+
config.doName = await ask("Durable Object namespace", config.doName);
|
|
142
|
+
|
|
143
|
+
// -- Adopt mode --
|
|
144
|
+
console.log("\n── Resource Mode ─────────────────────────\n");
|
|
145
|
+
console.log(" Adopt mode tells Alchemy to adopt existing Cloudflare");
|
|
146
|
+
console.log(" resources instead of creating new ones. Turn this on if");
|
|
147
|
+
console.log(" you already created resources via wrangler or the dashboard.\n");
|
|
148
|
+
config.adoptMode = await askBool("Enable adopt mode?", config.adoptMode);
|
|
149
|
+
|
|
150
|
+
// -- Queue tuning --
|
|
151
|
+
console.log("\n── Queue Settings ─────────────────────────\n");
|
|
152
|
+
const tuneQueue = await askBool("Customize queue settings?", false);
|
|
153
|
+
if (tuneQueue) {
|
|
154
|
+
config.queueBatchSize = await askNumber(" Batch size", config.queueBatchSize);
|
|
155
|
+
config.queueMaxConcurrency = await askNumber(" Max concurrency", config.queueMaxConcurrency);
|
|
156
|
+
config.queueMaxRetries = await askNumber(" Max retries", config.queueMaxRetries);
|
|
157
|
+
config.queueMaxWaitTimeMs = await askNumber(" Max wait time (ms)", config.queueMaxWaitTimeMs);
|
|
158
|
+
config.queueRetryDelay = await askNumber(" Retry delay (s)", config.queueRetryDelay);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return config;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Code generation
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
function generateAlchemyRunTs(c: SetupConfig): string {
|
|
169
|
+
const adopt = c.adoptMode ? "true" : "false";
|
|
170
|
+
|
|
171
|
+
const domainsBlock =
|
|
172
|
+
c.domains.length > 0
|
|
173
|
+
? c.domains
|
|
174
|
+
.map(
|
|
175
|
+
(d) =>
|
|
176
|
+
` {\n adopt: ${adopt},\n domainName: "${d}",\n }`,
|
|
177
|
+
)
|
|
178
|
+
.join(",\n")
|
|
179
|
+
: "";
|
|
180
|
+
|
|
181
|
+
const domainsProperty = domainsBlock
|
|
182
|
+
? `\n domains: [\n${domainsBlock},\n ],`
|
|
183
|
+
: "";
|
|
184
|
+
|
|
185
|
+
const apiWorkerBlock = c.apiWorkerEnabled
|
|
186
|
+
? `
|
|
187
|
+
await Worker("${c.apiWorkerName}", {
|
|
188
|
+
entrypoint: "./endpoints/api_worker.tsx",
|
|
189
|
+
dev: {
|
|
190
|
+
port: ${c.apiWorkerPort},
|
|
191
|
+
},
|
|
192
|
+
url: false,
|
|
193
|
+
adopt: ${adopt},${
|
|
194
|
+
c.apiWorkerDomain
|
|
195
|
+
? `\n domains: [{\n adopt: ${adopt},\n domainName: "${c.apiWorkerDomain}",\n }],`
|
|
196
|
+
: ""
|
|
197
|
+
}
|
|
198
|
+
bindings: {
|
|
199
|
+
STORAGE:
|
|
200
|
+
app.local && localDurableHost
|
|
201
|
+
? localDurableHost.bindings.SITE_DURABLE_OBJECT
|
|
202
|
+
: worker.bindings.SITE_DURABLE_OBJECT,
|
|
203
|
+
lytx_core_db: lytxCoreDb,
|
|
204
|
+
ENVIRONMENT: process.env.ENVIRONMENT ?? "development",
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
`
|
|
208
|
+
: "";
|
|
209
|
+
|
|
210
|
+
return `import type { SiteDurableObject } from "./db/durable/siteDurableObject";
|
|
211
|
+
import alchemy from "alchemy";
|
|
212
|
+
import {
|
|
213
|
+
D1Database,
|
|
214
|
+
KVNamespace,
|
|
215
|
+
DurableObjectNamespace,
|
|
216
|
+
Redwood,
|
|
217
|
+
Queue,
|
|
218
|
+
Worker,
|
|
219
|
+
} from "alchemy/cloudflare";
|
|
220
|
+
|
|
221
|
+
const app = await alchemy("${c.appName}");
|
|
222
|
+
if (app.local && app.stage !== "dev") {
|
|
223
|
+
throw new Error(\`Refusing local run on non-dev stage: \${app.stage}\`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const adoptMode = ${adopt};
|
|
227
|
+
|
|
228
|
+
const siteDurableObject = DurableObjectNamespace<SiteDurableObject>("${c.doName}", {
|
|
229
|
+
className: "SiteDurableObject",
|
|
230
|
+
sqlite: true,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const lytxKv = await KVNamespace("${c.kvEvents}", {
|
|
234
|
+
adopt: adoptMode,
|
|
235
|
+
delete: false,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const lytx_config = await KVNamespace("${c.kvConfig}", {
|
|
239
|
+
adopt: adoptMode,
|
|
240
|
+
delete: false,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const siteEventsQueue = await Queue("${c.queueName}", {
|
|
244
|
+
name: "${c.queueName}",
|
|
245
|
+
adopt: adoptMode,
|
|
246
|
+
delete: false,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const lytx_sessions = await KVNamespace("${c.kvSessions}", {
|
|
250
|
+
adopt: adoptMode,
|
|
251
|
+
delete: false,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const lytxCoreDb = await D1Database("${c.d1Name}", {
|
|
255
|
+
name: "${c.d1Name}",
|
|
256
|
+
migrationsDir: "./db/d1/migrations",
|
|
257
|
+
adopt: adoptMode,
|
|
258
|
+
delete: false,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const localDurableHost = app.local
|
|
262
|
+
? await Worker("${c.workerName}-do-host", {
|
|
263
|
+
entrypoint: "./endpoints/site_do_worker.ts",
|
|
264
|
+
bindings: {
|
|
265
|
+
SITE_DURABLE_OBJECT: siteDurableObject,
|
|
266
|
+
lytx_core_db: lytxCoreDb,
|
|
267
|
+
ENVIRONMENT: process.env.ENVIRONMENT ?? "development",
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
: undefined;
|
|
271
|
+
|
|
272
|
+
export const worker = await Redwood("${c.workerName}", {
|
|
273
|
+
adopt: ${adopt},
|
|
274
|
+
url: false,
|
|
275
|
+
noBundle: false,${domainsProperty}
|
|
276
|
+
wrangler: {
|
|
277
|
+
main: "src/worker.tsx",
|
|
278
|
+
transform: (spec) => ({
|
|
279
|
+
...spec,
|
|
280
|
+
compatibility_flags: ["nodejs_compat"],
|
|
281
|
+
}),
|
|
282
|
+
},
|
|
283
|
+
eventSources: [
|
|
284
|
+
{
|
|
285
|
+
queue: siteEventsQueue,
|
|
286
|
+
settings: {
|
|
287
|
+
batchSize: ${c.queueBatchSize},
|
|
288
|
+
maxConcurrency: ${c.queueMaxConcurrency},
|
|
289
|
+
maxRetries: ${c.queueMaxRetries},
|
|
290
|
+
maxWaitTimeMs: ${c.queueMaxWaitTimeMs},
|
|
291
|
+
retryDelay: ${c.queueRetryDelay},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
bindings: {
|
|
296
|
+
SITE_DURABLE_OBJECT: siteDurableObject,
|
|
297
|
+
LYTX_EVENTS: lytxKv,
|
|
298
|
+
lytx_config: lytx_config,
|
|
299
|
+
lytx_sessions: lytx_sessions,
|
|
300
|
+
lytx_core_db: lytxCoreDb,
|
|
301
|
+
SITE_EVENTS_QUEUE: siteEventsQueue,
|
|
302
|
+
LYTX_DOMAIN: process.env.LYTX_DOMAIN || "localhost:5173",
|
|
303
|
+
EMAIL_FROM: process.env.EMAIL_FROM || "noreply@example.com",
|
|
304
|
+
BETTER_AUTH_SECRET: alchemy.secret(process.env.BETTER_AUTH_SECRET),
|
|
305
|
+
GITHUB_CLIENT_SECRET: alchemy.secret(process.env.GITHUB_CLIENT_SECRET),
|
|
306
|
+
GOOGLE_CLIENT_SECRET: alchemy.secret(process.env.GOOGLE_CLIENT_SECRET),
|
|
307
|
+
RESEND_API_KEY: alchemy.secret(process.env.RESEND_API_KEY),
|
|
308
|
+
ENCRYPTION_KEY: alchemy.secret(process.env.ENCRYPTION_KEY),
|
|
309
|
+
AI_API_KEY: alchemy.secret(process.env.AI_API_KEY),
|
|
310
|
+
SEED_DATA_SECRET: alchemy.secret(process.env.SEED_DATA_SECRET),
|
|
311
|
+
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
|
|
312
|
+
GITHUB_CLIENT_ID: alchemy.secret(process.env.GITHUB_CLIENT_ID),
|
|
313
|
+
GOOGLE_CLIENT_ID: alchemy.secret(process.env.GOOGLE_CLIENT_ID),
|
|
314
|
+
REPORT_BUILDER: process.env.REPORT_BUILDER || "false",
|
|
315
|
+
ASK_AI: process.env.ASK_AI || "true",
|
|
316
|
+
ENVIRONMENT: process.env.ENVIRONMENT ?? "development",
|
|
317
|
+
AI_BASE_URL: process.env.AI_BASE_URL ?? "",
|
|
318
|
+
AI_MODEL: process.env.AI_MODEL ?? "",
|
|
319
|
+
AI_DAILY_TOKEN_LIMIT: process.env.AI_DAILY_TOKEN_LIMIT ?? "",
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
${apiWorkerBlock}
|
|
323
|
+
await app.finalize();
|
|
324
|
+
`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function generateEnvFile(c: SetupConfig): string {
|
|
328
|
+
const domain =
|
|
329
|
+
c.domains.length > 0 ? c.domains[0] : "localhost:5173";
|
|
330
|
+
|
|
331
|
+
return `# ── Lytx – Generated by \`bun run cli/setup.ts\` ──
|
|
332
|
+
|
|
333
|
+
# Domain that appears in emails and auth callbacks
|
|
334
|
+
LYTX_DOMAIN=${domain}
|
|
335
|
+
|
|
336
|
+
# Auth (required)
|
|
337
|
+
BETTER_AUTH_SECRET=change-me-${crypto.randomUUID().slice(0, 8)}
|
|
338
|
+
BETTER_AUTH_URL=http://localhost:5173
|
|
339
|
+
ENCRYPTION_KEY=change-me-${crypto.randomUUID().slice(0, 8)}
|
|
340
|
+
|
|
341
|
+
# Auth providers (optional – fill in to enable)
|
|
342
|
+
GITHUB_CLIENT_ID=
|
|
343
|
+
GITHUB_CLIENT_SECRET=
|
|
344
|
+
GOOGLE_CLIENT_ID=
|
|
345
|
+
GOOGLE_CLIENT_SECRET=
|
|
346
|
+
|
|
347
|
+
# Email via Resend (optional)
|
|
348
|
+
RESEND_API_KEY=
|
|
349
|
+
EMAIL_FROM=noreply@yourdomain.com
|
|
350
|
+
|
|
351
|
+
# AI features (optional)
|
|
352
|
+
AI_API_KEY=
|
|
353
|
+
AI_BASE_URL=
|
|
354
|
+
AI_MODEL=
|
|
355
|
+
AI_DAILY_TOKEN_LIMIT=
|
|
356
|
+
REPORT_BUILDER=false
|
|
357
|
+
ASK_AI=true
|
|
358
|
+
|
|
359
|
+
# Misc
|
|
360
|
+
SEED_DATA_SECRET=change-me-${crypto.randomUUID().slice(0, 8)}
|
|
361
|
+
ENVIRONMENT=development
|
|
362
|
+
`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// File writing with backup
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
function safeWrite(filePath: string, content: string, label: string): void {
|
|
370
|
+
const fullPath = resolve(filePath);
|
|
371
|
+
if (existsSync(fullPath)) {
|
|
372
|
+
const backupPath = `${fullPath}.bak`;
|
|
373
|
+
const existing = readFileSync(fullPath, "utf-8");
|
|
374
|
+
writeFileSync(backupPath, existing, "utf-8");
|
|
375
|
+
console.log(` ⚠ Backed up existing ${label} → ${filePath}.bak`);
|
|
376
|
+
}
|
|
377
|
+
writeFileSync(fullPath, content, "utf-8");
|
|
378
|
+
console.log(` ✓ Wrote ${label} → ${filePath}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// Config file (lytx.config.json) for re-running setup
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
const CONFIG_PATH = "lytx.config.json";
|
|
386
|
+
|
|
387
|
+
function saveSetupConfig(c: SetupConfig): void {
|
|
388
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(c, null, 2) + "\n", "utf-8");
|
|
389
|
+
console.log(` ✓ Saved setup config → ${CONFIG_PATH}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function loadSetupConfig(): SetupConfig | null {
|
|
393
|
+
if (!existsSync(CONFIG_PATH)) return null;
|
|
394
|
+
try {
|
|
395
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as SetupConfig;
|
|
396
|
+
} catch {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// Main
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
async function main() {
|
|
406
|
+
const args = process.argv.slice(2);
|
|
407
|
+
const nonInteractive = args.includes("--non-interactive") || args.includes("-y");
|
|
408
|
+
const regenOnly = args.includes("--regen");
|
|
409
|
+
|
|
410
|
+
let config: SetupConfig;
|
|
411
|
+
|
|
412
|
+
if (regenOnly) {
|
|
413
|
+
const saved = loadSetupConfig();
|
|
414
|
+
if (!saved) {
|
|
415
|
+
console.error("No lytx.config.json found. Run setup interactively first.");
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
418
|
+
config = saved;
|
|
419
|
+
console.log("\nRegenerating from saved lytx.config.json...\n");
|
|
420
|
+
} else if (nonInteractive) {
|
|
421
|
+
const saved = loadSetupConfig();
|
|
422
|
+
config = saved ?? { ...DEFAULTS };
|
|
423
|
+
console.log("\nUsing defaults (non-interactive mode)...\n");
|
|
424
|
+
} else {
|
|
425
|
+
config = await promptConfig();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
rl.close();
|
|
429
|
+
|
|
430
|
+
console.log("\n── Writing files ─────────────────────────\n");
|
|
431
|
+
|
|
432
|
+
safeWrite("alchemy.run.ts", generateAlchemyRunTs(config), "alchemy.run.ts");
|
|
433
|
+
|
|
434
|
+
if (!existsSync(".env")) {
|
|
435
|
+
safeWrite(".env", generateEnvFile(config), ".env");
|
|
436
|
+
} else {
|
|
437
|
+
console.log(" · .env already exists, skipping (won't overwrite secrets)");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
saveSetupConfig(config);
|
|
441
|
+
|
|
442
|
+
console.log(`
|
|
443
|
+
── Done! ──────────────────────────────────
|
|
444
|
+
|
|
445
|
+
Next steps:
|
|
446
|
+
|
|
447
|
+
1. Review and fill in secrets in .env
|
|
448
|
+
2. Install dependencies: bun install
|
|
449
|
+
3. Run locally: bun run dev
|
|
450
|
+
4. Deploy to Cloudflare: bun run deploy
|
|
451
|
+
|
|
452
|
+
Re-run this wizard anytime:
|
|
453
|
+
bun run cli/setup.ts
|
|
454
|
+
|
|
455
|
+
Regenerate files from saved config:
|
|
456
|
+
bun run cli/setup.ts --regen
|
|
457
|
+
`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
main().catch((err) => {
|
|
461
|
+
console.error(err);
|
|
462
|
+
process.exit(1);
|
|
463
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration Validation CLI Tool
|
|
5
|
+
*
|
|
6
|
+
* This script validates data integrity after migrating from original databases
|
|
7
|
+
* to site-specific durable objects.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* npx tsx cli/validate-migration.ts --site-id=123
|
|
11
|
+
* npx tsx cli/validate-migration.ts --all-sites
|
|
12
|
+
* npx tsx cli/validate-migration.ts --site-id=123 --strict
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { env } from 'cloudflare:workers';
|
|
16
|
+
import {
|
|
17
|
+
validateSiteMigration,
|
|
18
|
+
generateValidationReport,
|
|
19
|
+
type ValidationConfig
|
|
20
|
+
} from '@/utilities/dataValidation';
|
|
21
|
+
import type { SiteEventInput } from '@/session/siteSchema';
|
|
22
|
+
|
|
23
|
+
// Mock data fetching functions - replace with actual database queries
|
|
24
|
+
async function getOriginalSiteEvents(siteId: number): Promise<SiteEventInput[]> {
|
|
25
|
+
// TODO: Implement actual database query to fetch original events
|
|
26
|
+
// This would query postgres/singlestore for the site's events
|
|
27
|
+
console.log(`Fetching original events for site ${siteId}...`);
|
|
28
|
+
|
|
29
|
+
// For now, return empty array - replace with actual implementation
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function getOriginalEventCount(siteId: number): Promise<number> {
|
|
34
|
+
// TODO: Implement actual database query to count original events
|
|
35
|
+
console.log(`Counting original events for site ${siteId}...`);
|
|
36
|
+
|
|
37
|
+
// For now, return 0 - replace with actual implementation
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getAllSiteIds(): Promise<number[]> {
|
|
42
|
+
// TODO: Implement actual database query to get all site IDs
|
|
43
|
+
console.log('Fetching all site IDs...');
|
|
44
|
+
|
|
45
|
+
// For now, return empty array - replace with actual implementation
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate a single site's migration
|
|
51
|
+
*/
|
|
52
|
+
async function validateSite(
|
|
53
|
+
siteId: number,
|
|
54
|
+
config: ValidationConfig,
|
|
55
|
+
env: Env
|
|
56
|
+
): Promise<boolean> {
|
|
57
|
+
console.log(`\n=== Validating Site ${siteId} ===`);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Fetch original data
|
|
61
|
+
const originalEvents = await getOriginalSiteEvents(siteId);
|
|
62
|
+
const originalCount = await getOriginalEventCount(siteId);
|
|
63
|
+
|
|
64
|
+
if (originalCount === 0) {
|
|
65
|
+
console.log(`Site ${siteId} has no events to validate.`);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Run validation
|
|
70
|
+
const result = await validateSiteMigration(
|
|
71
|
+
siteId,
|
|
72
|
+
originalEvents,
|
|
73
|
+
originalCount,
|
|
74
|
+
env,
|
|
75
|
+
config
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Generate and display report
|
|
79
|
+
const report = generateValidationReport(result, siteId);
|
|
80
|
+
console.log(report);
|
|
81
|
+
|
|
82
|
+
// Return success status
|
|
83
|
+
return result.isValid;
|
|
84
|
+
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error(`Error validating site ${siteId}:`, error);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Main validation function
|
|
93
|
+
*/
|
|
94
|
+
async function main() {
|
|
95
|
+
const args = process.argv.slice(2);
|
|
96
|
+
|
|
97
|
+
// Parse command line arguments
|
|
98
|
+
let siteId: number | null = null;
|
|
99
|
+
let validateAllSites = false;
|
|
100
|
+
let strictMode = false;
|
|
101
|
+
|
|
102
|
+
for (const arg of args) {
|
|
103
|
+
if (arg.startsWith('--site-id=')) {
|
|
104
|
+
siteId = parseInt(arg.split('=')[1], 10);
|
|
105
|
+
} else if (arg === '--all-sites') {
|
|
106
|
+
validateAllSites = true;
|
|
107
|
+
} else if (arg === '--strict') {
|
|
108
|
+
strictMode = true;
|
|
109
|
+
} else if (arg === '--help') {
|
|
110
|
+
console.log(`
|
|
111
|
+
Migration Validation Tool
|
|
112
|
+
|
|
113
|
+
Usage:
|
|
114
|
+
npx tsx cli/validate-migration.ts --site-id=123 # Validate specific site
|
|
115
|
+
npx tsx cli/validate-migration.ts --all-sites # Validate all sites
|
|
116
|
+
npx tsx cli/validate-migration.ts --strict # Use strict validation mode
|
|
117
|
+
|
|
118
|
+
Options:
|
|
119
|
+
--site-id=N Validate specific site by ID
|
|
120
|
+
--all-sites Validate all sites
|
|
121
|
+
--strict Enable strict mode (warnings become errors)
|
|
122
|
+
--help Show this help message
|
|
123
|
+
`);
|
|
124
|
+
process.exit(0);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Validation configuration
|
|
129
|
+
const config: ValidationConfig = {
|
|
130
|
+
strictMode,
|
|
131
|
+
allowEmptyFields: ['bot_data', 'custom_data', 'query_params', 'rid', 'postal', 'region', 'city', 'country'],
|
|
132
|
+
maxStringLength: 2000,
|
|
133
|
+
dateRange: {
|
|
134
|
+
minDate: new Date('2020-01-01'),
|
|
135
|
+
maxDate: new Date(Date.now() + 24 * 60 * 60 * 1000)
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
console.log('Starting migration validation...');
|
|
140
|
+
console.log(`Strict mode: ${strictMode ? 'ON' : 'OFF'}`);
|
|
141
|
+
|
|
142
|
+
let allPassed = true;
|
|
143
|
+
let totalSites = 0;
|
|
144
|
+
let passedSites = 0;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
if (siteId) {
|
|
148
|
+
// Validate single site
|
|
149
|
+
totalSites = 1;
|
|
150
|
+
const passed = await validateSite(siteId, config, env as unknown as Env);
|
|
151
|
+
if (passed) passedSites = 1;
|
|
152
|
+
allPassed = passed;
|
|
153
|
+
|
|
154
|
+
} else if (validateAllSites) {
|
|
155
|
+
// Validate all sites
|
|
156
|
+
const siteIds = await getAllSiteIds();
|
|
157
|
+
totalSites = siteIds.length;
|
|
158
|
+
|
|
159
|
+
console.log(`Found ${siteIds.length} sites to validate.`);
|
|
160
|
+
|
|
161
|
+
for (const id of siteIds) {
|
|
162
|
+
const passed = await validateSite(id, config, env as unknown as Env);
|
|
163
|
+
if (passed) {
|
|
164
|
+
passedSites++;
|
|
165
|
+
} else {
|
|
166
|
+
allPassed = false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
} else {
|
|
171
|
+
console.error('Error: Must specify either --site-id=N or --all-sites');
|
|
172
|
+
console.log('Use --help for usage information.');
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Final summary
|
|
177
|
+
console.log(`\n=== Validation Summary ===`);
|
|
178
|
+
console.log(`Total sites validated: ${totalSites}`);
|
|
179
|
+
console.log(`Sites passed: ${passedSites}`);
|
|
180
|
+
console.log(`Sites failed: ${totalSites - passedSites}`);
|
|
181
|
+
console.log(`Overall status: ${allPassed ? 'PASSED' : 'FAILED'}`);
|
|
182
|
+
|
|
183
|
+
// Exit with appropriate code
|
|
184
|
+
process.exit(allPassed ? 0 : 1);
|
|
185
|
+
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error('Fatal error during validation:', error);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Run the script
|
|
193
|
+
if (require.main === module) {
|
|
194
|
+
main().catch(error => {
|
|
195
|
+
console.error('Unhandled error:', error);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export { validateSite, main };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lytx-migration-worker",
|
|
3
|
+
"main": "./cli/migration-worker.ts",
|
|
4
|
+
"compatibility_date": "2024-01-01",
|
|
5
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
6
|
+
|
|
7
|
+
"durable_objects": {
|
|
8
|
+
"bindings": [
|
|
9
|
+
{
|
|
10
|
+
"name": "SITE_DURABLE_OBJECT",
|
|
11
|
+
"class_name": "SiteDurableObject",
|
|
12
|
+
"script_name": "lytx-core"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
"d1_databases": [
|
|
18
|
+
{
|
|
19
|
+
"binding": "lytx_core_db",
|
|
20
|
+
"database_name": "lytx_core_db",
|
|
21
|
+
"database_id": "your-d1-database-id"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
|
|
25
|
+
"vars": {
|
|
26
|
+
"ENVIRONMENT": "migration"
|
|
27
|
+
}
|
|
28
|
+
}
|