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})}

{title}

@@ -81,6 +100,18 @@ const href = `/blog/${post.id}`; .pcard__tag { color: var(--text-brand); } + /* The engine chip navigates; the declared tags do not. Bordering only the + link keeps that difference visible without a second colour. */ + .pcard__tag--engine { + padding-inline: var(--space-03); + border: var(--border-width-1) solid var(--border); + border-radius: var(--radius-pill); + color: var(--text-secondary); + } + .pcard__tag--engine:hover { + border-color: var(--border-strong); + color: var(--text-primary); + } .pcard__title { font-family: var(--font-heading); font-weight: var(--weight-semibold); diff --git a/src/components/layout/CookieConsent.astro b/src/components/layout/CookieConsent.astro index f53e348..6db8ead 100644 --- a/src/components/layout/CookieConsent.astro +++ b/src/components/layout/CookieConsent.astro @@ -26,7 +26,7 @@

We use cookies to analyse site traffic and improve your experience. - See our Privacy Policy for details. + See our Privacy Policy for details.

diff --git a/src/components/layout/SiteHeader.astro b/src/components/layout/SiteHeader.astro index 1632800..b416efb 100644 --- a/src/components/layout/SiteHeader.astro +++ b/src/components/layout/SiteHeader.astro @@ -45,7 +45,7 @@ const href = (h: string) => (h.startsWith('#') && !onHome ? `/${h}` : h); )) } - Blog + Blog diff --git a/src/pages/blog/[...id].astro b/src/pages/blog/[...id].astro index ed21fe2..00a4337 100644 --- a/src/pages/blog/[...id].astro +++ b/src/pages/blog/[...id].astro @@ -4,7 +4,8 @@ import type { GetStaticPaths } from 'astro'; import BaseLayout from '../../layouts/BaseLayout.astro'; import TableOfContents from '../../components/blog/TableOfContents.astro'; import { formatDate, isoDate, readingTime } from '../../lib/format'; -import { site } from '../../lib/site'; +import { canonicalUrl, pagePath, site } from '../../lib/site'; +import { engineName, postEngine, postNeighbours, relatedPosts } from '../../lib/posts'; // PITFALLS B5: getStaticPaths is extracted into its own module at build time, so // nothing defined in the rest of this frontmatter is visible inside it. Every @@ -18,25 +19,48 @@ export const getStaticPaths = (async () => { type Props = { post: CollectionEntry<'posts'> }; const { post } = Astro.props; const { Content, headings } = await render(post); -const { title, description, publishedAt, author, tags, coverImage } = post.data; +const { title, description, publishedAt, author, tags, coverImage, seoTitle, seoDescription } = post.data; const all = (await getCollection('posts', ({ data }) => data.status === 'published')).sort( (a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime(), ); -const others = all.filter((p) => p.id !== post.id).slice(0, 2); +// Same engine first — see src/lib/posts.ts for why the slug is the grouping. +const others = relatedPosts(post, all); +const { prev, next } = postNeighbours(post, all); +const family = postEngine(post.id); +const familyName = family ? engineName(family) : undefined; +const updatedAt = (post.data as { updatedAt?: Date }).updatedAt; const minutes = readingTime(post.body ?? ''); +const url = canonicalUrl(`/blog/${post.id}`); + const schema = [ { '@type': 'BlogPosting', headline: title, description, + // Article rich results want an image, and every page already computes one + // for og:image — emitting only there left 104 posts ineligible for the + // richer listing while the asset sat one line away. + image: canonicalUrl(coverImage || '/og/default.png'), datePublished: publishedAt.toISOString(), + // Only when the post actually says so. A dateModified defaulted to the + // build date tells Google every post changed on every deploy. + ...(updatedAt ? { dateModified: updatedAt.toISOString() } : {}), inLanguage: 'en', author: { '@type': 'Organization', name: author.name || site.name }, publisher: { '@id': `${site.url}/#organization` }, - mainEntityOfPage: `${site.url}/blog/${post.id}`, + // The served URL, not the redirect that reaches it. + mainEntityOfPage: url, + }, + { + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: canonicalUrl('/') }, + { '@type': 'ListItem', position: 2, name: 'Blog', item: canonicalUrl('/blog') }, + { '@type': 'ListItem', position: 3, name: title, item: url }, + ], }, ]; --- @@ -44,21 +68,35 @@ const schema = [
-

← All posts

