Skip to main content
This tutorial builds on the LangChain chatbot from example 01. Instead of answering documentation questions, this version connects to a MySQL database and lets users query ecommerce data in plain English. The LLM translates questions into SQL, runs them against a read-only database endpoint, and formats the results for the user. It also uses Upsun’s MySQL read-only endpoints, because letting an LLM generate SQL against a database with write access is asking for trouble.

What you’re building

A chat interface where users type business questions (“What are the top 10 best-selling products?”, “Show me the monthly revenue trend”) and get answers from a real database. The app converts each question to SQL, validates it, runs it on a read-only MySQL connection, and sends the raw results back through the LLM for formatting. The pipeline:
  1. User asks a question in plain English
  2. The LLM generates a SQL query based on the database schema
  3. The SQL guard validates the query (SELECT only, enforced LIMIT, no dangerous keywords)
  4. The query runs on a read-only MySQL endpoint
  5. Results go back to the LLM
  6. The LLM formats an answer with markdown tables and summaries
  7. The answer streams to the user

What changed from the chatbot

If you’ve read the chatbot tutorial (example 01), here’s what’s different. Gone:
  • Context stuffing (loading all Upsun docs into the prompt)
  • Datamarking and message enclosure (security features for document context)
  • Output filtering and n-gram leakage detection
  • Prompt injection pattern detection
  • The build-context.ts script and context.txt file
New:
  • MySQL database with an ecommerce schema (products, customers, orders, carts, reviews)
  • A seed script that populates the database with coherent fixture data on deploy
  • A SQL guard that validates generated queries before execution
  • Separate read-only and write database connection pools
  • A two-step LLM flow (generate SQL, then format results)
  • Upsun MySQL service with read-only and admin endpoints
The frontend is the same chat UI from the LangChain chatbot tutorial with different starter prompts and markdown table rendering.

Prerequisites

Node.js 22+, pnpm, an OpenAI API key from platform.openai.com, a local MySQL server for development, the Upsun CLI, and Git.

Project setup

Create the project:
Install dependencies:
mysql2 is the MySQL client. It has built-in promise support, so no callback wrangling. Configure TypeScript (tsconfig.json): View source on GitHub
Note: rootDir is set to . instead of ./src because the seed script lives in scripts/ and imports from src/. Both directories need to compile. Add scripts to package.json:

The database layer

Database connection

Create src/database.ts: View source on GitHub
Two connection pools. getWritePool() connects through the DATABASE relationship (admin privileges), used only by the seed script. getReadPool() connects through the REPORTS relationship (read-only), used by the app at runtime. Locally, both pools use the same MySQL config from your .env. On Upsun, they get separate credentials. parseUpsunRelationship reads the environment variables that Upsun injects when you define a relationship. A relationship named database gives you DATABASE_HOST, DATABASE_PORT, DATABASE_USERNAME, DATABASE_PASSWORD, and DATABASE_PATH.

The schema definition

Create src/schema.ts: View source on GitHub
This string goes straight into the system prompt. The LLM needs exact table names, column names, types, and relationships to generate correct SQL. We hardcode it as plain text rather than querying information_schema at runtime because it’s simpler and the schema doesn’t change.

The SQL guard

The SQL guard sits between the LLM output and the database. LLMs generate text. They can be prompted to generate anything, including DROP TABLE. The guard rejects anything that isn’t a safe read query. Create src/sql-guard.ts: View source on GitHub
Four checks:
  1. The query must start with SELECT or WITH (for CTEs). Everything else is rejected.
  2. Certain keywords are forbidden even inside a SELECT. INSERT, UPDATE, DELETE, DROP are obvious. INTO OUTFILE and LOAD DATA prevent file system access. GRANT and REVOKE prevent privilege escalation.
  3. Semicolons inside the query are blocked. This stops stacking attacks like SELECT 1; DROP TABLE products.
  4. If the LLM forgets a LIMIT, the guard appends one (capped at 500 rows). No accidental full table scans.
The read-only MySQL endpoint would already reject writes at the database level. But catching bad queries in the application means faster feedback, better error messages, and less work for the database server.

The chat logic

