-
Current
+
Latest guide
Version {current.version}
Published {current.published} · {current.size} · 13 languages
+ {
+ behind && (
+
+ It describes {current.version}. The current release is {site.version}, and no guide covers it yet, so
+ some steps and screens may differ.
+
+ )
+ }
Open the guide
@@ -104,7 +116,7 @@ const schema = [
{g.published}
{g.size}
- {g.current &&
current}
+ {g.latest &&
latest}
))
}
diff --git a/src/pages/llms-full.txt.ts b/src/pages/llms-full.txt.ts
index 2fce026..dce9f6e 100644
--- a/src/pages/llms-full.txt.ts
+++ b/src/pages/llms-full.txt.ts
@@ -1,7 +1,7 @@
import type { APIRoute } from 'astro';
import { VENDOR, VENDOR_ADDRESS_FULL } from '../data/company';
import { channels } from '../data/deploy-channels';
-import { engines } from '../data/engines';
+import { engines, externalEngines } from '../data/engines';
import { featureDetails } from '../data/features';
import { faq } from '../data/faq';
import { features } from '../data/home';
@@ -85,14 +85,14 @@ each machine. Access is granted and revoked through an OIDC identity provider.
The comparison that matters is not a feature list but where the tool runs; that argument
is at ${site.url}/compare.
-## Supported engines (${engines.length})
+## Supported engines (${externalEngines.length}, plus the embedded LibreDB store)
${engineTable()}
### What each engine does NOT do
-These boundaries are documented rather than omitted. Agent mode, for example, is available
-on three engines and not on the rest.
+These boundaries are documented rather than omitted. Agent mode, for example, runs
+statements on four engines and not on the rest.
${engineLimits()}
@@ -104,16 +104,17 @@ ${featureBlocks()}
What is enforced and where, plus the documented limits it stops at, is at
${site.url}/security. The limits are published deliberately: credentials are held
-unencrypted in browser localStorage unless a server-side store is configured, the built-in
-local login compares passwords without hashing, masking is a display-level control, and
-rate limits are counted per replica. The security policy is at ${site.links.security}.
+unencrypted in browser localStorage whatever the storage provider (a server-side store
+encrypts only its own copy at rest), the built-in local login compares passwords without
+hashing when STORAGE_PROVIDER=local (sqlite and postgres store scrypt hashes), masking
+is a display-level control, and rate limits are counted per replica. The security policy is at ${site.links.security}.
## Deployment (${channels.length} live channels)
${channelList()}
The full channel inventory, including the update policy per channel, is at
-${site.url}/deploy. The canonical docker-compose.yml is served verbatim at
+${site.url}/deploy. The docker-compose.yml is served (upstream's file, a few false comments corrected) at
${site.url}/docker-compose.example.yml and explained at ${site.url}/docker-compose.
## The other product: LibreDB the database
diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts
index ccdf831..4a3db06 100644
--- a/src/pages/llms.txt.ts
+++ b/src/pages/llms.txt.ts
@@ -1,6 +1,6 @@
import type { APIRoute } from 'astro';
import { VENDOR, VENDOR_ADDRESS_FULL } from '../data/company';
-import { engines } from '../data/engines';
+import { engines, externalEngines } from '../data/engines';
import { site } from '../lib/site';
/**
@@ -20,15 +20,17 @@ import { site } from '../lib/site';
/** What a model should be told each page is for. Keyed by the route. */
const DESCRIPTIONS: Record
= {
'/': 'What LibreDB Studio is and why it deploys next to the data instead of onto a laptop.',
- '/features': 'The editor, the AI copilot, EXPLAIN plans, ER diagrams, masking and the monitoring dashboard.',
+ '/features':
+ 'The schema-aware editor, visual EXPLAIN plans, ER diagrams, the AI helpers, the monitoring dashboard and the database agent, each with its documented limit.',
'/databases': 'Every engine Studio connects to, what each provider supports and what it does not.',
'/libredb-database':
'LibreDB the embeddable database: one ordered key-value core, three lenses, zero dependencies, early beta.',
'/deploy': 'Every distribution channel that is live today — registries, package managers, Kubernetes and PaaS.',
- '/docker-compose': 'The canonical docker-compose.yml, byte for byte, with every environment variable explained.',
+ '/docker-compose':
+ 'The docker-compose.yml from the repository, with a few false comments corrected, and every variable it sets or lists explained; the full list is in .env.example.',
'/get-started': 'From nothing to a running Studio, by container or by npx.',
'/helper':
- 'The installation guide: one self-contained page per release, 22 install routes, every setting, in 13 languages.',
+ 'The installation guide: one self-contained page per release, the install routes, every setting, in 13 languages. The newest guide covers 0.17.0.',
'/playground': 'Run the LibreDB database in the browser, with no install.',
'/open-source': 'What the MIT licence permits, how to contribute, and how to report a vulnerability.',
'/supporters':
@@ -93,7 +95,7 @@ ${links}
- [Security policy](${site.links.security}): how to report a vulnerability.
- [RSS](${site.url}/rss.xml): the blog feed.
-## Supported engines (${engines.length})
+## Supported engines (${externalEngines.length}, plus the embedded LibreDB store)
${engineList}
diff --git a/src/pages/open-source.astro b/src/pages/open-source.astro
index 8840151..49d8abc 100644
--- a/src/pages/open-source.astro
+++ b/src/pages/open-source.astro
@@ -64,10 +64,11 @@ const schema = [
The commercial line
-
What costs money is someone else running it
+
Nothing is sold, nothing is held back
- No capability is moved across the line to create a reason to upgrade. The comparison below is licence and
- feature scope only — the things a community edition elsewhere tends to withhold.
+ There is no paid edition and no hosted offering, so no capability is kept out of the MIT build to create a
+ reason to pay. The comparison below is licence and feature scope only — the things a community edition
+ elsewhere tends to withhold.
diff --git a/src/pages/privacy-policy.astro b/src/pages/privacy-policy.astro
index 61ae720..f51701a 100644
--- a/src/pages/privacy-policy.astro
+++ b/src/pages/privacy-policy.astro
@@ -3,7 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
import { VENDOR, VENDOR_ADDRESS_FULL, vendorOrganizationSchema } from '../data/company';
import { site } from '../lib/site';
-const UPDATED = '2026-03-01';
+const UPDATED = '2026-10-11';
const updatedLabel = new Date(UPDATED).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
@@ -173,8 +173,13 @@ const schema = [
Your choices
When you first visit our site, a cookie consent banner will appear. You can choose to:
- - Accept — analytics cookies will be set and Google Analytics will load.
- - Decline — no analytics cookies will be set and Google Analytics will not load.
+ -
+ Accept — analytics cookies will be set, and Google Analytics and Meta Pixel will load.
+
+ -
+ Decline — no analytics cookies will be set, and neither Google Analytics nor Meta Pixel will
+ load.
+
You can change your preference at any time by clearing your browser's local storage for this site and
@@ -187,6 +192,10 @@ const schema = [
href="https://policies.google.com/privacy"
target="_blank"
rel="noreferrer">Privacy Policy ↗, and page-view data is sent to Meta by Meta Pixel under Meta's Privacy Policy ↗. We do not sell, rent, or share your personal data with any other third parties.
diff --git a/tests/channels.test.ts b/tests/channels.test.ts
index b7598ce..cf0fd18 100644
--- a/tests/channels.test.ts
+++ b/tests/channels.test.ts
@@ -18,10 +18,15 @@ import { faq } from '../src/data/faq';
* data file together; docs/CHANNELS.md § "Coverage snapshot" is the number to
* copy from.
*/
-const SCORECARD = { total: 33, live: 26, pending: 6, deprecated: 1 };
+const SCORECARD = { total: 46, live: 39, pending: 6, deprecated: 1 };
-/** Channels that exist in the inventory but are NOT live, so must never appear. */
-const NOT_LIVE = ['OperatorHub', 'OpenShift', 'CasaOS', 'Easypanel', 'Portainer', 'Umbrel', 'Flathub'];
+/**
+ * Channels that exist in the inventory but are NOT live, so must never appear.
+ * OperatorHub/OpenShift and CasaOS went live after this list was first written;
+ * the official portainer/templates entry is still pending, but a third-party
+ * Portainer aggregator is live, so 'Portainer' alone can no longer be banned.
+ */
+const NOT_LIVE = ['AUR', 'Arch User Repository', 'Easypanel', 'Umbrel', 'Civo', 'Koyeb One-Click', 'Flathub'];
describe('the deploy data holds every live channel and only live channels', () => {
it('has exactly as many entries as the scorecard has live channels', () => {
@@ -32,12 +37,12 @@ describe('the deploy data holds every live channel and only live channels', () =
const perCategory: Record = {
'registries-releases': 2,
containers: 2,
- 'kubernetes-operators': 2,
- 'package-managers': 5,
+ 'kubernetes-operators': 4,
+ 'package-managers': 6,
'os-desktop': 3,
- 'paas-catalogs': 8,
+ 'paas-catalogs': 15,
'deploy-recipes': 3,
- 'cloud-marketplaces': 1,
+ 'cloud-marketplaces': 4,
};
for (const [cat, n] of Object.entries(perCategory)) {
expect(channels.filter((c) => c.category === cat).length, cat).toBe(n);
diff --git a/tests/code-signing.test.ts b/tests/code-signing.test.ts
index d09330c..aefc755 100644
--- a/tests/code-signing.test.ts
+++ b/tests/code-signing.test.ts
@@ -36,13 +36,13 @@ const HOME = 'dist/index.html';
const POLICY = 'dist/code-signing-policy/index.html';
const DOWNLOAD = 'dist/deploy/index.html';
-/** Repository permission, read from the GitHub API on 2026-09-01. The published
+/** Repository permission, read from the GitHub API on 2026-10-11. The published
* roles are a claim about who may do what; if they drift from the repository,
* the claim is false, so the mapping is pinned rather than trusted. */
const REPO_PERMISSION: Record = {
cevheri: 'admin',
'kaya-abdullah': 'admin',
- 'yusuf-gundogdu': 'maintain',
+ 'yusuf-gundogdu': 'admin',
mfatihdayan: 'read',
};
diff --git a/tests/compose.test.ts b/tests/compose.test.ts
index 7980097..1522c8e 100644
--- a/tests/compose.test.ts
+++ b/tests/compose.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test';
import { readFileSync } from 'node:fs';
-import { composeGroups, composeQuickStart } from '../src/data/compose';
+import { createHash } from 'node:crypto';
+import { composeGroups, composeQuickStart, upstreamCompose } from '../src/data/compose';
import site from '../site.config.json' with { type: 'json' };
const yaml = readFileSync('src/data/docker-compose.example.yml', 'utf8');
@@ -11,9 +12,11 @@ const yaml = readFileSync('src/data/docker-compose.example.yml', 'utf8');
* are: the file we show is not the file that runs, and the variable table
* describes something the image never reads.
*
- * The compose file is a verbatim copy of libredb-studio's. These tests cannot
- * reach that repository, so they pin the shape instead — enough that a partial
- * or mangled copy fails here rather than in someone's terminal.
+ * The compose file is libredb-studio's, with a listed set of false comments
+ * corrected (`upstreamCompose` in src/data/compose.ts). These tests cannot reach
+ * that repository, so they pin the upstream file's hash and the shape instead —
+ * enough that a partial, mangled or silently edited copy fails here rather than
+ * in someone's terminal.
*/
/**
@@ -32,7 +35,12 @@ const envBlock = (() => {
return yaml.slice(start, end);
})();
-const declared = new Set([...envBlock.matchAll(/^\s*#?\s*([A-Z][A-Z0-9_]{2,}):/gm)].map((m) => m[1]));
+/**
+ * The negative lookahead skips section headings written in the same shape as a
+ * variable — `# PASSKEYS: off unless PASSKEY_ORIGIN is set` — whose "value" is
+ * prose. A real variable's value is a `${…}`, a quoted literal or a single path.
+ */
+const declared = new Set([...envBlock.matchAll(/^\s*#?\s*([A-Z][A-Z0-9_]{2,}):(?! [a-z]+ [a-z])/gm)].map((m) => m[1]));
const documented = composeGroups.flatMap((g) => g.vars);
@@ -62,6 +70,36 @@ describe('the compose file is intact', () => {
});
});
+describe('the site copy is upstream plus the listed comment corrections, nothing else', () => {
+ const lines = yaml.split('\n');
+
+ it('carries each correction on the line it names', () => {
+ for (const c of upstreamCompose.corrections) {
+ expect(lines[c.line - 1], `line ${c.line}`).toBe(c.site);
+ }
+ });
+
+ it('corrects comments only', () => {
+ for (const c of upstreamCompose.corrections) {
+ expect(c.site.trim().startsWith('#'), `line ${c.line} is not a comment`).toBe(true);
+ expect(c.upstream.trim().startsWith('#'), `line ${c.line} was not a comment upstream`).toBe(true);
+ // A commented-out variable may change only after its value: the part a
+ // reader uncomments must stay byte-identical.
+ if (/^\s*#\s*[A-Z][A-Z0-9_]+: /.test(c.upstream)) {
+ const head = (t: string) => t.split(' #')[0];
+ expect(head(c.site), `line ${c.line} changed more than its trailing comment`).toBe(head(c.upstream));
+ }
+ }
+ });
+
+ it(`hashes to upstream ${upstreamCompose.tag} once the corrections are reverted`, () => {
+ const restored = [...lines];
+ for (const c of upstreamCompose.corrections) restored[c.line - 1] = c.upstream;
+ const hash = createHash('sha256').update(restored.join('\n')).digest('hex');
+ expect(hash, 'the copy differs from upstream in a line the corrections do not list').toBe(upstreamCompose.sha256);
+ });
+});
+
describe('the table on the page matches the file', () => {
it('documents no variable the file never mentions', () => {
const extra = documented.map((v) => v.name).filter((n) => !declared.has(n));
diff --git a/tests/dist-smoke.test.ts b/tests/dist-smoke.test.ts
index e7ff32e..c4bb0b1 100644
--- a/tests/dist-smoke.test.ts
+++ b/tests/dist-smoke.test.ts
@@ -121,7 +121,7 @@ describe('assets are self-hosted', () => {
// and the literal here still said sixteen.
expect(marks.sort()).toEqual(engines.map((e) => e.logo.replace('/engines/', '')).sort());
for (const img of home.querySelectorAll('.hex img')) {
- expect(img.getAttribute('src')).toMatch(/^\/engines\/[a-z]+\.svg$/);
+ expect(img.getAttribute('src')).toMatch(/^\/engines\/[a-z0-9]+\.svg$/);
}
});
diff --git a/tests/engines.test.ts b/tests/engines.test.ts
index 909a99f..2017abe 100644
--- a/tests/engines.test.ts
+++ b/tests/engines.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'bun:test';
-import { engines, engineReference, defaultEngineId } from '../src/data/engines';
+import { engines, externalEngines, engineReference, defaultEngineId } from '../src/data/engines';
import { readFileSync, existsSync } from 'node:fs';
/**
@@ -36,20 +36,24 @@ describe('engine reference paths', () => {
});
});
-describe('agent mode names the same three engines everywhere', () => {
- // Exactly three providers implement `queryReadOnly`, which is what agent mode
- // runs on: postgres.ts, sqlite.ts and duckdb/index.ts. Cards that enumerate
- // the set must name all three — a stale pair reads as "DuckDB cannot".
- const AGENT_ENGINES = ['postgresql', 'sqlite', 'duckdb'];
+describe('agent mode names the same four engines everywhere', () => {
+ // Exactly four providers implement `queryReadOnly`, which is what agent mode
+ // runs on: postgres.ts, sqlite.ts, duckdb/index.ts and mssql.ts
+ // (libredb-studio/src/lib/agent/engine-support.ts). Cards that enumerate the
+ // set must name all four — a stale list reads as "SQL Server cannot".
+ const AGENT_ENGINES = ['postgresql', 'sqlite', 'duckdb', 'sqlserver'];
- it('advertises agent mode on exactly those three, and on no other', () => {
+ it('advertises agent mode on exactly those four, and on no other', () => {
const advertised = engines.filter((e) => /agent mode/i.test(e.desc)).map((e) => e.id);
expect(advertised.sort()).toEqual([...AGENT_ENGINES].sort());
});
- it('never enumerates the set without DuckDB', () => {
- const stale = engines.filter((e) => /every engine but PostgreSQL and SQLite/i.test(e.not)).map((e) => e.id);
- expect(stale, 'agent mode also runs on DuckDB').toEqual([]);
+ it('never enumerates the set without DuckDB or SQL Server', () => {
+ const enumerations = engines.filter((e) => /every engine but /i.test(e.not));
+ for (const e of enumerations) {
+ expect(e.not, `${e.id}: agent mode also runs on DuckDB`).toContain('DuckDB');
+ expect(e.not, `${e.id}: agent mode also runs on SQL Server`).toContain('SQL Server');
+ }
});
});
@@ -68,29 +72,39 @@ describe('engine card data is complete', () => {
});
describe('the engine count is stated consistently', () => {
- // The count appears as prose in five places. It has already drifted once — the
+ // The count appears as prose in several places. It has drifted twice — the
// site said sixteen while docs/providers/README.md listed seventeen providers,
- // because LibreDB (the embedded store /playground runs) was never added.
- const WORD = 'nineteen';
+ // and later nineteen (LibreDB included) while the product counted eighteen.
+ //
+ // The rule is libredb-studio's (src/lib/db/compatibility.ts,
+ // `connectableProductCount`, #424): a published count is the external engines,
+ // `EXTERNAL_DATABASE_TYPES`, so the embedded store is shown but never counted.
+ const WORD = 'twenty-seven';
+ const STALE = ['seventeen engines', 'eighteen engines', 'nineteen engines'];
+
+ it('matches the number of external engines actually defined', () => {
+ expect(externalEngines.length).toBe(27);
+ });
- it('matches the number of engines actually defined', () => {
- expect(engines.length).toBe(19);
+ it('flags exactly one entry as the embedded store, and it is LibreDB', () => {
+ expect(engines.filter((e) => e.embedded).map((e) => e.id)).toEqual(['libredb']);
});
it('is spelled the same way everywhere it is written out', () => {
- const sources = [
- 'src/data/home.ts',
- 'src/data/faq.ts',
- 'src/data/engines.ts',
- 'src/pages/databases.astro',
- ] as const;
+ const sources = ['src/data/home.ts', 'src/data/faq.ts', 'src/data/engines.ts', 'src/data/compare.ts'] as const;
for (const f of sources) {
const text = readFileSync(f, 'utf8').toLowerCase();
- expect(text.includes('seventeen'), `${f} still says "seventeen"`).toBe(false);
+ for (const stale of STALE) expect(text.includes(stale), `${f} still says "${stale}"`).toBe(false);
expect(text.includes(WORD), `${f} should name the count`).toBe(true);
}
});
+ it('derives the count on /databases instead of writing it out', () => {
+ const text = readFileSync('src/pages/databases.astro', 'utf8').toLowerCase();
+ for (const stale of STALE) expect(text.includes(stale), `databases.astro still says "${stale}"`).toBe(false);
+ expect(text).toContain('externalengines.length');
+ });
+
it('keeps every engine logo pointing at a file that exists', () => {
for (const e of engines) {
expect(existsSync(`public${e.logo}`), `${e.id}: missing public${e.logo}`).toBe(true);
diff --git a/tests/llms.test.ts b/tests/llms.test.ts
index 61ea3b8..188bd38 100644
--- a/tests/llms.test.ts
+++ b/tests/llms.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { readFileSync } from 'node:fs';
-import { engines } from '../src/data/engines';
+import { engines, externalEngines } from '../src/data/engines';
import { channels } from '../src/data/deploy-channels';
import { pageRoutes } from '../src/pages/llms.txt';
import site from '../site.config.json' with { type: 'json' };
@@ -40,6 +40,16 @@ const WORDS = [
'eighteen',
'nineteen',
'twenty',
+ 'twenty-one',
+ 'twenty-two',
+ 'twenty-three',
+ 'twenty-four',
+ 'twenty-five',
+ 'twenty-six',
+ 'twenty-seven',
+ 'twenty-eight',
+ 'twenty-nine',
+ 'thirty',
];
describe('llms.txt', () => {
@@ -63,7 +73,7 @@ describe('llms.txt', () => {
});
it('counts the engines the engine list actually holds', () => {
- expect(full()).toContain(`## Supported engines (${engines.length})`);
+ expect(full()).toContain(`## Supported engines (${externalEngines.length}, plus the embedded LibreDB store)`);
for (const e of engines) {
expect(full(), `${e.name} is missing from llms-full.txt`).toContain(e.name);
}
@@ -79,9 +89,9 @@ describe('llms.txt', () => {
// is the meta description of every page and the first paragraph of
// llms.txt, so it was the most-read wrong sentence on the site.
const named = (site.description.match(/\b(PostgreSQL|MySQL|MongoDB|Redis)\b/g) ?? []).length;
- const more = WORDS.indexOf(site.description.match(/and (\w+) more engines/)?.[1] ?? '');
+ const more = WORDS.indexOf(site.description.match(/and ([\w-]+) more engines/)?.[1] ?? '');
expect(more, 'the description no longer says "and more engines"').toBeGreaterThan(-1);
- expect(named + more, 'site.description undercounts or overcounts the engines').toBe(engines.length);
+ expect(named + more, 'site.description undercounts or overcounts the engines').toBe(externalEngines.length);
});
it('carries the vendor identity, which is what a model is asked to verify', () => {