Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,38 @@ import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import site from './site.config.json' with { type: 'json' };
import { redirectPaths } from './src/data/redirects.ts';
import { postEngine } from './src/lib/posts.ts';
import { readFileSync, readdirSync } from 'node:fs';

// Read from the posts themselves rather than a generated list: a second copy of
// this mapping is exactly the drift CLAUDE.md warns about. `updatedAt` wins when
// a post declares one, otherwise the publication date is the last time it
// changed.
const POSTS = './outstatic/content/posts';
const postDates = Object.fromEntries(
readdirSync(POSTS)
.filter((f) => f.endsWith('.md'))
.map((f) => {
const front = readFileSync(`${POSTS}/${f}`, 'utf8').split('---')[1] ?? '';
const pick = (key) => new RegExp(`^${key}:\\s*['"]?([0-9T:.Z+-]+)`, 'm').exec(front)?.[1];
return [f.replace(/\.md$/, ''), pick('updatedAt') ?? pick('publishedAt')];
})
.filter(([, date]) => Boolean(date)),
);

// Custom domain (libredb.org) => the site is served from the root, so `base`
// stays at its default. Setting it would double-prefix every asset and route;
// `base` is only for a GitHub *project* page (user.github.io/repo).
export default defineConfig({
site: site.url,
output: 'static',
trailingSlash: 'ignore',

// 'ignore' let links, canonicals and the sitemap disagree: the build emits
// directories, so the host serves `/features/` and 301s `/features` to it.
// Every internal link spent a redirect and every canonical pointed at one.
// 'always' makes the dev server agree with the deployed host, and
// `pagePath()` in src/lib/site.ts is what writes the slash into links.
trailingSlash: 'always',

// Astro 7 defaults compressHTML to 'jsx', which strips newline-containing
// whitespace between inline elements. That silently welded the hero headline
Expand All @@ -29,6 +53,34 @@ export default defineConfig({
sitemap({
filter: (page) =>
!page.includes('/404') && !redirectPaths.some((p) => new URL(page).pathname.replace(/\/$/, '') === p),

// lastmod only where a date is actually known — the posts' own front
// matter. Stamping every URL with the build date would tell Google the
// whole site changed on every deploy, which is the fastest way to have
// the signal ignored. Marketing pages carry no date and get none.
serialize: (item) => {
const path = new URL(item.url).pathname;

// An engine archive is as fresh as its newest post: it changes when one
// is published and at no other time.
const engine = /^\/blog\/engine\/([^/]+)\/?$/.exec(path)?.[1];
if (engine) {
// `postEngine` is the one place that reads an engine out of a slug;
// re-deriving it here is the drift this file already warns about, and
// it would miss the longest-match rule that keeps `sqlite` from
// claiming a `sqlserver-` post.
const dates = Object.entries(postDates)
.filter(([slug]) => postEngine(slug) === engine)
.map(([, d]) => d)
.sort();
const newest = dates.at(-1);
return newest ? { ...item, lastmod: newest } : item;
}

const slug = /^\/blog\/([^/]+)\/?$/.exec(path)?.[1];
const date = slug ? postDates[slug] : undefined;
return date ? { ...item, lastmod: date } : item;
},
}),
],

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ A Russian technology site ran a piece about LibreDB Studio, and a reader in the
One UI for sixteen engines, they asked, so is there really a written interface for each kind of database, for editing tables and columns?

The answer is that there is one UI and no per-engine interface: an abstract provider class with thirteen required methods, one file per type id, and a UI that branches on what a provider publishes about itself rather than on the engine's name.
That is why MongoDB's tree says Collection and document, Redis says Key Pattern and key, and the Cassandra editor is labelled CQL with no JOIN, no subquery and no OFFSET.
That is why MongoDB's tree says Collection and document, Redis says Key Pattern and key, and the [Cassandra](/blog/engine/cassandra/) editor is labelled CQL with no JOIN, no subquery and no OFFSET.
The create-table form appears where a provider declares `supportsCreateTable`, and where a provider does not declare it the button is simply absent rather than present and broken.

Then they pushed on exactly the right spot.
Expand All @@ -30,11 +30,11 @@ Then they pushed on exactly the right spot.

We had not solved it.
The form declared a `dbType` prop, the workspace passed the active connection's type into it, and the component never destructured it.
The prop was dead, so one form emitted one dialect for all eight engines, and that dialect was PostgreSQL's: the default column was `id SERIAL PRIMARY KEY` and the type list offered `JSONB`.
The prop was dead, so one form emitted one dialect for all eight engines, and that dialect was [PostgreSQL](/blog/engine/postgresql/)'s: the default column was `id SERIAL PRIMARY KEY` and the type list offered `JSONB`.

## The part that made it a bug rather than an inconvenience

On SQL Server, Oracle and Trino, `SERIAL` does not parse.
On [SQL Server](/blog/engine/sqlserver/), Oracle and Trino, `SERIAL` does not parse.
The form previews its SQL above the button, so a user saw a statement fail and knew why.
That is bad, and it is honest.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,38 +17,14 @@ tags:
publishedAt: 2026-09-13T09:00:00.000Z
---

> **Author:** Cevheri & The LibreDB Studio Engineering Team
> **Topic:** Software Architecture / Database Engineering / TypeScript & Node.js / AI Safety
> **Target Audience:** Senior Software Engineers, Systems Architects, and Technical Leads

---

## Table of Contents

1. [Introduction: The Missing SPI in Modern Runtimes](#introduction-the-missing-spi-in-modern-runtimes)
2. [The Problem Statement](#the-problem-statement)
3. [Architecture Overview: The `DatabaseProvider` SPI & Adapter Pattern](#architecture-overview-the-databaseprovider-spi--adapter-pattern)
4. [Deep Dive: Resolving Core Engineering Challenges](#deep-dive-resolving-core-engineering-challenges)
- [Challenge 1: Zero-Overhead Dynamic Module Loading](#challenge-1-zero-overhead-dynamic-module-loading)
- [Challenge 2: Unifying Heterogeneous Engine Schemas ("Object Surface API")](#challenge-2-unifying-heterogeneous-engine-schemas-object-surface-api)
- [Challenge 3: AI Agent Isolation & Read-Only Execution Profiles](#challenge-3-ai-agent-isolation--read-only-execution-profiles)
- [Challenge 4: Single-Writer File Locks & SSH Tunnel Forwarding](#challenge-4-single-writer-file-locks--ssh-tunnel-forwarding)
5. [Code Walkthrough & Implementation Details](#code-walkthrough--implementation-details)
- [The Provider Contract (`BaseDatabaseProvider`)](#the-provider-contract-basedatabaseprovider)
- [The Factory & Cache Registry](#the-factory--cache-registry)
- [Engine Adapter Case Studies (PostgreSQL, SQLite, Embedded LibreDB)](#engine-adapter-case-studies)
6. [Key Takeaways & Lessons Learned](#key-takeaways--lessons-learned)

---

## Introduction: The Missing SPI in Modern Runtimes

In mature enterprise ecosystems like Java or .NET, developer tools that interact with databases rely on standardized, runtime-level Service Provider Interfaces (SPIs):

* **Java:** `java.sql.Driver`, `java.sql.Connection`, `java.sql.Statement`, and `java.sql.ResultSet` (JDBC).
* **.NET:** `System.Data.Common.DbConnection`, `DbCommand`, and `DbDataReader` (ADO.NET).

In these environments, database vendors—whether Oracle, PostgreSQL, MySQL, or Microsoft SQL Server—author driver JARs or DLLs that conform strictly to these runtime interfaces. The GUI or client application calls standard APIs without needing to know low-level wire protocol nuances, connection pool nuances, or engine-specific error classes.
In these environments, database vendors—whether Oracle, [PostgreSQL](/blog/engine/postgresql/), MySQL, or Microsoft [SQL Server](/blog/engine/sqlserver/)—author driver JARs or DLLs that conform strictly to these runtime interfaces. The GUI or client application calls standard APIs without needing to know low-level wire protocol nuances, connection pool nuances, or engine-specific error classes.

### The JavaScript / TypeScript Gap

Expand All @@ -59,7 +35,7 @@ Instead, the npm ecosystem contains a fragmented collection of independent commu
* MySQL uses `mysql2`.
* SQLite relies on native bindings like `better-sqlite3`, `bun:sqlite`, or `node:sqlite`.
* Oracle DB relies on `oracledb`.
* NoSQL databases like Redis (`ioredis`), MongoDB (`mongodb`), and Cassandra (`cassandra-driver`) use entirely different paradigms (document descriptors, key-value commands, binary buffers).
* NoSQL databases like Redis (`ioredis`), MongoDB (`mongodb`), and [Cassandra](/blog/engine/cassandra/) (`cassandra-driver`) use entirely different paradigms (document descriptors, key-value commands, binary buffers).

Building a universal, self-hosted database IDE or management platform in TypeScript requires solving this fundamental problem: **How do you build a single, type-safe, performant, and secure application that can interact with 15+ relational, document, key-value, OLAP, and embedded database engines without a unifying runtime SPI?**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ no statement that could create the object the diagram is looking for.
That is recorded in the capability set rather than left to the canvas to imply:
`declaresForeignKeys` is `false`, so a reader knows `foreignKeys: []` means this
engine has none rather than this schema declares none. The [ER diagram
feature](/features) publishes the matching limit on the product side - edges come
feature](/features/) publishes the matching limit on the product side - edges come
from declared foreign keys, and a relationship your application enforces in code
but never declares has nothing to discover.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ refusal covers every SELECT, including one that already carries its own `LIMIT n
and is therefore never rewritten - because the failure mode being avoided is not
the rewrite, it is the duplicate rows.

This is the same rule the [capability declarations](/features) follow everywhere
This is the same rule the [capability declarations](/features/) follow everywhere
else in the product: a control that cannot work is absent with its reason
attached, rather than offered and then quietly wrong. The engine row on the
[databases page](/databases) states the same boundary before you connect.
[databases page](/databases/) states the same boundary before you connect.

## Keeping the filtering clause last when a bound is added

Expand Down
6 changes: 3 additions & 3 deletions outstatic/content/posts/cassandra-local-data-center-field.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ sits, and why there is no box to paste a URL into.
## The four fields, and the fifth one

Host, port, user and password are the four fields most connection forms open on. Cassandra
needs a fifth, and no other engine in [the supported list](/databases) requires it.
needs a fifth, and no other engine in [the supported list](/databases/) requires it.

| Field | Required | What it is |
| --- | --- | --- |
Expand All @@ -45,7 +45,7 @@ supplying them to an open server connects fine. So on a first local node, the tw
user expects to be mandatory are not, and the one they have never seen before is.

The form puts `localDataCenter` in the open rather than behind the Advanced accordion that
holds Oracle's service name. That placement is not a style call. Advanced is where optional
holds [Oracle](/blog/engine/oracle/)'s service name. That placement is not a style call. Advanced is where optional
things go, and a hidden mandatory field is a connection nobody can open: the reader would fill
in everything visible, press Connect, and be told about a field they were never shown. It is
also classified `public` in `connection-secrets.ts` rather than as a secret, because a data
Expand Down Expand Up @@ -111,7 +111,7 @@ Everything here was measured against Apache Cassandra 5.0.9, the official image,
## The keyspace is pinned at connect time

The fourth field deserves the same attention, because it fails at the same moment. The
connection's `database` field pins exactly one keyspace for the session, the way a PostgreSQL
connection's `database` field pins exactly one keyspace for the session, the way a [PostgreSQL](/blog/engine/postgresql/)
connection pins a database.

A keyspace that does not exist fails the **connect**, not the first statement:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Refusing this much is only defensible if the rest are real reads:
two microsecond readings. No user, no keyspace, no client address, so user and
database are reported as `unknown` rather than borrowed from the connected role.

That is the [capability declaration](/features) at work: a control renders from
That is the [capability declaration](/features/) at work: a control renders from
what the provider says it can answer.

## A structural probe instead of matching an error message
Expand Down
8 changes: 4 additions & 4 deletions outstatic/content/posts/cassandra-no-explain-in-cql.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ vocabulary is smaller, and this word is not in it.
An interface has two ways to handle that. It can show the button and let the
server produce the sentence above with a driver stack trace wrapped around it, or
it can not show the button. LibreDB Studio does the second, for the reason set
out in [the capability model behind the feature set](/features): a control that
out in [the capability model behind the feature set](/features/): a control that
cannot work is absent with its reason recorded. A parser error arriving from a
button the interface itself offered reads as a broken statement, or a broken
interface, long before it reads as an engine that has no such feature.
Expand Down Expand Up @@ -152,15 +152,15 @@ each with its own recorded reason. What the query surface does carry:
refused with that reason instead of returning page one again.
- **Agent Plan mode.** It is toolless, executes nothing, and drafts a statement
for a human to run. Agent AUTO mode ends `engine-unsupported` here: its
read-only profile is database-native and exists on PostgreSQL, SQLite and
DuckDB only.
read-only profile is database-native and exists on [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and
[DuckDB](/blog/engine/duckdb/) only.

What is not present, and says so where the number would be: no row count and no
table size anywhere, because `system.size_estimates` counts partitions per token
range from flushed SSTables only, and `system_views.disk_usage` reports whole
mebibytes. No slow-query list either - the threshold writes to the node's log
file rather than to a table, so there is nothing a session can read.

Each of those absences is published on the [engine reference](/databases)
Each of those absences is published on the [engine reference](/databases/)
alongside the transport and port, which is where a limit belongs: before the
evaluation, not twenty minutes into it.
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Two things are absent for the integration rather than for the server, and they a
on both. There is no EXPLAIN — the keyword is not in CQL's grammar, so the button and the
tab are not rendered rather than rendered dead. And Agent AUTO mode ends
`engine-unsupported`, because a tool-using run needs a database-native read-only
statement path, and that exists on PostgreSQL, SQLite and DuckDB only. Agent PLAN mode opens
statement path, and that exists on [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/) only. Agent PLAN mode opens
on the connection as it does everywhere: toolless, executing nothing, drafting a
statement for a person to run.

Expand Down Expand Up @@ -151,6 +151,6 @@ lives in `system.versions`, which this provider does not read.

That is a confusing figure for a human reading a version string, so it is published as
what it is rather than swapped for a friendlier one, and the caveat travels with it on
the [engine pages](/databases). The [capability declarations behind those
panels](/features) are data rather than layout, which is why a panel here can report
the [engine pages](/databases/). The [capability declarations behind those
panels](/features/) are data rather than layout, which is why a panel here can report
absence with a sentence rather than fail the screen it sits on.
8 changes: 4 additions & 4 deletions outstatic/content/posts/clickhouse-explain-json-plan-trees.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ for it - would not narrow the feature, it would switch the feature off: the
direct Explain action always builds with mode `analyze` and refuses to run when
the strategy declines, so the button would simply go dead while only the
background pre-warm still worked. The same call is made for the same reason in
the SQLite and Couchbase strategies.
the [SQLite](/blog/engine/sqlite/) and [Couchbase](/blog/engine/couchbase/) strategies.

That flag is a wiring detail, not a capability. There is still no analyze mode
here: both requests ask for the same estimated plan, and neither of them runs
Expand All @@ -114,7 +114,7 @@ SELECT. A pre-warm on a query that would scan a year of events costs the server
planning pass and nothing else. Nothing was executed in the background, so nothing
in the tree is a timing, in either path.

The [plan viewer's published limit](/features) says the same thing from the other
The [plan viewer's published limit](/features/) says the same thing from the other
side: plan rendering follows the engine, and nothing in the tree is simulated. What
the engine did not report is not drawn.

Expand Down Expand Up @@ -146,11 +146,11 @@ ClickHouse publishes no per-index counter the HTTP interface can reach, and a
guessed number would be worse than an obvious zero.

One more boundary, since a plan tree is where someone often reaches for the
model-backed helper. Agent mode reads PostgreSQL, SQLite and DuckDB only,
model-backed helper. Agent mode reads [PostgreSQL](/blog/engine/postgresql/), SQLite and DuckDB only,
because the read-only profile is database-native and exists only where a provider
implements it; on any other engine a run ends engine-unsupported. Plan mode opens
on every connection - it is toolless, runs nothing, and drafts a statement for you
to run yourself. On a [ClickHouse connection](/databases) that plan run is grounded
to run yourself. On a [ClickHouse connection](/databases/) that plan run is grounded
through the provider's own schema description.

Read the ratios, not the timings. There are no timings.
6 changes: 3 additions & 3 deletions outstatic/content/posts/clickhouse-http-interface-8123.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ the same endpoint, so a second HTTP surface next to `query()` would buy nothing.

Foreign keys are the one thing the catalogs cannot answer, and the list is always
empty. ClickHouse has no foreign-key concept anywhere - no engine, no table
setting, no DDL declares one - so the [engine pages](/databases) state it
setting, no DDL declares one - so the [engine pages](/databases/) state it
directly: ER diagrams here show structure without discovered relations.

## What to publish from a container, and what not to
Expand Down Expand Up @@ -160,11 +160,11 @@ operation with its query id, which needs its own grant like any other
`system.processes` operation.

One boundary that is not about the transport, but belongs next to these: agent
mode reads PostgreSQL, SQLite and DuckDB only, because the read-only profile is
mode reads [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/) only, because the read-only profile is
database-native and exists only where a provider implements it. On any other
engine a run ends `engine-unsupported`. Plan mode opens on every connection - it
is toolless, runs nothing, and drafts a statement for you to run yourself.

To check any of this against a real server, the compose service above is the
shortest path; the [getting started guide](/get-started) covers pointing a Studio
shortest path; the [getting started guide](/get-started/) covers pointing a Studio
container at it.
Loading
Loading