Create src/chat.ts: View source on GitHub
Simpler than the chatbot version. No security sandwich, no datamarking. The context is a fixed schema definition rather than user-contributed documents, so the injection surface is smaller. The LLM wraps SQL in <sql> tags, which makes extraction reliable. The chat flow happens in two LLM calls:
The first LLM call collects the full response without streaming it to the user. It extracts the SQL, validates it, runs it. Only then does the second call stream the formatted answer. There’s a pause while SQL generation and execution happen, but the typing indicator covers it. If the LLM doesn’t produce SQL (for a question like “What tables are available?”), the response streams directly. If the SQL guard rejects a query, the user sees why, and the LLM gets a second chance to answer without SQL.

Fixture data

The seed script generates deterministic ecommerce data using a seeded PRNG (mulberry32, seed 42). Same data every time, which makes testing predictable. Create scripts/seed.ts. The full script is in the repository. It generates:
  • 10 categories (Electronics, Clothing, Home & Kitchen, Books, Sports, Beauty, Toys, Food, Office, Garden)
  • 100 products, 10 per category, with realistic names, prices (5to5 to 300), and stock levels
  • 500 customers, names from common first/last name pools, spread across 20 cities in 10 countries
  • 750 orders with 1 to 5 items each, weighted toward “delivered” status (60%), totals calculated from actual product prices
  • 100 active carts for random customers, 1 to 4 items each
  • 800 reviews, ratings skewed toward 3 to 5 stars, one review per customer per product
The seed is idempotent. It checks if data exists before inserting:
Redeploys don’t wipe the database. The seed only runs when the tables are empty.

The Upsun configuration

This is the interesting part. Create .upsun/config.yaml: View source on GitHub

MySQL read-only endpoints

The services.mysql.configuration section defines two endpoints instead of one:
  • admin: full read/write access to the main schema. The seed script uses this during deploy.
  • reader: read-only (ro) access to the same schema. The app uses this at runtime.
The application maps relationships to these endpoints:
Upsun injects credentials for each relationship as environment variables. The database relationship gives you DATABASE_HOST, DATABASE_PORT, DATABASE_USERNAME, DATABASE_PASSWORD, and DATABASE_PATH. The reports relationship gives you the same variables with the REPORTS_ prefix. The reader endpoint gets its own MySQL user with only SELECT privileges. If the application sends a write query through the read pool, the database rejects it. Hard stop, at the infrastructure level, regardless of what the application code does. This matters because you’re trusting a probabilistic text generator to follow your instructions. It usually does. “Usually” is not a word you want next to DROP TABLE. The SQL guard catches problems at the application level. The read-only endpoint catches everything the guard misses at the database level.

The deploy hook

The deploy hook runs node dist/scripts/seed.js on every deployment. Since the seed script is idempotent, it only inserts data the first time. After that it detects existing data and exits. The seed script uses the database (admin) relationship because it needs write access. The application server uses reports (reader) because it should never write.

Local development

Create a MySQL database for local development:
Set up your .env:
Seed the database:
You should see output listing the inserted records. Start the dev server:
Open http://localhost:3000 and try asking questions.

Deploying to Upsun

Initialize Git and push:
Create the Upsun project:
Set the OpenAI API key:
Deploy:
Upsun provisions the Node.js container and the MariaDB service, runs the build hook (install deps, compile TypeScript), then the deploy hook (seed the database), then starts the server. First deploy takes a bit longer because of the seeding. Access your BI tool:

Testing

Try these questions:
  • “What are the top 10 best-selling products?”
  • “Which customers have spent the most money?”
  • “Show me the monthly revenue trend”
  • “What are the highest rated products by category?”
  • “How many orders are still pending?”
  • “What’s the average order value by country?”
  • “Which products have the most reviews but the lowest ratings?”
Each response shows the SQL that was generated, then the formatted answer. Monitor logs:
You’ll see the SQL queries, response times, and any validation rejections.

How the safety layers stack up

Three layers between the user and a write query:
  1. The system prompt tells the LLM to only generate SELECT queries. Works most of the time, but LLMs can be prompted to ignore instructions.
  2. The SQL guard validates the query in application code. Checks it starts with SELECT, has no forbidden keywords, no stacked statements, and a LIMIT. Deterministic, so it catches what the LLM gets wrong.
  3. The read-only MySQL endpoint rejects writes at the database level. If both layers above fail, the connection itself cannot modify data.
The prompt is soft. The guard is deterministic but still application code, so a bug could bypass it. The endpoint is a MySQL user that literally cannot write. You want all three.

Resources

Last modified on March 25, 2026