+
{ - tags.length > 0 && ( + (tags.length > 0 || family) && ( ) } + + { + (prev || next) && ( + + ) + }
@@ -116,18 +175,40 @@ const schema = [ .post__inner { padding-block: clamp(var(--space-09), 6vh, 72px) clamp(var(--section-y-desktop), 10vh, 112px); } - .post__back { + /* Replaces the old "← All posts" line: same role, but it shows the path the + page sits on, and the BreadcrumbList schema says the same thing. */ + .post__crumbs { font: var(--weight-medium) 12.5px / 1 var(--font-mono); } - .post__back a { + .post__crumbs ol { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-02); + margin: 0; + padding: 0; + list-style: none; + color: var(--text-tertiary); + } + .post__crumbs li { display: inline-flex; align-items: center; min-height: 24px; + } + .post__crumbs li + li::before { + content: '/'; + margin-inline-end: var(--space-02); + color: var(--border-strong); + } + .post__crumbs a { color: var(--text-tertiary); } - .post__back a:hover { + .post__crumbs a:hover { color: var(--text-primary); } + .post__crumbs [aria-current='page'] { + color: var(--text-secondary); + } .post__head { margin-top: var(--space-08); @@ -144,6 +225,15 @@ const schema = [ text-transform: uppercase; color: var(--text-brand); } + .post__tag--engine { + border: var(--border-width-1) solid var(--border); + border-radius: var(--radius-pill); + color: var(--text-secondary); + } + .post__tag--engine:hover { + border-color: var(--border-strong); + color: var(--text-primary); + } .post__title { margin-top: var(--space-04); font-family: var(--font-heading); @@ -186,6 +276,39 @@ const schema = [ align-items: start; } + /* Walks one engine's posts in the order they were written; see src/lib/posts.ts. */ + .post__seq { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); + gap: var(--space-04); + margin-top: var(--space-08); + } + .post__seqlink { + display: flex; + flex-direction: column; + gap: var(--space-02); + padding: var(--space-05); + border: var(--border-width-1) solid var(--border); + border-radius: var(--radius-l); + background: var(--surface); + } + .post__seqlink:hover { + border-color: var(--border-strong); + } + .post__seqlink--next { + text-align: right; + } + .post__seqdir { + font: var(--weight-semibold) var(--overline-size) / 1 var(--font-mono); + letter-spacing: 0.11em; + text-transform: uppercase; + color: var(--text-tertiary); + } + .post__seqtitle { + color: var(--text-primary); + line-height: var(--leading-snug); + } + .post__more { margin-top: clamp(var(--space-11), 8vh, var(--space-13)); padding-top: var(--space-08); diff --git a/src/pages/blog/engine/[engine].astro b/src/pages/blog/engine/[engine].astro new file mode 100644 index 0000000..eff0855 --- /dev/null +++ b/src/pages/blog/engine/[engine].astro @@ -0,0 +1,263 @@ +--- +import type { GetStaticPaths } from 'astro'; +import BaseLayout from '../../../layouts/BaseLayout.astro'; +import PostCard from '../../../components/blog/PostCard.astro'; +import { pagePath, site } from '../../../lib/site'; +import { engineName, postEngine } from '../../../lib/posts'; +import { engines } from '../../../data/engines'; + +/** + * One archive per database engine. + * + * The blog already groups by engine — nine PostgreSQL posts, eight MySQL — but + * the grouping lived in the filename, so a reader who wanted "everything about + * Postgres" had to scan a flat list of 104. These pages give that group an + * address, and give the engine chip on each post somewhere to point. + * + * Only engines that actually have posts get a page: an archive listing nothing + * is a thin page, and shipping eighteen of them to cover four would be padding. + * The threshold is two — one post is the post, not an archive of it. + * + * PITFALLS B5: getStaticPaths is extracted at build time and sees nothing from + * the rest of this frontmatter, so it imports what it needs itself. + */ +export const getStaticPaths = (async () => { + const { getCollection } = await import('astro:content'); + const { postEngine } = await import('../../../lib/posts'); + + const posts = await getCollection('posts', ({ data }) => data.status === 'published'); + const byEngine = new Map(); + for (const post of posts) { + const id = postEngine(post.id); + if (!id) continue; + byEngine.set(id, [...(byEngine.get(id) ?? []), post]); + } + + return [...byEngine.entries()] + .filter(([, list]) => list.length >= 2) + .map(([engine, list]) => ({ + params: { engine }, + props: { + posts: list.sort((a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime()), + }, + })); +}) satisfies GetStaticPaths; + +type Props = { posts: Awaited>> }; +const { engine } = Astro.params; +const { posts } = Astro.props; + +const name = engineName(engine!) ?? engine!; +const engineData = engines.find((e) => e.id === engine); + +// Sibling archives, so a reader comparing two engines can cross over without +// going back to the flat list. Built from what actually shipped, not from the +// full engine list — an archive that does not exist must not be linked. +const siblings = (await import('astro:content')) + .getCollection('posts', ({ data }) => data.status === 'published') + .then((all) => { + const counts = new Map(); + for (const p of all) { + const id = postEngine(p.id); + if (id) counts.set(id, (counts.get(id) ?? 0) + 1); + } + return [...counts.entries()] + .filter(([id, n]) => n >= 2 && id !== engine) + .map(([id]) => ({ id, name: engineName(id) ?? id })) + .sort((a, b) => a.name.localeCompare(b.name)); + }); +const others = await siblings; + +const description = `Every LibreDB Studio write-up about ${name}: what the provider does, where it stops, and why. ${posts.length} posts.`; + +const schema = [ + { + '@type': 'CollectionPage', + name: `${name} — LibreDB Studio blog`, + description, + isPartOf: { '@id': `${site.url}/#website` }, + mainEntity: { + '@type': 'ItemList', + numberOfItems: posts.length, + itemListElement: posts.map((p, i) => ({ + '@type': 'ListItem', + position: i + 1, + url: `${site.url}${pagePath(`/blog/${p.id}`)}`, + name: p.data.title, + })), + }, + }, + { + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: `${site.url}${pagePath('/')}` }, + { '@type': 'ListItem', position: 2, name: 'Blog', item: `${site.url}${pagePath('/blog')}` }, + { '@type': 'ListItem', position: 3, name, item: `${site.url}${pagePath(`/blog/engine/${engine}`)}` }, + ], + }, +]; +--- + + +
+ +
+ + +
+

{posts.length} posts

+

+ Writing on {name} +

+ { + engineData && ( +

+ {engineData.desc} + {engineData.not && Where it stops: {engineData.not}} +

+ ) + } + +
+ +
+ {posts.map((post) => )} +
+ + { + others.length > 0 && ( + + ) + } +
+
+
+ + 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