Architecture
Lizard is a single full-stack Next.js app. Every query to any target database — CRUD, AI, or charts — passes through one SQL Guard before it runs.
Request flow
Browser (table browser, AI query box, charts)
→ Next.js API layer (introspect / data / AI / charts services)
→ SQL Guard (parse, single SELECT only, LIMIT, timeout, read-only tx)
→ single connection (native) → target database (read or write role)
→ cross-connection (federated) → DuckDB (ATTACH, read-only) → target databasesTech stack
- App framework: Next.js (App Router) + TypeScript.
- DB driver / query builder:
pgand Kysely — table/column identifiers are only known at runtime, so a compile-time ORM doesn't fit the query layer. - Federation engine: DuckDB embedded in the Node process. It
ATTACHes multiple databases read-only (Postgres and MySQL natively, MongoDB via DuckDB's communitymongoextension) and runs one SQL statement across them, with filters pushed down. Nothing is installed on your databases. - AI: Anthropic (Claude) by default; OpenAI and Google also supported. The model gets a cheap, name-only schema skeleton plus tools it calls on demand — never a full schema dump.
- Charts: auto-picked from the result's own shape — time series, comparison, single value, or a plain table when nothing's chartable.
- Metadata store: a local SQLite file holding connections, overrides, virtual relationships, dashboards, and the audit log. Lizard never writes to the target database's schema.
The layer between the app and your databases
With a database per microservice, the interesting engineering isn't any single query — it's making sure nothing in the process scales with the size of the fleet.
- Connection pools expire. Pools are keyed by connection and role and swept on access; one untouched for ten minutes is closed and forgotten, so browsing hundreds of databases doesn't leave a live pool behind for each.
- The schema catalog is an LRU bounded by column count (not by number of connections), served stale-while-revalidate: past its TTL you get the last known schema immediately while re-introspection runs behind you. Schema edits made through Lizard invalidate it directly.
- Introspection is scoped and fanned out with a ceiling — an explicit list of connections, eight at a time, each with its own timeout, so one unreachable database is a single error entry rather than a stalled request.
- Session timezone is set on the connection, lazily and only when it changes, on Postgres, MySQL, and DuckDB alike — and folded into the query cache key so two zones never share a cached result.
How AI querying works
The model is never handed your whole schema. It gets a name-only skeleton (every connection, schema, and table — no columns) and three tools it calls on demand:
search_schema— fuzzy-match table/column names and comments fleet-wide.describe_table— full column detail for exactly one table.get_relations— real and virtual foreign keys touching one table, in either direction.
It settles the plan by calling a terminal submit_plan tool, validated and executed through the same Guard/read-only-role path as everything else. Every step streams to the UI live, and a Stop button cancels the request end to end — it aborts the in-flight LLM call and cancels the database connection it's running against.
By default the AI only joins tables with a real foreign key or a declared virtual relationship — it won't guess a join from matching column names. A cross-database join always requires a declared virtual relationship, since there's no way to verify a guessed one across separate databases.
Cross-database queries (federation)
Postgres can join across schemas natively but not across separate databases, and MySQL/MongoDB never share a server with anything else. For questions and charts that span connections — or involve MongoDB at all — Lizard spins up an embedded DuckDB, attaches each involved database read-only with its lizard_read credentials, and runs one SQL statement across them.
Since microservice databases have no real foreign keys between them, you declare virtual relationships (e.g. orders.customer_id → users_service.public.customers.id) in the table "Customize" panel. These power cross-database reference pickers in the UI and are the only way the AI is allowed to join tables across connections.