A lightweight, production-grade Go service that exposes predefined SQL queries as HTTP endpoints. Supports SQL Server, MySQL, and SQLite databases. Runs as a system service on Windows, Linux, and macOS with zero impact on the source database and zero maintenance requirements.
- Multi-Database Support - SQL Server, MySQL, and SQLite (same query syntax)
- Cross-Platform Service - Windows Service, Linux systemd, macOS launchd
- YAML Configuration - Easy query management, no code changes
- Read-only Safety - Zero interference with production database
- Workflows - Multi-step query pipelines with conditions, iteration, and external API calls
- Scheduled Workflows - Run workflows on cron schedules with retry
- Rate Limiting - Per-endpoint and per-client rate limiting with token bucket
- Structured Logging - JSON logs with automatic rotation
- Metrics Endpoints -
/_/metrics(Prometheus) and/_/metrics.json(human-readable) - Request Tracing - Wide events with request IDs
- Runtime Config - Change log level without restart
- Auto-Recovery - Automatic database reconnection
- Health Monitoring - Background health checks
This service is designed for long-running, fire-and-forget operation:
| Feature | Description |
|---|---|
| Log Rotation | Automatic rotation by size, age retention, compression |
| Metrics Endpoints | /_/metrics (Prometheus) and /_/metrics.json (JSON) |
| Workflows | Multi-step pipelines with conditions, iteration, external API calls |
| Scheduled Workflows | Cron-based execution with retry and backoff |
| DB Health Checks | Every 30s, auto-reconnect after 3 failures |
| Panic Recovery | Catches panics, logs them, returns 500 |
| Connection Recycling | Pool connections expire after 5 minutes |
| Graceful Shutdown | Closes connections cleanly |
| Service Auto-Restart | Automatic restart on crash (Windows/Linux/macOS) |
This service is designed to safely read from production databases without interfering with existing applications.
| Level | Setting | Purpose |
|---|---|---|
| Connection | ApplicationIntent=ReadOnly |
Signals read-only intent, enables AG routing |
| Connection | Max 5 connections, 5min lifetime | Conservative pool footprint |
| Session | READ UNCOMMITTED isolation |
No shared locks, never blocks writers |
| Session | LOCK_TIMEOUT 5000 |
Fails fast (5s) if any lock needed |
| Session | DEADLOCK_PRIORITY LOW |
If in deadlock, this connection is the victim |
| Session | IMPLICIT_TRANSACTIONS OFF |
No accidental open transactions |
| Database | Read-only SQL user | Database enforces no writes possible |
| Level | Setting | Purpose |
|---|---|---|
| Connection | parseTime=true |
Parses DATE/DATETIME as Go time.Time |
| Connection | collation=utf8mb4_general_ci |
Full Unicode support (utf8mb4 charset) |
| Connection | interpolateParams=false |
Prevents client-side parameter interpolation |
| Connection | Max 5 connections, 5min lifetime | Conservative pool footprint |
| Session | innodb_lock_wait_timeout |
Fails fast (5s) if lock needed |
| Session | TRANSACTION READ ONLY |
Prevents writes when readonly is true |
| Session | Configurable isolation level | Default: READ COMMITTED |
| Database | Read-only MySQL user | Database enforces no writes possible |
| Level | Setting | Purpose |
|---|---|---|
| Connection | mode=ro (for readonly) |
Prevents any writes at driver level |
| Connection | _txlock=immediate (for writes) |
Prevents write deadlocks |
| Session | journal_mode=WAL |
Concurrent reads during writes |
| Session | busy_timeout=5000 |
Waits 5s instead of failing immediately on lock |
| Session | synchronous=NORMAL |
Safe for WAL mode, better performance |
Create a dedicated read-only user in SQL Server. Connect to RDS as admin and run:
-- Create a login (server level)
CREATE LOGIN sqlproxy_reader WITH PASSWORD = 'YourSecurePassword123!';
-- Switch to your database
USE YourDatabaseName;
-- Create user mapped to login
CREATE USER sqlproxy_reader FOR LOGIN sqlproxy_reader;
-- Grant ONLY read access (db_datareader role)
ALTER ROLE db_datareader ADD MEMBER sqlproxy_reader;
-- Explicitly deny write operations (belt and suspenders)
DENY INSERT, UPDATE, DELETE, ALTER ON SCHEMA::dbo TO sqlproxy_reader;
-- Deny DDL operations
DENY CREATE TABLE TO sqlproxy_reader;
DENY CREATE VIEW TO sqlproxy_reader;
DENY CREATE PROCEDURE TO sqlproxy_reader;
DENY CREATE FUNCTION TO sqlproxy_reader;Create a dedicated read-only user in MySQL. Connect as admin and run:
-- Create a user with read-only access
CREATE USER 'sqlproxy_reader'@'%' IDENTIFIED BY 'YourSecurePassword123!';
-- Grant ONLY read access to your database
GRANT SELECT ON YourDatabaseName.* TO 'sqlproxy_reader'@'%';
-- Apply changes
FLUSH PRIVILEGES;# Build for current platform (development)
go build -ldflags '-s -w' -o sql-proxy .
# Build with version and build time (release)
VERSION=1.0.0
BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
go build -ldflags "-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" -o sql-proxy .
# Cross-compile for Windows
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" -o sql-proxy.exe .
# Cross-compile for Linux
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" -o sql-proxy .
# Cross-compile for macOS (Intel)
GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" -o sql-proxy .
# Cross-compile for macOS (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" -o sql-proxy .
# Using make (recommended - sets version and build time automatically)
make build # Current platform
make build-windows # Windows
make build-linux # Linux
make build-darwin # macOS Intel
make build-darwin-arm # macOS Apple Silicon
# Check version
./sql-proxy -versionValidate your config file without starting the service (like caddy validate):
# Validates config AND tests database connectivity
sql-proxy.exe -validate -config config.yamlExample output:
SQL Proxy Configuration Validator
==================================
Config file: config.yaml
Server: 127.0.0.1:8081
Database: sqlproxy_reader@myserver.rds.amazonaws.com/mydb
Workflows: 6 endpoints configured
Endpoints:
GET /api/machines - list_machines (0 params)
GET /api/machines/details - get_machine (1 params)
GET /api/checkins - checkin_logs (3 params)
Configuration valid
The validator checks:
- Server settings (port range, timeout values)
- Database connection settings
- Logging configuration
- Workflow definitions (triggers, steps, SQL syntax)
- Parameter definitions (types, duplicates, reserved names)
- SQL/parameter consistency (unused params, missing definitions)
- Warnings for write operations in SQL (INSERT, UPDATE, DELETE)
-
Copy
sql-proxy.exeandconfig.yamltoC:\Services\SQLProxy\ -
Create log directory:
mkdir C:\Services\SQLProxy\logs -
Edit
config.yamlwith your settings -
Test interactively first:
sql-proxy.exe -config C:\Services\SQLProxy\config.yaml
-
Install as Windows service (run as Administrator):
sql-proxy.exe -install -config C:\Services\SQLProxy\config.yaml
-
Manage the service:
sql-proxy.exe -start # Start the service sql-proxy.exe -stop # Stop the service sql-proxy.exe -restart # Restart the service sql-proxy.exe -status # Check service status sql-proxy.exe -uninstall # Remove the service
-
Copy files to installation directory:
sudo mkdir -p /opt/sql-proxy sudo mkdir -p /var/log/sql-proxy sudo cp sql-proxy /opt/sql-proxy/ sudo cp config.yaml /opt/sql-proxy/ sudo chmod +x /opt/sql-proxy/sql-proxy
-
Create service user:
sudo useradd -r -s /bin/false sqlproxy sudo chown -R sqlproxy:sqlproxy /opt/sql-proxy sudo chown -R sqlproxy:sqlproxy /var/log/sql-proxy
-
Generate the systemd unit file:
# Generate the unit file from template with your config path /opt/sql-proxy/sql-proxy -install -config /opt/sql-proxy/config.yaml # For multiple instances, use -service-name: # /opt/sql-proxy/sql-proxy -install -service-name sql-proxy-prod -config /opt/sql-proxy/prod.yaml # /opt/sql-proxy/sql-proxy -install -service-name sql-proxy-staging -config /opt/sql-proxy/staging.yaml
This generates a templated systemd unit file with your executable path, config path, and service name embedded. Follow the printed instructions to create the service file.
-
Install and enable:
sudo systemctl daemon-reload sudo systemctl enable sql-proxy # Enable auto-start sudo systemctl start sql-proxy # Start the service
-
Manage the service:
sudo systemctl stop sql-proxy # Stop the service sudo systemctl restart sql-proxy # Restart the service sudo systemctl status sql-proxy # Check status journalctl -u sql-proxy -f # View logs
-
Copy files to installation directory:
sudo mkdir -p /usr/local/etc/sql-proxy sudo mkdir -p /usr/local/var/log/sql-proxy sudo cp sql-proxy /usr/local/bin/ sudo cp config.yaml /usr/local/etc/sql-proxy/ sudo chmod +x /usr/local/bin/sql-proxy
-
Create service user (optional):
sudo dscl . -create /Users/_sqlproxy sudo dscl . -create /Users/_sqlproxy UserShell /usr/bin/false sudo dscl . -create /Users/_sqlproxy RealName "SQL Proxy Service" sudo dscl . -create /Users/_sqlproxy UniqueID 299 sudo dscl . -create /Users/_sqlproxy PrimaryGroupID 299
-
Generate the launchd plist file:
# Generate the plist file from template with your config path /usr/local/bin/sql-proxy -install -config /usr/local/etc/sql-proxy/config.yaml # For multiple instances, use -service-name: # /usr/local/bin/sql-proxy -install -service-name sql-proxy-prod -config /usr/local/etc/sql-proxy/prod.yaml
This generates a templated launchd plist with your executable path, config path, and service name embedded. Follow the printed instructions to create the plist file.
-
Manage the service:
sudo launchctl load /Library/LaunchDaemons/com.sqlproxy.sql-proxy.plist # Start sudo launchctl unload /Library/LaunchDaemons/com.sqlproxy.sql-proxy.plist # Stop sudo launchctl list | grep sqlproxy # Status tail -f /usr/local/var/log/sql-proxy/sql-proxy.log # View logs
All configuration fields are required unless noted otherwise. This ensures explicit, predictable behavior.
server:
host: "127.0.0.1"
port: 8081
default_timeout_sec: 30
max_timeout_sec: 300
# cache: # Optional: Enable response caching
# enabled: true
# max_size_mb: 256
# default_ttl_sec: 300
databases:
- name: "primary"
type: "sqlserver" # sqlserver, mysql, or sqlite (required)
host: "your-server.rds.amazonaws.com"
port: 1433
user: "sqlproxy_reader"
password: "${DB_PASSWORD}"
database: "YourDB"
readonly: true # Defaults to true if omitted
logging:
level: "info"
file_path: "./logs/sql-proxy.log" # Empty string = log to stdout (interactive mode)
max_size_mb: 100
max_backups: 5
max_age_days: 30
metrics:
enabled: true
# Optional: Debug endpoints (pprof) for profiling
# debug:
# enabled: true # Enable /_/debug/pprof/* endpoints
# port: 6060 # Separate port (0 = same as main server)
# host: "localhost" # Only valid with separate port; defaults to localhost
# Optional: Rate limit pools
# rate_limits:
# - name: "default"
# requests_per_second: 100
# burst: 200
# key: "{{.trigger.client_ip}}"
# Optional: Template variables (available as {{.vars.name}} in templates)
# variables:
# env_file: ".env" # Load from env file (relative to config)
# values:
# api_version: "v1"
# max_page_size: "${MAX_PAGE:100}" # Supports ${VAR:default} syntax
# Optional: Encrypted public IDs (prevents PK enumeration)
# public_ids:
# secret_key: "${PUBLIC_ID_SECRET}" # Required: 32+ character secret
# namespaces:
# - name: "user"
# prefix: "usr" # Output: usr_Xk9mPqR3vL2n
# - name: "order"
# prefix: "ord"
workflows:
- name: "list_machines"
triggers:
- type: http
path: "/api/machines"
method: GET
steps:
- name: fetch
type: query
database: "primary"
sql: |
SELECT MachineId, MachineName, LastPingTime
FROM Machines
ORDER BY MachineName
- type: response
template: |
{"success": true, "data": {{json .steps.fetch.data}}, "count": {{.steps.fetch.count}}}databases:
- name: "primary"
type: "sqlserver"
host: "server1.example.com"
port: 1433
user: "reader"
password: "${PRIMARY_DB_PASSWORD}"
database: "MainDB"
readonly: true # Full safety measures
- name: "reporting"
type: "sqlserver"
host: "server2.example.com"
port: 1433
user: "writer"
password: "${REPORTING_DB_PASSWORD}"
database: "ReportingDB"
readonly: false # Allows writes
workflows:
- name: "get_machines"
triggers:
- type: http
path: "/api/machines"
method: GET
steps:
- name: fetch
type: query
database: "primary"
sql: "SELECT * FROM Machines"
- type: response
template: '{"success": true, "data": {{json .steps.fetch.data}}}'
- name: "insert_report"
triggers:
- type: http
path: "/api/reports"
method: POST
parameters:
- name: "date"
type: "datetime"
required: true
- name: "data"
type: "string"
required: true
steps:
- name: insert
type: query
database: "reporting"
sql: "INSERT INTO Reports (Date, Data) VALUES (@date, @data)"
- type: response
template: '{"success": true}'MySQL databases are supported as a production backend. Use type: "mysql" with standard host/port/user/password connection settings:
databases:
- name: "primary"
type: "mysql"
host: "your-mysql-server.example.com"
port: 3306
user: "sqlproxy_reader"
password: "${DB_PASSWORD}"
database: "YourDB"
readonly: true # Defaults to true if omitted
encrypt: "true" # TLS: true, false, disable, skip-verify| Setting | Default | Description |
|---|---|---|
host |
(required) | MySQL server hostname |
port |
3306 |
MySQL server port |
user |
(required) | MySQL username |
password |
(required) | MySQL password (supports ${ENV_VAR} syntax) |
database |
(required) | Database name |
readonly |
true |
Sets session to READ ONLY transaction mode |
encrypt |
false |
TLS mode: true, false, disable, skip-verify |
| Setting | Default (readonly) | Default (readwrite) | Description |
|---|---|---|---|
isolation |
read_committed |
read_committed |
Transaction isolation level |
lock_timeout_ms |
5000 |
5000 |
InnoDB lock wait timeout (converted to seconds) |
All queries use @param syntax (the driver translates to ? positional placeholders for MySQL):
-- Named parameters are translated automatically
SELECT * FROM users WHERE status = @status AND age > @age
-- Becomes: SELECT * FROM users WHERE status = ? AND age > ?
-- Use LIMIT for pagination (MySQL style)
SELECT * FROM items ORDER BY id LIMIT @limit OFFSET @offset
-- Optional filter pattern (same as other databases)
SELECT * FROM users WHERE (@status IS NULL OR status = @status)When to use MySQL:
- Production deployments with MySQL/MariaDB infrastructure
- Read-heavy workloads with connection pooling
- Applications already running on MySQL/Aurora
When NOT to use MySQL:
- If you need SQL Server-specific features (AG routing, SNAPSHOT isolation)
- If you need embedded/serverless (use SQLite instead)
SQLite databases are supported for testing and lightweight deployments. Use type: "sqlite" with a path instead of host/port/user/password:
databases:
- name: "test_db"
type: "sqlite"
path: ":memory:" # In-memory database (useful for testing)
# Or use a file path:
# path: "/data/app.db"
# SQLite-specific settings (optional)
journal_mode: "wal" # wal (default), delete, truncate, memory, off
busy_timeout_ms: 5000 # Wait time for locked database (default: 5000)
workflows:
- name: "list_items"
triggers:
- type: http
path: "/api/items"
method: GET
steps:
- name: fetch
type: query
database: "test_db"
sql: "SELECT * FROM items ORDER BY name"
- type: response
template: '{"success": true, "data": {{json .steps.fetch.data}}}'| Setting | Default | Description |
|---|---|---|
path |
(required) | File path or :memory: for in-memory database |
readonly |
true |
Opens database in read-only mode |
journal_mode |
wal |
WAL mode enables concurrent reads during writes |
busy_timeout_ms |
5000 |
How long to wait when database is locked (ms) |
All database types support connection pool tuning. These settings are optional and have sensible defaults:
databases:
- name: "primary"
type: "sqlserver"
host: "server.example.com"
# ... other connection settings ...
# Connection pool settings (optional)
max_open_conns: 10 # Max open connections (default: 5)
max_idle_conns: 5 # Max idle connections (default: 2)
conn_max_lifetime: 300 # Max connection lifetime in seconds (default: 300)
conn_max_idle_time: 120 # Max idle time in seconds (default: 120)| Setting | Default | Description |
|---|---|---|
max_open_conns |
5 | Maximum number of open connections to the database |
max_idle_conns |
2 | Maximum number of idle connections in the pool |
conn_max_lifetime |
300 | Maximum time (seconds) a connection can be reused |
conn_max_idle_time |
120 | Maximum time (seconds) a connection can be idle |
When to tune:
- High-throughput services: Increase
max_open_connsandmax_idle_conns - Connection limits on DB server: Reduce
max_open_conns - Network instability: Reduce
conn_max_lifetimeto recycle connections more often
The driver automatically configures SQLite for optimal concurrent performance:
| Pragma | Value | Purpose |
|---|---|---|
busy_timeout |
5000ms | Critical: Wait instead of failing on lock |
journal_mode |
WAL | Readers don't block writers |
synchronous |
NORMAL | Safe for WAL, faster than FULL |
foreign_keys |
ON | Enable referential integrity |
temp_store |
MEMORY | Faster temp table operations |
cache_size |
64MB | Larger cache for concurrent reads |
mmap_size |
256MB | Memory-mapped I/O for faster reads |
wal_autocheckpoint |
1000 | Prevents WAL file from growing too large |
Concurrency Model:
- WAL mode allows unlimited concurrent readers with one writer
- Writers don't block readers; readers don't block writers
- Multiple simultaneous write attempts will queue (up to
busy_timeout) - After
busy_timeout, write returns "database is locked" error
When to use SQLite:
- Testing and development (
:memory:for fast unit tests) - Single-server deployments with moderate write load
- Read-heavy workloads with occasional writes
- Embedded/edge deployments
When NOT to use SQLite:
- High write concurrency (multiple writers)
- Multi-server deployments (no network access)
- Very large databases (>100GB)
File vs In-Memory:
| Mode | Path | Persistence | Use Case |
|---|---|---|---|
| File | /data/app.db |
Survives restart | Production |
| In-memory | :memory: |
Lost on restart | Testing |
Query Syntax:
- All queries use
@paramsyntax (driver translates to$paramfor SQLite,?for MySQL) - Use
LIMITinstead ofTOPfor pagination:-- SQL Server style (works on SQL Server) SELECT TOP (@limit) * FROM items -- MySQL/SQLite style (works on MySQL and SQLite) SELECT * FROM items LIMIT @limit
Session settings control database behavior at query execution time. Settings can be defined at connection level (defaults) and overridden per-step.
Database-Specific Behavior:
| Setting | SQL Server | MySQL | SQLite |
|---|---|---|---|
isolation |
Sets transaction isolation level | Sets transaction isolation level | Ignored (SQLite has limited isolation) |
lock_timeout_ms |
SET LOCK_TIMEOUT |
innodb_lock_wait_timeout (seconds) |
Maps to busy_timeout pragma |
deadlock_priority |
SET DEADLOCK_PRIORITY |
Ignored (InnoDB handles internally) | Ignored (SQLite handles differently) |
SQL Server Implicit Defaults (based on readonly flag):
| Setting | readonly: true |
readonly: false |
|---|---|---|
isolation |
read_uncommitted |
read_committed |
lock_timeout_ms |
5000 |
5000 |
deadlock_priority |
low |
low |
ApplicationIntent |
ReadOnly |
(none) |
Override at connection level:
databases:
- name: "primary"
readonly: true
# Override implicit defaults for all queries on this connection
isolation: "read_committed" # Need consistent reads
lock_timeout_ms: 10000 # Wait longer for locks
deadlock_priority: "normal" # Don't always be the victimOverride at step level:
workflows:
- name: "critical_read"
triggers:
- type: http
path: "/api/balance"
method: GET
parameters:
- name: "id"
type: "int"
required: true
steps:
- name: fetch
type: query
database: "primary"
# Override for this specific step
isolation: "repeatable_read" # Need stable reads within query
lock_timeout_ms: 30000 # Important query, wait longer
sql: "SELECT Balance FROM Accounts WHERE Id = @id"
- type: response
template: '{"success": true, "balance": {{index .steps.fetch.data 0 "Balance"}}}'Available values:
| Setting | Values |
|---|---|
isolation |
read_uncommitted, read_committed, repeatable_read, serializable, snapshot |
lock_timeout_ms |
Any non-negative integer (milliseconds) |
deadlock_priority |
low, normal, high |
Resolution order: Step settings -> Connection settings -> Implicit defaults (based on readonly)
Config validation enforces:
- All required fields present (no defaults)
- Steps with INSERT/UPDATE/DELETE on read-only connections -> error
- Unknown database references -> error
- Invalid isolation level or deadlock priority -> error
- Unused database connections -> warning
Timeouts are configurable at three levels (in order of precedence):
- Request parameter (
_timeout) - Caller specifies per-request - Step config (
timeout_sec) - Per-step timeout in YAML - Server config (
default_timeout_sec) - Global default
# Override timeout for this request
curl "http://localhost:8081/api/checkins?_timeout=120"Pagination is handled at the query level using database-native syntax. This is more efficient than service-level truncation because the database stops scanning once the limit is reached.
Note: SQL Server uses
TOP, MySQL and SQLite useLIMIT. Write queries for your specific database type.
For queries that just need a max row count:
workflows:
- name: "recent_checkins"
triggers:
- type: http
path: "/api/checkins/recent"
method: GET
parameters:
- name: "limit"
type: "int"
required: false
default: "100"
steps:
- name: fetch
type: query
database: "primary"
sql: |
SELECT TOP (@limit)
EmployeeId, PunchTime, MachineId
FROM AttendanceLog
ORDER BY PunchTime DESC
- type: response
template: '{"success": true, "data": {{json .steps.fetch.data}}, "count": {{.steps.fetch.count}}}'# Get last 50 check-ins
curl "http://localhost:8081/api/checkins/recent?limit=50"For paginated results with page navigation:
workflows:
- name: "checkins_paginated"
triggers:
- type: http
path: "/api/checkins/page"
method: GET
parameters:
- name: "fromDate"
type: "datetime"
required: true
- name: "offset"
type: "int"
required: false
default: "0"
- name: "limit"
type: "int"
required: false
default: "100"
steps:
- name: fetch
type: query
database: "primary"
sql: |
SELECT
EmployeeId, PunchTime, MachineId
FROM AttendanceLog
WHERE PunchTime >= @fromDate
ORDER BY PunchTime DESC
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY
- type: response
template: '{"success": true, "data": {{json .steps.fetch.data}}, "count": {{.steps.fetch.count}}}'# Page 1 (first 100)
curl "http://localhost:8081/api/checkins/page?fromDate=2024-01-01&offset=0&limit=100"
# Page 2 (next 100)
curl "http://localhost:8081/api/checkins/page?fromDate=2024-01-01&offset=100&limit=100"
# Page 3
curl "http://localhost:8081/api/checkins/page?fromDate=2024-01-01&offset=200&limit=100"For UI pagination, you often need the total count. Create a separate workflow:
workflows:
- name: "checkins_count"
triggers:
- type: http
path: "/api/checkins/count"
method: GET
parameters:
- name: "fromDate"
type: "datetime"
required: true
steps:
- name: count
type: query
database: "primary"
sql: |
SELECT COUNT(*) AS total_count
FROM AttendanceLog
WHERE PunchTime >= @fromDate
- type: response
template: '{"success": true, "total_count": {{index .steps.count.data 0 "total_count"}}}'# Get total count
curl "http://localhost:8081/api/checkins/count?fromDate=2024-01-01"
# Returns: {"success":true,"total_count":15234}For very large tables, keyset pagination is more efficient than OFFSET:
workflows:
- name: "checkins_keyset"
triggers:
- type: http
path: "/api/checkins/after"
method: GET
parameters:
- name: "afterId"
type: "int"
required: false
default: "0"
- name: "limit"
type: "int"
required: false
default: "100"
steps:
- name: fetch
type: query
database: "primary"
sql: |
SELECT TOP (@limit)
LogId, EmployeeId, PunchTime, MachineId
FROM AttendanceLog
WHERE LogId > @afterId
ORDER BY LogId ASC
- type: response
template: '{"success": true, "data": {{json .steps.fetch.data}}, "count": {{.steps.fetch.count}}}'# First page
curl "http://localhost:8081/api/checkins/after?afterId=0&limit=100"
# Returns rows with LogId 1-100, last row has LogId=100
# Next page - use last LogId from previous response
curl "http://localhost:8081/api/checkins/after?afterId=100&limit=100"
# Returns rows with LogId 101-200When an optional parameter is not provided and has no default, it's passed to SQL Server as NULL. Write your SQL to handle this:
# BAD - Won't match any rows when status is NULL
workflows:
- name: "bad_example"
triggers:
- type: http
path: "/api/users/bad"
method: GET
parameters:
- name: "status"
type: "string"
required: false
steps:
- name: fetch
type: query
database: "primary"
sql: |
SELECT * FROM Users WHERE status = @status
- type: response
template: '{"data": {{json .steps.fetch.data}}}'
# GOOD - Returns all rows when status is not provided
workflows:
- name: "good_example"
triggers:
- type: http
path: "/api/users/good"
method: GET
parameters:
- name: "status"
type: "string"
required: false
steps:
- name: fetch
type: query
database: "primary"
sql: |
SELECT * FROM Users
WHERE (@status IS NULL OR status = @status)
- type: response
template: '{"data": {{json .steps.fetch.data}}}'This pattern lets optional parameters act as filters only when provided.
All query results are loaded into memory before JSON serialization. For large result sets:
- Always use
TOP @limitorOFFSET/FETCHin queries - Set reasonable default limits (e.g., 100-1000 rows)
- Monitor memory usage for queries returning large text/blob columns
Workflows provide a powerful way to define complex multi-step query pipelines with conditional execution, iteration, and external API calls. A workflow consists of:
- Triggers - How the workflow is initiated (HTTP request or cron schedule)
- Steps - Sequential execution of query, httpcall, response, or block steps
- Conditions - Named expressions for conditional step execution
Workflows support:
- HTTP and cron triggers
- Query steps for database operations
- HTTPCall steps for external API calls
- Response steps with templated output
- Iteration over query results with blocks
- Conditional branching based on results
- Caching and rate limiting per trigger
HTTP triggers support all standard methods: GET, POST, PUT, DELETE, PATCH, HEAD, and OPTIONS.
You can define multiple workflows with the same path but different methods (RESTful pattern).
URL path parameters allow you to capture values from the URL path itself (e.g., /api/items/{id} instead of /api/items?id=123). Path parameters use Go 1.22+ routing syntax with curly braces.
workflows:
- name: "get_item"
triggers:
- type: http
path: "/api/items/{id}"
method: GET
parameters:
- name: "id" # Must match the path parameter name
type: "int"
required: true # Path parameters must be required
steps:
- name: fetch
type: query
database: "primary"
sql: "SELECT * FROM items WHERE id = @id"
- type: response
template: '{"item": {{json (index .steps.fetch.data 0)}}}'
- name: "get_item_reviews"
triggers:
- type: http
path: "/api/items/{item_id}/reviews/{review_id}"
method: GET
parameters:
- name: "item_id"
type: "int"
required: true
- name: "review_id"
type: "int"
required: true
steps:
- name: fetch
type: query
database: "primary"
sql: "SELECT * FROM reviews WHERE item_id = @item_id AND id = @review_id"
- type: response
template: '{"review": {{json (index .steps.fetch.data 0)}}}'Path parameter rules:
- Path parameters are declared using
{paramName}syntax in the path - Each path parameter must have a matching entry in
parameters - Path parameters must be
required: true(can't have optional path segments) - Path parameters take precedence over query parameters with the same name
- Parameter names must start with a letter or underscore, followed by alphanumeric characters
Example usage:
# Path parameter captured from URL
curl http://localhost:8081/api/items/42
# Equivalent to: /api/items?id=42
# Multiple path parameters
curl http://localhost:8081/api/items/42/reviews/7Combine path parameters with multiple methods for clean REST APIs:
workflows:
# List items: GET /api/items
- name: "list_items"
triggers:
- type: http
path: "/api/items"
method: GET
steps:
- name: fetch
type: query
database: "primary"
sql: "SELECT * FROM items"
- type: response
template: '{"data": {{json .steps.fetch.data}}}'
# Create item: POST /api/items
- name: "create_item"
triggers:
- type: http
path: "/api/items"
method: POST
parameters:
- name: "name"
type: "string"
required: true
steps:
- name: insert
type: query
database: "primary"
sql: "INSERT INTO items (name) VALUES (@name)"
- type: response
status_code: 201
template: '{"success": true}'
# Get single item: GET /api/items/{id}
- name: "get_item"
triggers:
- type: http
path: "/api/items/{id}"
method: GET
parameters:
- name: "id"
type: "int"
required: true
steps:
- name: fetch
type: query
database: "primary"
sql: "SELECT * FROM items WHERE id = @id"
- type: response
template: '{"item": {{json (index .steps.fetch.data 0)}}}'
# Update item: PUT /api/items/{id}
- name: "update_item"
triggers:
- type: http
path: "/api/items/{id}"
method: PUT
parameters:
- name: "id"
type: "int"
required: true
- name: "name"
type: "string"
required: true
steps:
- name: update
type: query
database: "primary"
sql: "UPDATE items SET name = @name WHERE id = @id"
- type: response
template: '{"success": true}'
# Delete item: DELETE /api/items/{id}
- name: "delete_item"
triggers:
- type: http
path: "/api/items/{id}"
method: DELETE
parameters:
- name: "id"
type: "int"
required: true
steps:
- name: delete
type: query
database: "primary"
sql: "DELETE FROM items WHERE id = @id"
- type: response
template: '{"deleted": true}'The simplest workflow exposes a single query as an HTTP endpoint:
workflows:
- name: "get_machines"
triggers:
- type: http
path: "/api/machines"
method: GET
steps:
- name: fetch
type: query
database: "primary"
sql: "SELECT * FROM Machines ORDER BY MachineName"
- type: response
template: |
{"success": true, "data": {{json .steps.fetch.data}}, "count": {{.steps.fetch.count}}}Parameters are defined on triggers and accessed via .trigger.params:
workflows:
- name: "get_machine_by_id"
triggers:
- type: http
path: "/api/machines/details"
method: GET
parameters:
- name: "id"
type: "int"
required: true
steps:
- name: fetch
type: query
database: "primary"
sql: "SELECT * FROM Machines WHERE MachineId = @id"
- type: response
template: |
{"success": true, "machine": {{json (index .steps.fetch.data 0)}}}Use named conditions and conditional response steps:
workflows:
- name: "get_user"
conditions:
found: "steps.fetch.count > 0"
not_found: "steps.fetch.count == 0"
triggers:
- type: http
path: "/api/user"
method: GET
parameters:
- name: "id"
type: "int"
required: true
steps:
- name: fetch
type: query
database: "primary"
sql: "SELECT * FROM Users WHERE Id = @id"
- type: response
condition: "found"
template: |
{"success": true, "user": {{json (index .steps.fetch.data 0)}}}
- type: response
condition: "not_found"
status_code: 404
template: |
{"success": false, "error": "User not found"}Note: Validation warns if all response steps have conditions with no unconditional fallback. In the example above, found and not_found are logically exhaustive, so the warning can be safely ignored. Alternatively, make the last response unconditional as a fallback.
Chain multiple queries and combine results:
workflows:
- name: "dashboard_stats"
triggers:
- type: http
path: "/api/dashboard"
method: GET
steps:
- name: machine_stats
type: query
database: "primary"
sql: "SELECT COUNT(*) AS total, SUM(CASE WHEN IsOnline = 1 THEN 1 ELSE 0 END) AS online FROM Machines"
- name: checkin_stats
type: query
database: "primary"
sql: "SELECT COUNT(*) AS total, COUNT(DISTINCT EmployeeId) AS unique_employees FROM AttendanceLog WHERE PunchTime >= DATE('now', '-1 day')"
- type: response
template: |
{
"success": true,
"machines": {{json (index .steps.machine_stats.data 0)}},
"checkins": {{json (index .steps.checkin_stats.data 0)}}
}Call external APIs between queries:
workflows:
- name: "sync_to_external"
triggers:
- type: cron
schedule: "0 * * * *" # Every hour
steps:
- name: fetch_data
type: query
database: "primary"
sql: "SELECT * FROM Machines WHERE UpdatedAt > datetime('now', '-1 hour')"
- name: notify_external
type: httpcall
condition: "steps.fetch_data.count > 0"
url: "https://api.example.com/webhooks/machines"
http_method: POST
headers:
Authorization: "Bearer ${API_TOKEN}"
Content-Type: "application/json"
body: |
{"machines": {{json .steps.fetch_data.data}}, "count": {{.steps.fetch_data.count}}}
parse: "json"
timeout_sec: 30
retry:
enabled: true
max_attempts: 3
initial_backoff_sec: 1
max_backoff_sec: 30Process each item from a query result:
workflows:
- name: "process_orders"
triggers:
- type: cron
schedule: "*/5 * * * *"
steps:
- name: fetch_orders
type: query
database: "primary"
sql: "SELECT * FROM Orders WHERE Status = 'pending' LIMIT 100"
- name: process_batch
condition: "steps.fetch_orders.count > 0"
iterate:
over: "steps.fetch_orders.data"
as: "order"
on_error: continue # Continue processing remaining items on failure
steps:
- name: call_payment
type: httpcall
url: "https://payments.example.com/charge"
http_method: POST
body: |
{"order_id": "{{.order.OrderId}}", "amount": {{.order.Amount}}}After iteration, access aggregate results:
.steps.process_batch.success_count- Number of successful iterations.steps.process_batch.failure_count- Number of failed iterations.steps.process_batch.skipped_count- Number of skipped iterations.steps.process_batch.iterations- Array of results from each iteration
HTTP example with response:
workflows:
- name: "batch_process"
triggers:
- type: http
path: "/api/batch"
method: POST
parameters:
- name: "ids"
type: "int[]"
required: true
steps:
- name: process_each
iterate:
over: "trigger.params.ids"
as: "id"
steps:
- name: lookup
type: query
database: "primary"
sql: "SELECT {{.id}} AS id, 'processed' AS status"
- type: response
template: |
{
"processed": {{.steps.process_each.success_count}},
"failed": {{.steps.process_each.failure_count}},
"results": {{json .steps.process_each.iterations}}
}The as: value (id in this case) becomes the variable name used in templates (.id).
Run workflows on a schedule:
workflows:
- name: "daily_report"
triggers:
- type: cron
schedule: "0 8 * * *" # 8 AM daily
params:
report_date: "yesterday" # Dynamic date value
steps:
- name: generate
type: query
database: "primary"
sql: "SELECT COUNT(*) AS total FROM AttendanceLog WHERE DATE(PunchTime) = DATE(@report_date)"Cron Expression Format:
Standard 5-field cron format: minute hour day-of-month month day-of-week
| Expression | Description |
|---|---|
*/5 * * * * |
Every 5 minutes |
0 * * * * |
Every hour |
0 8 * * * |
Daily at 8 AM |
0 8 * * 1 |
Mondays at 8 AM |
0 0 1 * * |
First day of month at midnight |
Dynamic Date Values:
The following special values are resolved at execution time:
| Value | Resolves To |
|---|---|
now |
Current timestamp |
today |
Start of today (00:00:00) |
yesterday |
Start of yesterday |
tomorrow |
Start of tomorrow |
Retry Behavior:
Scheduled workflows automatically retry on failure:
- 3 attempts total
- Exponential backoff: 1s, 5s, 25s between retries
- Logs error after all retries exhausted
A single workflow can have both HTTP and cron triggers:
workflows:
- name: "status_check"
triggers:
- type: http
path: "/api/status"
method: GET
- type: cron
schedule: "*/5 * * * *"
steps:
- name: check
type: query
database: "primary"
sql: "SELECT COUNT(*) AS online FROM Machines WHERE IsOnline = 1"
- type: response # Only used for HTTP trigger
template: |
{"online_machines": {{index .steps.check.data 0 "online"}}}SQL Proxy supports two levels of caching:
Trigger-Level Caching - Cache the entire workflow response. On cache hit, the workflow steps are not executed at all - the cached response is returned immediately.
workflows:
- name: "cached_dashboard"
triggers:
- type: http
path: "/api/dashboard"
method: GET
cache:
enabled: true
key: "dashboard:{{.trigger.params.period | default \"day\"}}"
ttl_sec: 60
evict_cron: "0 0 * * *" # Optional: evict at midnight
parameters:
- name: "period"
type: "string"
default: "day"
steps:
# ... query stepsResponse includes X-Cache: HIT or X-Cache: MISS header.
Trigger cache keys have access to request context:
| Template | Description |
|---|---|
{{.trigger.params.name}} |
URL/query parameters |
{{.trigger.client_ip}} |
Client IP address |
{{.trigger.method}} |
HTTP method (GET, POST, etc.) |
{{.trigger.path}} |
Request path |
{{.trigger.headers.Authorization}} |
HTTP headers (flattened) |
{{.trigger.query.page}} |
Query parameters (flattened) |
{{.trigger.cookies.session}} |
Parsed cookies |
{{.request_id}} |
Request ID |
Step-Level Caching - Cache individual step results (query or httpcall). The workflow still executes, but cached steps return their cached result instead of executing. Useful when multiple endpoints share common queries, or for expensive operations.
workflows:
- name: "user_dashboard"
triggers:
- type: http
path: "/api/user/dashboard"
method: GET
parameters:
- name: "user_id"
type: "int"
required: true
steps:
# User data cached for 5 minutes
- name: user
type: query
database: "primary"
sql: "SELECT * FROM users WHERE id = @user_id"
cache:
key: "user:{{.trigger.params.user_id}}"
ttl_sec: 300
# Stats cached for 1 minute (changes more often)
- name: stats
type: query
database: "primary"
sql: "SELECT COUNT(*) as count FROM orders WHERE user_id = @user_id"
cache:
key: "user_stats:{{.trigger.params.user_id}}"
ttl_sec: 60
- type: response
template: |
{
"user": {{json (index .steps.user.data 0)}},
"user_cached": {{.steps.user.cache_hit}},
"stats": {{json (index .steps.stats.data 0)}},
"stats_cached": {{.steps.stats.cache_hit}}
}You can combine both levels - trigger cache provides fast response for repeated requests, while step cache speeds up workflow execution when the trigger cache misses.
Apply rate limits to HTTP triggers:
workflows:
- name: "rate_limited_api"
triggers:
- type: http
path: "/api/limited"
method: GET
rate_limit:
- pool: "default" # Reference named pool
- requests_per_second: 10 # Or inline config
burst: 20
key: "{{.trigger.client_ip}}"
steps:
# ... steps| Type | Purpose |
|---|---|
query |
Execute SQL query against a database |
httpcall |
Call external HTTP API |
response |
Send HTTP response (HTTP triggers only) |
Steps with nested steps: are called blocks. Blocks provide a scoped namespace for their nested steps and support iteration via iterate:.
Query Step:
- name: "step_name"
type: query
database: "primary" # Required: database connection name
sql: "SELECT * FROM ..." # Required: SQL query
isolation: "read_committed" # Optional: transaction isolation
lock_timeout_ms: 5000 # Optional: lock timeout
deadlock_priority: "low" # Optional: deadlock priority
json_columns: ["data"] # Optional: parse JSON columns
cache: # Optional: step-level caching
key: "item:{{.trigger.params.id}}" # Cache key (supports templates)
ttl_sec: 300 # Time to live in seconds
on_error: fail # Optional: fail (default) or continue
disabled: false # Optional: skip this step if trueHTTPCall Step:
- name: "step_name"
type: httpcall
url: "https://api.example.com/..." # Required (supports templates)
http_method: POST # Required: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
headers: # Optional: HTTP headers
Authorization: "Bearer token"
body: '{"key": "value"}' # Optional: request body (supports templates)
parse: "json" # Optional: json, text, or none
timeout_sec: 30 # Optional: request timeout
retry: # Optional: retry configuration
enabled: true
max_attempts: 3
initial_backoff_sec: 1
max_backoff_sec: 30
cache: # Optional: step-level caching
key: "api:{{.trigger.params.id}}" # Cache key (supports templates)
ttl_sec: 300 # Time to live in secondsResponse Step:
- type: response
condition: "condition_name" # Optional: only send if condition is true
status_code: 200 # Optional: HTTP status code (default: 200)
headers: # Optional: response headers
X-Custom: "value"
template: | # Required: response body template
{"success": true, "data": {{json .steps.fetch.data}}}Block Step (iteration):
- name: process_items
condition: "condition_name" # Optional: only execute if condition is true
iterate: # Optional: iterate over a collection
over: "steps.fetch.data" # Expression to iterate over
as: "item" # Variable name for current item
on_error: continue # abort, continue, or skip
steps: # Nested steps (creates a block)
- name: process
type: query
database: "primary"
sql: "UPDATE items SET status = 'done' WHERE id = {{.item.id}}"Block results include:
.steps.<name>.iterations- Array of iteration results.steps.<name>.success_count- Number of successful iterations.steps.<name>.failure_count- Number of failed iterations.steps.<name>.skipped_count- Number of skipped iterations
Templates have access to:
| Variable | Description |
|---|---|
.trigger.type |
Trigger type ("http" or "cron") |
.trigger.params |
Parameter values from request/schedule |
.trigger.headers |
HTTP headers (HTTP trigger only) |
.trigger.cookies |
Parsed cookies as map (HTTP trigger only) |
.trigger.method |
HTTP method (HTTP trigger only) |
.trigger.path |
Request path (HTTP trigger only) |
.trigger.client_ip |
Client IP address |
.steps.<name>.data |
Query results (array of rows) |
.steps.<name>.row |
First row (shortcut for index .data 0) |
.steps.<name>.count |
Row count |
.steps.<name>.found |
True if count > 0 |
.steps.<name>.empty |
True if count == 0 |
.steps.<name>.one |
True if count == 1 |
.steps.<name>.many |
True if count > 1 |
.steps.<name>.error |
Error message if step failed |
.steps.<name>.status_code |
HTTP status (httpcall only) |
.item |
Current item in block iteration |
.vars |
Global variables from config variables: section |
.workflow.request_id |
Request ID |
.workflow.name |
Workflow name |
| Function | Description | Example |
|---|---|---|
json |
JSON encode value | {{json .steps.fetch.data}} |
jsonIndent |
JSON encode with indentation | {{jsonIndent .steps.fetch.row}} |
index |
Array/map access | {{index .steps.fetch.data 0 "name"}} |
dig |
Safe nested access (nil-safe) | {{dig .trigger.params "user" "profile" "name"}} |
pick |
Select keys from map | {{pick .steps.user.row "id" "name"}} |
omit |
Remove keys from map | {{omit .steps.user.row "password"}} |
merge |
Merge maps (later wins) | {{merge .defaults .overrides}} |
| Function | Description | Example |
|---|---|---|
require |
Get value, error if missing | {{require .trigger.headers "Authorization"}} |
getOr |
Get value with fallback | {{getOr .trigger.query "page" "1"}} |
has |
Check if key exists and non-empty | {{if has .trigger.headers "X-Api-Key"}}...{{end}} |
header |
Get header (canonical form) | {{header .trigger.headers "Content-Type" "text/plain"}} |
cookie |
Get cookie value | {{cookie .trigger.cookies "session" ""}} |
| Function | Description | Example |
|---|---|---|
first |
First element | {{first .steps.fetch.data}} |
last |
Last element | {{last .steps.fetch.data}} |
len |
Length | {{len .steps.fetch.data}} |
pluck |
Extract field from array of maps | {{pluck .steps.fetch.data "id"}} |
isEmpty |
Check if empty | {{if isEmpty .steps.fetch.data}}...{{end}} |
| Function | Description | Example |
|---|---|---|
int64 |
Convert to integer | {{int64 .trigger.query.page}} |
float |
Convert to float | {{float .trigger.query.price}} |
string |
Convert to string | {{string .steps.fetch.row.id}} |
bool |
Convert to boolean | {{bool .trigger.query.active}} |
| Function | Description | Example |
|---|---|---|
add, sub, mul, div, mod |
Arithmetic | {{add .a .b}} |
round, floor, ceil, trunc |
Rounding | {{round .price}} |
abs, min, max |
Comparisons | {{max .a .b}} |
zeropad |
Zero-padded number | {{zeropad .id 6}} → 000042 |
pad |
Custom padding | {{pad .code 4 "0"}} |
| Function | Description | Example |
|---|---|---|
upper, lower |
Case conversion | {{upper .name}} |
trim |
Remove whitespace | {{trim .input}} |
replace |
Replace all occurrences | {{replace "_" "-" .slug}} |
contains, hasPrefix, hasSuffix |
String tests | {{if hasPrefix .path "/api"}}...{{end}} |
truncate |
Truncate with suffix | {{truncate .desc 100 "..."}} |
split |
Split string | {{split "," .tags}} |
join |
Join array | {{join ", " .items}} |
substr |
Substring | {{substr .code 0 3}} |
quote |
Quote string | {{quote .value}} |
sprintf |
Format string | {{sprintf "%s-%d" .prefix .id}} |
repeat |
Repeat string | {{repeat "*" 5}} |
| Function | Description | Example |
|---|---|---|
isEmail |
Valid email format | {{if isEmail .email}}...{{end}} |
isUUID |
Valid UUID format | {{if isUUID .id}}...{{end}} |
isURL |
Valid URL with scheme | {{if isURL .link}}...{{end}} |
isIP |
Valid IP address (any type) | {{if isIP .addr}}...{{end}} |
isIPv4 |
IPv4 address (includes IPv4-mapped IPv6) | {{if isIPv4 .addr}}...{{end}} |
isIPv6 |
IPv6 address (excludes IPv4-mapped) | {{if isIPv6 .addr}}...{{end}} |
isNumeric |
Numeric string | {{if isNumeric .input}}...{{end}} |
matches |
Regex match (false for invalid patterns) | {{if matches "^[A-Z]{2}$" .code}}...{{end}} |
| Function | Description | Example |
|---|---|---|
ipNetwork |
Network portion of IP | {{ipNetwork .trigger.client_ip 24 64}} -> 192.168.1.0 |
ipPrefix |
Network in CIDR notation | {{ipPrefix .trigger.client_ip 24}} -> 192.168.1.0/24 |
normalizeIP |
Normalize IP format | {{normalizeIP .trigger.client_ip}} |
| Function | Description | Example |
|---|---|---|
urlEncode, urlDecode |
URL encoding | {{urlEncode .query}} |
base64Encode, base64Decode |
Base64 encoding | {{base64Encode .data}} |
sha256, md5 |
Hash functions | {{sha256 .password}} |
hmacSHA256 |
HMAC signature | {{hmacSHA256 .secret .payload}} |
| Function | Description | Example |
|---|---|---|
now |
Current time | {{now "YYYY-MM-DD"}} |
formatTime |
Format timestamp | {{formatTime .created_at "YYYY-MM-DD"}} |
parseTime |
Parse to Unix timestamp | {{parseTime .date "YYYY-MM-DD"}} |
unixTime |
Current Unix timestamp | {{unixTime}} |
| Function | Description | Example |
|---|---|---|
uuid / uuid4 |
UUID v4 | {{uuid}} → 550e8400-e29b-41d4-... |
uuidShort |
UUID without hyphens | {{uuidShort}} → 550e8400e29b41d4... |
shortID |
Base62 random ID | {{shortID 12}} → AbC3d5fGh12x |
nanoid |
NanoID-style ID | {{nanoid 21}} → V1StGXR8_Z5jdHi6B-myT |
publicID |
Encrypted public ID | {{publicID "user" .id}} → usr_Xk9mPqR3vL2n |
privateID |
Decode public ID | {{privateID "user" .public_id}} → 123 |
Public ID Configuration: The publicID and privateID functions require configuration in the public_ids section of your config file. This feature encrypts internal database IDs to prevent enumeration attacks and cross-entity ID reuse.
public_ids:
secret_key: "${PUBLIC_ID_SECRET}" # Required: 32+ char secret
namespaces:
- name: "user"
prefix: "usr" # Output: usr_Xk9mPqR3vL2n
- name: "order"
prefix: "ord" # Output: ord_7Kp2mNq8xL4vSecret Key Security:
- Key rotation invalidates all IDs: Changing
secret_keywill cause all previously generated public IDs to fail decoding. Plan migrations carefully. - Use cryptographically random keys: Generate with
openssl rand -base64 32or similar. Never use predictable values (passwords, dictionary words, sequential strings). - Store securely: Use environment variables or secrets management. Never commit keys to version control.
- Minimum 32 characters: Shorter keys reduce the security of the encryption.
Use isValidPublicID("namespace", id) in conditions to validate IDs before decoding:
conditions:
valid_id: 'isValidPublicID("user", trigger.params.id)'
steps:
- name: fetch_user
type: query
params:
internal_id: '{{privateID "user" .trigger.params.id}}'
sql: "SELECT * FROM users WHERE id = @internal_id"
condition: valid_id| Function | Description | Example |
|---|---|---|
default |
Default if empty | {{default "N/A" .value}} or {{.value | default "N/A"}} |
coalesce |
First non-empty value | {{coalesce .preferred .fallback "default"}} |
ternary |
Conditional value | {{ternary .active "yes" "no"}} |
when |
Value if true, else empty | {{when .premium "PRO "}}{{.name}} |
| Function | Description | Example |
|---|---|---|
typeOf |
Get type name | {{typeOf .value}} → string |
keys |
Get map keys | {{keys .data}} |
values |
Get map values | {{values .data}} |
| Function | Description | Example |
|---|---|---|
formatNumber |
With thousand separators | {{formatNumber 1234567}} → 1,234,567 |
formatPercent |
As percentage | {{formatPercent 0.156}} → 15.6% |
formatBytes |
Human-readable bytes (handles negatives) | {{formatBytes 1572864}} → 1.5 MB |
Conditions use the expr expression language:
conditions:
has_data: "steps.fetch.count > 0"
no_data: "steps.fetch.count == 0"
is_admin: "trigger.params.role == 'admin'"
large_result: "steps.fetch.count >= 100"Available operators: ==, !=, <, >, <=, >=, &&, ||, !, in, contains, matches
Note: contains and matches are operators, not functions. Use them infix: trigger.params.name contains "admin" or trigger.params.email matches "^.*@example\\.com$"
Using condition aliases in compound expressions:
Named condition aliases can be used in compound expressions with &&, ||, and !:
conditions:
valid_id: 'isValidPublicID("task", trigger.params.public_id)'
found: "steps.fetch.count > 0"
is_owner: "steps.fetch.row.owner_id == steps.auth.row.id"
steps:
- type: response
condition: "!valid_id || !found" # Aliases work in compound expressions
status_code: 404
template: '{"error": "Not found"}'
- type: response
condition: "found && is_owner" # Multiple aliases combined
template: '{"data": {{json .steps.fetch.row}}}'Aliases are expanded at compile time, so "found && is_owner" becomes "(steps.fetch.count > 0) && (steps.fetch.row.owner_id == steps.auth.row.id)".
Available functions:
| Function | Description | Example |
|---|---|---|
isValidPublicID |
Check if public ID is valid | isValidPublicID("user", trigger.params.id) |
divOr |
Safe division with fallback | divOr(total, count, 0) |
modOr |
Safe modulo with fallback | modOr(value, divisor, 0) |
upper |
Uppercase string | upper(trigger.params.name) |
lower |
Lowercase string | lower(trigger.params.name) |
trim |
Trim whitespace | trim(trigger.params.input) |
hasPrefix |
Check string prefix | hasPrefix(trigger.params.path, "/api") |
hasSuffix |
Check string suffix | hasSuffix(trigger.params.file, ".json") |
len |
Length of string/array/map | len(steps.fetch.rows) > 0 |
isEmpty |
Check if value is empty | isEmpty(trigger.params.filter) |
first |
First element of array | first(steps.fetch.rows) |
last |
Last element of array | last(steps.fetch.rows) |
coalesce |
First non-nil value | coalesce(trigger.params.name, "default") |
isEmail |
Validate email format | isEmail(trigger.params.email) |
isUUID |
Validate UUID format | isUUID(trigger.params.id) |
isURL |
Validate URL format | isURL(trigger.params.callback) |
isIP |
Validate IP address | isIP(trigger.params.addr) |
isIPv4 |
Validate IPv4 address | isIPv4(trigger.params.addr) |
isIPv6 |
Validate IPv6 address | isIPv6(trigger.params.addr) |
isNumeric |
Check if string is numeric | isNumeric(trigger.params.code) |
Division safety: Conditions with dynamic divisors must use divOr(a, b, fallback) instead of a / b. This prevents runtime panics from division by zero. Division by literal non-zero values (e.g., x / 2) is allowed. Violations are caught at workflow compile time.
Step references: Conditions using steps.X are validated at compile time. Steps can only reference results from earlier steps - forward references and self-references are errors. Unknown step names are also caught.
Control step failure behavior with on_error:
steps:
- name: optional_step
type: query
database: "primary"
sql: "SELECT * FROM optional_table"
on_error: continue # Continue to next step even if this fails
- name: required_step
type: query
database: "primary"
sql: "SELECT * FROM required_table"
on_error: fail # Stop workflow on failure (default)For blocks, control iteration error behavior:
- name: process_items
iterate:
over: "steps.fetch.data"
as: "item"
on_error: continue # Process remaining items even if one fails
steps:
# ... nested stepsProtect your database from excessive requests with configurable rate limiting. Rate limits use the token bucket algorithm with configurable request rate and burst capacity.
Define reusable rate limit pools at the server level:
rate_limits:
- name: "global"
requests_per_second: 100
burst: 200
key: "{{.trigger.client_ip}}"
- name: "per_user"
requests_per_second: 10
burst: 20
key: "{{.trigger.headers.Authorization}}"
- name: "per_tenant"
requests_per_second: 50
burst: 100
key: "{{getOr .trigger.headers \"X-Tenant-ID\" \"default\"}}"Apply rate limits to specific workflows by referencing pools or using inline configuration:
workflows:
- name: "expensive_report"
triggers:
- type: http
path: "/api/report"
method: GET
rate_limit:
- pool: "global" # Reference named pool
- pool: "per_user" # Multiple pools can be applied
steps:
- name: fetch
type: query
database: "primary"
sql: "SELECT * FROM large_table"
- type: response
template: '{"success": true, "data": {{json .steps.fetch.data}}}'
- name: "public_api"
triggers:
- type: http
path: "/api/public"
method: GET
rate_limit:
- requests_per_second: 5 # Inline rate limit
burst: 10
key: "{{.trigger.client_ip}}"
steps:
- name: fetch
type: query
database: "primary"
sql: "SELECT * FROM items"
- type: response
template: '{"success": true, "data": {{json .steps.fetch.data}}}'Keys determine how requests are grouped for rate limiting. Use Go template syntax with access to request context:
| Template | Description |
|---|---|
{{.trigger.client_ip}} |
Client IP address (handles proxies via X-Forwarded-For) |
{{.trigger.headers.Authorization}} |
Authorization header value |
{{.trigger.headers.X-API-Key}} |
Custom header value |
{{.trigger.query.tenant}} |
Query parameter value |
{{.trigger.params.user_id}} |
URL parameter value |
{{getOr .trigger.headers "X-Tenant" "default"}} |
Header with fallback |
{{.trigger.client_ip}}:{{.trigger.headers.X-Tenant-ID}} |
Composite key |
When a request is rate limited:
- HTTP 429 (Too Many Requests) is returned
- Response includes
Retry-Afterheader with seconds to wait - Request is logged with
rate_limited: true
{
"success": false,
"error": "rate limit exceeded",
"retry_after_sec": 2
}When multiple rate limits apply to a workflow, all must pass for the request to proceed:
rate_limit:
- pool: "global" # 100 req/s global limit
- pool: "per_user" # 10 req/s per userThis allows layered rate limiting (e.g., global cap + per-client fairness).
The following parameter types are supported:
| Type | Description | Example Value |
|---|---|---|
string |
Text value (default) | "hello" |
int, integer |
Integer number | 42 |
float, double |
Floating point number | 3.14 |
bool, boolean |
Boolean value | true, false, 1, 0 |
datetime, date |
Date/time value | "2024-01-15", "2024-01-15T10:30:00Z" |
json |
Any JSON value (serialized to string) | {"key": "value"} |
int[] |
Array of integers | [1, 2, 3] |
string[] |
Array of strings | ["a", "b", "c"] |
float[] |
Array of numbers | [1.5, 2.5] |
bool[] |
Array of booleans | [true, false] |
Use json type when you need to accept nested objects or arbitrary JSON. The value is serialized to a JSON string for use with SQL JSON functions:
workflows:
- name: "save_config"
triggers:
- type: http
path: "/api/config"
method: POST
parameters:
- name: "name"
type: "string"
required: true
- name: "data"
type: "json"
required: true
steps:
- name: insert
type: query
database: "primary"
sql: |
INSERT INTO configs (name, data) VALUES (@name, @data)
- type: response
template: '{"success": true}'# Send nested JSON object
curl -X POST http://localhost:8081/api/config \
-H "Content-Type: application/json" \
-d '{"name": "settings", "data": {"theme": "dark", "notifications": {"email": true}}}'In SQL, use JSON functions to extract values:
-- SQL Server: JSON_VALUE
SELECT name, JSON_VALUE(data, '$.theme') AS theme FROM configs
-- SQLite: json_extract
SELECT name, json_extract(data, '$.theme') AS theme FROM configsUse array types (int[], string[], etc.) for IN clause queries. Arrays are serialized to JSON strings and used with json_each (SQLite) or OPENJSON (SQL Server):
workflows:
- name: "get_users_by_ids"
triggers:
- type: http
path: "/api/users/batch"
method: POST
parameters:
- name: "ids"
type: "int[]"
required: true
steps:
- name: fetch
type: query
database: "primary"
sql: |
SELECT * FROM users
WHERE id IN (SELECT value FROM json_each(@ids))
- type: response
template: '{"success": true, "data": {{json .steps.fetch.data}}}'
- name: "filter_by_status"
triggers:
- type: http
path: "/api/users/filter"
method: POST
parameters:
- name: "statuses"
type: "string[]"
required: true
steps:
- name: fetch
type: query
database: "primary"
sql: |
SELECT * FROM users
WHERE status IN (SELECT value FROM json_each(@statuses))
- type: response
template: '{"success": true, "data": {{json .steps.fetch.data}}}'# Get users with IDs 1, 2, and 3
curl -X POST http://localhost:8081/api/users/batch \
-H "Content-Type: application/json" \
-d '{"ids": [1, 2, 3]}'
# Filter by multiple statuses
curl -X POST http://localhost:8081/api/users/filter \
-H "Content-Type: application/json" \
-d '{"statuses": ["active", "pending"]}'For SQL Server, use OPENJSON:
SELECT * FROM users
WHERE id IN (SELECT CAST(value AS INT) FROM OPENJSON(@ids))Array type validation:
- Array elements must match the declared base type
- Mixed types are rejected (e.g.,
[1, "two"]forint[])
By default, JSON stored in database columns is returned as escaped strings. Use json_columns to parse them as objects in the response:
workflows:
- name: "get_config"
triggers:
- type: http
path: "/api/config"
method: GET
parameters:
- name: "name"
type: "string"
required: true
steps:
- name: fetch
type: query
database: "primary"
sql: "SELECT id, name, data FROM configs WHERE name = @name"
json_columns: ["data"] # Parse 'data' column as JSON
- type: response
template: '{"success": true, "data": {{json .steps.fetch.data}}}'Without json_columns (default):
{
"data": [{"id": 1, "name": "settings", "data": "{\"theme\":\"dark\"}"}]
}With json_columns: ["data"]:
{
"data": [{"id": 1, "name": "settings", "data": {"theme": "dark"}}]
}Notes:
- Only string columns are parsed; other types are left unchanged
- Empty strings are left as empty strings
- Invalid JSON in a configured column returns a 500 error
- Non-existent columns are silently ignored
Uses Go's log/slog with JSON output. Rotation via lumberjack.
Output destination (controlled by file_path):
file_path: ""(empty): logs to stdout (default for interactive mode)file_path: "/path/to/file.log": logs to file with rotation (recommended for service mode)
{"time":"2024-01-15T10:30:45.123Z","level":"INFO","msg":"request_completed","request_id":"a1b2c3d4","endpoint":"/api/machines","workflow_name":"list_machines","query_duration_ms":45,"row_count":150,"total_duration_ms":48}| Level | What's Logged |
|---|---|
debug |
Everything: request received, params parsed, query start/end |
info |
Request completions, service lifecycle, config changes |
warn |
Slow queries (>80% of timeout), health check failures, rejected requests |
error |
Query failures, panics, database errors |
No restart required:
# Check current level
curl http://localhost:8081/_/config/loglevel
# Change to debug
curl -X POST "http://localhost:8081/_/config/loglevel?level=debug"
# Back to info for production
curl -X POST "http://localhost:8081/_/config/loglevel?level=info"SQL Proxy exposes metrics in two formats:
For monitoring systems like Prometheus, Grafana, etc.:
curl http://localhost:8081/_/metricsReturns OpenMetrics/Prometheus format with metrics including:
sqlproxy_requests_total- Request counts by endpoint, method, statussqlproxy_request_duration_seconds- Request latency histogramsqlproxy_query_duration_seconds- SQL query latency histogramsqlproxy_errors_total- Errors by typesqlproxy_db_healthy- Database health (1=healthy, 0=unhealthy)sqlproxy_cache_hits_total,sqlproxy_cache_misses_total- Cache statisticssqlproxy_ratelimit_allowed_total,sqlproxy_ratelimit_denied_total- Rate limit statssqlproxy_cron_panics_total- Panics recovered in cron workflows- Standard Go runtime metrics (
go_*,process_*)
Human-readable JSON for debugging and dashboards:
curl http://localhost:8081/_/metrics.jsonResponse:
{
"timestamp": "2024-01-15T10:35:00Z",
"version": "1.0.0",
"build_time": "2024-01-15T10:30:00Z",
"uptime_sec": 86400,
"total_requests": 15234,
"total_errors": 12,
"total_timeouts": 2,
"db_healthy": true,
"endpoints": {
"/api/machines": {
"endpoint": "/api/machines",
"query_name": "list_machines",
"request_count": 1523,
"error_count": 0,
"timeout_count": 0,
"total_rows": 228450,
"avg_duration_ms": 45.2,
"max_duration_ms": 234,
"min_duration_ms": 12,
"avg_query_ms": 38.1
}
},
"runtime": {
"go_version": "go1.21.0",
"goroutines": 12,
"num_cpu": 4,
"mem_alloc_bytes": 2458624,
"mem_total_alloc": 158236744,
"mem_sys_bytes": 12648448,
"mem_heap_objects": 18542,
"gc_runs": 245,
"gc_pause_total_ns": 12456789,
"gc_last_pause_ns": 48521
}
}| Field | Description |
|---|---|
goroutines |
Active goroutines (should be stable, ~10-20) |
mem_alloc_bytes |
Currently allocated heap memory |
mem_total_alloc |
Cumulative bytes allocated (grows over time) |
mem_sys_bytes |
Total memory obtained from OS |
mem_heap_objects |
Number of allocated heap objects |
gc_runs |
Number of completed GC cycles |
gc_pause_total_ns |
Total time spent in GC pauses |
gc_last_pause_ns |
Duration of most recent GC pause |
What to watch for:
mem_alloc_bytesgrowing unbounded -> memory leakgoroutinesgrowing unbounded -> goroutine leakgc_last_pause_ns> 10ms -> GC pressure, may need tuning
All internal service endpoints use the /_/ prefix (reserved, user workflows cannot use this prefix).
| Endpoint | Method | Description |
|---|---|---|
/ |
GET | List all workflow endpoints with parameters |
/_/health |
GET | Aggregate health check (always 200, parse status field) |
/_/health/{dbname} |
GET | Per-database health check (200 or 404 if not found) |
/_/metrics |
GET | Prometheus/OpenMetrics format for monitoring |
/_/metrics.json |
GET | Human-readable JSON metrics snapshot |
/_/openapi.json |
GET | OpenAPI 3.0 specification |
/_/config/loglevel |
GET/POST | View/change log level |
/_/cache/clear |
POST/DELETE | Clear cache (all or specific endpoint via ?endpoint=/api/path) |
/_/ratelimits |
GET | Rate limit pool status and metrics |
/_/debug/pprof/* |
GET | Go profiling endpoints (if enabled) |
When enabled via debug.enabled: true in config, Go profiling endpoints are available:
| Endpoint | Description |
|---|---|
/_/debug/pprof/ |
Index page with links to all profiles |
/_/debug/pprof/heap |
Memory allocation profile |
/_/debug/pprof/goroutine |
Stack traces of all goroutines |
/_/debug/pprof/profile |
CPU profile (30s by default, add ?seconds=N) |
/_/debug/pprof/trace |
Execution trace (add ?seconds=N) |
Configuration:
debug:
enabled: true # Enable debug endpoints
port: 6060 # Separate port (0 = same as main server)
host: "localhost" # Only valid with separate port; defaults to localhostPort and host behavior:
port: 0or same as main server: Debug endpoints share main server's binding (hostmust not be set)port: <different>: Debug endpoints run on separate server,hostsetting applieshostdefaults tolocalhostwhen using separate port (for security)- Setting
hostwhen sharing the main server port is a config validation error
Security considerations:
- Debug endpoints can expose sensitive information
- For production: use separate port bound to localhost, restrict via firewall
- If sharing main port: debug endpoints are exposed on same interface as main server
- Consider disabling in production unless actively debugging
Usage with pprof:
# View profiles in browser
curl http://localhost:6060/_/debug/pprof/
# Capture CPU profile and analyze
go tool pprof http://localhost:6060/_/debug/pprof/profile?seconds=30
# Capture heap profile
go tool pprof http://localhost:6060/_/debug/pprof/heapHealth endpoints always return HTTP 200 with status details in the response body. This design:
- Avoids confusion with proxy/middleware 503 errors
- Allows clients to always receive health details
- Enables monitoring tools to parse JSON for actual status
Aggregate health (/_/health):
{
"status": "healthy",
"databases": {
"primary": "connected",
"reporting": "connected"
},
"uptime": "24h35m12s"
}Status values:
healthy- All databases connecteddegraded- Some databases connected, some disconnectedunhealthy- All databases disconnected
Per-database health (/_/health/{dbname}):
{
"database": "primary",
"status": "connected",
"type": "sqlserver",
"readonly": true
}MySQL example:
{
"database": "analytics",
"status": "connected",
"type": "mysql",
"readonly": true
}Returns 404 only if the database name doesn't exist in configuration.
The service auto-generates an OpenAPI 3.0 spec at runtime:
curl http://localhost:8081/_/openapi.jsonYou can use this with:
- Swagger UI - Paste URL into https://petstore.swagger.io or run Swagger UI locally
- Postman - Import > Link >
http://localhost:8081/_/openapi.json - Code generators - Generate client SDKs for any language
The spec includes all configured workflow endpoints with their parameters, types, and response schemas.
Defined in config.yaml. Each returns:
{
"success": true,
"data": [...],
"count": 42,
"timeout_sec": 30,
"request_id": "a1b2c3d4e5f6"
}The request_id can be used to trace the request in logs.
POST endpoints accept parameters via JSON body (in addition to query parameters and form data):
# JSON body
curl -X POST http://localhost:8081/api/reports \
-H "Content-Type: application/json" \
-d '{"date": "2024-01-15", "status": "active", "count": 100}'
# Query parameters still work
curl -X POST "http://localhost:8081/api/reports?date=2024-01-15&status=active"
# Form data still works
curl -X POST http://localhost:8081/api/reports \
-d "date=2024-01-15" -d "status=active"Important notes:
- JSON body must be flat unless parameter type is
jsonor an array type - Query parameters override JSON body values
- Maximum request body size: 1MB
- Nested objects/arrays are rejected for scalar types (string, int, etc.)
Every request gets a unique ID for end-to-end tracing.
If your Spring Boot service sends a request ID, sql-proxy will use it:
# Spring Boot sends its trace ID
curl -H "X-Request-ID: spring-trace-abc123" http://localhost:8081/api/machinesThe service checks these headers (in order):
X-Request-IDX-Correlation-ID
If neither is provided, a new ID is generated.
The request ID appears in:
- Response JSON:
"request_id": "spring-trace-abc123" - Response header:
X-Request-ID: spring-trace-abc123 - All log entries for that request
# Find all log entries for a request
grep "spring-trace-abc123" sql-proxy.logResponses are automatically gzip-compressed when the client sends Accept-Encoding: gzip:
# Without compression
curl http://localhost:8081/api/machines
# Response: ~50KB
# With compression
curl -H "Accept-Encoding: gzip" http://localhost:8081/api/machines | gunzip
# Response: ~8KB (compressed)Most HTTP clients (including Spring's RestTemplate/WebClient) send this header by default.
your-domain.com {
handle /sqlproxy/* {
uri strip_prefix /sqlproxy
reverse_proxy localhost:8081
}
}
All Databases:
- No arbitrary SQL - Only predefined queries executable
- Parameterized queries - SQL injection safe (uses named parameters)
- HTTPS via Caddy - Encrypt all traffic
- Configurable timeouts - Caller-controlled with server max
SQL Server Specific:
5. Read-only SQL user - db_datareader role only, explicit DENYs
6. Non-blocking reads - READ UNCOMMITTED isolation
7. Lock timeout (5s) - Fails fast if lock needed
8. Low deadlock priority - Always yields to production app
9. ApplicationIntent=ReadOnly - Enables AG read routing
MySQL Specific:
5. Read-only MySQL user - SELECT privilege only, no write grants
6. TLS encryption - encrypt: "true" for production connections
7. Session READ ONLY - readonly: true sets transaction to read-only mode
8. Lock timeout (5s) - Fails fast via innodb_lock_wait_timeout
SQLite Specific:
5. Read-only mode - readonly: true opens DB in read-only mode
6. File permissions - Ensure appropriate filesystem permissions
7. WAL mode - Better concurrency, readers don't block writers
8. Busy timeout - Prevents immediate failure on lock contention
Windows:
- Check log file:
C:\Services\SQLProxy\logs\sql-proxy.log - Run interactively:
sql-proxy.exe -config config.yaml
Linux:
- Check journal:
journalctl -u sql-proxy -n 50 - Check log file:
tail -50 /var/log/sql-proxy/sql-proxy.log - Run interactively:
./sql-proxy -config config.yaml
macOS:
- Check logs:
tail -100 /usr/local/var/log/sql-proxy/sql-proxy.err - Run interactively:
./sql-proxy -config config.yaml
All platforms:
- Verify config.yaml syntax with a YAML validator
- Check file permissions on config and log directories
- Check
/_/healthendpoint for status - Look for
health_check_failedin logs - Verify security group allows port 1433
- Check credentials in config
- Check
/_/healthendpoint for status - Verify security group/firewall allows port 3306
- Check credentials and database name in config
- Verify TLS settings match server configuration (
encrypt: "true"or"skip-verify") - Check
max_connectionson MySQL server if getting connection pool exhaustion
"database is locked" errors:
- Increase
busy_timeout_ms(default 5000ms may not be enough for high contention) - Check if another process has the database open exclusively
- Ensure WAL mode is enabled (
journal_mode: "wal") - Reduce write concurrency if possible
"unable to open database file":
- Check file path is correct and accessible
- Verify directory exists and has write permissions
- For in-memory (
:memory:), no path issues should occur
WAL file growing large:
- WAL checkpoints automatically every 1000 pages
- Manually checkpoint:
PRAGMA wal_checkpoint(TRUNCATE) - Check for long-running read transactions blocking checkpoints
Performance issues:
- Ensure WAL mode is enabled for concurrent reads
- Check
cache_sizepragma (default: 64MB) - Consider
mmap_sizefor large databases - Add indexes for frequently-queried columns
- Check
/_/metrics.jsonforavg_duration_msandmax_duration_ms - Look for
slow_querywarnings in logs - Consider adding indexes on SQL Server side
- Increase
timeout_secfor known slow queries
- Logs rotate automatically, but check
max_backupssetting - Compressed logs use
.gzextension
- Most changes require service restart
- Log level can be changed at runtime via
/_/config/loglevel
Windows:
sql-proxy.exe -stop
copy /Y sql-proxy-new.exe C:\Services\SQLProxy\sql-proxy.exe
sql-proxy.exe -start
curl http://localhost:8081/_/healthLinux:
sudo systemctl stop sql-proxy
sudo cp sql-proxy-new /opt/sql-proxy/sql-proxy
sudo chown sqlproxy:sqlproxy /opt/sql-proxy/sql-proxy
sudo systemctl start sql-proxy
curl http://localhost:8081/_/healthmacOS:
sudo launchctl unload /Library/LaunchDaemons/com.sqlproxy.plist
sudo cp sql-proxy-new /usr/local/bin/sql-proxy
sudo launchctl load /Library/LaunchDaemons/com.sqlproxy.plist
curl http://localhost:8081/_/healthFor config changes only, just restart the service (no binary replacement needed).
Before deploying to production:
./sql-proxy -validate -config config.yaml./sql-proxy -config config.yaml
# In another terminal:
curl http://localhost:8081/_/health
curl http://localhost:8081/
curl "http://localhost:8081/api/your-endpoint?param=value"Windows (as Administrator):
sql-proxy.exe -install -config C:\Services\SQLProxy\config.yaml
sql-proxy.exe -start
sql-proxy.exe -statusLinux:
# Generate systemd unit file from template
/opt/sql-proxy/sql-proxy -install -config /opt/sql-proxy/config.yaml
# Follow printed instructions, then:
sudo systemctl daemon-reload
sudo systemctl enable sql-proxy
sudo systemctl start sql-proxy
sudo systemctl status sql-proxymacOS:
# Generate launchd plist from template
/usr/local/bin/sql-proxy -install -config /usr/local/etc/sql-proxy/config.yaml
# Follow printed instructions, then:
sudo launchctl load /Library/LaunchDaemons/com.sqlproxy.sql-proxy.plistcurl http://localhost:8081/_/health
curl http://localhost:8081/_/metrics.jsonCheck logs:
- Windows:
type C:\Services\SQLProxy\logs\sql-proxy.log - Linux:
journalctl -u sql-proxy -fortail -f /var/log/sql-proxy/sql-proxy.log - macOS:
tail -f /usr/local/var/log/sql-proxy/sql-proxy.log
- Caddy/nginx in front: Don't expose sql-proxy directly to the internet
- Monitor
/_/health: Set up alerting on unhealthy status - Review metrics: Check
/_/metrics.jsonendpoint for slow queries - Log level: Use
infoin production,debugonly for troubleshooting - Backup config: Keep config.yaml in version control
The project includes comprehensive tests at multiple levels:
- Unit tests - Test individual functions and methods in isolation
- Integration tests - Test component interactions using
httptest(in-process HTTP server) - End-to-end tests - Start the actual binary as a subprocess and test via real HTTP
# Run all tests (unit + integration)
make test
# Run by test type
make test-unit # Unit tests only (internal packages)
make test-integration # Integration tests (httptest-based)
make test-e2e # All end-to-end test apps
# Run individual E2E apps
make test-e2e-taskapp # Task management app
make test-e2e-crmapp # CRM app (auth, roles, relationships)
make test-e2e-shopapp # E-commerce app (state machines, inventory)
make test-e2e-blogapp # Blog/CMS app (content, comments, search)
# Run by package
make test-db
make test-server
make test-config
make test-workflow
# Run with coverage (unit + e2e)
make test-cover
# Run benchmarks
make test-benchThe E2E test suite includes four demo applications that comprehensively exercise sql-proxy features:
| App | Config | Focus Areas |
|---|---|---|
| taskapp | testdata/taskapp.yaml |
Basic CRUD, caching, rate limiting, batch operations |
| crmapp | testdata/crmapp.yaml |
Auth, role-based access, relationships, multiple rate limits |
| shopapp | testdata/shopapp.yaml |
State machines, order workflows, inventory, JSON columns |
| blogapp | testdata/blogapp.yaml |
Content hierarchy, nested comments, search, pagination |
Each test app is a shell script with human-friendly colored output:
# Run individual apps
./e2e/taskapp_test.sh
./e2e/crmapp_test.sh
./e2e/shopapp_test.sh
./e2e/blogapp_test.sh
# Run with coverage
./e2e/taskapp_test.sh --cover
# Run with custom coverage directory
E2E_COVERAGE_DIR=my-coverage ./e2e/taskapp_test.sh --coverThe test apps share common infrastructure in e2e/lib/:
helpers.sh- HTTP wrappers, assertions, colored outputrunner.sh- Server lifecycle, coverage collection, config templating
See TESTS.md for a complete list of all tests with descriptions.
To regenerate test documentation after adding/modifying tests:
make test-docs| Type | Location | Description |
|---|---|---|
| Unit tests | internal/*/ |
Test individual functions and methods |
| Integration tests | internal/server/ |
Test component interactions via httptest |
| End-to-end tests | e2e/*_test.sh |
Shell scripts, start binary, make real HTTP requests |
| E2E configs | testdata/*.yaml |
App configurations for E2E tests |
| E2E shared lib | e2e/lib/ |
Shared test infrastructure |
| Benchmarks | internal/*/benchmark_test.go |
Performance tests |
All unit and integration tests use SQLite in-memory databases (:memory:) to avoid external dependencies.
Planned features for future releases:
- MySQL Support - Add MySQL/MariaDB as a database backend option alongside SQL Server and SQLite.
- PostgreSQL Support - Add PostgreSQL as a database backend option.
- TLS Support - Native HTTPS termination without requiring a reverse proxy (Caddy/nginx). Will support configurable certificate paths and automatic Let's Encrypt integration.
- Rate Limiting - Per-endpoint and per-client rate limiting to protect database resources from excessive requests.
- Workflows - Multi-step query pipelines with conditional execution, iteration, and external API calls.
- Authentication - API key and/or JWT-based authentication for endpoint access control.