diff --git a/astro.config.mjs b/astro.config.mjs
index e425cf7..a23f74f 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -3,6 +3,24 @@ 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;
@@ -10,7 +28,13 @@ import { redirectPaths } from './src/data/redirects.ts';
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
@@ -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;
+ },
}),
],
diff --git a/outstatic/content/posts/a-comment-found-a-bug-coverage-did-not.md b/outstatic/content/posts/a-comment-found-a-bug-coverage-did-not.md
index e7355af..712ccd1 100644
--- a/outstatic/content/posts/a-comment-found-a-bug-coverage-did-not.md
+++ b/outstatic/content/posts/a-comment-found-a-bug-coverage-did-not.md
@@ -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.
@@ -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.
diff --git a/outstatic/content/posts/building-universal-database-provider-typescript.md b/outstatic/content/posts/building-universal-database-provider-typescript.md
index 8061132..161ea4e 100644
--- a/outstatic/content/posts/building-universal-database-provider-typescript.md
+++ b/outstatic/content/posts/building-universal-database-provider-typescript.md
@@ -17,30 +17,6 @@ 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):
@@ -48,7 +24,7 @@ In mature enterprise ecosystems like Java or .NET, developer tools that interact
* **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
@@ -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?**
diff --git a/outstatic/content/posts/cassandra-column-order-and-no-foreign-keys.md b/outstatic/content/posts/cassandra-column-order-and-no-foreign-keys.md
index 693c0a2..a30c0d5 100644
--- a/outstatic/content/posts/cassandra-column-order-and-no-foreign-keys.md
+++ b/outstatic/content/posts/cassandra-column-order-and-no-foreign-keys.md
@@ -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.
diff --git a/outstatic/content/posts/cassandra-cql-paging-and-cancellation.md b/outstatic/content/posts/cassandra-cql-paging-and-cancellation.md
index 036ac37..832d458 100644
--- a/outstatic/content/posts/cassandra-cql-paging-and-cancellation.md
+++ b/outstatic/content/posts/cassandra-cql-paging-and-cancellation.md
@@ -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
diff --git a/outstatic/content/posts/cassandra-local-data-center-field.md b/outstatic/content/posts/cassandra-local-data-center-field.md
index 3d7d08d..c4dedaa 100644
--- a/outstatic/content/posts/cassandra-local-data-center-field.md
+++ b/outstatic/content/posts/cassandra-local-data-center-field.md
@@ -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 |
| --- | --- | --- |
@@ -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
@@ -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:
diff --git a/outstatic/content/posts/cassandra-monitoring-panels-that-refuse.md b/outstatic/content/posts/cassandra-monitoring-panels-that-refuse.md
index ae8027f..7654d02 100644
--- a/outstatic/content/posts/cassandra-monitoring-panels-that-refuse.md
+++ b/outstatic/content/posts/cassandra-monitoring-panels-that-refuse.md
@@ -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
diff --git a/outstatic/content/posts/cassandra-no-explain-in-cql.md b/outstatic/content/posts/cassandra-no-explain-in-cql.md
index 6d238ab..70a63f7 100644
--- a/outstatic/content/posts/cassandra-no-explain-in-cql.md
+++ b/outstatic/content/posts/cassandra-no-explain-in-cql.md
@@ -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.
@@ -152,8 +152,8 @@ 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
@@ -161,6 +161,6 @@ 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.
diff --git a/outstatic/content/posts/cassandra-scylladb-through-one-provider.md b/outstatic/content/posts/cassandra-scylladb-through-one-provider.md
index 351d682..5fce4f0 100644
--- a/outstatic/content/posts/cassandra-scylladb-through-one-provider.md
+++ b/outstatic/content/posts/cassandra-scylladb-through-one-provider.md
@@ -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.
@@ -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.
diff --git a/outstatic/content/posts/clickhouse-explain-json-plan-trees.md b/outstatic/content/posts/clickhouse-explain-json-plan-trees.md
index 59278af..9d498d0 100644
--- a/outstatic/content/posts/clickhouse-explain-json-plan-trees.md
+++ b/outstatic/content/posts/clickhouse-explain-json-plan-trees.md
@@ -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
@@ -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.
@@ -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.
diff --git a/outstatic/content/posts/clickhouse-http-interface-8123.md b/outstatic/content/posts/clickhouse-http-interface-8123.md
index 84248d5..264b943 100644
--- a/outstatic/content/posts/clickhouse-http-interface-8123.md
+++ b/outstatic/content/posts/clickhouse-http-interface-8123.md
@@ -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
@@ -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.
diff --git a/outstatic/content/posts/clickhouse-monitoring-system-tables.md b/outstatic/content/posts/clickhouse-monitoring-system-tables.md
index b20245b..beabd48 100644
--- a/outstatic/content/posts/clickhouse-monitoring-system-tables.md
+++ b/outstatic/content/posts/clickhouse-monitoring-system-tables.md
@@ -62,7 +62,7 @@ The Queries panel does not list executions. It lists statement shapes.
`system.query_log` is filtered to `type = 'QueryFinish'` and grouped by
`normalized_query_hash`, so one row is one statement shape with its call count and
its minimum, maximum and average duration. That is the same grouping
-`pg_stat_statements` performs on PostgreSQL, and it is the grouping that makes the
+`pg_stat_statements` performs on [PostgreSQL](/blog/engine/postgresql/), and it is the grouping that makes the
panel readable: a dashboard query fired ten thousand times with the same literals
substituted is one row worth reading, not ten thousand rows worth scrolling.
@@ -126,7 +126,7 @@ finding of this provider, not general folklore.
The practical shape of a restricted user, then: the schema tree and the overview
survive, and only the panels and maintenance operations that need their own grant
go quiet. Which panel went quiet tells you which grant is missing. It is the same
-rule the [engine list](/databases) states — a control that cannot work is hidden,
+rule the [engine list](/databases/) states — a control that cannot work is hidden,
not offered and then failed — applied to a surface that degrades panel by panel
rather than all at once.
@@ -155,5 +155,5 @@ them correctly:
Storage numbers here are as good as `system.parts` and `system.disks` are, and no
better. The panel reports the engine's own catalog, and where the catalog is silent
it says zero or unknown rather than filling the gap. Each engine's published
-boundary sits on the [engine list](/databases) next to its transport and default
+boundary sits on the [engine list](/databases/) next to its transport and default
port, so it is readable before you connect rather than after.
diff --git a/outstatic/content/posts/clickhouse-no-foreign-keys-diagram.md b/outstatic/content/posts/clickhouse-no-foreign-keys-diagram.md
index 28b0f62..89c0c29 100644
--- a/outstatic/content/posts/clickhouse-no-foreign-keys-diagram.md
+++ b/outstatic/content/posts/clickhouse-no-foreign-keys-diagram.md
@@ -27,7 +27,7 @@ different query would have found.
The diagram in LibreDB Studio is discovered rather than drawn. `getSchema()`
returns a `foreignKeys` list per table, and each entry becomes one edge, laid out
hierarchically by ELK.js. That list is the entire input. The [ER diagram
-feature](/features) publishes the consequence beside the claim: a relationship
+feature](/features/) publishes the consequence beside the claim: a relationship
your application enforces in code but never declares in the schema has nothing to
discover, and will not appear.
@@ -83,7 +83,7 @@ relation list is always empty, because this engine has no foreign-key concept
anywhere, so the diagram shows structure without discovered relations.** No
permission fixes it, no DDL adds it, and no reorganisation of your tables turns
the edges on. It is the published boundary for this engine on the [engine
-matrix](/databases), written next to its transport and default port rather than
+matrix](/databases/), written next to its transport and default port rather than
discovered at runtime.
## What the structure map is still worth
diff --git a/outstatic/content/posts/clickhouse-row-editing-alter-update.md b/outstatic/content/posts/clickhouse-row-editing-alter-update.md
index 83fcf6d..412fc8b 100644
--- a/outstatic/content/posts/clickhouse-row-editing-alter-update.md
+++ b/outstatic/content/posts/clickhouse-row-editing-alter-update.md
@@ -38,7 +38,7 @@ changed column, and a `WHERE` clause identifying the row.
UPDATE events SET status = 'closed' WHERE id = 41;
```
-On PostgreSQL that runs. On ClickHouse it does not, and it does not fail quietly at
+On [PostgreSQL](/blog/engine/postgresql/) that runs. On ClickHouse it does not, and it does not fail quietly at
the edge of the driver either, because there is no driver. The ClickHouse provider
carries no dependency at all: every statement is the body of a `POST /` on the
documented HTTP interface, default port `8123`, answered through the runtime's own
@@ -81,7 +81,7 @@ cluster.
So the provider declares `supportsInlineRowEdit: false`, and the interface renders
from that declaration. Neither the EDIT toggle nor an editable cell appears on a
-ClickHouse connection. The [capability declarations behind each feature](/features)
+ClickHouse connection. The [capability declarations behind each feature](/features/)
are data rather than layout, which is why a missing control here is a stated
absence rather than a gap.
@@ -142,7 +142,7 @@ count from the predicate, and it does not suppress the zero to avoid the questio
A fabricated two is worse than an honest zero, because the zero is falsifiable and
the two is not.
-The [ClickHouse engine page](/databases) carries the transport and default port for
+The [ClickHouse engine page](/databases/) carries the transport and default port for
this connection. The boundary behind both halves of this post is stated here:
**inline row editing is not offered on ClickHouse, because a bare update answers
code `48` `NOT_IMPLEMENTED`, and the documented alternative reports zero rows
diff --git a/outstatic/content/posts/clickhouse-tls-platform-trust-store.md b/outstatic/content/posts/clickhouse-tls-platform-trust-store.md
index 7a5f7cf..745d401 100644
--- a/outstatic/content/posts/clickhouse-tls-platform-trust-store.md
+++ b/outstatic/content/posts/clickhouse-tls-platform-trust-store.md
@@ -97,7 +97,7 @@ of the scheme.
So the failure is a boundary, not a misconfiguration. Nothing you type into the
connection form will make a self-signed node verify, and the app does not offer a
checkbox that pretends otherwise - the same rule that governs every control on
-[the engine pages](/databases), where what an engine deliberately cannot do is
+[the engine pages](/databases/), where what an engine deliberately cannot do is
published beside what it can.
## What that leaves for a self-signed deployment
@@ -135,7 +135,7 @@ There is a third shape that is not a workaround but is worth naming, because it
the deployment this product is built around: the app runs beside the database, on
the same private network, and the link between them never leaves it. TLS on that hop
is a decision about your own network rather than an unavoidable requirement, and the
-[security page](/security) sets out what is protected where when it is made either
+[security page](/security/) sets out what is protected where when it is made either
way.
For a local instance the question does not arise at all. The pinned compose service
diff --git a/outstatic/content/posts/couchbase-bucket-and-discovered-query-port.md b/outstatic/content/posts/couchbase-bucket-and-discovered-query-port.md
index 5de3ecb..4b0a2b9 100644
--- a/outstatic/content/posts/couchbase-bucket-and-discovered-query-port.md
+++ b/outstatic/content/posts/couchbase-bucket-and-discovered-query-port.md
@@ -64,7 +64,7 @@ So a connection without one is refused rather than guessed at, with this message
Couchbase requires a bucket (use the "database" field)
```
-Below the bucket, the flattening follows the rule PostgreSQL already established
+Below the bucket, the flattening follows the rule [PostgreSQL](/blog/engine/postgresql/) already established
for schema and table. The default scope is implicit, everything else is qualified:
```text
@@ -144,7 +144,7 @@ docker exec cb couchbase-cli bucket-create -c 127.0.0.1 \
```
Then point a connection at `127.0.0.1:8091` with bucket `travel`. The
-[get started guide](/get-started) covers the container side in general;
+[get started guide](/get-started/) covers the container side in general;
`--storage-backend couchstore` and `--bucket-replica 0` are the Couchbase-specific
part, and both are required.
@@ -178,7 +178,7 @@ projection alias `__id`, which the shared editor's primary-key heuristic would
turn into `WHERE __id = ''` - a predicate no document satisfies, so the edit
would match zero documents and still report success. And Agent AUTO mode ends
`engine-unsupported` here, because the read-only profile it runs under is
-database-native and only PostgreSQL, SQLite and DuckDB implement it. Agent PLAN
+database-native and only PostgreSQL, [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/) implement it. Agent PLAN
mode opens on this connection like any other: it runs no statement of yours,
writes nothing, and hands every statement it drafts to you to run. On Couchbase
its grounding infers field names from a sample of your own documents rather than
diff --git a/outstatic/content/posts/couchbase-capella-connection.md b/outstatic/content/posts/couchbase-capella-connection.md
index b59142c..1f440d9 100644
--- a/outstatic/content/posts/couchbase-capella-connection.md
+++ b/outstatic/content/posts/couchbase-capella-connection.md
@@ -51,7 +51,7 @@ set from the scheme, a `couchbases://` paste posted plain HTTP to port 18091,
which is a connection failure that looks like a network problem and is not one.
`require` and not a verifying mode, for the same reason it is `require` on
-PostgreSQL and MySQL here: a self-hosted Couchbase node ships a self-signed
+[PostgreSQL](/blog/engine/postgresql/) and [MySQL](/blog/engine/mysql/) here: a self-hosted Couchbase node ships a self-signed
certificate, and a default that refused it would be a default nobody could use on
their own cluster. Capella is the case where you should change it. Its
certificate is signed by a public root, so `verify-system` verifies against the
@@ -167,6 +167,6 @@ callers opt out per statement with `{ scanConsistency: 'not_bounded' }`.
Capella is the cloud vendor this provider is documented against. Its management
APIs - allowed-IP administration, cluster provisioning - are not covered here,
and neither are Analytics, Full-Text Search or Eventing. The transport and the
-default port for this engine are on the [engine list](/databases), and the
+default port for this engine are on the [engine list](/databases/), and the
container that has to sit close enough to the cluster to reach it at all is the
-subject of [getting started](/get-started).
+subject of [getting started](/get-started/).
diff --git a/outstatic/content/posts/couchbase-monitoring-catalog-role.md b/outstatic/content/posts/couchbase-monitoring-catalog-role.md
index 7f683be..5b7facb 100644
--- a/outstatic/content/posts/couchbase-monitoring-catalog-role.md
+++ b/outstatic/content/posts/couchbase-monitoring-catalog-role.md
@@ -87,7 +87,7 @@ through the maintenance `kill` operation, which is
`DELETE FROM system:active_requests WHERE requestId = $1` and takes the request
id shown in the sessions panel. No sessions panel, no request id, no kill. The
maintenance toolkit and the audit trail are admin-only in any case, which is
-described alongside the other [access boundaries](/security) the deployment
+described alongside the other [access boundaries](/security/) the deployment
publishes.
## Omitted rather than zero, and the bug behind that rule
@@ -170,4 +170,4 @@ those zeroes into a total that was quietly wrong.
"not measured" unless you know the account holds the catalog role. That is the
one seam where the omission rule could not be applied, and it is stated here
rather than smoothed over - the same way the rest of the
-[capability model](/features) names what each engine cannot answer.
+[capability model](/features/) names what each engine cannot answer.
diff --git a/outstatic/content/posts/couchbase-plan-tree-estimates.md b/outstatic/content/posts/couchbase-plan-tree-estimates.md
index 9f474bf..7d30a1e 100644
--- a/outstatic/content/posts/couchbase-plan-tree-estimates.md
+++ b/outstatic/content/posts/couchbase-plan-tree-estimates.md
@@ -75,7 +75,7 @@ comparing two plans would be comparing two truncations.
either shape - an array of operators or a single operator - before walking into
it. The rendered result is the same `{ kind: "tree" }` model every other engine's
plan renders into, which is why the Explain panel looks the same here as it does
-elsewhere in [the interface](/features) while the parsing underneath is
+elsewhere in [the interface](/features/) while the parsing underneath is
engine-specific.
## Why there is no analyze form to compare with
@@ -146,5 +146,5 @@ empty rather than zeroed.
Couchbase states the missing case explicitly, with `-1`, rather than leaving the
field out. The work on this side was to keep that statement intact instead of
rendering it as a number. What each engine does and does not do here is published
-per engine on [the databases page](/databases), beside its transport and its
+per engine on [the databases page](/databases/), beside its transport and its
default port.
diff --git a/outstatic/content/posts/couchbase-sqlpp-document-key.md b/outstatic/content/posts/couchbase-sqlpp-document-key.md
index edb5e0c..effa92e 100644
--- a/outstatic/content/posts/couchbase-sqlpp-document-key.md
+++ b/outstatic/content/posts/couchbase-sqlpp-document-key.md
@@ -94,7 +94,7 @@ absent rather than offered and then quietly useless. Addressing a document needs
`META(d).id` or `USE KEYS`, which is per-dialect statement building rather than
a shared template. Until that exists, a document is edited with a hand-written
SQL++ statement. The same rule governs every other absent control across
-[the capability surface](/features): a control that cannot work on the connected
+[the capability surface](/features/): a control that cannot work on the connected
engine is absent rather than present and broken.
Knowing the key also buys you something. `USE KEYS` reads a document with no
@@ -142,8 +142,8 @@ A document store keeps identity in metadata rather than in a column, and an inde
has to catch up with a write before a scan can see it. The other boundaries those
two facts produce here - no transactions over stateless HTTP, no foreign keys to
draw an ER diagram from, EXPLAIN without an analyze mode - are listed on
-[the Couchbase entry in the engine grid](/databases). Agent AUTO mode is a
+[the Couchbase entry in the engine grid](/databases/). Agent AUTO mode is a
separate absence: an auto run ends engine-unsupported on Couchbase, because the
-read-only profile it needs is database-native and exists only on PostgreSQL,
-SQLite and DuckDB. Plan mode opens here, drafts statements and runs none of
+read-only profile it needs is database-native and exists only on [PostgreSQL](/blog/engine/postgresql/),
+[SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/). Plan mode opens here, drafts statements and runs none of
them.
diff --git a/outstatic/content/posts/couchbase-unindexed-collection-scan.md b/outstatic/content/posts/couchbase-unindexed-collection-scan.md
index a42b282..f96a57a 100644
--- a/outstatic/content/posts/couchbase-unindexed-collection-scan.md
+++ b/outstatic/content/posts/couchbase-unindexed-collection-scan.md
@@ -174,7 +174,7 @@ Everything above is the un-indexed story only. The other Couchbase boundaries -
one bucket per connection, no inline row editing, no transactions over stateless
HTTP, no foreign keys and therefore no ER diagram edges, and Agent AUTO mode
ending `engine-unsupported` because the read-only profile is database-native and
-exists only on PostgreSQL, SQLite and DuckDB - are published on
-[the engine pages](/databases), beside what each engine's transport and default
+exists only on [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/) - are published on
+[the engine pages](/databases/), beside what each engine's transport and default
port actually are. The rule that produces those lines, and what it costs to keep,
-is on [the features page](/features).
+is on [the features page](/features/).
diff --git a/outstatic/content/posts/counting-databases.md b/outstatic/content/posts/counting-databases.md
index 19dfc2c..69d7a8e 100644
--- a/outstatic/content/posts/counting-databases.md
+++ b/outstatic/content/posts/counting-databases.md
@@ -32,9 +32,9 @@ because we got it wrong first.
## Three numbers, three denominators
-**Sixteen — engines with a first-class provider.** PostgreSQL, MySQL, Oracle, SQL
-Server, SQLite, libSQL, DuckDB, ClickHouse, Druid, Trino, Cassandra,
-Elasticsearch, OpenSearch, MongoDB, Couchbase and Redis. Each one has a provider
+**Sixteen — engines with a first-class provider.** [PostgreSQL](/blog/engine/postgresql/), MySQL, Oracle, SQL
+Server, SQLite, libSQL, DuckDB, [ClickHouse](/blog/engine/clickhouse/), Druid, Trino, Cassandra,
+[Elasticsearch](/blog/engine/elasticsearch/), OpenSearch, MongoDB, Couchbase and Redis. Each one has a provider
module, a page under `docs/providers/`, and integration tests that run against a
real container. Adding one is weeks of work, and the honest capability line it
carries — what it deliberately cannot do — is written at the same time as the
@@ -122,7 +122,7 @@ re-added from memory in six months by someone who remembers that it connected.
opinion. That is the honest state, and it is different from a failure.
One consequence you can see on this site: the engine grid on
-[the databases page](/databases) shows **seventeen**, not sixteen. The
+[the databases page](/databases/) shows **seventeen**, not sixteen. The
seventeenth is LibreDB's own embedded store, which is a thing you can select in
the connection dialog and therefore belongs in a list of things you can select.
It is not an external engine, so it is outside the sixteen and outside the
diff --git a/outstatic/content/posts/druid-catalog-shows-what-is-servable.md b/outstatic/content/posts/druid-catalog-shows-what-is-servable.md
index 31bd254..b79e86d 100644
--- a/outstatic/content/posts/druid-catalog-shows-what-is-servable.md
+++ b/outstatic/content/posts/druid-catalog-shows-what-is-servable.md
@@ -141,7 +141,7 @@ an interval someone marked unused.
Two things this cannot become. There is no query log to consult afterwards - Druid
keeps none, in no system table, at no endpoint, in no file - so the failed statement
leaves no trace to read later. And an agent cannot go and look for you: agent AUTO mode
-runs only on PostgreSQL, SQLite and DuckDB, because the read-only profile it needs is
+runs only on [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/), because the read-only profile it needs is
database-native, and on a Druid connection an auto run ends `engine-unsupported`. PLAN
mode opens on the connection and will draft the `sys.servers` statement, toolless, for
a human to run.
diff --git a/outstatic/content/posts/druid-plan-tree-without-costs.md b/outstatic/content/posts/druid-plan-tree-without-costs.md
index 94150a6..2958107 100644
--- a/outstatic/content/posts/druid-plan-tree-without-costs.md
+++ b/outstatic/content/posts/druid-plan-tree-without-costs.md
@@ -109,7 +109,7 @@ alternative was available and was rejected: the field is optional, an empty
metrics slot is legal, and filling it with a plausible number would have been the
worst option on the list. A fabricated cost renders identically to a measured one.
-The general description of the [plan tree feature](/features) stops describing
+The general description of the [plan tree feature](/features/) stops describing
Druid at this point. That copy mentions costs laid out so the expensive node is
the one you see first, and it is accurate on the engines that publish a cost.
Druid publishes none, so on this engine the tree answers "what will run" and
diff --git a/outstatic/content/posts/druid-router-or-broker-connection.md b/outstatic/content/posts/druid-router-or-broker-connection.md
index 19afef6..f09ece9 100644
--- a/outstatic/content/posts/druid-router-or-broker-connection.md
+++ b/outstatic/content/posts/druid-router-or-broker-connection.md
@@ -127,7 +127,7 @@ submitting a native batch task with an inline input source to
the concrete reason the Router's extra surface is worth having in a dev cluster.
For a real deployment the rule follows from the architecture this whole product
-is built on: [the tool goes to the data](/get-started), so what you expose is one
+is built on: [the tool goes to the data](/get-started/), so what you expose is one
HTTP port on one process inside the network the cluster already lives in.
Fronting Brokers with the Router or a load balancer is what a Druid deployment
does anyway, and that is exactly the host a connection should point at - which
@@ -163,4 +163,4 @@ datasource that vanished from the tree is an availability question before it is
SQL question.
The rest of what this engine deliberately does not do is published on
-[the engine pages](/databases), next to its transport and default port.
+[the engine pages](/databases/), next to its transport and default port.
diff --git a/outstatic/content/posts/druid-sessions-are-ingestion-tasks.md b/outstatic/content/posts/druid-sessions-are-ingestion-tasks.md
index 3e3b16a..e57a825 100644
--- a/outstatic/content/posts/druid-sessions-are-ingestion-tasks.md
+++ b/outstatic/content/posts/druid-sessions-are-ingestion-tasks.md
@@ -165,7 +165,7 @@ true count and an empty list states it correctly. A denied panel is a different
claim, and the two are not allowed to look alike.
None of this is unique to Druid in kind, only in which panels it hits. The
-[monitoring feature page](/features) states the same boundary in general terms -
+[monitoring feature page](/features/) states the same boundary in general terms -
what a panel can show is bounded by what the engine reports - and the
-[engine pages](/databases) carry each engine's line next to its transport and
+[engine pages](/databases/) carry each engine's line next to its transport and
default port.
diff --git a/outstatic/content/posts/druid-sql-cannot-write.md b/outstatic/content/posts/druid-sql-cannot-write.md
index 1c9ed42..9edf5b3 100644
--- a/outstatic/content/posts/druid-sql-cannot-write.md
+++ b/outstatic/content/posts/druid-sql-cannot-write.md
@@ -68,10 +68,10 @@ parser will reject. `supportsTransactions` is `false`, so the transaction contro
withheld rather than answering HTTP 400, since a language with no DML gives a transaction
nothing to hold.
-This is the rule the whole [capability model](/features) runs on, applied to the engine
+This is the rule the whole [capability model](/features/) runs on, applied to the engine
where it bites hardest: a control that cannot work is absent, with the reason written
where it would have been, rather than present and failing on the server's time. The
-[engine page for Druid](/databases) publishes the same line next to its transport and
+[engine page for Druid](/databases/) publishes the same line next to its transport and
default port, so it is readable before a connection is made rather than after.
## The refusals, quoted as the cluster wrote them
diff --git a/outstatic/content/posts/duckdb-agent-external-access-boundary.md b/outstatic/content/posts/duckdb-agent-external-access-boundary.md
index 301782b..c21da31 100644
--- a/outstatic/content/posts/duckdb-agent-external-access-boundary.md
+++ b/outstatic/content/posts/duckdb-agent-external-access-boundary.md
@@ -40,8 +40,8 @@ The flag was genuinely in force during every measurement below. `INSERT` was
refused throughout. What it does not touch is the process: the DuckDB library is
linked into Studio's own process, holds Studio's own privileges, and its
file-reaching functions are ordinary reads as far as `access_mode` is concerned.
-This is the same class of escape as `VACUUM INTO` on SQLite and `COPY TO PROGRAM`
-on PostgreSQL, both already closed in their own providers.
+This is the same class of escape as `VACUUM INTO` on [SQLite](/blog/engine/sqlite/) and `COPY TO PROGRAM`
+on [PostgreSQL](/blog/engine/postgresql/), both already closed in their own providers.
## What still succeeded under it, measured
@@ -93,7 +93,7 @@ DuckDB *process* on an open file is refused with a lock error even when it asks
for `READ_ONLY`. And the writable editor connection passes neither option, by
design - there `COPY ... TO` and `read_csv_auto('...')` are features, and they
were measured unaffected. The file-reading capability listed for DuckDB on
-[the engine list](/databases) is a property of the editor connection, not of an
+[the engine list](/databases/) is a property of the editor connection, not of an
agent run.
This is also the concrete meaning of a boundary published elsewhere on this
@@ -152,6 +152,6 @@ rather than the process, the boundary is external access disabled at open, and
the SQL denylist is defence in depth with three measured bypasses on record.
One more boundary sits outside DuckDB entirely and is worth carrying into your
threat model with the rest of [what this product publishes about its own
-security](/security): anyone who can create a DuckDB connection chooses a path
+security](/security/): anyone who can create a DuckDB connection chooses a path
on the server's filesystem, so on a shared deployment that right is closer to a
shell on the Studio host than to a database login.
diff --git a/outstatic/content/posts/duckdb-er-diagram-current-database-scope.md b/outstatic/content/posts/duckdb-er-diagram-current-database-scope.md
index 207a3c4..9aff9d3 100644
--- a/outstatic/content/posts/duckdb-er-diagram-current-database-scope.md
+++ b/outstatic/content/posts/duckdb-er-diagram-current-database-scope.md
@@ -82,7 +82,7 @@ per-column relationship records. On a `PRIMARY KEY` row `referenced_table` is
NULL and `referenced_column_names` is `[]`, which is how one read serves both
key kinds without a second statement.
-This is the same rule the [ER diagram feature](/features) states everywhere: the
+This is the same rule the [ER diagram feature](/features/) states everywhere: the
edges are discovered from declared constraints, not inferred. A relationship your
loader enforces in application code and never declares in the schema has nothing
in `duckdb_constraints()` to find, and no edge is drawn for it. That is worth
@@ -138,7 +138,7 @@ The workaround is the connection dialog. A DuckDB connection is a server-local
file path - there is no host, no port and no credential, since `defaultPort` is
`null` and the filesystem is the access control. Adding a second connection
pointed at `/data/side.duckdb` gives that file its own tree and its own diagram.
-The [DuckDB engine page](/databases) carries the transport details for this.
+The [DuckDB engine page](/databases/) carries the transport details for this.
One deployment note goes with that. A DuckDB file admits exactly one operating
system process: a second read-write process is refused with `IO Error: Could not
diff --git a/outstatic/content/posts/duckdb-explain-estimates-only.md b/outstatic/content/posts/duckdb-explain-estimates-only.md
index b3f8457..2ac893e 100644
--- a/outstatic/content/posts/duckdb-explain-estimates-only.md
+++ b/outstatic/content/posts/duckdb-explain-estimates-only.md
@@ -86,7 +86,7 @@ So the Explain action turns the analyze form off for DuckDB rather than
sending it and post-processing whatever comes back. There is no timing data to
show, and none is fabricated. A control that cannot work is absent with its
reason written where it would have been, which is the same rule the rest of
-[the capability model](/features) runs on.
+[the capability model](/features/) runs on.
## Why a later release makes this worse, not better
@@ -167,4 +167,4 @@ which matters more than usual on an engine with no session to `KILL` from a seco
connection.
The full set of what DuckDB answers and what it declines is on
-[its engine page](/databases).
+[its engine page](/databases/).
diff --git a/outstatic/content/posts/duckdb-parquet-csv-editor-versus-agent.md b/outstatic/content/posts/duckdb-parquet-csv-editor-versus-agent.md
index 261268f..74242fb 100644
--- a/outstatic/content/posts/duckdb-parquet-csv-editor-versus-agent.md
+++ b/outstatic/content/posts/duckdb-parquet-csv-editor-versus-agent.md
@@ -95,11 +95,11 @@ one operating-system process: a second read-write process is refused with
`IO Error: Could not set lock on file ...`, and a second `READ_ONLY` process is
refused with the same error. Two Studio replicas pointed at one file is a broken
configuration, not a degraded one. That measurement is why the engine's row on
-[the engines page](/databases) says health shows storage rather than connections.
+[the engines page](/databases/) says health shows storage rather than connections.
## The same statements under the agent handle
-Agent AUTO mode runs on PostgreSQL, SQLite and DuckDB only, because the read-only
+Agent AUTO mode runs on [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and DuckDB only, because the read-only
profile is database-native and exists only where a provider implements it. On any
other engine an auto run ends `engine-unsupported`. PLAN mode is a different
thing entirely: it opens on every connection, holds no tools, executes nothing,
@@ -192,7 +192,7 @@ estimate - after a delete it answered 1,076,480 where `count(*)` answered
**Use PLAN mode where AUTO cannot go.** Plan mode drafts the file-reaching
statement and hands it back for you to run in the editor. Nothing executes, so
nothing has to be sandboxed, and the statement lands on the connection that is
-allowed to run it. The [feature pages](/features) carry the same split for agent
+allowed to run it. The [feature pages](/features/) carry the same split for agent
mode generally.
Two smaller traps worth knowing while you set this up. A catalog is named after
diff --git a/outstatic/content/posts/duckdb-single-process-file-lock.md b/outstatic/content/posts/duckdb-single-process-file-lock.md
index c679223..e26ca6b 100644
--- a/outstatic/content/posts/duckdb-single-process-file-lock.md
+++ b/outstatic/content/posts/duckdb-single-process-file-lock.md
@@ -42,7 +42,7 @@ The word "server-local" is the load-bearing part. The path is resolved on the
machine Studio runs on, not on the machine holding the browser. A remote user of a
hosted deployment cannot point Studio at a file on their own laptop, because there
is nothing to connect to over a network. That constraint is inherited from the
-engine being embedded, and it is the same one the SQLite provider carries.
+engine being embedded, and it is the same one the [SQLite](/blog/engine/sqlite/) provider carries.
## What a second process actually gets
@@ -110,7 +110,7 @@ balancer will send some requests to the replica that holds the lock and some to
one that cannot get it.
Mixed deployments are the case to watch. An instance holding only the networked
-engines on [the engine list](/databases) puts no limit of its own on the replica
+engines on [the engine list](/databases/) puts no limit of its own on the replica
count. Add one DuckDB connection to it and the whole deployment inherits the
constraint, because the DuckDB connection lives wherever the replica lives.
@@ -125,7 +125,7 @@ a database login. Grant it accordingly.
Given all of the above, the agent's read-only access looks like it should be
impossible. It is not, and the reason is exactly the distinction the table draws.
-Agent AUTO mode runs on DuckDB, alongside PostgreSQL and SQLite, because the
+Agent AUTO mode runs on DuckDB, alongside [PostgreSQL](/blog/engine/postgresql/) and SQLite, because the
provider implements a read-only query path. What it opens is a second handle in the
**same process** as the writer, with two engine options fixed at open time:
diff --git a/outstatic/content/posts/duckdb-storage-panels-no-sessions.md b/outstatic/content/posts/duckdb-storage-panels-no-sessions.md
index 08d9449..e914aec 100644
--- a/outstatic/content/posts/duckdb-storage-panels-no-sessions.md
+++ b/outstatic/content/posts/duckdb-storage-panels-no-sessions.md
@@ -79,7 +79,7 @@ That reads as *nothing is running right now*, which is a claim about the current
moment. "This panel can never show a row" is a claim about the engine. They are
different sentences and only one of them is true here.
-The SQLite provider makes the opposite call: it answers the sessions panel with a
+The [SQLite](/blog/engine/sqlite/) provider makes the opposite call: it answers the sessions panel with a
single row describing its own handle. That row would be true on DuckDB too, and it
was still not shipped, because it would be the only row the panel could ever
produce, and *the engine reports one session* is a different claim from *the
@@ -143,8 +143,8 @@ surface — a zero on a size panel is a claim, and the provider will not make on
cannot support.
This is the same rule that decides what appears anywhere else in the product: the
-[capability declarations behind each feature](/features) are data, and the
-[per-engine pages](/databases) publish what each engine deliberately cannot answer
+[capability declarations behind each feature](/features/) are data, and the
+[per-engine pages](/databases/) publish what each engine deliberately cannot answer
next to what it can. On DuckDB that comes to two panels that will never carry a
row, each naming the table function the engine does not publish, and a per-table
byte figure presented as the allocation it measures rather than as the size of the
diff --git a/outstatic/content/posts/elasticsearch-connect-four-fields.md b/outstatic/content/posts/elasticsearch-connect-four-fields.md
index d143ca9..a1072d3 100644
--- a/outstatic/content/posts/elasticsearch-connect-four-fields.md
+++ b/outstatic/content/posts/elasticsearch-connect-four-fields.md
@@ -95,7 +95,7 @@ Two counts in the schema read the same way. Foreign keys are always `[]` and
`declaresForeignKeys` is `false`, so the empty list means impossible here rather
than none declared. `indexCount` is 0 and stays 0, because every mapped field is
inverted-indexed as a property of being mapped, so there is no index object to
-name. The [engine list](/databases) states the short version: no row editing, no
+name. The [engine list](/databases/) states the short version: no row editing, no
ER diagrams.
## There is no connection string either
@@ -108,7 +108,7 @@ and port; the official client takes a `node` URL, which is not a
credential-carrying DSN a shared parser could round-trip.
And `http://` and `https://` are already claimed in the shared connection-string
-parser, where an HTTP URL is the canonical connection target for ClickHouse.
+parser, where an HTTP URL is the canonical connection target for [ClickHouse](/blog/engine/clickhouse/).
Pasting `http://localhost:9200` therefore selects ClickHouse. That consequence is
recorded rather than hidden: `connection-string-parser.ts` is not touched by this
provider, and the connection form's unparseable-string message lists the schemes
@@ -162,7 +162,7 @@ are listed as deliberately absent rather than assumed to work, so treat those
three as untested rather than supported.
Agent AUTO mode - the tool-using run - does not open on this connection at all.
-`queryReadOnly` exists on exactly three providers, PostgreSQL, SQLite and DuckDB,
+`queryReadOnly` exists on exactly three providers, [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and DuckDB,
because there the read-only profile is enforced by the database itself; the
search providers implement none, so an AUTO run ends `engine-unsupported`. The
fact that this grammar cannot write - `INSERT`, `UPDATE`, `DELETE`, `CREATE
diff --git a/outstatic/content/posts/elasticsearch-document-counts-and-empty-metrics.md b/outstatic/content/posts/elasticsearch-document-counts-and-empty-metrics.md
index 6cf7224..c93ef44 100644
--- a/outstatic/content/posts/elasticsearch-document-counts-and-empty-metrics.md
+++ b/outstatic/content/posts/elasticsearch-document-counts-and-empty-metrics.md
@@ -146,13 +146,13 @@ The inverse encoding appears on the same screen and is also correct: `maxConnect
reads a zero maximum as "no limit published" rather than dividing by it.
None of this is specific to search engines. It is the rule the whole [capability
-model](/features) runs on: a figure that cannot be answered on the connected engine is
+model](/features/) runs on: a figure that cannot be answered on the connected engine is
absent with the reason written where it would have been, not rendered as a confident
-zero. The [engine pages](/databases) publish those boundaries before you connect.
+zero. The [engine pages](/databases/) publish those boundaries before you connect.
Two more, in the same spirit. The SQL surface here has no writes of any kind, so row
editing is not offered and then failed. And Agent AUTO mode does not run on this
connection: the tool-using run needs a database-native read-only profile, which
-exists on PostgreSQL, SQLite and DuckDB only, so an auto run here ends
+exists on [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/) only, so an auto run here ends
`engine-unsupported`. Agent PLAN mode does open, toolless, and drafts a statement for
you to run yourself.
diff --git a/outstatic/content/posts/elasticsearch-mapping-is-the-schema.md b/outstatic/content/posts/elasticsearch-mapping-is-the-schema.md
index b4bc062..663938d 100644
--- a/outstatic/content/posts/elasticsearch-mapping-is-the-schema.md
+++ b/outstatic/content/posts/elasticsearch-mapping-is-the-schema.md
@@ -158,11 +158,11 @@ None of this is a browser waiting for write support. This grammar has no `INSERT
parser listing everything it would have accepted - so inline row editing and the
create-table toggle are not offered rather than offered and failed, which is the
same rule that governs
-[every capability this interface shows or hides](/features).
+[every capability this interface shows or hides](/features/).
A grammar that cannot write is still not the guarantee agent auto mode asks for.
Auto mode runs only where the provider implements a database-native read-only
-profile, which is PostgreSQL, SQLite and DuckDB and no other entry in the
-[supported engine list](/databases); on this engine an auto run ends
+profile, which is [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/) and no other entry in the
+[supported engine list](/databases/); on this engine an auto run ends
engine-unsupported. Plan mode opens on the connection, is toolless, runs nothing,
and drafts a statement for a human to run.
diff --git a/outstatic/content/posts/elasticsearch-sql-not-query-dsl.md b/outstatic/content/posts/elasticsearch-sql-not-query-dsl.md
index fc4e2c7..73aaebf 100644
--- a/outstatic/content/posts/elasticsearch-sql-not-query-dsl.md
+++ b/outstatic/content/posts/elasticsearch-sql-not-query-dsl.md
@@ -31,7 +31,7 @@ in the Docker image.
The SQL endpoint is first-party and available on the basic tier with no plugin to
install, which is why it is the query surface here rather than the DSL. ES|QL
exists on this product and works on basic - measured - and is deliberately unused:
-one implementation serves both `elasticsearch` and `opensearch`, and OpenSearch
+one implementation serves both `elasticsearch` and `opensearch`, and [OpenSearch](/blog/engine/opensearch/)
has no ES|QL at all.
The schema does not come from that endpoint at all. Indices are read from `GET
@@ -73,7 +73,7 @@ and `declaresForeignKeys` is `false` - the empty list means "impossible here", n
"none declared". And no column is ever marked primary: nothing a mapping
declares is unique, and `_id` is not even selectable (`SELECT _id FROM
probe_orders` answers "Unknown column [_id], did you mean [id]?"). The absent ER
-diagram is published on [the engine grid](/databases).
+diagram is published on [the engine grid](/databases/).
## Why a second page is refused rather than approximated
@@ -142,11 +142,11 @@ not a restraint the integration applies, and it invites the conclusion that the
tool-using agent is safe to run here. It is not.
Agent AUTO mode requires a read-only mode the database itself enforces per
-statement: a read-only transaction on PostgreSQL, `PRAGMA query_only` re-asserted
-per statement on SQLite, a `READ_ONLY` handle plus an SQL guard on DuckDB.
+statement: a read-only transaction on [PostgreSQL](/blog/engine/postgresql/), `PRAGMA query_only` re-asserted
+per statement on [SQLite](/blog/engine/sqlite/), a `READ_ONLY` handle plus an SQL guard on DuckDB.
`queryReadOnly` exists on exactly those three providers. The search providers
implement none of it, so an AUTO run on this connection ends `engine-unsupported`
-- the same answer given on [the agent's own feature entry](/features).
+- the same answer given on [the agent's own feature entry](/features/).
"This grammar has no INSERT" and "the database refused to let this statement
write" are different guarantees. The first is a claim about a parser at the time
diff --git a/outstatic/content/posts/four-problems-a-server-side-sql-client-creates.md b/outstatic/content/posts/four-problems-a-server-side-sql-client-creates.md
index 766bcbc..35da235 100644
--- a/outstatic/content/posts/four-problems-a-server-side-sql-client-creates.md
+++ b/outstatic/content/posts/four-problems-a-server-side-sql-client-creates.md
@@ -17,7 +17,7 @@ publishedAt: 2026-09-08T09:00:00.000Z
A desktop database client makes two demands that nobody writes down. Every developer laptop has to be able to reach the production network, and every developer laptop has to hold a copy of every credential. Both are load-bearing, and both are the reason a database client is usually the last tool a team gets around to putting behind SSO.
-Moving the client to a server next to the data removes both demands. The credential stops living on sixty laptops and starts living in one place. The network path stops being a VPN grant per person and starts being a deployment topology. [The tool goes to the data](/blog/the-tool-goes-to-the-data) is the argument for making that trade; this is the invoice.
+Moving the client to a server next to the data removes both demands. The credential stops living on sixty laptops and starts living in one place. The network path stops being a VPN grant per person and starts being a deployment topology. [The tool goes to the data](/blog/the-tool-goes-to-the-data/) is the argument for making that trade; this is the invoice.
It is not free, because it creates four new problems that a desktop client never had. The client is now a multi-tenant network service holding every connection your team owns. We hit all four building LibreDB Studio. What follows is the specific shape each one took, including the two we got wrong first.
@@ -61,7 +61,7 @@ There is a keying subtlety too. For the "authenticated user hit an admin route"
The browser was the store. Connections, tabs and query history all lived in `localStorage`, which is the right default for one developer and useless the moment two people share a deployment. Server mode is one environment variable: reads still come from `localStorage` as a write-through cache, and mutations get pushed to a per-user scoped server store, with connection credentials encrypted at rest.
-What the encryption buys is a stolen database file or a dump. It is not a vault: anyone who can read the server's environment can read the key. And the browser copy of your credentials is still plaintext, because encrypting it would require a master password and a recovery flow, which changes what the product is. That single admission is why the cross-site scripting controls are the highest-leverage rows in [our security posture](/security) and not a checkbox.
+What the encryption buys is a stolen database file or a dump. It is not a vault: anyone who can read the server's environment can read the key. And the browser copy of your credentials is still plaintext, because encrypting it would require a master password and a recovery flow, which changes what the product is. That single admission is why the cross-site scripting controls are the highest-leverage rows in [our security posture](/security/) and not a checkbox.
## What this does not buy you
@@ -69,4 +69,4 @@ Relocating the client narrows the blast radius. It does not produce an authoriza
The posture page listing all of this is checked against the repository on every build. A row that names a file that does not exist, or a test that does not run, fails CI. It cannot verify that a linked test is *true*, which is the residual we carry knowingly, and which is also written down.
-None of this is specific to us. If you are moving any credential-holding client onto a shared host, the four problems arrive with it: the boundary lands inside your process, ambient browser authority becomes a real vector, your own error paths become a resource to exhaust, and your state stops being one person's. LibreDB Studio is MIT licensed and [deploys as a package, an image or a chart](/deploy), so you can read exactly how we answered each one.
+None of this is specific to us. If you are moving any credential-holding client onto a shared host, the four problems arrive with it: the boundary lands inside your process, ambient browser authority becomes a real vector, your own error paths become a resource to exhaust, and your state stops being one person's. LibreDB Studio is MIT licensed and [deploys as a package, an image or a chart](/deploy/), so you can read exactly how we answered each one.
diff --git a/outstatic/content/posts/libredb-agent-plan-derived-namespaces.md b/outstatic/content/posts/libredb-agent-plan-derived-namespaces.md
index bb600af..759b3cb 100644
--- a/outstatic/content/posts/libredb-agent-plan-derived-namespaces.md
+++ b/outstatic/content/posts/libredb-agent-plan-derived-namespaces.md
@@ -40,7 +40,7 @@ transaction, `PRAGMA query_only`, a `READ_ONLY` handle - and the LibreDB provide
implements no such method. Grep for `queryReadOnly` under the LibreDB provider
and there is no hit. **An agent AUTO run on a LibreDB connection ends
`engine-unsupported`.** That is the whole story of AUTO mode on this engine, and
-it is the same sentence the [feature pages](/features) publish for every engine
+it is the same sentence the [feature pages](/features/) publish for every engine
outside those three.
PLAN mode is a different thing and opens on every connection, this one included.
@@ -55,8 +55,8 @@ grounded.
Since 2026-08-15 the server reads the connection's schema through the provider
before the model's first turn, so a plan run is not guessing at names. The read
-is bounded, and the bound is the provider's own: MongoDB stops at 200
-collections, Redis scans 1000 keys, LibreDB 10000.
+is bounded, and the bound is the provider's own: [MongoDB](/blog/engine/mongodb/) stops at 200
+collections, [Redis](/blog/engine/redis/) scans 1000 keys, LibreDB 10000.
On this engine that number is `LIBREDB_MAX_KEY_SCAN`, and the scan is one
half-open range over the entire keyspace:
@@ -136,7 +136,7 @@ LibreDB-specific agent audit path, and this site does not claim one.
That is a narrower guarantee than the AUTO engines carry, and it is narrower in a
specific direction: there is less to audit because there is less that runs. The
-[security page](/security) publishes the same boundary from the other side.
+[security page](/security/) publishes the same boundary from the other side.
Two absences reinforce it. `getActiveSessions()` refuses with
`LIBREDB_ACTIVE_SESSIONS_REFUSAL` - the file is opened inside this server's own
diff --git a/outstatic/content/posts/libredb-embedded-file-exclusive-lock.md b/outstatic/content/posts/libredb-embedded-file-exclusive-lock.md
index d90d361..f89927b 100644
--- a/outstatic/content/posts/libredb-embedded-file-exclusive-lock.md
+++ b/outstatic/content/posts/libredb-embedded-file-exclusive-lock.md
@@ -46,7 +46,7 @@ connection model, because the database has no server and no wire protocol to
carry one; embedded in-process is the only supported mode. Run Studio as a
container beside your data and the file has to be on a volume that container can
see. That is the whole networking story, and it is why this engine appears on
-[the engine grid](/databases) as a file rather than a host and port.
+[the engine grid](/databases/) as a file rather than a host and port.
## Every open takes a lock, and the second one is refused
@@ -63,7 +63,7 @@ file admits exactly one handle and a second open is refused.** Not queued, not
degraded to read-only, not resolved last-writer-wins. Refused.
This is the only engine in the product that declares `singleWriterFile: true`.
-The other file-backed engine, SQLite, takes its locks per transaction rather than
+The other file-backed engine, [SQLite](/blog/engine/sqlite/), takes its locks per transaction rather than
at open, so two connections to one file coexist and contend statement by
statement. LibreDB moves that contention forward to `connect()`. You find out at
connection time, once, instead of at an arbitrary write.
@@ -91,8 +91,8 @@ borrower and never cached under the profiled key. Two bounds keep the agent's
isolation intact: only `agent-operations` borrows, and no borrow happens for a
connection that configures an `agentUser`, because a reuse cannot substitute one
principal for another. Agent AUTO mode is unavailable on this engine regardless —
-it requires a provider-level `queryReadOnly`, which exists only on PostgreSQL,
-SQLite and DuckDB, so an auto run here ends `engine-unsupported`. Plan mode opens
+it requires a provider-level `queryReadOnly`, which exists only on [PostgreSQL](/blog/engine/postgresql/),
+SQLite and [DuckDB](/blog/engine/duckdb/), so an auto run here ends `engine-unsupported`. Plan mode opens
and drafts a command for a person to run.
## Working alongside command-line tooling
@@ -165,5 +165,5 @@ Studio instance seeds a connection named "Sample (LibreDB)" on first startup,
covering all three lenses — a relational table, a document collection and raw
key-value keys. `LIBREDB_EMBEDDED_SAMPLE=false` turns it off and
`LIBREDB_EMBEDDED_SAMPLE_PATH` moves the file. The
-[getting started guide](/get-started) covers bringing the container up next to
+[getting started guide](/get-started/) covers bringing the container up next to
the volume that holds it.
diff --git a/outstatic/content/posts/libredb-monitoring-refusal-taxonomy.md b/outstatic/content/posts/libredb-monitoring-refusal-taxonomy.md
index a1ab070..06ef6c6 100644
--- a/outstatic/content/posts/libredb-monitoring-refusal-taxonomy.md
+++ b/outstatic/content/posts/libredb-monitoring-refusal-taxonomy.md
@@ -149,7 +149,7 @@ here and a bloat count over no rows once produced a `0` badged green, which is a
clean bill of health for an operation that does not exist.
This is the rule the rest of the product runs on: [what each panel can show
-is bounded by what the engine reports](/features), and every engine's deliberate
-absences are published on [its own page](/databases) rather than discovered at
+is bounded by what the engine reports](/features/), and every engine's deliberate
+absences are published on [its own page](/databases/) rather than discovered at
runtime. The dashboard is just where that rule is hardest to follow, because
blankness is cheap and a reason costs someone a paragraph.
diff --git a/outstatic/content/posts/libsql-monitoring-stateless-hrana.md b/outstatic/content/posts/libsql-monitoring-stateless-hrana.md
index 3dd0ef7..122466a 100644
--- a/outstatic/content/posts/libsql-monitoring-stateless-hrana.md
+++ b/outstatic/content/posts/libsql-monitoring-stateless-hrana.md
@@ -21,7 +21,7 @@ engine - uptime, active connections, a cache hit ratio, a slow query log - misse
almost every line here, and every miss traces back to one protocol decision made above
the storage layer.
-libSQL is SQLite's dialect with a server in front of it. The server is `sqld`, and what
+libSQL is [SQLite](/blog/engine/sqlite/)'s dialect with a server in front of it. The server is `sqld`, and what
`sqld` speaks is Hrana: a list of requests posted as JSON to `POST /v2/pipeline`, one
result per request. One type-id, `libsql`, reaches both a self-hosted `sqld` and Turso
Cloud, because they speak that same protocol and embed the same SQLite - 3.47.0 measured
@@ -69,7 +69,7 @@ because `0 B` next to a table name reads as an empty table.
lock and refuses a second with `SQLITE_BUSY`, so `0` there is the engine's behaviour
written down, not a counter that failed to load. That distinction is why what each panel
can show is bounded by what the engine reports rather than by a shared layout, which is
-the trade [the interface makes on every engine](/features).
+the trade [the interface makes on every engine](/features/).
State the boundary plainly, because it is the shape of this engine and not a defect:
**uptime is N/A, active connections are absent because each statement is its own
diff --git a/outstatic/content/posts/libsql-read-only-token-boundary.md b/outstatic/content/posts/libsql-read-only-token-boundary.md
index 558a7f2..a45759b 100644
--- a/outstatic/content/posts/libsql-read-only-token-boundary.md
+++ b/outstatic/content/posts/libsql-read-only-token-boundary.md
@@ -40,7 +40,7 @@ publishes:
The shared requirement is that the refusal comes from the database. A parser that
inspects a string before sending it is guessing about a dialect it does not execute;
-`VACUUM INTO ''` is the example that made the point on SQLite, because it reads
+`VACUUM INTO ''` is the example that made the point on [SQLite](/blog/engine/sqlite/), because it reads
as a read and writes a file. The SQLite provider refuses it because the handle
refuses it, not because a regular expression recognised it.
@@ -109,7 +109,7 @@ for the statements that follow, because there is no "following" — each stateme
one stateless HTTP request.
**A fake boundary is worse than an absent one.** The [capability model this site
-publishes](/features) exists so a control that cannot work is absent with its reason
+publishes](/features/) exists so a control that cannot work is absent with its reason
written where it would have been, rather than offered and then failed. A read-only
profile that was enforced by our parser instead of the database would pass a demo and
misdescribe itself in the only sentence anyone would rely on: that the database
@@ -153,7 +153,7 @@ mint a read-only token, paste that URL into a separate connection, and let the s
enforce it. The application does not need to know. That is a connection-level control
and it applies to everything running through it — the editor, the browser, row
editing — not only to an agent run. It also means the boundary does not depend on our
-code being correct, which is the distinction [the security page](/security) draws when
+code being correct, which is the distinction [the security page](/security/) draws when
it says a display control is not a boundary.
## What plan mode still does here
@@ -175,7 +175,7 @@ So a plan on libSQL knows your tables, your columns, your declared foreign keys
your row counts. It drafts against them, then stops. You press Run, or you do not.
That split is the same everywhere: AUTO mode needs a boundary the database enforces,
-and runs on PostgreSQL, SQLite and DuckDB only. Plan mode needs a schema, and every
+and runs on [PostgreSQL](/blog/engine/postgresql/), SQLite and [DuckDB](/blog/engine/duckdb/) only. Plan mode needs a schema, and every
engine has one. libSQL sits on the plan side of that line because of one refused
pragma, and the credential that replaces it is issued by the same server for the same
purpose — just earlier, and by you.
diff --git a/outstatic/content/posts/libsql-self-hosted-sqld.md b/outstatic/content/posts/libsql-self-hosted-sqld.md
index 0cea008..d27a0bb 100644
--- a/outstatic/content/posts/libsql-self-hosted-sqld.md
+++ b/outstatic/content/posts/libsql-self-hosted-sqld.md
@@ -18,7 +18,7 @@ publishedAt: 2026-03-20T09:00:00.000Z
Most of a self-hosted libSQL sqld connection is a question of what to leave out.
There is one integration for this engine, one type id, and it reaches both a
container you started yourself and a managed Turso Cloud database, because the
-two speak the same protocol and embed the same SQLite - 3.47.0 measured on both.
+two speak the same protocol and embed the same [SQLite](/blog/engine/sqlite/) - 3.47.0 measured on both.
What separates them in the connection dialog is a host, a TLS switch and a
credential. Locally, two of those three are the absence of something.
@@ -62,7 +62,7 @@ libsql://-.turso.io?authToken=
That is what `turso db show --url` prints, and `libsql://` implies TLS on 443.
There is no plaintext spelling of the scheme. `http://` is already claimed by
-ClickHouse in the connection string parser, and two engines cannot own one
+[ClickHouse](/blog/engine/clickhouse/) in the connection string parser, and two engines cannot own one
scheme, so a self-hosted server on plain HTTP is reached through the fields
instead.
@@ -116,7 +116,7 @@ The read-only form is the engine-side answer to a gap. `PRAGMA query_only = true
is refused by the server on both deployments, so this provider implements no
read-only query path of its own. Agent AUTO mode - the run that uses tools -
therefore ends `engine-unsupported` on libSQL, because that profile needs the
-read-only path and exists on PostgreSQL, SQLite and DuckDB only. Agent PLAN mode
+read-only path and exists on [PostgreSQL](/blog/engine/postgresql/), SQLite and DuckDB only. Agent PLAN mode
opens on every connection here as it does everywhere: toolless, executing
nothing, drafting a statement for a person to run. The read-only token is a
credential you create, not a statement the provider can issue.
@@ -190,5 +190,5 @@ only what the deployment publishes: no uptime, no cache hit ratio, no slow query
list, because libSQL keeps no statistics about finished statements.
The published capability line for this engine and the sixteen others is on the
-[databases page](/databases), and LibreDB Studio itself is one `docker run` away
-in [get started](/get-started).
+[databases page](/databases/), and LibreDB Studio itself is one `docker run` away
+in [get started](/get-started/).
diff --git a/outstatic/content/posts/libsql-turso-url-and-auth-token.md b/outstatic/content/posts/libsql-turso-url-and-auth-token.md
index 6a47bde..d760e4e 100644
--- a/outstatic/content/posts/libsql-turso-url-and-auth-token.md
+++ b/outstatic/content/posts/libsql-turso-url-and-auth-token.md
@@ -95,7 +95,7 @@ typing it produce the same connection.
`libsql://` is not a hint. It implies TLS, and it implies 443, which is how Turso
Cloud serves every database. There is no plaintext form of the scheme, and that is
-a deliberate refusal rather than a gap: `http://` already resolves to ClickHouse in
+a deliberate refusal rather than a gap: `http://` already resolves to [ClickHouse](/blog/engine/clickhouse/) in
this codebase's connection-string parser, and two engines cannot own one scheme. A
self-hosted server on plain HTTP is reached through the host and port fields with
TLS off, on `sqld`'s own default port `8080`.
@@ -114,7 +114,7 @@ uptime reads `N/A`. Nothing publishes them.
One type-id, `libsql`, reaches both a self-hosted libSQL server and Turso Cloud.
They are not two integrations. They speak the same protocol and embed the same
-SQLite - 3.47.0 measured on both, on 2026-08-27 - and every statement, every
+[SQLite](/blog/engine/sqlite/) - 3.47.0 measured on both, on 2026-08-27 - and every statement, every
catalog read and every refusal measured the same on each.
Where they differ, they differ in what they publish about themselves rather than in
@@ -152,6 +152,6 @@ connection like any other: it is toolless, executes nothing, and drafts a statem
for a human to run.
The rest is a normal SQLite session over the network. The published capability line
-for this engine sits with the others on the [engine pages](/databases), and the
+for this engine sits with the others on the [engine pages](/databases/), and the
container that holds the connection is set up in
-[getting started](/get-started).
+[getting started](/get-started/).
diff --git a/outstatic/content/posts/mongodb-agent-plan-declining-checks.md b/outstatic/content/posts/mongodb-agent-plan-declining-checks.md
index fab4bb5..5311846 100644
--- a/outstatic/content/posts/mongodb-agent-plan-declining-checks.md
+++ b/outstatic/content/posts/mongodb-agent-plan-declining-checks.md
@@ -24,7 +24,7 @@ LibreDB Studio's plan mode takes the second one.
Agent AUTO mode — the tool-using, metered run that executes statements itself
— does not run on MongoDB. The read-only execution profile it needs is
database-native, and a provider-native `queryReadOnly` exists only on
-PostgreSQL, SQLite and DuckDB, so an auto run on a MongoDB connection ends
+[PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/), so an auto run on a MongoDB connection ends
`engine-unsupported`. Plan mode opens on every connection, including this one.
It is toolless, it executes nothing, and it drafts one statement for a person
to run.
@@ -99,7 +99,7 @@ It checks no columns. With no inventory it records `no-inventory`, because an
empty list would be a claim that every table exists.
**Read-only classification**, through the same statement guard the rest of the
-[agent surface uses](/features). A statement that is not read-only is not
+[agent surface uses](/features/). A statement that is not read-only is not
blocked — that was ruled on deliberately — but it is marked on the event and
visibly in the rail, so a hand-off never quietly gives someone a delete.
@@ -158,7 +158,7 @@ The execution confirmation gate reads the same JSON shape and treats
exactly as a `DELETE FROM` does on a SQL engine. A payload it cannot read as a
document with a string `operation` asks as well, rather than staying silent.
That gate is a runtime control on your own execution, not a verdict on the
-draft; the [published boundaries](/security) say what it does not cover.
+draft; the [published boundaries](/security/) say what it does not cover.
Then check the result size, because the provider's defaults are asymmetric. A
`find` with no explicit `options.limit` is capped at 100 documents. An
diff --git a/outstatic/content/posts/mongodb-atlas-srv-tls-modes.md b/outstatic/content/posts/mongodb-atlas-srv-tls-modes.md
index ac3d2d8..0c4e16b 100644
--- a/outstatic/content/posts/mongodb-atlas-srv-tls-modes.md
+++ b/outstatic/content/posts/mongodb-atlas-srv-tls-modes.md
@@ -67,7 +67,7 @@ removes the trade, so the form can now say what the URI says.
`tls`, `ca`, `cert`, `key` and `rejectUnauthorized` are all on the driver's
allow-list - `LEGAL_TLS_SOCKET_OPTIONS` in `mongodb/lib/cmap/connect.js` - and
reach `tls.connect` under Node's names, so the material maps the same way it does
-for PostgreSQL, MySQL and Couchbase.
+for [PostgreSQL](/blog/engine/postgresql/), [MySQL](/blog/engine/mysql/) and [Couchbase](/blog/engine/couchbase/).
| `ssl.mode` | Options added |
| --- | --- |
@@ -110,7 +110,7 @@ rather than a per-engine list. What they must not do is imply a control you have
exercised: choosing `verify-full` over `verify-ca` on MongoDB changes nothing
about the connection. If host-name verification is part
of your threat model, this is the wrong layer to satisfy it, and the [security
-page](/security) lists this class of boundary next to the controls rather than
+page](/security/) lists this class of boundary next to the controls rather than
underneath them.
One consequence: `tlsAllowInvalidHostnames=true` in a pasted URI is deliberately
diff --git a/outstatic/content/posts/mongodb-authsource-connection-failure.md b/outstatic/content/posts/mongodb-authsource-connection-failure.md
index 0161fbb..c5b64bd 100644
--- a/outstatic/content/posts/mongodb-authsource-connection-failure.md
+++ b/outstatic/content/posts/mongodb-authsource-connection-failure.md
@@ -165,7 +165,7 @@ be run here - because a plan run on 2026-08-22 drafted
Plan mode is the only agent mode this connection has. It is toolless, it runs
nothing, and it drafts a statement for a person to run; the metered auto run ends
`engine-unsupported` here, because the read-only execution profile it needs is
-database-native and exists only on PostgreSQL, SQLite and DuckDB.
+database-native and exists only on [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/).
A `find` with no explicit limit returns at most 100 documents, so a first query
that looks suspiciously round is not a truncation bug. The collection list is
@@ -175,5 +175,5 @@ catalog, which is worth remembering the first time a field you know exists is
missing from the tree.
The engine's published limits, transport and default port are listed on the
-[databases page](/databases), and the connection dialog itself is the first
-thing covered in [getting started](/get-started).
+[databases page](/databases/), and the connection dialog itself is the first
+thing covered in [getting started](/get-started/).
diff --git a/outstatic/content/posts/mongodb-inferred-schema-sample.md b/outstatic/content/posts/mongodb-inferred-schema-sample.md
index 7afaaa2..dd05f88 100644
--- a/outstatic/content/posts/mongodb-inferred-schema-sample.md
+++ b/outstatic/content/posts/mongodb-inferred-schema-sample.md
@@ -15,7 +15,7 @@ tags:
publishedAt: 2026-05-20T09:00:00.000Z
---
-Open a PostgreSQL connection and the field list under a table is a read: the tree
+Open a [PostgreSQL](/blog/engine/postgresql/) connection and the field list under a table is a read: the tree
asks `information_schema` what the columns are and the answer is the definition
itself. Open a MongoDB connection and there is nothing equivalent to ask. A
collection has no declared shape, so the field list has to come from the
@@ -122,8 +122,8 @@ considered.
Agent AUTO mode - the tool-using, metered run - does not run on MongoDB. The
read-only execution profile it depends on is database-native and exists only on
-PostgreSQL, SQLite and DuckDB, so an auto run here ends `engine-unsupported`. That
-rule is published in the [feature limits](/features).
+PostgreSQL, [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/), so an auto run here ends `engine-unsupported`. That
+rule is published in the [feature limits](/features/).
PLAN mode does open on a MongoDB connection. It is toolless, executes nothing, and
drafts a statement for a human to run. Its grounding is `getSchema()` - the same
diff --git a/outstatic/content/posts/mongodb-json-command-editor.md b/outstatic/content/posts/mongodb-json-command-editor.md
index 718db6f..49e2087 100644
--- a/outstatic/content/posts/mongodb-json-command-editor.md
+++ b/outstatic/content/posts/mongodb-json-command-editor.md
@@ -39,7 +39,7 @@ as an MQL object and dispatches on `operation`. Eleven operations are supported:
A missing `collection` or `operation`, or a string that is not valid JSON, is a
`QueryError` that quotes the format it wanted. The engine row on the
-[databases page](/databases) states the consequence flatly: no SQL translation
+[databases page](/databases/) states the consequence flatly: no SQL translation
layer is faked, and queries here are MongoDB queries.
The cost of that decision is not hidden either. Because there is no
@@ -54,8 +54,8 @@ drafted `db.orders.aggregate([...])` - correct MongoDB, unrunnable here. Naming
what the language is did not survive contact with the model's prior; naming what
it is not did. Plan mode opens on a MongoDB connection and drafts statements for
a human to run. Agent AUTO mode does not run here at all: the read-only
-execution profile is database-native and exists only on PostgreSQL, SQLite and
-DuckDB, so an auto run on MongoDB ends `engine-unsupported`.
+execution profile is database-native and exists only on [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and
+[DuckDB](/blog/engine/duckdb/), so an auto run on MongoDB ends `engine-unsupported`.
## find, aggregate, distinct and their real defaults
diff --git a/outstatic/content/posts/mongodb-no-foreign-keys-diagram.md b/outstatic/content/posts/mongodb-no-foreign-keys-diagram.md
index f1fd879..545a172 100644
--- a/outstatic/content/posts/mongodb-no-foreign-keys-diagram.md
+++ b/outstatic/content/posts/mongodb-no-foreign-keys-diagram.md
@@ -26,7 +26,7 @@ has no place to record them.
The diagram in LibreDB Studio is not drawn by hand and is not inferred. It is
discovered: `getSchema()` returns a `foreignKeys` list per table, and every edge
on the canvas is one entry from that list, laid out hierarchically by ELK.js.
-That is the whole input. The [ER diagram feature](/features) publishes the
+That is the whole input. The [ER diagram feature](/features/) publishes the
consequence next to the claim - a relationship your application enforces in code
but never declares in the schema has nothing to discover, and will not appear.
@@ -58,7 +58,7 @@ on, because there is no constraint to add.
The flag is not decoration. It is what lets the interface distinguish an empty
panel from a broken one, which is the rule the whole [engine
-matrix](/databases) is built on. Where a control cannot work, it is absent with
+matrix](/databases/) is built on. Where a control cannot work, it is absent with
the reason written where it would have been, rather than offered and then failed.
## Inferring edges from names would be a guess
diff --git a/outstatic/content/posts/mongodb-profiler-slow-queries.md b/outstatic/content/posts/mongodb-profiler-slow-queries.md
index d6b1a46..2ee0304 100644
--- a/outstatic/content/posts/mongodb-profiler-slow-queries.md
+++ b/outstatic/content/posts/mongodb-profiler-slow-queries.md
@@ -28,7 +28,7 @@ is broken. It prints the sentence that names the missing step:
> start recording into system.profile.
That string is `slowQueriesEmptyState`, a per-engine label on the MongoDB
-provider. It used to be PostgreSQL's `pg_stat_statements` advice, hardcoded into
+provider. It used to be [PostgreSQL](/blog/engine/postgresql/)'s `pg_stat_statements` advice, hardcoded into
the panel for every engine including this one, and `pg_stat_statements` is a
PostgreSQL extension that a MongoDB server has no equivalent of.
@@ -61,7 +61,7 @@ permissions answer, not a bug. The Overview read is the exception, because its
Every one of those methods is wrapped in try/catch, so one refusal costs one panel
rather than the dashboard. The same principle runs through the rest of the
-[monitoring surface](/features): what a panel shows is bounded by what the engine
+[monitoring surface](/features/): what a panel shows is bounded by what the engine
reports.
## Why the MongoDB slow query log, system.profile, is empty by default
@@ -82,7 +82,7 @@ editor here parses a JSON command object and only that:
A statement beginning `db.` cannot be executed through this provider at all. That
is the same boundary the engine grid states on
-[the databases page](/databases) - queries here are MongoDB queries, with no SQL
+[the databases page](/databases/) - queries here are MongoDB queries, with no SQL
translation layer faked over them - and it cuts the other way too: mongosh syntax
is not the language the editor accepts either.
diff --git a/outstatic/content/posts/mysql-agent-plan-mode-grounded-drafts.md b/outstatic/content/posts/mysql-agent-plan-mode-grounded-drafts.md
index 98f8ca6..74f305e 100644
--- a/outstatic/content/posts/mysql-agent-plan-mode-grounded-drafts.md
+++ b/outstatic/content/posts/mysql-agent-plan-mode-grounded-drafts.md
@@ -30,9 +30,9 @@ So an AI SQL assistant for MySQL, in this product, is a plan-mode run.
Agent mode's guarantee is not "the model was told to be careful". It is that every
statement the run issues goes through an audited pipeline - a policy decision, an
audit event and budget accounting before the driver is touched - under a read-only
-profile the *database* enforces: a read-only transaction on PostgreSQL,
-`PRAGMA query_only` re-asserted per statement on SQLite, a `READ_ONLY` engine
-handle plus an SQL-level guard on DuckDB.
+profile the *database* enforces: a read-only transaction on [PostgreSQL](/blog/engine/postgresql/),
+`PRAGMA query_only` re-asserted per statement on [SQLite](/blog/engine/sqlite/), a `READ_ONLY` engine
+handle plus an SQL-level guard on [DuckDB](/blog/engine/duckdb/).
MySQL's provider has no equivalent handle to acquire, so the profile cannot be
established, so the run is refused. A run whose workflow sends statements is
@@ -44,7 +44,7 @@ The alternative would have been to open the run anyway and rely on prompt text a
statement classification to keep it read-only. That is a guarantee made by the
layer being guarded, which is the shape of guarantee this project does not ship.
The controls this project does ship, and the limits they do not cover, are
-[published on the security page](/security) for the same reason.
+[published on the security page](/security/) for the same reason.
One consequence worth stating plainly: MariaDB, Percona Server, TiDB, Vitess and
the other wire-compatible engines all connect through this same `mysql` provider.
@@ -138,4 +138,4 @@ prompt, a confused model and a mis-scoped objective because it is structural
rather than behavioural. A plan-mode draft is also worth less than a completed
investigation: it is a statement, not a finding. Which side of that trade you want
is not a choice this engine offers, and the refusal is printed on MySQL's own row
-in the [engine list](/databases) rather than discovered when a run fails.
+in the [engine list](/databases/) rather than discovered when a run fails.
diff --git a/outstatic/content/posts/mysql-cancel-runaway-query.md b/outstatic/content/posts/mysql-cancel-runaway-query.md
index 0da5e3d..885d9d9 100644
--- a/outstatic/content/posts/mysql-cancel-runaway-query.md
+++ b/outstatic/content/posts/mysql-cancel-runaway-query.md
@@ -25,7 +25,7 @@ here you press Cancel, and that press sends one statement to one connection.
The MySQL provider builds a `mysql2` pool and sets only options that pool has:
`connectionLimit` from the pool `max` (default 10), `waitForConnections`,
`queueLimit`, `enableKeepAlive`, `keepAliveInitialDelay` and `timezone`. The
-provider's own `queryTimeout` option is not among them. PostgreSQL's provider can
+provider's own `queryTimeout` option is not among them. [PostgreSQL](/blog/engine/postgresql/)'s provider can
translate that option into `statement_timeout` because `pg` has a place to put it;
the mysql2 pool has no equivalent, so the value is not translated and no
server-side bound is applied.
@@ -158,5 +158,5 @@ None of that bounds execution time. A `LIMIT 500` over an unindexed join still
scans everything before it returns five hundred rows. It bounds the transfer and
the browser, not the server's work, which is exactly why the cancel path exists
and why its caveat is published rather than buried. The per-engine capability
-lines on [the engine pages](/databases) and the boundaries listed with each
-[feature](/features) are written the same way, for the same reason.
+lines on [the engine pages](/databases/) and the boundaries listed with each
+[feature](/features/) are written the same way, for the same reason.
diff --git a/outstatic/content/posts/mysql-connect-docker-pool-limits.md b/outstatic/content/posts/mysql-connect-docker-pool-limits.md
index 8abe5f0..880206b 100644
--- a/outstatic/content/posts/mysql-connect-docker-pool-limits.md
+++ b/outstatic/content/posts/mysql-connect-docker-pool-limits.md
@@ -24,7 +24,7 @@ never reads.
## The fixture, the port and the credentials
MySQL reaches LibreDB Studio through `MySQLProvider`, built on `mysql2/promise`,
-extending the same `SQLBaseProvider` the PostgreSQL provider extends. Default port
+extending the same `SQLBaseProvider` the [PostgreSQL](/blog/engine/postgresql/) provider extends. Default port
3306. Connection strings are supported and passed to the pool as its `uri` option.
The studio repository ships a fixture so there is nothing to guess. The `mysql`
@@ -93,7 +93,7 @@ by service name whether or not anything is published at all.
Every provider funnels its driver's errors through the same shared
`mapDatabaseError()`, so this mistake reads the same whichever engine you point
-at, which is why it is worth naming once. The [engine pages](/databases) print
+at, which is why it is worth naming once. The [engine pages](/databases/) print
each engine's transport and default port next to its name for exactly this
moment.
@@ -178,5 +178,5 @@ an estimate of distinct values rather than a usage counter, because MySQL
publishes no equivalent of `pg_stat_user_indexes.idx_scan`. Both are useful for
ordering things by size. Neither is a number to quote in a report.
-Once the badge is green, [the setup guide](/get-started) covers the rest of the
+Once the badge is green, [the setup guide](/get-started/) covers the rest of the
first run.
diff --git a/outstatic/content/posts/mysql-connection-uri-ssl-ignored.md b/outstatic/content/posts/mysql-connection-uri-ssl-ignored.md
index 1eecb78..0fea976 100644
--- a/outstatic/content/posts/mysql-connection-uri-ssl-ignored.md
+++ b/outstatic/content/posts/mysql-connection-uri-ssl-ignored.md
@@ -105,7 +105,7 @@ exactly like a connection that is encrypted and verified, from the results grid.
`require` is the same object, chosen deliberately rather than detected. Both of
these are worth saying out loud on the same page as the rest of the
-[security posture](/security), because "SSL is on" is the claim that most often
+[security posture](/security/), because "SSL is on" is the claim that most often
turns out to mean less than the person making it believed.
## Which verifying mode to choose, and what it needs
@@ -174,4 +174,4 @@ The through-line is the same in all three cases: a setting that exists on a form
is not a setting that reached the driver, and the branch a connection takes
decides which of the two it is. If the connection is a URI, the URI is the
configuration. The engine's transport, port and published limits are on the
-[MySQL engine page](/databases) alongside every other engine's.
+[MySQL engine page](/databases/) alongside every other engine's.
diff --git a/outstatic/content/posts/mysql-er-diagram-single-database-scope.md b/outstatic/content/posts/mysql-er-diagram-single-database-scope.md
index 1964e6b..1a2146e 100644
--- a/outstatic/content/posts/mysql-er-diagram-single-database-scope.md
+++ b/outstatic/content/posts/mysql-er-diagram-single-database-scope.md
@@ -44,7 +44,7 @@ The third row is the entire edge set. An edge on the diagram is a row in
The layout is done by ELK.js and the boxes carry cardinality labels, but the
graph itself is discovered rather than authored: if the server does not publish
the constraint, there is no line to lay out. That is the published limit on
-[the features page](/features) too - a relationship your application enforces in
+[the features page](/features/) too - a relationship your application enforces in
code but never declares in the schema has nothing to discover.
For a MySQL foreign key diagram this has a consequence worth checking before you
@@ -67,7 +67,7 @@ prefix with. And there is no cross-schema foreign-key resolution: a constraint
whose referenced table lives elsewhere on the server has no box to point at, so
it is not drawn as an edge.
-This is where the shape differs sharply from the PostgreSQL provider in the same
+This is where the shape differs sharply from the [PostgreSQL](/blog/engine/postgresql/) provider in the same
codebase, which walks all non-system schemas in one pass and resolves foreign
keys across them. Both are reading the catalog honestly; they are reading
differently shaped catalogs. If your MySQL server holds a logical application
@@ -142,7 +142,7 @@ the statement above.
The same provider answers for MariaDB, Percona, TiDB and the other
MySQL-protocol engines - there is no separate MariaDB type id - and their
-support levels differ, listed per engine on [the databases page](/databases).
+support levels differ, listed per engine on [the databases page](/databases/).
The scoping rule is this provider's code rather than any one server's, so it
holds wherever this provider is what answered.
diff --git a/outstatic/content/posts/mysql-explain-format-json-plan-tree.md b/outstatic/content/posts/mysql-explain-format-json-plan-tree.md
index 82e7196..c1a03ba 100644
--- a/outstatic/content/posts/mysql-explain-format-json-plan-tree.md
+++ b/outstatic/content/posts/mysql-explain-format-json-plan-tree.md
@@ -48,7 +48,7 @@ not the server refusing.
The point of drawing the JSON as a tree is that the nesting already is the tree - the
terminal just renders it as indentation you have to hold in your head. The plan panel
lays out scan type, join strategy and cost per node, so the expensive node is the one
-you see first. The [feature page](/features) says which engines expose a plan this can
+you see first. The [feature page](/features/) says which engines expose a plan this can
draw and which have nothing to render.
Take an ordinary two-table statement:
@@ -147,7 +147,7 @@ To get from shape to duration, something has to actually run:
One more boundary, because it is the one people assume their way past. Agent AUTO mode,
the tool-using run that reads results and cites them, does not run on MySQL: the
read-only execution profile it depends on is database-native and exists only for
-PostgreSQL, SQLite and DuckDB, so an auto run on a MySQL connection ends
+[PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/), so an auto run on a MySQL connection ends
`engine-unsupported`. Agent PLAN mode does open here, grounded in the schema this
provider introspects; it executes nothing and drafts a statement for a person to run.
-The [engine list](/databases) states this per engine, next to the transport.
+The [engine list](/databases/) states this per engine, next to the transport.
diff --git a/outstatic/content/posts/mysql-performance-schema-off-metrics-absent.md b/outstatic/content/posts/mysql-performance-schema-off-metrics-absent.md
index 775c421..b58cbac 100644
--- a/outstatic/content/posts/mysql-performance-schema-off-metrics-absent.md
+++ b/outstatic/content/posts/mysql-performance-schema-off-metrics-absent.md
@@ -149,8 +149,8 @@ sentence for every empty list, whatever produced it, because the one cause a rea
would guess - instrumentation off - is the cause that never reaches the error path.
Guessing there would be the same defect one level up.
-The [monitoring surface](/features) is bounded by what each engine's own reporting
+The [monitoring surface](/features/) is bounded by what each engine's own reporting
interface publishes, and that boundary differs from engine to engine. What each one
-does and does not answer is published on the [engine pages](/databases) next to its
+does and does not answer is published on the [engine pages](/databases/) next to its
transport and default port, so you can check before you build a runbook on a figure
that turns out to be a cardinality estimate.
diff --git a/outstatic/content/posts/mysql-wire-compatible-engines-one-provider.md b/outstatic/content/posts/mysql-wire-compatible-engines-one-provider.md
index de99aba..a7d3d48 100644
--- a/outstatic/content/posts/mysql-wire-compatible-engines-one-provider.md
+++ b/outstatic/content/posts/mysql-wire-compatible-engines-one-provider.md
@@ -148,7 +148,7 @@ successfully, because the constraint is a planner hint there. SingleStore refuse
outright, and with `ignore_foreign_keys` on it accepts an inline one and strips it.
**Do not expect agent AUTO mode anywhere on this type id.** The read-only execution
-profile is database-native, and only PostgreSQL, SQLite and DuckDB implement it. An auto
+profile is database-native, and only [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/) implement it. An auto
run against any of these nine ends `engine-unsupported`. PLAN mode does open on every
one of them: it is toolless, executes nothing, and drafts a statement for a human to
run. On Databend that difference is legible: the schema read fails, so a plan run
diff --git a/outstatic/content/posts/opensearch-agent-plan-sql-not-dsl.md b/outstatic/content/posts/opensearch-agent-plan-sql-not-dsl.md
index d9caff7..325dc80 100644
--- a/outstatic/content/posts/opensearch-agent-plan-sql-not-dsl.md
+++ b/outstatic/content/posts/opensearch-agent-plan-sql-not-dsl.md
@@ -103,14 +103,14 @@ drafted nothing while the user is looking at a statement.
The label is not a UI string. No screen renders it; it exists only in what the model is
told, and a provider declares one only where the engine's own name misleads a model about
what a statement is here. It is one instance of the rule the [capability
-model](/features) is built on: what a provider can and cannot do is declared, not
+model](/features/) is built on: what a provider can and cannot do is declared, not
discovered at runtime.
## Why the tool-using run cannot open here at all
**Agent AUTO mode - the tool-using, metered run - cannot run on OpenSearch. A run ends
-`engine-unsupported`.** The read-only execution profile is database-native: only PostgreSQL,
-SQLite and DuckDB implement `queryReadOnly`, and a run whose workflow sends a statement is
+`engine-unsupported`.** The read-only execution profile is database-native: only [PostgreSQL](/blog/engine/postgresql/),
+[SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/) implement `queryReadOnly`, and a run whose workflow sends a statement is
refused when it is started rather than after a model turn has been spent. A run that reaches
the driver some other way fails profiled acquisition with `PROFILE_UNSUPPORTED_BY_PROVIDER`
and ends `engine-unsupported`.
@@ -119,7 +119,7 @@ This provider could not implement that profile if it wanted to. The read-only gu
to be enforced by the engine, and this grammar has no transaction to open read-only and no
session-scoped setting to make read-only - the surface is one stateless HTTP request per
statement. An integration-level imitation of read-only would be a promise made by the wrong
-party, which is the argument the [security page](/security) makes about every control it
+party, which is the argument the [security page](/security/) makes about every control it
publishes.
## What the person on the other end has to do
diff --git a/outstatic/content/posts/opensearch-monitoring-partial-absence.md b/outstatic/content/posts/opensearch-monitoring-partial-absence.md
index 9f43d9f..3b49d7c 100644
--- a/outstatic/content/posts/opensearch-monitoring-partial-absence.md
+++ b/outstatic/content/posts/opensearch-monitoring-partial-absence.md
@@ -155,8 +155,8 @@ and secondary-index statistics are absent from the engine's model, not from this
integration, and no amount of work here produces them.
That distinction is why the empty states are published rather than left for a
-reader to infer. Each page in the [engine reference](/databases) carries what is
+reader to infer. Each page in the [engine reference](/databases/) carries what is
deliberately absent next to the transport and the port, and the
-[monitoring surface](/features) declares which tabs an engine can fill before
+[monitoring surface](/features/) declares which tabs an engine can fill before
rendering any of them. A blank panel and a broken panel look identical on
screen; the text beside them is the only thing that separates the two.
diff --git a/outstatic/content/posts/opensearch-security-plugin-tls.md b/outstatic/content/posts/opensearch-security-plugin-tls.md
index 00435b6..47e2dd7 100644
--- a/outstatic/content/posts/opensearch-security-plugin-tls.md
+++ b/outstatic/content/posts/opensearch-security-plugin-tls.md
@@ -49,7 +49,7 @@ against a cluster holding nothing yet.
## Why the port does not change under TLS
`9200` is the default for both schemes. A TLS deployment serves HTTPS on that
-same number. The [engine grid](/databases) names this engine's transport `http`
+same number. The [engine grid](/databases/) names this engine's transport `http`
and its port `9200` for exactly that reason: there is one port, and the scheme in
front of it is a separate question.
@@ -61,7 +61,7 @@ password to a port nothing is listening on.
If you have seen `9201` attached to OpenSearch somewhere, it came from a
container fixture. The studio repo's compose file publishes the node on host port
-`9201` because the Elasticsearch service in the same file already claims `9200`.
+`9201` because the [Elasticsearch](/blog/engine/elasticsearch/) service in the same file already claims `9200`.
That is a collision on one machine, not a fact about the product. A real node is
on `9200`, TLS or not.
@@ -92,7 +92,7 @@ does. `require` here does not mean "encrypt without checking" the way it does on
the driver-based engines, because nothing in this code path can skip a check, and
`verify-ca` and `verify-full` cannot pin against a pasted CA. Nothing in the code
branches on which mode you picked. That is the kind of boundary the
-[security page](/security) exists to publish.
+[security page](/security/) exists to publish.
## Three deployment shapes that work
diff --git a/outstatic/content/posts/opensearch-sql-grammar-surprises.md b/outstatic/content/posts/opensearch-sql-grammar-surprises.md
index c2f9314..92d1f86 100644
--- a/outstatic/content/posts/opensearch-sql-grammar-surprises.md
+++ b/outstatic/content/posts/opensearch-sql-grammar-surprises.md
@@ -125,7 +125,7 @@ is no generated `SELECT` doing introspection for you to inherit a quoting bug
from.
Where the product does quote, it quotes with a backtick. The `opensearch` type-id
-shares MySQL's branch in the codebase's quoter (`src/lib/sql/identifier.ts`), and
+shares [MySQL](/blog/engine/mysql/)'s branch in the codebase's quoter (`src/lib/sql/identifier.ts`), and
the sibling search type-id, served by the same directory, cannot share that
branch: the quoting rule is a per-product fact, not a family one.
Index names make this concrete. A stock cluster already carries
@@ -136,8 +136,8 @@ connection means a backtick.
Which quote a provider uses is data the interface reads rather than a branch
someone maintains, the same shape as every other
-[declared capability](/features); each engine's published boundary sits on its
-row on the [databases page](/databases).
+[declared capability](/features/); each engine's published boundary sits on its
+row on the [databases page](/databases/).
## Paging, and why it is expressed as data rather than a branch
diff --git a/outstatic/content/posts/oracle-er-diagram-owner-scoped.md b/outstatic/content/posts/oracle-er-diagram-owner-scoped.md
index b32b303..ca6f649 100644
--- a/outstatic/content/posts/oracle-er-diagram-owner-scoped.md
+++ b/outstatic/content/posts/oracle-er-diagram-owner-scoped.md
@@ -45,7 +45,7 @@ the process rather than in a query per table.
The two other shapes in this codebase put the cost somewhere else. A per-table
loop is N+1: the round-trip count follows the table count. A single stitched
-query is one round trip, which is what the PostgreSQL provider does, but it
+query is one round trip, which is what the [PostgreSQL](/blog/engine/postgresql/) provider does, but it
needs a materialized CTE that only holds together in one dialect. Five bulk
statements sit between the two: a fixed count, no generated SQL, and five flat
result sets grouped in memory by table name.
@@ -72,11 +72,11 @@ WHERE ac.OWNER = :1 AND ac.CONSTRAINT_TYPE = 'R'
Two consequences follow from that being the only source of an edge.
-The first is the rule the [feature page](/features) publishes for every engine:
+The first is the rule the [feature page](/features/) publishes for every engine:
edges are discovered from declared foreign keys. A relationship your application
enforces in code, or that lives only in a naming convention, declares nothing for
this query to find and draws no line. Oracle is not special there; it is the same
-sentence as ClickHouse, arrived at from the opposite direction, since ClickHouse
+sentence as [ClickHouse](/blog/engine/clickhouse/), arrived at from the opposite direction, since ClickHouse
declares no foreign keys at all.
The second is specific to this dictionary read. The join above matches
@@ -150,4 +150,4 @@ count is `SELECT COUNT(*)`, and it costs what it costs.
The rest of what the Oracle provider does and declines to do - Thin-mode
transport, the maintenance vocabulary, and an agent auto run that ends
engine-unsupported on Oracle while plan mode still opens and executes nothing -
-is on the [engine page](/databases).
+is on the [engine page](/databases/).
diff --git a/outstatic/content/posts/oracle-monitoring-privilege-question.md b/outstatic/content/posts/oracle-monitoring-privilege-question.md
index d0424f2..1b2acbf 100644
--- a/outstatic/content/posts/oracle-monitoring-privilege-question.md
+++ b/outstatic/content/posts/oracle-monitoring-privilege-question.md
@@ -66,7 +66,7 @@ standing. The default a guard degrades to is `N/A` or `[]` where the shape has
somewhere to say "not measured" - and, where it does not, nothing at all.
This is the same rule the rest of the product follows for engine capabilities:
-[a control that cannot work](/features) is absent with the reason written where
+[a control that cannot work](/features/) is absent with the reason written where
it would have been, rather than offered and then failed. Here the reason is a
grant rather than an engine limit, which makes it more actionable, not less.
@@ -147,11 +147,11 @@ Nothing there is required to use the product. A user with only `CREATE SESSION`
and object privileges gets the editor, the schema tree, the ER diagram, row
editing, the Tables and Storage panels, and Agent plan mode, which is toolless
and executes nothing. Agent auto mode is a different question, and no grant
-reaches it: auto runs on PostgreSQL, SQLite and DuckDB only, and an auto run
+reaches it: auto runs on [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/) only, and an auto run
started against Oracle ends engine-unsupported. The `V$` grants buy
observability and nothing else.
-They are also not free, and the [security page](/security) is the argument for
+They are also not free, and the [security page](/security/) is the argument for
treating them that way: `V$SESSION` exposes what every other session in the
instance is running, across schemas the grantee cannot otherwise read. Killing a
session is a further step again - the operation issues `ALTER SYSTEM KILL
diff --git a/outstatic/content/posts/oracle-no-explain-plan-rendering.md b/outstatic/content/posts/oracle-no-explain-plan-rendering.md
index b69cafb..8de3cc7 100644
--- a/outstatic/content/posts/oracle-no-explain-plan-rendering.md
+++ b/outstatic/content/posts/oracle-no-explain-plan-rendering.md
@@ -28,7 +28,7 @@ and the code path that produces an explain request can only send one.
## What a plan flow on Oracle needs
Every engine whose plan LibreDB Studio renders answers the same way: one statement
-in, one result set back. `EXPLAIN (FORMAT JSON) SELECT ...` on PostgreSQL is a
+in, one result set back. `EXPLAIN (FORMAT JSON) SELECT ...` on [PostgreSQL](/blog/engine/postgresql/) is a
statement whose rows are the plan. The renderer takes those rows and draws nodes.
Oracle does not work like that. The plan is produced by one statement and read by
@@ -55,7 +55,7 @@ the `oracledb` pool, runs `conn.execute(...)`, and returns
`{ rows, fields, rowCount, executionTime }`. There is no place in that shape for
"run this, then run that, and give me the rows from the second one".
-The builder that composes the explain string handles the PostgreSQL and MySQL
+The builder that composes the explain string handles the PostgreSQL and [MySQL](/blog/engine/mysql/)
dialects. Oracle is not in it, and adding a case is not a one-line change, because
the string is the wrong unit. A pair of statements needs a sequencing decision the
single-statement path never had to make: whether the display call may be sent on a
@@ -93,7 +93,7 @@ So the flag was flipped. **The explain capability is declared false on Oracle an
the action is hidden, because a real plan flow needs a plan statement followed by a
display call, which the single-statement explain path cannot express.** That is not
a note in a changelog. It is the published capability of this engine, and it lines
-up with what [the plan-rendering feature page](/features) states in general form:
+up with what [the plan-rendering feature page](/features/) states in general form:
where an engine has no plan interface, there is nothing to render. On Oracle the
interface that is missing is the single-statement one, not the plan facility.
@@ -110,7 +110,7 @@ reads `V$SQL` ordered by elapsed time - which is not a plan, but it is the engin
own account of what has been expensive. Agent plan mode opens here too: it is
toolless, executes nothing, and drafts a statement for a human to run. Agent auto
mode does not run on Oracle at all; that is a separate boundary, published on the
-[Oracle engine page](/databases), and it is not what this post is about.
+[Oracle engine page](/databases/), and it is not what this post is about.
And the plan itself is not out of reach. The editor sends what you type, so the two
statements above run there today, in order, and `DBMS_XPLAN.DISPLAY()` returns the
diff --git a/outstatic/content/posts/oracle-number-and-timestamp-fidelity.md b/outstatic/content/posts/oracle-number-and-timestamp-fidelity.md
index 22474c2..2fcc9df 100644
--- a/outstatic/content/posts/oracle-number-and-timestamp-fidelity.md
+++ b/outstatic/content/posts/oracle-number-and-timestamp-fidelity.md
@@ -65,7 +65,7 @@ total crosses with its digits intact. A `NUMBER(38,0)` identifier does not, and
the two sit in the same grid looking equally trustworthy.
There is a known fix and it is not applied. Fetching `NUMBER` as a string keeps
-every digit - the same move that keeps a Cassandra `bigint` intact one provider
+every digit - the same move that keeps a [Cassandra](/blog/engine/cassandra/) `bigint` intact one provider
over. It is not in the product because it changes **every numeric cell Oracle
produces**: the ones the grid right-aligns, the ones the CSV writes, the ones the
SQL export puts inside an `INSERT`. A change of that blast radius is tracked on
@@ -145,7 +145,7 @@ standard the `NUMBER` path does not yet meet.
## Reading around both, on the server side
Oracle still has everything the client dropped. Ask for it in the same result
-set, in the [editor](/features) you were already in:
+set, in the [editor](/features/) you were already in:
```sql
SELECT
@@ -175,4 +175,4 @@ change waiting to be made across every numeric cell at once, and measured the wa
the LOB and interval changes were. The stored offset is not recoverable at this
layer at all: the driver reduces the value before provider code sees it, so
`TO_CHAR` is the answer rather than a stopgap. Both are listed on the
-[engine pages](/databases) as what Oracle cannot answer here.
+[engine pages](/databases/) as what Oracle cannot answer here.
diff --git a/outstatic/content/posts/oracle-thin-mode-service-name.md b/outstatic/content/posts/oracle-thin-mode-service-name.md
index e0415ae..86e942b 100644
--- a/outstatic/content/posts/oracle-thin-mode-service-name.md
+++ b/outstatic/content/posts/oracle-thin-mode-service-name.md
@@ -170,7 +170,7 @@ low-privilege user gets a dashboard with gaps rather than a failure. There is no
Explain action: `supportsExplain` is false until an Oracle dialect wrapper
exists, and the UI hides the action rather than running the query unchanged.
Agent AUTO mode does not run here either - it needs a database-native read-only
-profile, which exists for PostgreSQL, SQLite and DuckDB only, so an auto run on
+profile, which exists for [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/) only, so an auto run on
Oracle ends engine-unsupported. Plan mode does open on the connection, toolless,
and drafts a statement for a human to run. The per-engine boundaries are listed
-on the [databases page](/databases).
+on the [databases page](/databases/).
diff --git a/outstatic/content/posts/postgresql-agent-least-privilege-role.md b/outstatic/content/posts/postgresql-agent-least-privilege-role.md
index 2df7b3f..427197c 100644
--- a/outstatic/content/posts/postgresql-agent-least-privilege-role.md
+++ b/outstatic/content/posts/postgresql-agent-least-privilege-role.md
@@ -123,7 +123,7 @@ something that never covered `COPY ... TO PROGRAM` in the first place. A
boundary you can talk your way out of inside one statement is not one worth
shipping under the word read-only.
-Agent AUTO mode runs on PostgreSQL, SQLite and DuckDB only, because the
+Agent AUTO mode runs on 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. Even on PostgreSQL, a superuser connection is refused with
`PROFILE_PRIVILEGES_TOO_BROAD`. On every other engine an auto run ends
@@ -176,7 +176,7 @@ Four falses and the role clears the privilege probe. Anything else and the open
is refused with `PROFILE_PRIVILEGES_TOO_BROAD`, which names the check that
failed but not which of the four answers tripped it - the query above is how you
find that out. The rest of what agent mode does - the metering,
-the citation rule, the verdict - is on the [features page](/features); what the
+the citation rule, the verdict - is on the [features page](/features/); what the
run is allowed to touch and what it records is on the
-[security page](/security); and which engines carry the profile at all is on the
-[engine grid](/databases).
+[security page](/security/); and which engines carry the profile at all is on the
+[engine grid](/databases/).
diff --git a/outstatic/content/posts/postgresql-agent-read-only-boundary.md b/outstatic/content/posts/postgresql-agent-read-only-boundary.md
index 47ed4fd..35ec9a5 100644
--- a/outstatic/content/posts/postgresql-agent-read-only-boundary.md
+++ b/outstatic/content/posts/postgresql-agent-read-only-boundary.md
@@ -152,10 +152,10 @@ policy layer's catalog and schema allowlist screens the target the agent
*declared*; only the grants bound what a hostile statement could touch instead.
This is the AUTO path - the metered, tool-using run, which exists on PostgreSQL,
-SQLite and DuckDB only, because the read-only profile is database-native and
+[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. PLAN mode opens on every connection: it
is toolless, executes nothing, and drafts a statement for a human to run. The
-[agent mode entry in the feature list](/features) states that boundary, and
-[the security page](/security) publishes the known limitations next to the
+[agent mode entry in the feature list](/features/) states that boundary, and
+[the security page](/security/) publishes the known limitations next to the
controls, which is where the `SET TRANSACTION READ WRITE` result belongs as much
as it belongs here.
diff --git a/outstatic/content/posts/postgresql-connect-docker-container.md b/outstatic/content/posts/postgresql-connect-docker-container.md
index 39c3b91..bb401e2 100644
--- a/outstatic/content/posts/postgresql-connect-docker-container.md
+++ b/outstatic/content/posts/postgresql-connect-docker-container.md
@@ -65,7 +65,7 @@ docker compose -f database-compose.yml up -d postgres
If Studio is running on your machine rather than in a container - the npx package,
the desktop build, a dev server - you are done. The [get started
-walkthrough](/get-started) picks up from the connection dialog. If Studio is a
+walkthrough](/get-started/) picks up from the connection dialog. If Studio is a
container, read on, because the next thing you will see is a connection error.
## Why localhost fails from inside a container
@@ -147,7 +147,7 @@ certificate is not verified. Verified TLS to a managed host needs an explicit SS
mode of `verify-system`, `verify-ca` or `verify-full`.
The same reasoning scales past one machine. Among the [deployment
-channels](/deploy), a Helm release addresses the database by its Kubernetes service
+channels](/deploy/), a Helm release addresses the database by its Kubernetes service
name for the same reason a compose stack addresses it by its compose service name.
Only a client on your own machine ever addresses `localhost`.
@@ -189,4 +189,4 @@ Both are trades made in the same direction: an exact count on every table in a l
schema means a sequential scan per table at every tree refresh. The estimate is free,
because the planner was keeping it anyway. What the estimate cannot answer is how many
rows are in the table right now; for that, type the `COUNT(*)` in the editor. The
-[engine grid](/databases) carries the same kind of line for every other engine.
+[engine grid](/databases/) carries the same kind of line for every other engine.
diff --git a/outstatic/content/posts/postgresql-editor-automatic-row-limits.md b/outstatic/content/posts/postgresql-editor-automatic-row-limits.md
index 014faa7..3e43a45 100644
--- a/outstatic/content/posts/postgresql-editor-automatic-row-limits.md
+++ b/outstatic/content/posts/postgresql-editor-automatic-row-limits.md
@@ -129,7 +129,7 @@ Agent AUTO mode reads at most 200 rows per statement, and that is a separate bud
enforced on a separate path - the agent's `queryReadOnly()` runs exactly one statement
inside `BEGIN READ ONLY` and does no rewriting at all. The editor's row cap and the
agent's row budget are not the same mechanism and do not share a number; the agent's is
-published with the rest of that run's budgets on the [features page](/features). AUTO
+published with the rest of that run's budgets on the [features page](/features/). AUTO
mode also needs a least-privilege role on this engine: the execution profile probes the
role when it opens and refuses a superuser connection with
`PROFILE_PRIVILEGES_TOO_BROAD`.
diff --git a/outstatic/content/posts/postgresql-er-diagram-two-phase-introspection.md b/outstatic/content/posts/postgresql-er-diagram-two-phase-introspection.md
index 65e2933..3aa4f18 100644
--- a/outstatic/content/posts/postgresql-er-diagram-two-phase-introspection.md
+++ b/outstatic/content/posts/postgresql-er-diagram-two-phase-introspection.md
@@ -125,7 +125,7 @@ shown schema-qualified, and referenced tables follow the same rule so a
cross-schema key points somewhere legible.
The consequence is the one stated on the [ER diagram feature
-page](/features): a relationship your application enforces in code and never
+page](/features/): a relationship your application enforces in code and never
declares in the schema has nothing to discover. If `orders.customer_id` is an
`integer` with no constraint on it, the schema has not said that it references
anything, and the graph is built from what the schema says.
diff --git a/outstatic/content/posts/postgresql-explain-analyze-executes.md b/outstatic/content/posts/postgresql-explain-analyze-executes.md
index b34ed78..cfbb0b1 100644
--- a/outstatic/content/posts/postgresql-explain-analyze-executes.md
+++ b/outstatic/content/posts/postgresql-explain-analyze-executes.md
@@ -154,5 +154,5 @@ open a transaction yourself, or run it on a copy.
Every number in the plan-only tree is an estimate. That limitation is the price of
the guarantee that reading it changed nothing, and it is published on the
-[feature pages](/features) and the [engine page](/databases) rather than left in a
+[feature pages](/features/) and the [engine page](/databases/) rather than left in a
tooltip.
diff --git a/outstatic/content/posts/postgresql-health-panels-extension-gated.md b/outstatic/content/posts/postgresql-health-panels-extension-gated.md
index 43ba8e1..b6e2cc4 100644
--- a/outstatic/content/posts/postgresql-health-panels-extension-gated.md
+++ b/outstatic/content/posts/postgresql-health-panels-extension-gated.md
@@ -130,8 +130,8 @@ statement about the code, not about your locks.
That last one is the sharpest example of why the distinction matters. A `false` you
believe is worse than a panel you know is missing, and it is the reason the [monitoring
-surface](/features) reports absences as absences. PostgreSQL is the engine where
-[nothing is held back](/databases) in feature coverage, and it is still an engine whose
+surface](/features/) reports absences as absences. PostgreSQL is the engine where
+[nothing is held back](/databases/) in feature coverage, and it is still an engine whose
health surface depends in places on an extension, a grant or a major version. Those are
two compatible facts, and publishing both is cheaper than explaining one of them during
an incident.
diff --git a/outstatic/content/posts/postgresql-managed-url-ssl-modes.md b/outstatic/content/posts/postgresql-managed-url-ssl-modes.md
index 2c0444c..31d7daa 100644
--- a/outstatic/content/posts/postgresql-managed-url-ssl-modes.md
+++ b/outstatic/content/posts/postgresql-managed-url-ssl-modes.md
@@ -137,7 +137,7 @@ connect time with a file error that reads like a permissions problem.
The SSL / TLS panel holds PEM **text**, not a path. Paste the certificate
contents - `caCert`, `clientCert` and `clientKey` map onto `pg`'s `ca`, `cert`
-and `key`. [The security page](/security) states the same boundary alongside the
+and `key`. [The security page](/security/) states the same boundary alongside the
others this build publishes.
## Setting a verified TLS mode by hand
@@ -174,5 +174,5 @@ every managed provider on a custom domain.
The shortest correct habit: after pasting a URL, open the SSL / TLS panel and
read the mode. A mode you chose is the mode you get. A mode you did not choose -
including the Disable the form opens on - hands the decision to a host-name match
-that encrypts without checking who answered. The [engine reference](/databases)
+that encrypts without checking who answered. The [engine reference](/databases/)
carries the rest of the PostgreSQL provider's transport detail.
diff --git a/outstatic/content/posts/postgresql-query-audit-trail-admin-only.md b/outstatic/content/posts/postgresql-query-audit-trail-admin-only.md
index edf239f..408c645 100644
--- a/outstatic/content/posts/postgresql-query-audit-trail-admin-only.md
+++ b/outstatic/content/posts/postgresql-query-audit-trail-admin-only.md
@@ -23,7 +23,7 @@ outcome and the error detail, and it is readable by admins only.
That is a smaller claim than a server-side audit extension makes, and it is
deliberately a different one. The trail is application-level and
-engine-independent: the same record shape on PostgreSQL as on Redis, because it is
+engine-independent: the same record shape on PostgreSQL as on [Redis](/blog/engine/redis/), because it is
written by the application, not by the engine.
## What the trail records, statement by statement
@@ -95,7 +95,7 @@ reach the stdout channel: its body is client-supplied, and giving it the
authoritative channel would let an admin session forge an indistinguishable log
line. Authoritative events are emitted as one structured JSON line on stdout, into
whatever already collects your container logs. The rest of the control set is on
-[the security page](/security), stated with its gaps.
+[the security page](/security/), stated with its gaps.
## Application history is not a database audit extension
@@ -149,7 +149,7 @@ with `PROFILE_PRIVILEGES_TOO_BROAD` unless superuser and membership of
`pg_read_server_files`, `pg_write_server_files` and `pg_execute_server_program`
all read back false. PLAN mode opens on every connection, is handed no tools, and
executes nothing, so it produces no execution events to audit. The rest of that
-boundary is on [the features page](/features).
+boundary is on [the features page](/features/).
## Using it as change evidence, and where that stops
diff --git a/outstatic/content/posts/redis-acl-user-degraded-connection.md b/outstatic/content/posts/redis-acl-user-degraded-connection.md
index 30b8e41..af0068f 100644
--- a/outstatic/content/posts/redis-acl-user-degraded-connection.md
+++ b/outstatic/content/posts/redis-acl-user-degraded-connection.md
@@ -150,7 +150,7 @@ withhold, because their absence is already handled as an empty list.
The reason to bother getting this right is that the ACL is the enforcement point,
not the provider. The generic `call()` dispatch runs `SET`, `DEL` and `FLUSHALL`
the same way it runs `GET`; there is no read-only guard in the Redis provider, so
-access control is whatever the ACL enforces. The [security page](/security) makes
+access control is whatever the ACL enforces. The [security page](/security/) makes
the same point about the controls Studio does apply: masking is display-level,
and a hard guarantee needs database-side grants on the account the connection
uses. A read-only Redis session is an ACL you wrote, or it is nothing.
diff --git a/outstatic/content/posts/redis-agent-plan-bounded-inventory.md b/outstatic/content/posts/redis-agent-plan-bounded-inventory.md
index 17f9dc3..8ce460e 100644
--- a/outstatic/content/posts/redis-agent-plan-bounded-inventory.md
+++ b/outstatic/content/posts/redis-agent-plan-bounded-inventory.md
@@ -26,7 +26,7 @@ handed is not a list of keys; it is a summary this server computed.
## What grounding means on a key-value store
Before a plan run's first turn, the server reads the connection's schema through the
-provider and writes it into the prompt. On PostgreSQL that is catalog reads plus a
+provider and writes it into the prompt. On [PostgreSQL](/blog/engine/postgresql/) that is catalog reads plus a
statistics read. On Redis it is one call, because there are no statistics this run
knows how to read here, and it comes out of the same per-run statement budget and is
audited the same way as every other read.
@@ -52,7 +52,7 @@ driven by what the provider declares, not by a check on the connection's type.
The first is the noun. This product records every schema in one shape, `TableSchema`,
and the prompts had been using that shape's name as a word. `ProviderLabels.entityName`
has carried the right word all along - "Table" on the SQL engines, "Collection" on
-MongoDB, "Datasource" on Druid, "Key Pattern" on Redis - and only the browser was
+[MongoDB](/blog/engine/mongodb/), "Datasource" on Druid, "Key Pattern" on Redis - and only the browser was
being shown it. The fenced inventory header now takes it, so a Redis run reads
"17 key pattern(s)".
@@ -119,12 +119,12 @@ and asks the one question that would unblock it.
Nobody in this loop does. Agent AUTO mode - the tool-using run - does not open on
Redis at all. The read-only profile that mode depends on is database-native,
-and `queryReadOnly` exists on the PostgreSQL, SQLite and DuckDB providers only. It
+and `queryReadOnly` exists on the PostgreSQL, [SQLite](/blog/engine/sqlite/) and DuckDB providers only. It
appears nowhere in `redis.ts`, so a Redis agent run ends `engine-unsupported`. Plan
mode opens on every connection instead, and it is toolless: it executes nothing, and
its inventory is that bounded thousand-key scan whose rows are derived groupings rather
than objects a command can be given. Those are two different modes, and the distinction
-is published on the [features page](/features).
+is published on the [features page](/features/).
That leaves a person holding a drafted command. Two things meet them there. The
provider has no read-only guard - the generic `call()` dispatch executes `SET`, `DEL`
@@ -137,5 +137,5 @@ its destructive vocabulary. A body it cannot parse asks rather than staying sile
The drafted statement is also recorded, as a `plan-statement-drafted` event carrying
the command, the dialect, whether it is read-only and what the identifier check found.
The audit trail is admin-only, which is stated with the rest of the boundaries on the
-[security page](/security). A plan run leaves a record of what it proposed. What
+[security page](/security/). A plan run leaves a record of what it proposed. What
happened next is on the person who pressed Run.
diff --git a/outstatic/content/posts/redis-compatible-servers-one-type-id.md b/outstatic/content/posts/redis-compatible-servers-one-type-id.md
index 3a29429..460e45d 100644
--- a/outstatic/content/posts/redis-compatible-servers-one-type-id.md
+++ b/outstatic/content/posts/redis-compatible-servers-one-type-id.md
@@ -139,13 +139,13 @@ in the maintenance list here.
Two more boundaries travel with the family, not with any one relative. Agent
AUTO mode does not run on any of these servers: the read-only profile is
-database-native and only PostgreSQL, SQLite and DuckDB implement it, so an auto
+database-native and only [PostgreSQL](/blog/engine/postgresql/), [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/) implement it, so an auto
run ends `engine-unsupported`. Agent PLAN mode does open, toolless, executing
nothing, and it is told in one sentence that a `user:*` row is a grouping this
server derived from a bounded scan rather than a key any command can be given.
That same fact is why the schema explorer offers no per-row Profile Table,
Generate Test Data or maintenance action here.
-The [engine list](/databases) publishes what each engine deliberately cannot do
-next to its transport and port, and the [capability pages](/features) publish
+The [engine list](/databases/) publishes what each engine deliberately cannot do
+next to its transport and port, and the [capability pages](/features/) publish
the limit beside each feature.
diff --git a/outstatic/content/posts/redis-key-prefixes-derived-groupings.md b/outstatic/content/posts/redis-key-prefixes-derived-groupings.md
index b981d51..8866d5e 100644
--- a/outstatic/content/posts/redis-key-prefixes-derived-groupings.md
+++ b/outstatic/content/posts/redis-key-prefixes-derived-groupings.md
@@ -19,7 +19,7 @@ Open a Redis connection and the schema tree fills with rows that look like table
`user:*`, `session:*`, `queue:*`, each with a key count beside it. They are not tables.
A Redis key prefix browser has to scan keys rather than list them, and what comes back
is a summary computed from the slice the scan reached. Right-click one of those rows
-and the menu is shorter than it is on PostgreSQL. That is the same fact, showing up
+and the menu is shorter than it is on [PostgreSQL](/blog/engine/postgresql/). That is the same fact, showing up
where you can see it.
## How the Redis key prefix browser builds a row
@@ -89,7 +89,7 @@ keyspace; the command line is not bounded by that cap.
The provider declares `tablesAreDerivedGroupings: true`. The interface reads that flag
and removes controls rather than letting them fail, which is the [capability rule the
-interface follows](/features): a control that cannot work is absent, with its reason,
+interface follows](/features/): a control that cannot work is absent, with its reason,
instead of present and broken.
Four schema-explorer actions are missing on Redis, all for one reason.
@@ -119,7 +119,7 @@ The same sentence travels into the agent layer. Plan mode opens on a Redis conne
it is toolless and executes nothing - and its grounding rules carry one line saying the
inventory rows are groupings derived from a bounded scan, so a plan does not draft a
command against `user:*`. Agent AUTO mode does not run here at all: the read-only
-profile is database-native and only PostgreSQL, SQLite and DuckDB implement it, so an
+profile is database-native and only PostgreSQL, [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/) implement it, so an
auto run on Redis ends `engine-unsupported`.
## Scan Keys is one iteration, not a listing
@@ -145,6 +145,6 @@ mixed, it emits `TYPE ` instead of guessing, because a wrong reader -
are escaped in the `MATCH` half of a `SCAN` and nowhere else, since a key argument
containing a literal `*` is a real key name.
-The [engine page for Redis](/databases) states the top half of this before you connect:
+The [engine page for Redis](/databases/) states the top half of this before you connect:
no SQL, and none is pretended. The prefix rows are the bottom half - no tables either,
and the rows that stand in for them are labelled as the groupings they are.
diff --git a/outstatic/content/posts/redis-monitoring-info-slowlog.md b/outstatic/content/posts/redis-monitoring-info-slowlog.md
index 07bb19e..e5c9791 100644
--- a/outstatic/content/posts/redis-monitoring-info-slowlog.md
+++ b/outstatic/content/posts/redis-monitoring-info-slowlog.md
@@ -55,7 +55,7 @@ sessions show. It is the parser's fallback, not a login.
The queries panel's empty state says what is empty: Redis lists what `SLOWLOG`
holds, and nothing has yet run slower than `slowlog-log-slower-than`. That
-sentence used to be PostgreSQL's advice about `pg_stat_statements` on every
+sentence used to be [PostgreSQL](/blog/engine/postgresql/)'s advice about `pg_stat_statements` on every
engine, which is worse than no sentence, because it sends the reader to look for
an extension that does not exist here.
@@ -100,11 +100,11 @@ and `user:456` collapse into a row called `user:*`. That row is this server's ow
summary of a bounded scan. It is not a key any command can be given, and it has
no statistics of its own to report, because Redis does not keep any.
-The stated limit on [the monitoring feature](/features) is that what each panel
+The stated limit on [the monitoring feature](/features/) is that what each panel
can show is bounded by what the engine reports, and Redis is that sentence at its
plainest: the tab renders the list the provider returned, and the list is empty,
so nothing fills it with plausible zeros. The engine's line on [the databases
-page](/databases) makes the same point one level up: no SQL, and none is
+page](/databases/) makes the same point one level up: no SQL, and none is
pretended.
## Degrading under a restricted ACL instead of breaking
diff --git a/outstatic/content/posts/redis-tls-rediss-verification.md b/outstatic/content/posts/redis-tls-rediss-verification.md
index 8ee3907..21fffb1 100644
--- a/outstatic/content/posts/redis-tls-rediss-verification.md
+++ b/outstatic/content/posts/redis-tls-rediss-verification.md
@@ -91,7 +91,7 @@ an open question on the backlog rather than a settled one.
`buildTLSOptions()` turns `connection.ssl` into the single `tls` object ioredis
hands to Node's `tls.connect`, so the material travels under Node's own names -
-the same mapping the PostgreSQL, MySQL and Couchbase adapters use.
+the same mapping the [PostgreSQL](/blog/engine/postgresql/), [MySQL](/blog/engine/mysql/) and [Couchbase](/blog/engine/couchbase/) adapters use.
| `ssl.mode` | `tls` option |
|---|---|
@@ -174,7 +174,7 @@ only a single standalone node is supported. What is published is the mapping fro
scheme to mode to driver option, measured against a local TLS-only node, and the
rule that a `rediss://` paste selects `require` rather than a verifying mode. The
boundaries this product publishes about itself are collected on
-[the security page](/security).
+[the security page](/security/).
If you need the chain checked, pick the mode. The URL will not pick it for you,
and it should not.
diff --git a/outstatic/content/posts/sqlite-agent-read-only-two-controls.md b/outstatic/content/posts/sqlite-agent-read-only-two-controls.md
index f0eef41..63713fb 100644
--- a/outstatic/content/posts/sqlite-agent-read-only-two-controls.md
+++ b/outstatic/content/posts/sqlite-agent-read-only-two-controls.md
@@ -31,7 +31,7 @@ is a different question, and `VACUUM INTO` is the answer.
## A second handle, physically separate
-PostgreSQL establishes read-only enforcement per transaction. SQLite has no such
+[PostgreSQL](/blog/engine/postgresql/) establishes read-only enforcement per transaction. SQLite has no such
construct, so the boundary has to be established at open time instead. The agent's
read-only profile does not borrow the shared, writable provider the editor uses. It
acquires a dedicated provider keyed by connection id and execution profile, and that
@@ -143,14 +143,14 @@ segments simply resolve. On a shared self-hosted instance, any logged-in user ca
any SQLite file the Studio process can read; the mitigations available today are
OS-level - the process user, the container mount - and an optional base-directory
allowlist is tracked as an open issue. That, and the rest of what this profile does
-not cover, is written down on the [security page](/security) rather than left for a
+not cover, is written down on the [security page](/security/) rather than left for a
reader to infer.
-None of this transfers to libSQL. It speaks SQLite's dialect over a network protocol
+None of this transfers to [libSQL](/blog/engine/libsql/). It speaks SQLite's dialect over a network protocol
and refuses `PRAGMA query_only`, so agent AUTO mode does not extend to it. AUTO runs
-on PostgreSQL, SQLite and DuckDB only, because those are the three providers that
+on PostgreSQL, SQLite and [DuckDB](/blog/engine/duckdb/) only, because those are the three providers that
implement a database-native read-only path; anywhere else a run ends
`engine-unsupported`. PLAN mode opens on every connection - it is toolless, executes
nothing, and drafts a statement for a human to run. The difference between the two
modes, and the rest of what agent mode does with the handle it gets, is on the
-[features page](/features).
+[features page](/features/).
diff --git a/outstatic/content/posts/sqlite-health-without-a-server.md b/outstatic/content/posts/sqlite-health-without-a-server.md
index 2273eb1..1e76e05 100644
--- a/outstatic/content/posts/sqlite-health-without-a-server.md
+++ b/outstatic/content/posts/sqlite-health-without-a-server.md
@@ -108,7 +108,7 @@ pending a better query, and `getHealth()` says so in its own string field:
`cacheHitRatio` is `N/A`.
Slow queries are the same shape of absence. `getSlowQueries()` returns `[]`
-unconditionally, and the empty state is overridden away from PostgreSQL's
+unconditionally, and the empty state is overridden away from [PostgreSQL](/blog/engine/postgresql/)'s
`pg_stat_statements` advice to the sentence that is actually true here: SQLite
keeps no statistics about finished statements, so there is nothing to enable.
Index `scans` is always `0` for the same reason - there is no usage counter to
@@ -168,9 +168,9 @@ the byte fields are absent rather than zero, and why every consumer gates on the
absent `tableSizeBytes` instead of reading a placeholder.
The rule that came out of it governs the rest of this dashboard, and it is the
-same rule every entry in the [feature list](/features) follows: a control that
+same rule every entry in the [feature list](/features/) follows: a control that
cannot answer is absent, with the reason written where it would have been. An
empty panel is indistinguishable from a broken one, so the absence carries a
-sentence. [The SQLite engine page](/databases) publishes that sentence before
+sentence. [The SQLite engine page](/databases/) publishes that sentence before
anyone connects: there is no server to monitor, and health reads file size and
pragma statistics only.
diff --git a/outstatic/content/posts/sqlite-no-transaction-controls.md b/outstatic/content/posts/sqlite-no-transaction-controls.md
index 362a35e..7af815f 100644
--- a/outstatic/content/posts/sqlite-no-transaction-controls.md
+++ b/outstatic/content/posts/sqlite-no-transaction-controls.md
@@ -16,7 +16,7 @@ publishedAt: 2026-03-17T09:00:00.000Z
---
Open a SQLite connection in Studio and the editor toolbar is missing a group of
-buttons you will find on the PostgreSQL connection next to it. There is no BEGIN,
+buttons you will find on the [PostgreSQL](/blog/engine/postgresql/) connection next to it. There is no BEGIN,
no COMMIT, no ROLLBACK, and no SANDBOX toggle. The reason is not that SQLite
lacks transactions. SQLite has `BEGIN`. The provider does not have a session to
run it in.
@@ -84,7 +84,7 @@ broken. The flag describes the provider's surface, not the engine's grammar -
that distinction is written into the capability table in the provider doc,
because the two are genuinely different claims and conflating them would make the
flag a lie about SQLite. This is the same rule the rest of the interface follows:
-a control that cannot work is [absent with its reason published](/features),
+a control that cannot work is [absent with its reason published](/features/),
not disabled and not silently missing.
## The cancel path that does not exist either
@@ -133,7 +133,7 @@ what the engine said and stops there. Inline row editing works, because
full-table read in a scratch tab comes back bounded.
Agent AUTO mode runs here. SQLite is one of the three engines where it does -
-PostgreSQL and DuckDB are the others, because those three are the providers that
+PostgreSQL and [DuckDB](/blog/engine/duckdb/) are the others, because those three are the providers that
implement `queryReadOnly`; on any other engine an auto run ends
`engine-unsupported`, and PLAN mode, which is toolless and executes nothing,
opens everywhere. AUTO mode reaches the missing session from a different
@@ -151,4 +151,4 @@ the ones the file model already offers: point the connection at a copy of the
file, or at `:memory:` for a scratch database that is discarded on disconnect.
Neither is a rollback. Saying so is cheaper than a button that answers 400. The
per-engine capability lines, including this one, are published on the
-[engine pages](/databases) rather than discovered at runtime.
+[engine pages](/databases/) rather than discovered at runtime.
diff --git a/outstatic/content/posts/sqlite-path-is-the-trust-boundary.md b/outstatic/content/posts/sqlite-path-is-the-trust-boundary.md
index f05164a..32b3d6b 100644
--- a/outstatic/content/posts/sqlite-path-is-the-trust-boundary.md
+++ b/outstatic/content/posts/sqlite-path-is-the-trust-boundary.md
@@ -155,4 +155,4 @@ is the trade, stated in one place rather than discovered per user.
For a single-operator install none of this is necessary and the plain path field is the
feature working as designed. For anything shared, the mount is the access control, and
it should be written down next to the deployment rather than held in someone's head. The
-[deployment guide](/deploy) lists the channels this image ships through.
+[deployment guide](/deploy/) lists the channels this image ships through.
diff --git a/outstatic/content/posts/sqlite-query-plan-without-costs.md b/outstatic/content/posts/sqlite-query-plan-without-costs.md
index 1006134..65a162a 100644
--- a/outstatic/content/posts/sqlite-query-plan-without-costs.md
+++ b/outstatic/content/posts/sqlite-query-plan-without-costs.md
@@ -131,12 +131,12 @@ rather than its arithmetic.
Timing is a separate absence with the same shape. SQLite keeps no statistics about
finished statements: `getSlowQueries()` returns an empty list unconditionally, and
the monitoring panel's empty state says so in those words rather than repeating
-PostgreSQL's advice about enabling an extension that does not exist here. Per-index
+[PostgreSQL](/blog/engine/postgresql/)'s advice about enabling an extension that does not exist here. Per-index
usage counts are the same story - index `scans` is always `0`, because there is no
usage counter to read.
This is what the engine grid means when the SQLite row on
-[the databases page](/databases) says there is no server to monitor. The absences
+[the databases page](/databases/) says there is no server to monitor. The absences
all come from one fact: an embedded engine with a single file and no server process
keeps no runtime accounting for anyone to query.
@@ -152,14 +152,14 @@ The plan answers structure. For the rest, go to a surface that measures somethin
are not available here, so the decision is made from plans and definitions, not
from a hit counter.
- **"What should I change?"** Agent mode's auto run works on SQLite, one of the
- three engines it runs on at all, with PostgreSQL and DuckDB. It reads through a
+ three engines it runs on at all, with PostgreSQL and [DuckDB](/blog/engine/duckdb/). It reads through a
read-only profile: a
second, physically separate handle to the same file, with `PRAGMA query_only` set
and verified again before every statement. It takes the catalog out of
`sqlite_master` rather than the pragma table-valued functions the statement guard
refuses, and composes a report whose every claim cites the result it came from.
The statements it drafts for you, it does not run - the handle it holds refuses
- writes. The rest is on [the features page](/features).
+ writes. The rest is on [the features page](/features/).
- **"Have the statistics gone stale?"** Run `ANALYZE`. It is in the maintenance
toolkit, which is admin-only, and on SQLite it is offered per table as well as for
the whole database. It will not add numbers to the plan tree. It will change which
diff --git a/outstatic/content/posts/sqlite-server-side-file-path.md b/outstatic/content/posts/sqlite-server-side-file-path.md
index 596af70..79cb649 100644
--- a/outstatic/content/posts/sqlite-server-side-file-path.md
+++ b/outstatic/content/posts/sqlite-server-side-file-path.md
@@ -98,7 +98,7 @@ means that path on the machine running Studio, always.
So SQLite as a target fits self-hosted installs, Docker, local development, edge
deployments and zero-configuration trials, where the file already sits beside
the app. It is not a multi-tenant SaaS target, and the
-[engine list](/databases) is where to look if the database you need to reach is
+[engine list](/databases/) is where to look if the database you need to reach is
somewhere else.
There is a second consequence for shared instances, and it is not a corner case.
@@ -135,14 +135,14 @@ that actually holds.
The connection form is not where you ask for read-only. `connect()` opens the
file `readwrite` and sets `journal_mode = WAL`, which is itself a write. The
read-only handle belongs to agent mode instead. SQLite is one of the three
-engines - with PostgreSQL and DuckDB - where an agent run executes statements at
+engines - with [PostgreSQL](/blog/engine/postgresql/) and [DuckDB](/blog/engine/duckdb/) - where an agent run executes statements at
all, and its execution profile opens a second, physically separate handle to the
same file with the driver's read-only flag, then sets and verifies
`PRAGMA query_only` at open and before every statement, because a read-only open
alone reads `query_only` back as 0. Plan mode opens on every connection, is
toolless, and drafts statements for you to run yourself. Each packaged channel
puts its data directory in a different place, so check the
-[deployment channels](/deploy) for the one you use.
+[deployment channels](/deploy/) for the one you use.
## In-memory databases, and what they are for
diff --git a/outstatic/content/posts/sqlserver-azure-sql-restricted-dmvs.md b/outstatic/content/posts/sqlserver-azure-sql-restricted-dmvs.md
index 5a55100..dd3b289 100644
--- a/outstatic/content/posts/sqlserver-azure-sql-restricted-dmvs.md
+++ b/outstatic/content/posts/sqlserver-azure-sql-restricted-dmvs.md
@@ -110,7 +110,7 @@ What survives is worth naming too, because it is not nothing. Where the grants
exist, blocked-session detection is real here (`blocking_session_id > 0` from
`sys.dm_exec_sessions` joined to `sys.dm_exec_requests`) and index scan counts are
real usage data from `sys.dm_db_index_usage_stats`, seeks plus scans plus lookups.
-Both readings sit under the limit the [feature list](/features) already states,
+Both readings sit under the limit the [feature list](/features/) already states,
that what each panel can show is bounded by what the engine reports. On this engine
that reads as the widest DMV surface of any engine in Studio, and a dashboard a
single permission can still empty.
diff --git a/outstatic/content/posts/sqlserver-encrypted-not-authenticated.md b/outstatic/content/posts/sqlserver-encrypted-not-authenticated.md
index 84d6594..985a97e 100644
--- a/outstatic/content/posts/sqlserver-encrypted-not-authenticated.md
+++ b/outstatic/content/posts/sqlserver-encrypted-not-authenticated.md
@@ -127,7 +127,7 @@ handed to tedious" - which replaces the driver with a mock rather than talking t
server, so it fixes the options handed over and not what a server does with them. A
mode added later cannot quietly fall through to the trusting branch. That is a
compensating control rather than a fix; the names still overpromise. The
-[security page](/security) carries the same boundary.
+[security page](/security/) carries the same boundary.
## What a pasted string now maps to, and what can break
@@ -166,4 +166,4 @@ connection carrying only a raw URL and no fields would be built against `localho
Authentication into the server is SQL authentication only - `user` and `password`.
Windows Integrated and Azure AD are not wired. That, the TLS default, and the rest of
-this engine's boundaries sit on its entry in [the engine list](/databases).
+this engine's boundaries sit on its entry in [the engine list](/databases/).
diff --git a/outstatic/content/posts/sqlserver-no-execution-plan-shown.md b/outstatic/content/posts/sqlserver-no-execution-plan-shown.md
index f4bdec9..5dd21a6 100644
--- a/outstatic/content/posts/sqlserver-no-execution-plan-shown.md
+++ b/outstatic/content/posts/sqlserver-no-execution-plan-shown.md
@@ -23,7 +23,7 @@ asked for it can only send one statement.
## What a SQL Server execution plan viewer actually requires
-On PostgreSQL and MySQL, a plan is a statement. You write `EXPLAIN` in front of
+On [PostgreSQL](/blog/engine/postgresql/) and MySQL, a plan is a statement. You write `EXPLAIN` in front of
the query, send the resulting text, and read the rows that come back. The whole
transaction with the server is one request and one response, and a builder that
prepends a keyword to a string is a complete implementation.
@@ -106,7 +106,7 @@ this feature. It is absent.
`getCapabilities()` on this provider returns `supportsExplain: false`, and the
interface renders from that declaration rather than from a layout guess. That is
the same mechanism described in
-[the capability declarations behind the feature list](/features) - a control that cannot
+[the capability declarations behind the feature list](/features/) - a control that cannot
work is absent, with the reason written where it would have been, rather than
offered and then failed.
@@ -122,7 +122,7 @@ the showplan sequence above against your real tables, grounded on this provider'
own inventory. Running it is your decision and your session, which is precisely
the boundary the product cannot cross on your behalf here. AUTO mode does not run
on SQL Server at all; it ends `engine-unsupported`, because the read-only
-execution profile it needs exists only on PostgreSQL, SQLite and DuckDB.
+execution profile it needs exists only on PostgreSQL, [SQLite](/blog/engine/sqlite/) and [DuckDB](/blog/engine/duckdb/).
## What re-enabling it would take
@@ -150,5 +150,5 @@ blocked-session detection from `blocking_session_id` and real index usage counts
from `dm_db_index_usage_stats`, and it is graded partial for a separate reason:
those DMVs need `VIEW SERVER STATE`, and a login without it gets `N/A` and empty
lists across the dashboard rather than numbers. The per-engine boundaries,
-including this one, are published on the [engine pages](/databases) rather than
+including this one, are published on the [engine pages](/databases/) rather than
discovered when you press something.
diff --git a/outstatic/content/posts/sqlserver-result-grid-numeric-fidelity.md b/outstatic/content/posts/sqlserver-result-grid-numeric-fidelity.md
index a9daf44..e14360f 100644
--- a/outstatic/content/posts/sqlserver-result-grid-numeric-fidelity.md
+++ b/outstatic/content/posts/sqlserver-result-grid-numeric-fidelity.md
@@ -155,6 +155,6 @@ Wide integers, decimals and money values are surfaced as JavaScript numbers and
lose precision; only one result set of a multi-statement batch is surfaced; and a
parameterised paged statement is not recognised as already bounded, so the statement
fails. Those three sentences are the SQL Server entry's cost of admission. They are
-on [the engine pages](/databases) and in the published
-[feature boundaries](/features), and we would rather you read them now than reconcile
+on [the engine pages](/databases/) and in the published
+[feature boundaries](/features/), and we would rather you read them now than reconcile
a ledger against them later.
diff --git a/outstatic/content/posts/sqlserver-view-server-state-monitoring.md b/outstatic/content/posts/sqlserver-view-server-state-monitoring.md
index e6b7bf8..eb65f24 100644
--- a/outstatic/content/posts/sqlserver-view-server-state-monitoring.md
+++ b/outstatic/content/posts/sqlserver-view-server-state-monitoring.md
@@ -59,7 +59,7 @@ other engines here are filled differently.
**Blocking is measured, not defaulted.** The active-sessions panel derives its
`blocked` flag from `blocking_session_id > 0` in `sys.dm_exec_requests`. This is
-the only provider in the product that does. The PostgreSQL, MySQL and Oracle
+the only provider in the product that does. The [PostgreSQL](/blog/engine/postgresql/), [MySQL](/blog/engine/mysql/) and [Oracle](/blog/engine/oracle/)
providers report `blocked: false` for every session. A `false` there is a
placeholder; here it is an answer, and the session id it points at is the one
you would pass to a kill.
@@ -75,7 +75,7 @@ lookups are all zero is an index nothing has touched since that reset.
Who is blocking whom, and which indexes are earning their maintenance cost, are
both readings rather than defaults here, which is why this is the widest read
-surface of any provider in the [capability set](/features).
+surface of any provider in the [capability set](/features/).
## The one grant everything is behind
@@ -114,7 +114,7 @@ DMVs are restricted by the platform, and `sys.dm_exec_sessions` there wants
misconfigured in that case; the panels are simply empty for the same reason.
Granting server-state to a reporting login is a real privilege decision, and it
belongs in the same conversation as the rest of the
-[deployment boundaries](/security) rather than being handed out to make a chart
+[deployment boundaries](/security/) rather than being handed out to make a chart
render.
## Why performance metrics stop at a single figure
diff --git a/outstatic/content/posts/trino-database-field-is-a-catalog.md b/outstatic/content/posts/trino-database-field-is-a-catalog.md
index 3a30faa..17a350a 100644
--- a/outstatic/content/posts/trino-database-field-is-a-catalog.md
+++ b/outstatic/content/posts/trino-database-field-is-a-catalog.md
@@ -63,7 +63,7 @@ There is also no field for a session schema, which matters in a moment.
## Catalog, schema, table, and how the tree gets shaped
Trino's hierarchy is catalog to schema to table, one level deeper than the tree's
-database to schema to table. The mapping chosen is the PostgreSQL one: the
+database to schema to table. The mapping chosen is the [PostgreSQL](/blog/engine/postgresql/) one: the
connection's database field holds the catalog, exactly as a PostgreSQL connection
pins one database, and the schemas inside it become the schema level. So the tree
is two levels deep, and a table's display name is always `schema.table`.
@@ -88,7 +88,7 @@ eight views - `applicable_roles`, `columns`, `enabled_roles`, `roles`, `schemata
row editing is not offered, because an `UPDATE ... WHERE` with no column that
identifies one row would rewrite every row that matches. That is a fact about the
engine, not a gap in the client, and it is [published on the engine
-pages](/databases) rather than discovered at runtime.
+pages](/databases/) rather than discovered at runtime.
## A connection that pins no catalog
@@ -107,7 +107,7 @@ For a local cluster to try this against, `trinodb/trino:476` ships `tpch`,
`tpcds`, `memory`, `system` and `jmx` already configured, so there is no seed
step. Point a connection at `localhost:8080` with no user, no password and `tpch`
in the database field, and `tpch.tiny.nation` is there. The rest of the setup is
-in [getting started](/get-started).
+in [getting started](/get-started/).
## Cross-catalog statements, fully qualified
diff --git a/outstatic/content/posts/trino-explain-format-json-only.md b/outstatic/content/posts/trino-explain-format-json-only.md
index e20b3ff..3ada52c 100644
--- a/outstatic/content/posts/trino-explain-format-json-only.md
+++ b/outstatic/content/posts/trino-explain-format-json-only.md
@@ -138,7 +138,7 @@ about the engine rather than a gap in the integration: `indexes` and `foreignKey
as empty arrays by construction, and the ER diagram for a Trino connection draws the
tables and never an edge, permanently.
-The trade is stated on the [plan rendering feature](/features) as well: plans are read
+The trade is stated on the [plan rendering feature](/features/) as well: plans are read
from the engine's own output and nothing is simulated. On Trino, the engine's own output
that can be had without executing anything is the estimate. If you need measured timings,
type `EXPLAIN ANALYZE` in the editor yourself, having decided that a second execution is
diff --git a/outstatic/content/posts/trino-monitoring-owns-no-storage.md b/outstatic/content/posts/trino-monitoring-owns-no-storage.md
index 26f59b9..16b0313 100644
--- a/outstatic/content/posts/trino-monitoring-owns-no-storage.md
+++ b/outstatic/content/posts/trino-monitoring-owns-no-storage.md
@@ -59,7 +59,7 @@ connection, so there is nothing to ask.
## Sessions and slow queries on a coordinator
-A sessions tab on a PostgreSQL connection lists connections. Trino has none: the
+A sessions tab on a [PostgreSQL](/blog/engine/postgresql/) connection lists connections. Trino has none: the
client protocol is stateless HTTP, each statement is its own exchange, and there
is no session object anywhere to count. The panel shows statements in flight
instead, the nearest true thing.
@@ -158,6 +158,6 @@ maintenance toolkit is admin-only in any case, and on this engine no maintenance
control is rendered at all.
A panel can be absent with a sentence rather than empty because of the
-[capability declarations](/features) each engine publishes, and Trino's line on
-the [engine list](/databases) says the rest: it queries catalogs, and the storage
+[capability declarations](/features/) each engine publishes, and Trino's line on
+the [engine list](/databases/) says the rest: it queries catalogs, and the storage
behind them is somebody else's.
diff --git a/outstatic/content/posts/trino-no-keys-boxes-without-edges.md b/outstatic/content/posts/trino-no-keys-boxes-without-edges.md
index e35311d..e199820 100644
--- a/outstatic/content/posts/trino-no-keys-boxes-without-edges.md
+++ b/outstatic/content/posts/trino-no-keys-boxes-without-edges.md
@@ -55,7 +55,7 @@ is where a primary key would be named and `key_column_usage` is where its column
would be. Trino publishes neither, in any catalog, on any connector.
So this is not a read that came back empty. It is a read that has no place to
-happen. A PostgreSQL table reached through the PostgreSQL connector still has its
+happen. A [PostgreSQL](/blog/engine/postgresql/) table reached through the PostgreSQL connector still has its
primary key and its indexes on the PostgreSQL server; Trino will not name one of
them, because the interface it would name them through does not exist. Whether
the system behind a connector has keys is that system's business and unreadable
@@ -76,7 +76,7 @@ the connection, so there is nothing to ask.
## Boxes with no edges, permanently
The ER diagram on this site is described plainly on the
-[feature page](/features): edges come from declared foreign keys, and a
+[feature page](/features/): edges come from declared foreign keys, and a
relationship your application enforces in code but never declares in the schema
has nothing to discover. Trino is that rule at its limit. Every foreign key it
could declare is a foreign key it declares nowhere.
@@ -132,7 +132,7 @@ default for names that are not. The join condition is the relationship, stated b
you, for that statement.
If a diagram with edges is the thing you need, connect to the system that
-declares them. The [engine list](/databases) publishes what each one answers next
+declares them. The [engine list](/databases/) publishes what each one answers next
to its transport and default port, so a PostgreSQL catalog you reach through
Trino for federation is the same server you can connect to directly for its keys.
Two connections, two honest answers, neither one inventing the other's.
diff --git a/outstatic/content/posts/trino-offset-before-limit-stateless-session.md b/outstatic/content/posts/trino-offset-before-limit-stateless-session.md
index 78d2cfe..d631c34 100644
--- a/outstatic/content/posts/trino-offset-before-limit-stateless-session.md
+++ b/outstatic/content/posts/trino-offset-before-limit-stateless-session.md
@@ -141,7 +141,7 @@ the schema read says that is why rather than showing an empty one.
So the working habit on Trino is the habit the warning asks for. Write
`catalog.schema.table` in the editor and the missing session cannot change what a name
resolves to. Write a `USE` and it can. The engine's published line on
-[the engine pages](/databases) names the neighbouring boundary: Trino queries catalogs, it
+[the engine pages](/databases/) names the neighbouring boundary: Trino queries catalogs, it
does not manage a lakehouse, and writes depend on the underlying connector. Measured, a
connector that accepts `CREATE TABLE` answers `UPDATE` with `This connector does not
support modifying table rows`, and that refusal is shown verbatim.
@@ -149,4 +149,4 @@ support modifying table rows`, and that refusal is shown verbatim.
Two of these three facts the tool absorbs and you never see. The third arrives as a
warning on a statement that succeeded, because that is the only place a reader can still
act on it. What the editor generates per engine, and what it does not, is written down on
-[the features page](/features).
+[the features page](/features/).
diff --git a/outstatic/content/posts/trino-password-requires-tls.md b/outstatic/content/posts/trino-password-requires-tls.md
index 195c493..a70b87c 100644
--- a/outstatic/content/posts/trino-password-requires-tls.md
+++ b/outstatic/content/posts/trino-password-requires-tls.md
@@ -101,7 +101,7 @@ message that names both ways out:
> remove the password to connect as an unauthenticated user.
The reasoning is the same one that governs which controls appear per engine on
-[the engines page](/databases): a round trip that can only end one way is not
+[the engines page](/databases/): a round trip that can only end one way is not
worth taking, and an error that arrives from the server carries less than a
sentence written where the decision is made. A 401 in a connection dialog reads
as a rejected credential. It sends people to look for the account, the realm,
@@ -131,7 +131,7 @@ short one. The coordinator's host is the only field that has to be filled in:
| SSL | off |
The Database field is a catalog, not a database. Trino's hierarchy is catalog to
-schema to table, so the pinned catalog occupies the slot a PostgreSQL database
+schema to table, so the pinned catalog occupies the slot a [PostgreSQL](/blog/engine/postgresql/) database
would, and the tree below it is two levels deep with every table displayed
`schema.table`. A connection that pins no catalog still connects and still runs
fully qualified statements; what it cannot do is show a tree, and it says so
@@ -147,7 +147,7 @@ and `system.runtime.queries` will show against every statement it runs. If you
are reading a query history to find out who ran something, an unauthenticated
Trino connection has told you nothing. That is a property of the deployment, not
of the client, and it is one of the reasons the [security
-model](/security) treats the network the container sits on as part of the
+model](/security/) treats the network the container sits on as part of the
control set rather than an implementation detail.
## Turning on TLS, and what that changes about the port
diff --git a/outstatic/content/posts/what-one-interface-costs.md b/outstatic/content/posts/what-one-interface-costs.md
index e64b26c..a79230e 100644
--- a/outstatic/content/posts/what-one-interface-costs.md
+++ b/outstatic/content/posts/what-one-interface-costs.md
@@ -17,7 +17,7 @@ publishedAt: 2026-08-29T09:00:00.000Z
Every tool that claims to speak many databases makes the same promise on its front page and breaks it in the same place: the fourth engine.
-The first three are easy, because the first three are usually PostgreSQL, MySQL and SQL Server, and those three agree about almost everything. They have tables, foreign keys, an `information_schema`, a query planner, a session list. A single interface over them is barely an abstraction — it is a dialect switch.
+The first three are easy, because the first three are usually [PostgreSQL](/blog/engine/postgresql/), MySQL and [SQL Server](/blog/engine/sqlserver/), and those three agree about almost everything. They have tables, foreign keys, an `information_schema`, a query planner, a session list. A single interface over them is barely an abstraction — it is a dialect switch.
Then someone connects Redis, and the promise has to decide what it meant.
@@ -25,7 +25,7 @@ Then someone connects Redis, and the promise has to decide what it meant.
There are two ordinary ways to answer this, and both cost more than they look like they do.
-**Reduce to the intersection.** Ship only what every engine can do. The interface is consistent, honest, and useless: no EXPLAIN, because Cassandra has none; no ER diagram, because ClickHouse declares no foreign keys; no row editing, because Druid is append-oriented. You end up with a text box and a grid, which is the tool everyone already has.
+**Reduce to the intersection.** Ship only what every engine can do. The interface is consistent, honest, and useless: no EXPLAIN, because Cassandra has none; no ER diagram, because [ClickHouse](/blog/engine/clickhouse/) declares no foreign keys; no row editing, because Druid is append-oriented. You end up with a text box and a grid, which is the tool everyone already has.
**Ship the union and let it fail.** Show every control on every engine and let the error come from the server. This is the common choice, because it demos well. The cost lands later, on the user: they click *Explain* on a Cassandra query, wait, and get a driver exception with a stack trace in it. They now know less than before they clicked, because the error does not distinguish "this engine cannot do this" from "your query is wrong" or "the cluster is down".
@@ -51,7 +51,7 @@ Here is what that looks like in practice, engine by engine:
| Cassandra | No joins and no EXPLAIN; the grid respects partition-key query rules instead of hiding them |
| Elasticsearch, OpenSearch | Query-and-browse: no row editing, no ER diagrams |
-Every line in that table is published on the [engine pages](/databases), next to the engine's transport and default port, rather than being discovered at runtime.
+Every line in that table is published on the [engine pages](/databases/), next to the engine's transport and default port, rather than being discovered at runtime.
## The claim is the span, not the number
diff --git a/src/components/blog/PostCard.astro b/src/components/blog/PostCard.astro
index e1f4a90..ec27bc7 100644
--- a/src/components/blog/PostCard.astro
+++ b/src/components/blog/PostCard.astro
@@ -1,15 +1,27 @@
---
import { formatDate, isoDate } from '../../lib/format';
import type { CollectionEntry } from 'astro:content';
+import { pagePath } from '../../lib/site';
+import { engineName, postEngine } from '../../lib/posts';
interface Props {
post: CollectionEntry<'posts'>;
/** the first card on the index gets the wider treatment */
featured?: boolean;
+ /** an engine archive already says which engine these are; the chip would
+ * link every card back to the page the reader is standing on */
+ hideEngine?: boolean;
}
-const { post, featured = false } = Astro.props;
+const { post, featured = false, hideEngine = false } = Astro.props;
const { title, description, publishedAt, tags, coverImage } = post.data;
-const href = `/blog/${post.id}`;
+const href = pagePath(`/blog/${post.id}`);
+
+// The engine is the axis the blog is actually organised on, so it is the one
+// chip that navigates. The declared tags stay plain text: "Engineering" is on
+// every post and "Databases" on all but four, so a link on either would lead to
+// a page that duplicates /blog rather than narrowing anything.
+const engine = hideEngine ? undefined : postEngine(post.id);
+const engineLabel = engine ? engineName(engine) : undefined;
---
@@ -30,6 +42,13 @@ const href = `/blog/${post.id}`;
)
}
+ {
+ engine && engineLabel && (
+
+ {engineLabel}
+
+ )
+ }
{tags.map((t) => {t.label})}
GitHub ↗
diff --git a/src/content.config.ts b/src/content.config.ts
index b51e5a4..8a25eae 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -24,6 +24,18 @@ const text = (fallback = '') => z.preprocess((v) => (v === null || v === undefin
const list = (item: T) =>
z.preprocess((v) => (v === null || v === undefined || v === '' ? [] : v), z.array(item));
+/**
+ * An optional date, in the same shape as `text()` and `list()`: Outstatic writes
+ * three kinds of empty and `.optional()` accepts only one of them, so the empties
+ * are folded to `undefined` before the union sees them. A bare `.optional()` here
+ * is what tests/content.test.ts forbids, and why.
+ */
+const maybeDate = () =>
+ z.preprocess(
+ (v) => (v === null || v === undefined || v === '' ? undefined : v),
+ z.union([z.coerce.date(), z.undefined()]),
+ );
+
const tag = z.object({ value: z.string(), label: z.string() });
const posts = defineCollection({
@@ -39,6 +51,9 @@ const posts = defineCollection({
// Outstatic writes publishedAt as an UNQUOTED ISO-8601 timestamp, so the
// YAML parser hands us a Date, not a string.
publishedAt: z.coerce.date(),
+ // Optional: a post that has been revised says so, and only then does the
+ // page emit dateModified and the sitemap a lastmod for it.
+ updatedAt: maybeDate(),
author: z
.preprocess(
(v) => (v === null || v === undefined || v === '' ? {} : v),
@@ -46,6 +61,10 @@ const posts = defineCollection({
)
.default({ name: '', picture: '' }),
description: text(),
+ // Optional: a title written for a search listing when the headline the post
+ // wants on the page runs past what Google renders. The H1 is unaffected.
+ seoTitle: text(),
+ seoDescription: text(),
coverImage: text(),
tags: list(tag).default([]),
}),
diff --git a/src/data/home.ts b/src/data/home.ts
index 42a0084..cc6ac3b 100644
--- a/src/data/home.ts
+++ b/src/data/home.ts
@@ -449,45 +449,45 @@ export const footer = {
{
title: 'Product',
links: [
- { label: 'Features', href: '/features' },
- { label: 'Databases', href: '/databases' },
- { label: 'How it compares', href: '/compare' },
- { label: 'Playground', href: '/playground' },
+ { label: 'Features', href: '/features/' },
+ { label: 'Databases', href: '/databases/' },
+ { label: 'How it compares', href: '/compare/' },
+ { label: 'Playground', href: '/playground/' },
{ label: 'Live demo', href: 'https://app.libredb.org' },
],
},
{
title: 'Deploy',
links: [
- { label: 'Docker Compose', href: '/docker-compose' },
- { label: 'Every channel', href: '/deploy' },
- { label: 'Get started', href: '/get-started' },
+ { label: 'Docker Compose', href: '/docker-compose/' },
+ { label: 'Every channel', href: '/deploy/' },
+ { label: 'Get started', href: '/get-started/' },
{ label: 'Documentation', href: `${gh}/tree/main/docs` },
],
},
{
title: 'Company',
links: [
- { label: 'Open source', href: '/open-source' },
- { label: 'Supporters', href: '/supporters' },
- { label: 'Security model', href: '/security' },
+ { label: 'Open source', href: '/open-source/' },
+ { label: 'Supporters', href: '/supporters/' },
+ { label: 'Security model', href: '/security/' },
// The label is fixed, not chosen: the SignPath Foundation terms require
// the term "Code signing policy" to appear on the project's home page,
// as a section header or a link. This is that link, and the footer is on
// every page, so the download page carries it too.
- { label: 'Code signing policy', href: '/code-signing-policy' },
- { label: 'Vendor support', href: '/support' },
- { label: 'LibreDB Platform', href: '/platform' },
- { label: 'Privacy', href: '/privacy-policy' },
+ { label: 'Code signing policy', href: '/code-signing-policy/' },
+ { label: 'Vendor support', href: '/support/' },
+ { label: 'LibreDB Platform', href: '/platform/' },
+ { label: 'Privacy', href: '/privacy-policy/' },
],
},
{
title: 'Projects',
links: [
{ label: 'LibreDB Studio', href: gh },
- { label: 'LibreDB database', href: '/libredb-database' },
- { label: 'Blog', href: '/blog' },
- { label: 'FAQ', href: '/faq' },
+ { label: 'LibreDB database', href: '/libredb-database/' },
+ { label: 'Blog', href: '/blog/' },
+ { label: 'FAQ', href: '/faq/' },
],
},
],
diff --git a/src/data/platform.ts b/src/data/platform.ts
index 5cbd376..0db4d6f 100644
--- a/src/data/platform.ts
+++ b/src/data/platform.ts
@@ -89,7 +89,7 @@ export const tiers = [
line: 'MIT · free · self-hosted · community support',
detail:
'The full editor. Every engine, every capability, no seat count and no enterprise tier. This is what most people need.',
- href: '/open-source',
+ href: '/open-source/',
cta: 'What MIT lets you do →',
},
{
diff --git a/src/data/redirects.ts b/src/data/redirects.ts
index 1f5c4b0..e65865c 100644
--- a/src/data/redirects.ts
+++ b/src/data/redirects.ts
@@ -43,7 +43,7 @@ export interface Redirect {
export const redirects: Redirect[] = [
{
from: '/providers',
- to: '/databases',
+ to: '/databases/',
canonical: '/databases',
label: 'Databases',
because: 'Same page, renamed: "providers" is the code word for it, "databases" is what people search for.',
@@ -57,7 +57,7 @@ export const redirects: Redirect[] = [
},
{
from: '/docker-compose-example',
- to: '/docker-compose',
+ to: '/docker-compose/',
canonical: '/docker-compose',
label: 'Docker Compose',
because:
@@ -65,7 +65,7 @@ export const redirects: Redirect[] = [
},
{
from: '/database',
- to: '/libredb-database',
+ to: '/libredb-database/',
canonical: '/libredb-database',
label: 'LibreDB, the embeddable database',
because:
@@ -73,21 +73,21 @@ export const redirects: Redirect[] = [
},
{
from: '/database-architecture',
- to: '/libredb-database#architecture',
+ to: '/libredb-database/#architecture',
canonical: '/libredb-database',
label: 'LibreDB architecture',
because: 'Three pages on one product became three sections of one page; this is its second section.',
},
{
from: '/database-reliability',
- to: '/libredb-database#reliability',
+ to: '/libredb-database/#reliability',
canonical: '/libredb-database',
label: 'LibreDB reliability',
because: 'Three pages on one product became three sections of one page; this is its third section.',
},
{
from: '/tech-stack',
- to: '/open-source',
+ to: '/open-source/',
canonical: '/open-source',
label: 'Open source',
because:
diff --git a/src/lib/posts.ts b/src/lib/posts.ts
new file mode 100644
index 0000000..0adcca2
--- /dev/null
+++ b/src/lib/posts.ts
@@ -0,0 +1,65 @@
+import { engines } from '../data/engines';
+
+/**
+ * Which engine a post is about, read from its slug.
+ *
+ * The blog is organised by engine — `postgresql-explain-analyze-executes`,
+ * `mysql-cancel-runaway-query`, `sqlserver-no-execution-plan-shown`. That
+ * grouping is real and load-bearing (nine PostgreSQL posts, eight MySQL) but it
+ * lived only in the filename, so nothing could use it: "Keep reading" showed the
+ * two newest posts to all 104, and a reader on a Postgres page was never offered
+ * the other eight.
+ *
+ * `engines.ts` is the source of the id list — a second copy here would drift the
+ * moment an engine is added. Longest match first, because `sqlserver` and
+ * `sqlite` both start with `sql`, and `libsql` contains `sql` outright.
+ */
+const ENGINE_IDS = [...engines.map((e) => e.id)].sort((a, b) => b.length - a.length);
+
+export function postEngine(id: string): string | undefined {
+ const slug = id.toLowerCase();
+ return ENGINE_IDS.find((engineId) => slug === engineId || slug.startsWith(`${engineId}-`));
+}
+
+/** The engine's display name, for a heading that names what it is offering. */
+export function engineName(engineId: string): string | undefined {
+ return engines.find((e) => e.id === engineId)?.name;
+}
+
+type Datable = { id: string; data: { publishedAt: Date } };
+
+/**
+ * Posts to offer at the end of `post`: same engine first, newest first, topped
+ * up with recent posts when that engine has too few to fill the block.
+ *
+ * Falling back matters — seven of the eighteen engines have four posts or fewer,
+ * and a post with no engine at all (`counting-databases`) must still get a
+ * block rather than an empty aside.
+ */
+export function relatedPosts(post: T, all: T[], limit = 4): T[] {
+ const family = postEngine(post.id);
+ const rest = all.filter((p) => p.id !== post.id);
+ const byDate = (a: T, b: T) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime();
+
+ const sameEngine = family ? rest.filter((p) => postEngine(p.id) === family).sort(byDate) : [];
+ if (sameEngine.length >= limit) return sameEngine.slice(0, limit);
+
+ const seen = new Set(sameEngine.map((p) => p.id));
+ const filler = rest.filter((p) => !seen.has(p.id)).sort(byDate);
+ return [...sameEngine, ...filler].slice(0, limit);
+}
+
+/**
+ * The previous and next post within the same engine, oldest → newest, so a
+ * reader can walk one engine's posts in the order they were written. Falls back
+ * to the whole blog for a post that belongs to no engine.
+ */
+export function postNeighbours(post: T, all: T[]): { prev?: T; next?: T } {
+ const family = postEngine(post.id);
+ const series = (family ? all.filter((p) => postEngine(p.id) === family) : [...all]).sort(
+ (a, b) => a.data.publishedAt.getTime() - b.data.publishedAt.getTime(),
+ );
+ const i = series.findIndex((p) => p.id === post.id);
+ if (i === -1) return {};
+ return { prev: series[i - 1], next: series[i + 1] };
+}
diff --git a/src/lib/seo.ts b/src/lib/seo.ts
index 759b000..9807c92 100644
--- a/src/lib/seo.ts
+++ b/src/lib/seo.ts
@@ -2,6 +2,10 @@ import { canonicalUrl, site } from './site';
export interface SeoInput {
title?: string;
+ /** overrides the title tag only; the page keeps its own H1 */
+ seoTitle?: string;
+ /** overrides the description tag only; the page keeps its own lede */
+ seoDescription?: string;
description?: string;
path: string;
/** site-relative or absolute image used for og:image / twitter:image */
@@ -27,11 +31,61 @@ export interface Seo {
const DEFAULT_IMAGE = '/og/default.png';
+/**
+ * Google renders about 60 characters of a title before it truncates. The brand
+ * suffix was costing 17 of them unconditionally, which pushed 96 of 146 pages
+ * past the limit — the titles themselves were rarely the problem. Spending the
+ * budget on the words that distinguish the page beats spending it on the same
+ * two words every page already repeats.
+ *
+ * So the suffix is fitted, not fixed: the full name where it fits, the short
+ * one where that fits, and nothing where neither does. 75 pages still carry the
+ * brand; none of them lose their own words to it.
+ */
+const TITLE_LIMIT = 60;
+
+/**
+ * Google renders roughly 160 characters of a description. 23 of ours ran past
+ * it — up to 264 — so the closing clause, which is usually the part that says
+ * why to click, was the part being cut.
+ *
+ * The same string is the lede printed on the page, and that one should stay
+ * whole: a post deserves a full opening paragraph. So the trim happens here,
+ * for the tag only, and it stops at a sentence rather than mid-word. Where a
+ * post wants a listing written differently from its lede, `seoDescription`
+ * overrides this outright.
+ */
+const DESCRIPTION_LIMIT = 160;
+
+function fitDescription(text: string): string {
+ const clean = text.trim();
+ if (clean.length <= DESCRIPTION_LIMIT) return clean;
+
+ // One character short of the limit: the ellipsis added below occupies it.
+ const window = clean.slice(0, DESCRIPTION_LIMIT);
+ const sentence = Math.max(window.lastIndexOf('. '), window.lastIndexOf('? '), window.lastIndexOf('! '));
+ // A sentence break is only useful if it leaves something worth reading.
+ if (sentence > DESCRIPTION_LIMIT * 0.55) return clean.slice(0, sentence + 1);
+
+ const word = window.lastIndexOf(' ');
+ return `${clean.slice(0, word > 0 ? word : DESCRIPTION_LIMIT).replace(/[,;:\s]+$/, '')}…`;
+}
+
+function withBrand(title: string): string {
+ for (const suffix of [` — ${site.name}`, ` — ${site.shortName}`]) {
+ if (title.length + suffix.length <= TITLE_LIMIT) return title + suffix;
+ }
+ return title;
+}
+
export function buildSeo(input: SeoInput): Seo {
- const title = input.title ? `${input.title} — ${site.name}` : `${site.name} — ${site.tagline}`;
+ // `seoTitle` is the escape hatch for a headline written for the page rather
+ // than for a result listing: it replaces the title tag and leaves the H1 alone.
+ const heading = input.seoTitle || input.title;
+ const title = heading ? withBrand(heading) : `${site.name} — ${site.tagline}`;
return {
title,
- description: input.description ?? site.description,
+ description: fitDescription(input.seoDescription || input.description || site.description),
canonical: canonicalUrl(input.path),
image: canonicalUrl(input.image ?? DEFAULT_IMAGE),
type: input.type ?? 'website',
diff --git a/src/lib/site.ts b/src/lib/site.ts
index dafeca6..f4f3b31 100644
--- a/src/lib/site.ts
+++ b/src/lib/site.ts
@@ -2,9 +2,25 @@ import config from '../../site.config.json' with { type: 'json' };
export const site = config;
-/** Absolute, trailing-slash-free canonical URL for a site-relative path. */
+/**
+ * A site-relative path in the form the static host actually serves.
+ *
+ * `build.format: 'directory'` emits `/features/index.html`, so GitHub Pages
+ * serves `/features/` and answers `/features` with a 301 to it. A link or a
+ * canonical written without the slash therefore points at a redirect rather
+ * than at the page — the crawler spends two requests per link and the
+ * canonical disagrees with the URL it is stamped on.
+ *
+ * Assets are left alone: `/og/default.png/` is not a file.
+ */
+export function pagePath(path: string): string {
+ const clean = `/${path.replace(/^\/+/, '')}`.replace(/\/+$/, '');
+ if (clean === '') return '/';
+ return /\.[a-z0-9]+$/i.test(clean) ? clean : `${clean}/`;
+}
+
+/** Absolute canonical URL for a site-relative path, slashed as {@link pagePath}. */
export function canonicalUrl(path: string): string {
const base = config.url.replace(/\/+$/, '');
- const clean = `/${path.replace(/^\/+/, '')}`.replace(/\/+$/, '');
- return clean === '' ? `${base}/` : `${base}${clean}`;
+ return `${base}${pagePath(path)}`;
}
diff --git a/src/pages/404.astro b/src/pages/404.astro
index 69a8635..4681cb1 100644
--- a/src/pages/404.astro
+++ b/src/pages/404.astro
@@ -21,8 +21,8 @@ import { navLinks } from '../data/home';
))
}
-
+
+
+
+
diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro
index 7a2c150..9359536 100644
--- a/src/pages/blog/index.astro
+++ b/src/pages/blog/index.astro
@@ -2,11 +2,25 @@
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostCard from '../../components/blog/PostCard.astro';
-import { site } from '../../lib/site';
+import { pagePath, site } from '../../lib/site';
+import { engineName, postEngine } from '../../lib/posts';
const posts = (await getCollection('posts', ({ data }) => data.status === 'published')).sort(
(a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime(),
);
+
+// A way into 104 posts that is not "scroll". These are plain links to the
+// archives rather than a client-side filter: the grouping is then crawlable,
+// linkable and survives with JavaScript off, and the work is already done.
+const byEngine = new Map();
+for (const post of posts) {
+ const id = postEngine(post.id);
+ if (id) byEngine.set(id, (byEngine.get(id) ?? 0) + 1);
+}
+const archives = [...byEngine.entries()]
+ .filter(([, n]) => n >= 2)
+ .map(([id, n]) => ({ id, n, name: engineName(id) ?? id }))
+ .sort((a, b) => b.n - a.n || a.name.localeCompare(b.name));
---
data.status === 'publi
·GitHub ↗
+ {
+ archives.length > 0 && (
+
+ )
+ }
{
@@ -58,6 +89,43 @@ const posts = (await getCollection('posts', ({ data }) => data.status === 'publi