diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f1984e1..3ace860 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -317,29 +317,42 @@ the prototype's raw `rgba()`, which is the same composite through the token. ## Engine marks -All sixteen logos are **self-hosted** in `public/engines/` (the prototype -hot-linked jsDelivr and `cdn.simpleicons.org`). `scripts/fetch-engine-logos.sh` -re-downloads them with devicon pinned to `v2.17.0`; `@latest` is a moving target -that can restructure a path and break a production build. - -Two ids deliberately differ from their upstream slug — `libsql` is served as -`turso`, `druid` as `apachedruid`. Renaming them to match breaks the fetch. - -**Open question for design.** Four marks are near-invisible on the white hexagon -plate the design specifies: +Every mark is **self-hosted** in `public/engines/` (the prototype hot-linked +jsDelivr and `cdn.simpleicons.org`). `scripts/fetch-engine-logos.sh` re-downloads +the nineteen that come from a CDN, with devicon pinned to `v2.17.0`; `@latest` is +a moving target that can restructure a path and break a production build. + +Three ids deliberately differ from their upstream slug — `libsql` is served as +`turso`, `druid` as `apachedruid`, `kafka` as `apachekafka`. Renaming them to +match breaks the fetch. + +Nine marks are not fetched. `libredb.svg` is our own. Seven — Db2, Neo4j, +Milvus, Qdrant, both InfluxDB types and Oxia — are the +generic marks the product draws for itself +(`libredb-studio/src/components/icons/db-icons.tsx`), stroked in `#334155`. +The product chose not to use those vendors' logos (Neo4j's provider doc lists it +as a known limitation), and the site does not show a vendor mark the product +itself refuses; Db2 has no CDN mark in any case. Databend's is +its official icon, used at the Databend team's request +(databendlabs/databend-docs#3512), paths and colours unchanged. The fetch +script's header names each. + +**Open question for design.** Five marks are faint on the white hexagon plate the +design specifies, the first four near-invisible: ``` 1.19:1 duckdb #FFF000 1.34:1 libsql #4FF8D2 1.39:1 druid #29F1FB 1.51:1 clickhouse #FFCC01 +2.95:1 etcd #419EDA ``` They are shipped **in brand colour anyway**. These are third-party trademarks and vendor brand guidelines forbid recolouring; the engine name is rendered as text directly beneath each mark, so the logo is decorative (`alt=""`) and carries no WCAG requirement. Recolouring them was tried and reverted. If this matters -visually the fix belongs in the design — a darker plate for those four, or +visually the fix belongs in the design — a darker plate for those five, or licensed alternate marks. `node scripts/logo-contrast-report.mjs` prints the current numbers. @@ -401,6 +414,17 @@ Each of these was a live failure here, not a hypothetical. Each has a test. `.optional()` accepts only `undefined` (`PITFALLS A2`). The schema uses tolerant combinators; `tests/content.test.ts` feeds it all three. +## The compose file is upstream's, with listed comment fixes + +`src/data/docker-compose.example.yml` was a verbatim copy of libredb-studio's +file. At 0.18.2 four of its comments were false (the image's Node version, what +happens without ADMIN_PASSWORD, which providers read LLM_API_URL, and the claim +that every supported variable is listed). The studio repository is not edited +from this site, so the site copy corrects those comments and lists each one in +`upstreamCompose` (`src/data/compose.ts`). `tests/compose.test.ts` reverts the +listed lines and checks the result against the upstream file's SHA-256, so any +other difference still fails the gate. Functional lines stay byte-identical. + ## Known limits - `app.libredb.org` and the two Netlify demo iframes are third-party embeds; if diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 961f813..40b498c 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -177,7 +177,7 @@ drifts out of step. ## Regenerating assets ```sh -./scripts/fetch-engine-logos.sh # the sixteen engine marks +./scripts/fetch-engine-logos.sh # the nineteen fetched engine marks node scripts/logo-contrast-report.mjs # how each reads on the white plate bun run build && bun run scripts/og/build-og.mjs # public/og/default.png node scripts/contrast.mjs "#7f87a7" "#141829" # one contrast pair diff --git a/package.json b/package.json index 68e17f6..c9d2196 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "libredb-website", - "version": "1.3.7", + "version": "1.4.0", "private": true, "type": "module", "description": "libredb.org — marketing website for LibreDB Studio (Astro static + Outstatic CMS)", diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt index b052797..5ddc535 100644 --- a/public/.well-known/security.txt +++ b/public/.well-known/security.txt @@ -1,3 +1,4 @@ +Contact: mailto:info@sekoya.tech Contact: https://github.com/libredb/libredb-studio/security/advisories Expires: 2027-03-11T00:00:00.000Z Preferred-Languages: en, tr diff --git a/public/engines/databend.svg b/public/engines/databend.svg new file mode 100644 index 0000000..2d59020 --- /dev/null +++ b/public/engines/databend.svg @@ -0,0 +1 @@ +Databend diff --git a/public/engines/db2.svg b/public/engines/db2.svg new file mode 100644 index 0000000..b6d951d --- /dev/null +++ b/public/engines/db2.svg @@ -0,0 +1 @@ +IBM Db2 (generic mark) diff --git a/public/engines/etcd.svg b/public/engines/etcd.svg new file mode 100644 index 0000000..aaaf827 --- /dev/null +++ b/public/engines/etcd.svg @@ -0,0 +1 @@ +etcd diff --git a/public/engines/influxdb.svg b/public/engines/influxdb.svg new file mode 100644 index 0000000..9559107 --- /dev/null +++ b/public/engines/influxdb.svg @@ -0,0 +1 @@ +InfluxDB (generic time-series mark) diff --git a/public/engines/influxdb3.svg b/public/engines/influxdb3.svg new file mode 100644 index 0000000..f8b7fbc --- /dev/null +++ b/public/engines/influxdb3.svg @@ -0,0 +1 @@ +InfluxDB 3 (generic time-series mark) diff --git a/public/engines/milvus.svg b/public/engines/milvus.svg new file mode 100644 index 0000000..a20305c --- /dev/null +++ b/public/engines/milvus.svg @@ -0,0 +1 @@ +Milvus (generic vector mark) diff --git a/public/engines/neo4j.svg b/public/engines/neo4j.svg new file mode 100644 index 0000000..7180bf3 --- /dev/null +++ b/public/engines/neo4j.svg @@ -0,0 +1 @@ +Neo4j (generic graph mark) diff --git a/public/engines/oxia.svg b/public/engines/oxia.svg new file mode 100644 index 0000000..9f611bb --- /dev/null +++ b/public/engines/oxia.svg @@ -0,0 +1 @@ +Oxia (generic mark) diff --git a/public/engines/qdrant.svg b/public/engines/qdrant.svg new file mode 100644 index 0000000..d20ca01 --- /dev/null +++ b/public/engines/qdrant.svg @@ -0,0 +1 @@ +Qdrant (generic vector mark) diff --git a/public/humans.txt b/public/humans.txt index b444528..18ae7e2 100644 --- a/public/humans.txt +++ b/public/humans.txt @@ -15,7 +15,7 @@ Site: https://sekoya.tech Contact: info@sekoya.tech /* SITE */ -Last update: 2026/08/01 +Last update: 2026/10/11 Language: English Standards: HTML5, CSS3, ES2024 Framework: Astro 7 diff --git a/scripts/fetch-engine-logos.sh b/scripts/fetch-engine-logos.sh index 03abf69..d93c257 100755 --- a/scripts/fetch-engine-logos.sh +++ b/scripts/fetch-engine-logos.sh @@ -1,13 +1,26 @@ #!/usr/bin/env bash -# Re-download the sixteen engine marks into public/engines/. +# Re-download the nineteen fetched engine marks into public/engines/. +# +# Ten more marks in public/engines/ are NOT fetched and this script never touches +# them: libredb.svg is our own, and eight come from the product's own icon set, +# libredb-studio/src/components/icons/db-icons.tsx, copied as static SVG: +# - db2, neo4j, milvus, qdrant, influxdb, influxdb3, oxia: marks drawn for +# Studio, deliberately not the vendor's logo. The product decided that per +# engine (Neo4j's provider doc lists "never Neo4j's logo" as a known +# limitation; InfluxDB, Milvus, Qdrant and Oxia say the same in their icon +# comments), so the site does not use a vendor mark the product refuses. +# Db2 has no mark on either CDN anyway. Stroke #334155. +# - databend: Databend's official icon (databend.com/brand, "Icon · Light"), +# used at the Databend team's request (databendlabs/databend-docs#3512); +# paths and colours unchanged. # # The prototype hot-linked jsDelivr and cdn.simpleicons.org. Production self-hosts # them: @latest is a moving target, and a CDN outage must not take the page with # it. devicon is pinned; simpleicons has no version in the URL, so the downloaded # files are committed and this script is only for a deliberate refresh. # -# Note two ids do not match their upstream slug: libsql is served as `turso`, and -# druid as `apachedruid`. Renaming them to match breaks the fetch. +# Note three ids do not match their upstream slug: libsql is served as `turso`, +# druid as `apachedruid` and kafka as `apachekafka`. Renaming them to match breaks the fetch. # # Marks are third-party trademarks — never recoloured. Run # `node scripts/logo-contrast-report.mjs` afterwards to see how each reads on the @@ -44,6 +57,9 @@ fetch clickhouse "$SI/clickhouse" fetch druid "$SI/apachedruid" fetch trino "$SI/trino" fetch opensearch "$SI/opensearch" +fetch prometheus "$SI/prometheus" +fetch kafka "$SI/apachekafka" +fetch etcd "$SI/etcd" echo -echo "16 marks in $OUT" +echo "19 marks in $OUT" diff --git a/site.config.json b/site.config.json index fc3d15f..edabd0c 100644 --- a/site.config.json +++ b/site.config.json @@ -4,8 +4,8 @@ "url": "https://libredb.org", "domain": "libredb.org", "tagline": "The database editor that deploys next to your data.", - "description": "LibreDB Studio is the open-source database IDE that deploys next to your data instead of onto your laptop — one browser tab for PostgreSQL, MySQL, MongoDB, Redis and fifteen more engines, with SSO and audit built in.", - "version": "0.17.0", + "description": "LibreDB Studio is the open-source database IDE that deploys next to your data instead of onto your laptop — one browser tab for PostgreSQL, MySQL, MongoDB, Redis and twenty-three more engines, with SSO and audit built in.", + "version": "0.18.2", "license": "MIT", "company": "Sekoya Grup Bilişim ve Teknoloji Ltd. Şti., İstanbul", "copyrightYear": 2026, diff --git a/src/data/code-signing.ts b/src/data/code-signing.ts index 21950d2..6e9f0c8 100644 --- a/src/data/code-signing.ts +++ b/src/data/code-signing.ts @@ -24,6 +24,14 @@ * * SOURCE OF TRUTH for the roles: repository collaborator permissions on * github.com/libredb/libredb-studio. If those change, this file is wrong. + * + * The Reviewers text was re-read from the live API on 2026-10-11: rulesets + * `main` and `pr-required` (pull request, 0 approving reviews; Lint, Typecheck + * and Build plus Unit & Integration Tests), classic protection on main (adds + * Secret Scan, enforce_admins false), every ruleset with OrganizationAdmin + * bypass_mode "always". Commits 4ebc7876 (0.18.2), 62cfce50 (0.17.0), d4000e9c + * and bb31671e reached main with no pull request. Org 2FA requirement: on. + * Immutable releases: enabled. Org admins: cevheri, kaya-abdullah. */ const REPO = 'https://github.com/libredb/libredb-studio'; @@ -73,21 +81,21 @@ export const roles: Role[] = [ definition: 'People trusted to modify the source code in the version control system without an additional review.', members: ['cevheri', 'kaya-abdullah', 'yusuf-gundogdu'], enforcement: - 'Write access on the repository — two administrators and one maintainer. Everyone else, including the fourth organisation member, has read access and lands changes only through a pull request.', + 'Admin access on the repository, held by all three. Everyone else, including the fourth organisation member, has read access and lands changes only through a pull request.', }, { role: 'Reviewers', definition: 'Each change proposed by someone who is not a committer is reviewed by a team member.', members: ['cevheri', 'kaya-abdullah', 'yusuf-gundogdu'], enforcement: - 'main is protected: no direct pushes, and lint, typecheck, build, the full test suite and a secret scan must pass before a merge. An outside pull request is additionally read by one of the people above before it lands.', + 'Rules on main require a pull request and three passing checks before a merge: lint, typecheck and build; the unit and integration tests; and a secret scan. They require no approving review, and they are not enforced on administrators: organisation admins (cevheri and kaya-abdullah) can bypass them, and release commits and some small changes are pushed to main directly. By policy, not by a rule, an outside pull request is read by one of the people above before it lands.', }, { role: 'Approvers', definition: 'Each signing request is approved by a team member trusted by the entire team.', members: ['cevheri', 'kaya-abdullah', 'yusuf-gundogdu'], enforcement: - 'Three approvers, so a release is never gated on one person being available. Signing is never automatic: a release is a tag pushed by hand, and each signing request is approved individually.', + 'Three approvers, so a release is never gated on one person being available. A release is a tag pushed by hand. No signing happens yet; once it does, the policy is that each signing request is approved individually and none is automatic.', }, ]; @@ -102,8 +110,8 @@ export const codeSigningPage = { kicker: SIGNING_ACTIVE ? 'Current state' : 'Current state — unsigned', headline: SIGNING_ACTIVE ? 'Windows builds are signed' : 'Windows builds are not signed yet', body: SIGNING_ACTIVE - ? 'The Windows portable zip and the winget and Chocolatey payloads are Authenticode-signed. The private key never leaves the signing service’s HSM, and the signature is applied from the public release workflow.' - : 'The Windows portable zip and the winget and Chocolatey payloads ship unsigned, so SmartScreen will warn on first run. That warning is correct and you should treat it as correct: verify the checksum published with the release before running the binary. An application for free open-source code signing is open; this page is written so that when it is granted, nothing about who approves a release has to change.', + ? 'The Windows portable zip, and the winget, Chocolatey and Scoop packages that install it, are Authenticode-signed. The private key never leaves the signing service’s HSM, and the signature is applied from the public release workflow.' + : 'The Windows portable zip, and the winget, Chocolatey and Scoop packages that install it, ship unsigned, so SmartScreen will warn on first run. That warning is correct and you should treat it as correct: verify the checksum published with the release before running the binary. An application for free open-source code signing is open; this page is written so that when it is granted, nothing about who approves a release has to change.', href: `${REPO}/releases`, linkLabel: 'Releases and checksums', }, @@ -122,7 +130,7 @@ export const codeSigningPage = { process: [ { t: 'Built from a tag, in public', - d: 'Every artifact is produced by GitHub Actions from a pushed tag, using workflows anyone can read in the repository. Nothing is built on a maintainer’s machine and uploaded.', + d: 'Every release artifact is built by GitHub Actions, triggered by a pushed release tag or the release published from it, using workflows anyone can read in the repository. Nothing is built on a maintainer’s machine and uploaded.', }, { t: 'Multi-factor authentication, enforced', @@ -130,11 +138,11 @@ export const codeSigningPage = { }, { t: 'Each signing request approved by hand', - d: 'A release is never signed as a side effect of a merge. An Approver authorises each request individually, after the release assets are built and verified.', + d: 'This is the policy for when signing starts, since nothing is signed today: a release is never signed as a side effect of a merge, and an Approver authorises each request individually, after the release assets are built and verified.', }, { t: 'A release is never rewritten', - d: 'Published releases and package versions are immutable here by policy: a bad release is replaced by the next patch version, never re-uploaded under the same one. A signature therefore always describes the bytes it was made for.', + d: 'Published releases and package versions are immutable here by policy, and GitHub’s immutable releases setting is on for the repository: a bad release is replaced by the next patch version, never re-uploaded under the same one. A signature therefore always describes the bytes it was made for.', }, ], diff --git a/src/data/compare.ts b/src/data/compare.ts index cc43bb0..ee4a650 100644 --- a/src/data/compare.ts +++ b/src/data/compare.ts @@ -98,7 +98,7 @@ export const consequences: Consequence[] = [ { question: 'Working across several engines at once', desktop: 'Depends on the tool; often several tools.', - beside: 'Nineteen engines behind one tab and one grid.', + beside: 'Twenty-seven engines behind one tab and one grid.', }, ]; diff --git a/src/data/compose.ts b/src/data/compose.ts index c3f93c1..08f9594 100644 --- a/src/data/compose.ts +++ b/src/data/compose.ts @@ -1,8 +1,11 @@ /** * The environment-variable reference behind /docker-compose. * - * SOURCE OF TRUTH is `src/data/docker-compose.example.yml`, which is a verbatim - * copy of libredb-studio's `docker-compose.example.yml`. This file only describes + * SOURCE OF TRUTH is `src/data/docker-compose.example.yml`, which is + * libredb-studio's `docker-compose.example.yml` at the tag in `upstreamCompose` + * with the comment lines listed there corrected, and nothing else changed (the + * studio repo is not edited from here, so a false comment is fixed in this copy + * and listed). This file only describes * what is in there — it must never introduce a variable the compose file does not * mention, and must never omit one it does. `tests/compose.test.ts` checks both * directions, so a drifting copy fails the gate rather than shipping a table that @@ -34,7 +37,7 @@ export const composeGroups: ComposeGroup[] = [ { id: 'auth', title: 'Authentication', - note: 'The local email/password provider. Studio will not start a usable login without ADMIN_PASSWORD and JWT_SECRET.', + note: 'The local email/password provider. This compose file refuses to start until ADMIN_PASSWORD and JWT_SECRET are set in .env. Studio on its own generates both on first start and prints the admin password to the log once; the file asks for them so the values are yours.', vars: [ { name: 'ADMIN_EMAIL', @@ -45,7 +48,7 @@ export const composeGroups: ComposeGroup[] = [ { name: 'ADMIN_PASSWORD', def: 'required', - desc: 'Admin password. Without it the login screen shows a configuration error instead of a form.', + desc: 'Admin password. Unset, docker compose up stops with an error before the container starts.', state: 'active', }, { @@ -77,7 +80,7 @@ export const composeGroups: ComposeGroup[] = [ { id: 'oidc', title: 'OIDC single sign-on', - note: 'Only read when NEXT_PUBLIC_AUTH_PROVIDER=oidc. Tested against Auth0, Keycloak, Okta, Microsoft Entra ID and Zitadel.', + note: "Only read when NEXT_PUBLIC_AUTH_PROVIDER=oidc. Studio's OIDC guide has setup examples for Auth0, Keycloak, Okta, Microsoft Entra ID and Zitadel. The Keycloak steps are verified on Keycloak 26.4 and ship as a local demo stack; the others are configuration examples, and Okta's logout has not been tried against a live tenant.", vars: [ { name: 'OIDC_ISSUER', @@ -115,7 +118,7 @@ export const composeGroups: ComposeGroup[] = [ { id: 'storage', title: 'Storage', - note: 'Where saved connections and configuration live. The default keeps them in the browser, so a fresh container starts empty on purpose.', + note: 'Where saved connections and configuration live. The default keeps them in the browser. A first start also adds two editable sample connections, on by default; .env.example has the switches that turn them off.', vars: [ { name: 'STORAGE_PROVIDER', @@ -131,7 +134,7 @@ export const composeGroups: ComposeGroup[] = [ }, { name: 'STORAGE_POSTGRES_URL', - def: '—', + def: 'postgresql://postgres:postgres@libredb-postgres:5432/libredb_storage?sslmode=disable', desc: 'Connection string for the postgres provider. The file ships a matching optional postgres:18 service.', state: 'commented', }, @@ -153,7 +156,7 @@ export const composeGroups: ComposeGroup[] = [ { name: 'LLM_API_URL', def: '—', - desc: 'Base URL for ollama and custom, e.g. http://host:11434/v1.', + desc: 'Required for custom, and read by every provider: an Ollama that is not on localhost:11434, e.g. http://host:11434/v1, or a proxy or regional endpoint for gemini.', state: 'commented', }, ], @@ -183,6 +186,50 @@ export const composeGroups: ComposeGroup[] = [ }, ], }, + { + id: 'mcp', + title: 'MCP server', + note: 'Off unless switched on. An AI client of your own reads schemas and runs read-only SQL at /api/mcp, with a token minted on the settings screen.', + vars: [ + { + name: 'LIBREDB_MCP_ENABLED', + def: 'unset', + desc: 'true, on or 1 turns the MCP endpoint on; unset leaves it off.', + state: 'commented', + }, + { + name: 'LIBREDB_MCP_URL', + def: 'http://localhost:3000/api/mcp', + desc: 'The address clients use, with your BASE_PATH. Every token is bound to it, so changing it invalidates them all.', + state: 'commented', + }, + { + name: 'LIBREDB_MCP_TOKEN_LABEL', + def: '—', + desc: 'Any non-empty value. Changing it revokes every MCP token at once.', + state: 'commented', + }, + { + name: 'LIBREDB_MCP_TOKEN_TTL_DAYS', + def: '30', + desc: 'How many days a minted token stays valid, from 1 to 365.', + state: 'commented', + }, + ], + }, + { + id: 'passkeys', + title: 'Passkeys', + note: 'Off unless PASSKEY_ORIGIN is set. Needs local sign-in and STORAGE_PROVIDER sqlite or postgres, since the server store keeps the passkey records.', + vars: [ + { + name: 'PASSKEY_ORIGIN', + def: 'http://localhost:3000', + desc: 'The exact address people open Studio at: scheme, host and port, no path. https is required except on localhost.', + state: 'commented', + }, + ], + }, { id: 'seed', title: 'Seed connections', @@ -204,3 +251,54 @@ export const composeQuickStart = `curl -O https://libredb.org/docker-compose.exa mv docker-compose.example.yml docker-compose.yml printf 'ADMIN_PASSWORD=change-me\\nJWT_SECRET=%s\\n' "$(openssl rand -base64 32)" > .env docker compose up -d`; + +/** + * The site copy against upstream. Every line that differs from libredb-studio's + * file at `tag` is listed here, and each one is a comment that was false at that + * tag. tests/compose.test.ts puts the upstream text back, hashes the result and + * compares it with `sha256` (the upstream file's own hash), so any other edit, + * functional or not, fails the gate. When the upstream file changes: copy it, + * re-apply only the corrections still needed, and update `tag` and `sha256`. + */ +export const upstreamCompose = { + tag: '0.18.2', + sha256: '305c389ed153de7c9c1244fc62b1a3cf5d906bbab165f627a5ad006561963d85', + corrections: [ + { + line: 14, + upstream: '# Every supported environment variable is listed below. Commonly-used ones are', + site: '# The variables most deployments use are listed below; .env.example has the rest.', + why: 'The file lists a subset of what Studio reads; .env.example is the full set.', + }, + { + line: 15, + upstream: '# active; less-frequently-used ones are shown commented out — uncomment as needed.', + site: '# Commonly-used ones are active; others are commented out — uncomment as needed.', + why: 'Rewrapped with line 14.', + }, + { + line: 33, + upstream: ' # AUTHENTICATION — local provider. ADMIN_PASSWORD is required (without it', + site: ' # AUTHENTICATION — local provider. ADMIN_PASSWORD is required here (without', + why: 'Rewrapped with line 34.', + }, + { + line: 34, + upstream: ' # the login screen shows a clear config error); USER_* is optional.', + site: ' # it docker compose refuses to start); USER_* is optional.', + why: 'The file uses ${ADMIN_PASSWORD:?}, so compose stops before the container starts; Studio itself would generate a password (.env.example, ZERO-CONFIG BOOTSTRAP).', + }, + { + line: 75, + upstream: ' # LLM_API_URL: ${LLM_API_URL} # ollama/custom only, e.g. http://host:11434/v1', + site: ' # LLM_API_URL: ${LLM_API_URL} # required for custom, read by every provider, e.g. http://host:11434/v1', + why: '.env.example: "required for the custom provider, and read by every kind" (Gemini reads it for a proxy or regional endpoint).', + }, + { + line: 129, + upstream: " # Image runs as node:24.16.0-trixie-slim (no curl/wget) — use Node's built-in fetch.", + site: " # Image runs as node:26.10.0-trixie-slim (no curl/wget) — use Node's built-in fetch.", + why: 'The Dockerfile at 0.18.2 builds the runner FROM node:26.10.0-trixie-slim.', + }, + ], +} as const; diff --git a/src/data/deploy-channels.ts b/src/data/deploy-channels.ts index c99809b..392ae8c 100644 --- a/src/data/deploy-channels.ts +++ b/src/data/deploy-channels.ts @@ -8,8 +8,8 @@ * RULE: only `status: live` channels appear here. A channel that is `pending` * (submitted, not yet listed) or `deprecated` must never be shown — the page * would be promising an install that does not exist yet. As of the transcription - * below, channels.yaml holds 33 channels: 26 live, 6 pending, 1 deprecated. The - * 26 live ones are all present. + * below (studio 0.18.2), channels.yaml holds 46 channels: 39 live, 6 pending, + * 1 deprecated. The 39 live ones are all present. * * When channels.yaml changes, re-transcribe rather than adding by hand — the * scorecard in docs/CHANNELS.md is the number to reconcile against. @@ -61,7 +61,7 @@ export const channelCategories: CategoryMeta[] = [ { id: 'kubernetes-operators', title: 'Kubernetes', - tagline: 'The published Helm chart, on any cluster — including a certified partner listing.', + tagline: 'The published Helm chart and operator, on any cluster — including a certified partner listing.', }, { id: 'package-managers', @@ -134,7 +134,7 @@ export const channels: Channel[] = [ automated: true, }, - // --- kubernetes (2 live) --- + // --- kubernetes (4 live) --- { id: 'helm', name: 'Helm chart', @@ -154,8 +154,26 @@ export const channels: Channel[] = [ note: 'A certified chart in Rancher Partner Charts, with a SUSE Partner Certification listing.', automated: false, }, + { + id: 'operatorhub-community', + name: 'OperatorHub / OpenShift community operators', + category: 'kubernetes-operators', + platforms: ['Kubernetes'], + url: 'https://operatorhub.io/operator/libredb-studio-operator', + note: 'The operator in the community catalogs of operatorhub.io and the OpenShift console. Community, not Red Hat certified, so the console lists it under Community.', + automated: false, + }, + { + id: 'zopdev-helm-charts', + name: 'ZopDev Helm Charts', + category: 'kubernetes-operators', + platforms: ['Kubernetes'], + url: 'https://helm.zop.dev', + note: 'A chart of its own in the ZopDev repository, served from helm.zop.dev. Not a copy of the libredb.org chart.', + automated: false, + }, - // --- package managers (5 live) --- + // --- package managers (6 live) --- { id: 'homebrew', name: 'Homebrew tap', @@ -196,6 +214,16 @@ export const channels: Channel[] = [ note: 'The same standalone zip as winget, from the Chocolatey community repository.', automated: true, }, + { + id: 'scoop', + name: 'Scoop', + category: 'package-managers', + platforms: ['Windows'], + url: 'https://github.com/ScoopInstaller/Extras/blob/master/bucket/libredb-studio.json', + command: 'scoop install extras/libredb-studio', + note: 'The same standalone zip, from the Scoop Extras bucket (scoop bucket add extras first). The bucket updates itself from each release.', + automated: false, + }, { id: 'flatpark', name: 'FlatPark (Flatpak)', @@ -235,7 +263,7 @@ export const channels: Channel[] = [ automated: false, }, - // --- PaaS catalogs (8 live) --- + // --- PaaS catalogs (15 live) --- { id: 'railway-template', name: 'Railway', @@ -254,6 +282,24 @@ export const channels: Channel[] = [ note: 'Listed in CapRover’s official one-click apps.', automated: false, }, + { + id: 'caprover-autoconnect', + name: 'CapRover auto-connect', + category: 'paas-catalogs', + platforms: ['Cloud'], + url: 'https://github.com/caprover/one-click-apps', + note: 'An opt-in second CapRover entry: a discovery companion reads the Docker socket and connects Studio to the databases CapRover runs. The plain entry stays socket-free.', + automated: false, + }, + { + id: 'dokku-plugin', + name: 'Dokku plugin', + category: 'paas-catalogs', + platforms: ['Cloud'], + url: 'https://dokku.com/docs/community/plugins/', + note: 'A community plugin, listed on Dokku’s community plugins page.', + automated: false, + }, { id: 'dokploy', name: 'Dokploy', @@ -285,11 +331,20 @@ export const channels: Channel[] = [ id: 'cosmos', name: 'Cosmos', category: 'paas-catalogs', - platforms: ['Container'], + platforms: ['Cloud'], url: 'https://github.com/azukaar/cosmos-servapps-official', note: 'A servapp in the Cosmos marketplace.', automated: false, }, + { + id: 'railyard', + name: 'Railyard', + category: 'paas-catalogs', + platforms: ['Cloud'], + url: 'https://railyard.run/templates/libredb-studio', + note: 'A template in the Railyard catalog. Railyard builds the Dockerfile from main at deploy time, so a deploy can carry commits no release has shipped yet.', + automated: false, + }, { id: 'unraid-ca', name: 'Unraid Community Applications', @@ -308,6 +363,42 @@ export const channels: Channel[] = [ note: 'In the TrueNAS SCALE community apps catalog.', automated: false, }, + { + id: 'casaos', + name: 'CasaOS App Store', + category: 'paas-catalogs', + platforms: ['Container'], + url: 'https://github.com/IceWhaleTech/CasaOS-AppStore', + note: 'In the CasaOS App Store; installs a container on your own device.', + automated: false, + }, + { + id: 'okxlin-appstore', + name: '1Panel community app store', + category: 'paas-catalogs', + platforms: ['Container'], + url: 'https://github.com/okxlin/appstore/tree/localApps/apps/libredb-studio', + note: 'The community-maintained app store for 1Panel, bumped by the store’s own Renovate.', + automated: false, + }, + { + id: 'portainer-templates-as93', + name: 'Portainer templates (lissy93)', + category: 'paas-catalogs', + platforms: ['Container'], + url: 'https://portainer-templates.as93.net', + note: 'In the lissy93 Portainer templates aggregator, a third-party list rather than the official portainer/templates repository. It names the :latest image tag.', + automated: false, + }, + { + id: 'yunohost', + name: 'YunoHost', + category: 'paas-catalogs', + platforms: ['Linux'], + url: 'https://apps.yunohost.org/app/libredb_studio', + note: 'In the YunoHost app catalog. No container: it unpacks the standalone Linux tarball under systemd with its own Node 24.', + automated: false, + }, // --- deploy recipes (3 live) --- { @@ -334,11 +425,11 @@ export const channels: Channel[] = [ category: 'deploy-recipes', platforms: ['Cloud'], url: 'https://github.com/libredb/libredb-studio/tree/main/deploy/koyeb', - note: 'A deploy button in the repository README. It deliberately ships no default credentials — you supply your own.', + note: 'A deploy button in the repository README. It prefills no password and no working secret: you set JWT_SECRET (32+ characters) in the deploy form, or the instance stops at boot; the admin password is generated on first run and printed to the Koyeb runtime log.', automated: false, }, - // --- cloud marketplaces (1 live) --- + // --- cloud marketplaces (4 live) --- { id: 'digitalocean', name: 'DigitalOcean Marketplace', @@ -348,4 +439,31 @@ export const channels: Channel[] = [ note: 'Provisioned straight from DigitalOcean’s marketplace.', automated: false, }, + { + id: 'aws-marketplace', + name: 'AWS Marketplace', + category: 'cloud-marketplaces', + platforms: ['Cloud'], + url: 'https://aws.amazon.com/marketplace/pp/prodview-tsahkrgdqpnws', + note: 'A free AMI product.', + automated: false, + }, + { + id: 'azure-marketplace', + name: 'Azure Marketplace', + category: 'cloud-marketplaces', + platforms: ['Cloud'], + url: 'https://marketplace.microsoft.com/en-us/product/libredb.libredb-studio', + note: 'A free (“Get It Now”) Azure Application solution template.', + automated: false, + }, + { + id: 'gcp-marketplace', + name: 'Google Cloud Marketplace', + category: 'cloud-marketplaces', + platforms: ['Kubernetes', 'Cloud'], + url: 'https://console.cloud.google.com/marketplace/product/libredb-public/libredb-studio', + note: 'A free Terraform Kubernetes app, deployed from the Google Cloud console into your own GKE cluster.', + automated: false, + }, ]; diff --git a/src/data/docker-compose.example.yml b/src/data/docker-compose.example.yml index bacde58..255ed9a 100644 --- a/src/data/docker-compose.example.yml +++ b/src/data/docker-compose.example.yml @@ -11,8 +11,8 @@ # 3. docker compose up -d # 4. open http://localhost:3000 # -# Every supported environment variable is listed below. Commonly-used ones are -# active; less-frequently-used ones are shown commented out — uncomment as needed. +# The variables most deployments use are listed below; .env.example has the rest. +# Commonly-used ones are active; others are commented out — uncomment as needed. # Secrets are read from the .env file via ${VAR} interpolation and are never # hardcoded in this file. # ============================================================================= @@ -30,8 +30,8 @@ services: # condition: service_healthy environment: # ----------------------------------------------------------------------- - # AUTHENTICATION — local provider. ADMIN_PASSWORD is required (without it - # the login screen shows a clear config error); USER_* is optional. + # AUTHENTICATION — local provider. ADMIN_PASSWORD is required here (without + # it docker compose refuses to start); USER_* is optional. # ----------------------------------------------------------------------- ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@libredb.org} # admin: full access + maintenance tools ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env} @@ -72,7 +72,7 @@ services: # LLM_PROVIDER: ${LLM_PROVIDER:-gemini} # LLM_API_KEY: ${LLM_API_KEY} # required for gemini/openai # LLM_MODEL: ${LLM_MODEL:-gemini-2.5-flash} - # LLM_API_URL: ${LLM_API_URL} # ollama/custom only, e.g. http://host:11434/v1 + # LLM_API_URL: ${LLM_API_URL} # required for custom, read by every provider, e.g. http://host:11434/v1 # ----------------------------------------------------------------------- # AGENT RUNTIME — standalone only, and there is no flag to turn it on. @@ -94,6 +94,24 @@ services: # below either way, or runs vanish when the container is recreated. # WORKFLOW_LOCAL_DATA_DIR: ${WORKFLOW_LOCAL_DATA_DIR:-/app/data/workflow} + # ----------------------------------------------------------------------- + # MCP SERVER: off unless LIBREDB_MCP_ENABLED says on. An AI client of your + # own reads schemas and runs read-only SQL at /api/mcp with a token minted on + # the settings screen. The URL is the address clients use, with your + # BASE_PATH, and every token is bound to it; changing the label revokes + # every token at once. See docs/MCP.md. + # ----------------------------------------------------------------------- + # LIBREDB_MCP_ENABLED: "true" + # LIBREDB_MCP_URL: ${LIBREDB_MCP_URL:-http://localhost:3000/api/mcp} + # LIBREDB_MCP_TOKEN_LABEL: ${LIBREDB_MCP_TOKEN_LABEL} + # LIBREDB_MCP_TOKEN_TTL_DAYS: ${LIBREDB_MCP_TOKEN_TTL_DAYS:-30} + + # ----------------------------------------------------------------------- + # PASSKEYS: off unless PASSKEY_ORIGIN is set; also needs STORAGE_PROVIDER + # sqlite or postgres. Open Studio at exactly this address. See docs/PASSKEYS.md. + # ----------------------------------------------------------------------- + # PASSKEY_ORIGIN: ${PASSKEY_ORIGIN:-http://localhost:3000} + # ----------------------------------------------------------------------- # SEED CONNECTIONS (optional) — pre-configure databases on boot. # Uncomment the seed volume mount below, then provide seed-connections.yaml. @@ -108,9 +126,10 @@ services: # # Seed connections file (read-only): # - ./seed-connections.yaml:/app/config/seed-connections.yaml:ro - # Image runs as node:24.16.0-trixie-slim (no curl/wget) — use Node's built-in fetch. + # Image runs as node:26.10.0-trixie-slim (no curl/wget) — use Node's built-in fetch. + # BASE_PATH must match a custom image built with that prefix (docs/SUBPATH.md). healthcheck: - test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/db/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + test: ["CMD", "node", "-e", "fetch('http://localhost:3000${BASE_PATH:-}/api/db/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 30s timeout: 5s retries: 3 @@ -130,7 +149,10 @@ services: # POSTGRES_PASSWORD: postgres # POSTGRES_DB: libredb_storage # volumes: - # - pgdata:/var/lib/postgresql/data + # # PostgreSQL 18+ images expect the mount at /var/lib/postgresql, not /var/lib/postgresql/data. + # # A pgdata volume from the old layout has to be removed once: run docker compose down, then + # # docker volume rm on the volume whose name ends in _pgdata (find it with docker volume ls). + # - pgdata:/var/lib/postgresql # healthcheck: # test: ["CMD-SHELL", "pg_isready -U postgres"] # interval: 10s diff --git a/src/data/engines.ts b/src/data/engines.ts index fabc89d..52df82c 100644 --- a/src/data/engines.ts +++ b/src/data/engines.ts @@ -1,13 +1,32 @@ /** - * The nineteen engines shown in the hexagon grid (design/Home.dc.html → engineData). - * Copy, transport strings and the "what this engine does not do" lines are final — - * they are the honest-capability claim the design is built around. + * Every engine shown in the hexagon grid and on /databases: the twenty-seven + * external engines of libredb-studio's `EXTERNAL_DATABASE_TYPES` + * (src/lib/db/compatibility.ts), plus LibreDB, the embedded store the product + * carries, which is flagged `embedded` and is not counted. + * + * Counting rule, from the same file (`connectableProductCount`, #424's claim + * discipline): a published engine count is the products a user can point the + * app at, so the embedded store is out of it. Count with `externalEngines`, never + * with `engines.length`. Each card's copy is transcribed from + * libredb-studio/docs/providers/.md; the transport strings and the + * "what this engine does not do" lines are the honest-capability claim the + * design is built around. * * `logo` points at a self-hosted copy under public/engines/; the prototype * hot-linked devicon / simpleicons CDNs (see scripts/fetch-engine-logos.sh). */ export type EngineCategory = - 'SQL' | 'Analytics' | 'Federated' | 'Document' | 'Key-value' | 'Wide-column' | 'Search' | 'Time-series' | 'Streaming'; + | 'SQL' + | 'Analytics' + | 'Federated' + | 'Document' + | 'Key-value' + | 'Wide-column' + | 'Graph' + | 'Vector' + | 'Search' + | 'Time-series' + | 'Streaming'; export interface Engine { id: string; @@ -19,6 +38,8 @@ export interface Engine { tr: string; /** the capability that is deliberately absent, rendered under "What this engine does not do" */ not: string; + /** true only for the store the product carries; it is shown, but never counted as an engine */ + embedded?: true; } export const engines: Engine[] = [ @@ -38,14 +59,14 @@ export const engines: Engine[] = [ logo: '/engines/mysql.svg', desc: 'Pooled connections with information_schema introspection, EXPLAIN plans and performance_schema-backed monitoring.', tr: 'mysql2 pool · 3306', - not: 'Agent mode is unavailable, as on every engine but PostgreSQL, SQLite and DuckDB.', + not: 'Agent mode is unavailable, as on every engine but PostgreSQL, SQLite, DuckDB and SQL Server.', }, { id: 'oracle', name: 'Oracle', cat: 'SQL', logo: '/engines/oracle.svg', - desc: 'Pooled connections with introspection and monitoring through the ALL_* and DBA_* data-dictionary views.', + desc: 'Pooled connections with introspection through the ALL_* data-dictionary views and monitoring through the V$ views, each read guarded because they need privileges an app user may lack.', tr: 'oracledb pool · 1521', not: "Agent mode is unavailable here. Operate still reads the server's own reporting interface.", }, @@ -54,9 +75,18 @@ export const engines: Engine[] = [ name: 'SQL Server', cat: 'SQL', logo: '/engines/sqlserver.svg', - desc: 'Pooled connections with sys.* catalog introspection, estimated plans and DMV-backed monitoring.', + desc: 'Pooled connections with sys.* catalog introspection, DMV-backed monitoring and agent mode under a least-privilege read-only profile.', tr: 'mssql pool · 1433', - not: 'Agent mode is unavailable here; the editor, diagrams and health are identical.', + not: "The editor has no Explain action: SQL Server's estimated plan is a session mode that a statement prefix cannot express. Only agent mode reads a plan, to admit a statement.", + }, + { + id: 'db2', + name: 'Db2 LUW', + cat: 'SQL', + logo: '/engines/db2.svg', + desc: 'IBM Db2 for Linux, UNIX and Windows over DRDA, through a Rust driver that needs no IBM client. Schema introspection, server-side paging, inline edits on tables, and RUNSTATS and REORG one table at a time.', + tr: 'drda · 50000', + not: 'No EXPLAIN, no query cancel and no Studio-side timeout in this version, and most monitoring panels are empty on purpose. Db2 for z/OS and for IBM i are out of scope.', }, { id: 'sqlite', @@ -73,15 +103,15 @@ export const engines: Engine[] = [ cat: 'SQL', logo: '/engines/libsql.svg', desc: "SQLite's dialect over the network — Turso and self-hosted sqld, spoken natively.", - tr: 'hrana · 443', - not: 'Monitoring is limited to what the sqld server itself exposes.', + tr: 'hrana · 8080 (443 with TLS)', + not: 'Monitoring reads only what SQLite pragmas and dbstat answer over Hrana: no uptime, cache hit ratio, slow queries or sessions, because libSQL publishes none.', }, { id: 'duckdb', name: 'DuckDB', cat: 'Analytics', logo: '/engines/duckdb.svg', - desc: 'In-process analytics — query Parquet and CSV files directly, with full EXPLAIN support and agent mode.', + desc: 'In-process analytics with full EXPLAIN support and agent mode. Admins can query Parquet and CSV files directly; every other role is confined to the database file.', tr: 'file · embedded', not: 'Single-writer by design: no sessions to list, so health shows storage, not connections.', }, @@ -90,7 +120,7 @@ export const engines: Engine[] = [ name: 'ClickHouse', cat: 'Analytics', logo: '/engines/clickhouse.svg', - desc: 'HTTP interface with system.* introspection, EXPLAIN pipelines and query-log monitoring.', + desc: 'HTTP interface with system.* introspection, EXPLAIN plans that show index usage, and query-log monitoring.', tr: 'http · 8123', not: 'No foreign keys exist, so ER diagrams show structure without discovered relations.', }, @@ -103,6 +133,15 @@ export const engines: Engine[] = [ tr: 'http · 8888', not: 'Append-oriented: row editing is disabled rather than offered and then failed.', }, + { + id: 'databend', + name: 'Databend', + cat: 'Analytics', + logo: '/engines/databend.svg', + desc: 'Self-hosted Databend and Databend Cloud warehouses over its own HTTP query API, with no driver. Catalogs, databases, tables, views and dynamic tables in the tree, EXPLAIN on a SELECT, and monitoring from system tables.', + tr: 'http · 8000', + not: 'No inline row or object edit: Databend declares no keys, so a row has no identity to update. Transactions are not exposed, and EXPLAIN ANALYZE is never sent.', + }, { id: 'trino', name: 'Trino', @@ -128,7 +167,7 @@ export const engines: Engine[] = [ logo: '/engines/couchbase.svg', desc: 'SQL++ queries with bucket, scope and collection introspection over the documented REST surfaces.', tr: 'http · 8091', - not: 'Agent mode is unavailable, as on every engine but PostgreSQL, SQLite and DuckDB.', + not: 'Agent mode is unavailable, as on every engine but PostgreSQL, SQLite, DuckDB and SQL Server.', }, { id: 'redis', @@ -139,12 +178,31 @@ export const engines: Engine[] = [ tr: 'resp · 6379', not: 'No SQL, and none is pretended — the editor speaks commands here.', }, + { + id: 'etcd', + name: 'etcd', + cat: 'Key-value', + logo: '/engines/etcd.svg', + desc: "Reads and writes a cluster through a subset of the etcdctl command line, with keys grouped by prefix and an admin's compaction, defragmentation and alarm disarm. Kubernetes-aware.", + tr: 'grpc · 2379', + not: "Writes under Kubernetes' own prefixes are refused before anything is sent, and Kubernetes secrets, protobuf and encrypted values are withheld on every surface. No agent execution and no MCP.", + }, + { + id: 'oxia', + name: 'Oxia', + cat: 'Key-value', + logo: '/engines/oxia.svg', + desc: 'Key lookups, lists and range scans over an Oxia namespace, written as oxia client commands, with its shards in the tree and every key in the Keys panel.', + tr: 'grpc · 6648', + not: "Read-only whatever the toggle says: Oxia has no authorization and usually holds Pulsar's metadata, so writes, watches and sessions wait for a design of their own.", + }, { id: 'libredb', name: 'LibreDB', cat: 'Key-value', logo: '/engines/libredb.svg', desc: 'The embedded ordered key-value store — a file, no server and no wire protocol. Mapped onto the interface by convention rather than by emulating SQL.', + embedded: true, tr: 'file · embedded', not: 'No sessions and no index objects exist, so those two panels are absent with their reason rather than answered empty. Maintenance operations are refused, not faked.', }, @@ -157,6 +215,33 @@ export const engines: Engine[] = [ tr: 'cql · 9042', not: 'No joins and no EXPLAIN — the grid respects partition-key query rules instead of hiding them.', }, + { + id: 'neo4j', + name: 'Neo4j', + cat: 'Graph', + logo: '/engines/neo4j.svg', + desc: 'Cypher over Bolt, with node labels, relationship types, indexes and constraints in the tree, and a Graph tab that draws the nodes and relationships a result returns.', + tr: 'bolt · 7687', + not: "Read-only by construction: a read policy, the server's own classification and a READ session stand between a statement and a write. No EXPLAIN or PROFILE, no APOC and no LOAD CSV.", + }, + { + id: 'milvus', + name: 'Milvus', + cat: 'Vector', + logo: '/engines/milvus.svg', + desc: "Collections, exact counts, queries and vector searches over the gRPC API, typed as Milvus's own REST requests, with integers above 2^53 kept exact.", + tr: 'grpc · 19530', + not: "Reads only — an admin's Load and Release are the one state change, and a collection is never loaded implicitly. Server-side functions, model rankers and calls to an embedding provider are refused.", + }, + { + id: 'qdrant', + name: 'Qdrant', + cat: 'Vector', + logo: '/engines/qdrant.svg', + desc: "Collections, point reads, scrolls, counts, facets and queries over dense, sparse and multivector data, written as Qdrant's own REST requests.", + tr: 'http · 6333', + not: 'Writes nothing — every request is one of seventeen reads — and refuses every server-side inference input except the local BM25 model.', + }, { id: 'elasticsearch', name: 'Elasticsearch', @@ -184,6 +269,24 @@ export const engines: Engine[] = [ tr: 'http \u00b7 9090', not: 'Read-only: Studio calls the read APIs only, so there is nothing to write back.', }, + { + id: 'influxdb', + name: 'InfluxDB (InfluxQL)', + cat: 'Time-series', + logo: '/engines/influxdb.svg', + desc: 'InfluxQL over the v1 query API on InfluxDB 1.x, 2.x and 3, with databases, measurements, tag keys and field keys in the tree.', + tr: 'http \u00b7 8086', + not: 'Read-only whatever the toggle says, and no Flux: Flux is a server-side language, and on a read-only token it still made the server issue HTTP requests, so no read-only Flux policy can be sound.', + }, + { + id: 'influxdb3', + name: 'InfluxDB 3 (SQL)', + cat: 'Time-series', + logo: '/engines/influxdb3.svg', + desc: 'InfluxDB 3 in SQL over /api/v3/query_sql, the DataFusion dialect its server runs: the tables of one database, their columns and their newest rows.', + tr: 'http \u00b7 8181', + not: 'Read-only, with no maintenance. Cloud Serverless and Cloud Dedicated serve SQL only over Arrow Flight, which Studio does not use; reach them with the InfluxQL type.', + }, { id: 'kafka', name: 'Apache Kafka', @@ -195,6 +298,9 @@ export const engines: Engine[] = [ }, ]; +/** The engines a user points Studio at: every entry but the embedded store. This is the published count. */ +export const externalEngines: Engine[] = engines.filter((e) => !e.embedded); + export const defaultEngineId = 'postgresql'; /** diff --git a/src/data/faq.ts b/src/data/faq.ts index fd9eb99..5eb6fac 100644 --- a/src/data/faq.ts +++ b/src/data/faq.ts @@ -10,11 +10,11 @@ export const faq = [ }, { q: 'Which databases are supported?', - a: 'Nineteen engines: PostgreSQL, MySQL, Oracle, SQL Server, SQLite, libSQL, DuckDB, ClickHouse, Apache Druid, Trino, MongoDB, Couchbase, Redis, LibreDB, Cassandra, Elasticsearch, OpenSearch, Prometheus and Apache Kafka — relational, document, key-value, wide-column, analytical, search, time-series, streaming and federated query. One of the nineteen is LibreDB itself, the embedded store the playground runs on, so the count you will see on the product listings is eighteen: those count the engines you point Studio at, not the one it carries.', + a: 'Twenty-seven engines: PostgreSQL, MySQL, Oracle, Db2 LUW, SQL Server, SQLite, libSQL, DuckDB, ClickHouse, Apache Druid, Databend, Trino, MongoDB, Couchbase, Redis, etcd, Oxia, Cassandra, Neo4j, Milvus, Qdrant, Elasticsearch, OpenSearch, Prometheus, InfluxDB (InfluxQL), InfluxDB 3 (SQL) and Apache Kafka — relational, document, key-value, wide-column, graph, vector, analytical, search, time-series, streaming and federated query. Twenty-seven more, such as MariaDB, TiDB, CockroachDB, Valkey and ScyllaDB, speak one of those wire protocols and connect through the same drivers, each measured against a live instance, so the drivers reach fifty-four named engines in all. LibreDB itself, the embedded store the playground runs on, is in the list on /databases but not in either count: those count the engines you point Studio at, not the one it carries.', }, { q: 'Is anything held back from the open-source build?', - a: 'No. OIDC single sign-on, ER diagrams, the AI assistant and the MongoDB and Redis drivers are all in the MIT build. What costs money is someone else running it for you; no capability moves across that line to create a reason to upgrade.', + a: 'No. OIDC single sign-on, ER diagrams, the AI assistant and the MongoDB and Redis drivers are all in the MIT build. Nothing is sold: there is no paid edition or hosted offering, and no capability is held back from the MIT build to create a reason to pay.', }, { q: 'Why does each engine list what it does not do?', @@ -22,7 +22,7 @@ export const faq = [ }, { q: 'How do I install it?', - a: 'One container: docker run -p 3000:3000 libredb/libredb-studio. There is also an npx package, a Helm chart, a certified chart in Rancher Partner Charts, an embeddable npm package and one-click templates for Railway, Dokploy, CapRover, DigitalOcean, Sealos and Fly.io — 33 channels, 26 of them live.', + a: 'One container: docker run -p 3000:3000 libredb/libredb-studio. There is also an npx package, a Helm chart, a certified chart in Rancher Partner Charts, an embeddable npm package and one-click templates for Railway, Dokploy, CapRover and Sealos, a DigitalOcean Marketplace listing and a fly.toml that fly launch reads — 46 channels, 39 of them live.', }, { q: 'Does it work on a phone?', @@ -30,7 +30,7 @@ export const faq = [ }, { q: 'What does the database agent do?', - a: 'You state an objective; the agent runs read-only, budgeted SQL and writes a report where every claim cites a result. It is available on PostgreSQL and SQLite.', + a: 'You state an objective; the agent runs read-only, budgeted SQL and writes a report where every claim cites a result. It runs statements on PostgreSQL, SQLite, DuckDB and SQL Server; plan mode, which runs nothing, opens on every connection.', }, { q: 'How is it secured for a team?', diff --git a/src/data/features.ts b/src/data/features.ts index b57d9be..623da0b 100644 --- a/src/data/features.ts +++ b/src/data/features.ts @@ -5,9 +5,10 @@ * handoff) and are the spine of this page too — this file only adds the depth a * dedicated page owes a reader, keyed by the same `n`. * - * Source: libredb-studio README.md § "Key Features" and docs/AGENT.md. Every + * Source: libredb-studio README.md § "Key Features", docs/AGENT.md, docs/MCP.md + * and the 0.18.x release notes, re-verified at 0.18.2. Every * `limit` is stated there too; none of them is softened here. Where the product - * documents a boundary (agent mode's three engines, masking being client-side + * documents a boundary (agent mode's four engines, masking being client-side * only, the toolkit being admin-only), the boundary is part of the feature. */ export interface FeatureDetail { @@ -33,7 +34,7 @@ export const featureDetails: FeatureDetail[] = [ 'A snapshot timeline — pick any two points and compare', ], limit: - 'Migration SQL is generated for PostgreSQL, MySQL, SQLite, Oracle and SQL Server, plus ClickHouse column modifications. Other engines diff but do not emit migrations.', + 'Migration SQL is generated for the relational engines, Db2 LUW and Databend included, but where the generator cannot write a change safely it writes a comment instead: a Db2 or Databend column modification, a foreign key on Trino, ClickHouse or Databend, a primary key on Trino or Databend, and index changes on Trino, ClickHouse and Databend. Document, key-value, search, time-series, graph, vector and streaming engines, and Druid, diff but get an explanatory comment rather than DDL, and no migration is an idempotent script.', }, { n: '02', @@ -42,6 +43,7 @@ export const featureDetails: FeatureDetail[] = [ 'Graphical execution plans, node by node', 'Scan type, join strategy and cost surfaced per node', 'Reads each engine’s own plan output — nothing is simulated', + 'MySQL, MariaDB and Percona Server plans drawn as a tree too', ], limit: 'Plan rendering follows the engine: PostgreSQL, MySQL, DuckDB, ClickHouse and Trino among others expose a plan this can draw. Where an engine has no plan interface, there is nothing to render.', @@ -87,16 +89,17 @@ export const featureDetails: FeatureDetail[] = [ }, { n: '06', - body: 'You state an objective and press Start. The run drafts SQL, reads what comes back, and composes a report in which every claim cites the result it came from. It never starts itself, never writes to the editor, and never executes what it recommends.', + body: 'You state an objective and press Start. The run drafts SQL, reads what comes back, and composes a report in which every claim cites the result it came from. It never starts itself and never executes what it recommends. It stays out of your editor unless you opt in: an Analyze run opened with auto-execute, after a consent step, places its answer’s statement in your editor and runs it there, under the same database-enforced read-only boundary.', points: [ - 'Three workflows — Investigate, Optimize, Assess', - 'Read-only enforced by the database, not by a parser: 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', + 'Five workflows: Investigate, Optimize, Assess, Analyze, which answers a question about the data rather than about the database, and Operate, which sends no SQL at all and reads the engine’s own reporting interface', + 'Read-only enforced by the database, not by a parser: 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, and on SQL Server a principal verified unable to write and a transaction always rolled back', 'Every statement passes a policy decision, an audit event and budget accounting before the driver is touched', - 'Bounded and metered on screen: 20 statements, 60s of database time, 200 rows per read, a 5-minute deadline', + 'Bounded and metered on screen: 18 to 45 statements and a 6 to 15 minute deadline depending on workflow, 200 rows per read', 'Evidence or nothing — a claim with no citation cannot be composed', 'The run states its own verdict: “Run answered” or “Run did not answer”', + 'An MCP endpoint, off by default, lets an AI client of your own read schemas and run bounded read-only SQL through Studio while the credentials stay on the server', ], limit: - 'Agent mode reads 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. The embedded @libredb/studio package carries no agent surface at all.', + 'Agent mode reads PostgreSQL, SQLite, DuckDB and SQL Server only, because the read-only profile is database-native and exists only where a provider implements it. On any other engine an agent run that sends statements is refused when it is started; the same four engines are the only ones where the MCP endpoint runs a query. Plan mode opens on every connection — it is toolless, runs nothing, and drafts a statement for you to run yourself. The embedded @libredb/studio package carries no agent surface at all.', }, ]; diff --git a/src/data/home.ts b/src/data/home.ts index 2de2e2e..2baf62c 100644 --- a/src/data/home.ts +++ b/src/data/home.ts @@ -3,6 +3,7 @@ * Copy is final — see README.md § Fidelity. Nothing here is paraphrased. */ import { site } from '../lib/site'; +import { externalEngines } from './engines'; /* --- header / nav --------------------------------------------------------- */ export const navLinks = [ @@ -22,7 +23,7 @@ export const hero = { lineOne: ['You', 'created', 'the', 'database.'], lineTwo: ['The', 'editor', 'is', 'already', 'beside', 'it.'], }, - sub: 'LibreDB Studio is the database IDE that deploys next to your data instead of onto your laptop — one browser tab for PostgreSQL, MySQL, MongoDB, Redis and fifteen more engines, with SSO and audit built in.', + sub: 'LibreDB Studio is the database IDE that deploys next to your data instead of onto your laptop — one browser tab for PostgreSQL, MySQL, MongoDB, Redis and twenty-three more engines, with SSO and audit built in.', primaryCta: { label: 'Open live demo', href: 'https://app.libredb.org' }, secondaryCta: { label: 'Deploy in one click', href: '#deploy' }, command: 'docker run -p 3000:3000 libredb/libredb-studio', @@ -98,7 +99,7 @@ export const flowBand = { toggles: { out: 'Data → tool', in: 'Tool → data' }, /** "out" = the old way (data travels), "in" = Studio's way (the tool travels) */ out: { - chip: '✕ 4,2 TB · copies out', + chip: '✕ 4.2 TB · copies out', destKicker: 'YOU', destTitle: 'Desktop client', destSub: 'one licensed laptop', @@ -112,7 +113,7 @@ export const flowBand = { caption: '→ the data never leaves the network — a URL travels instead', }, specs: ['runs in a browser', 'reaches a phone', 'deploys like infrastructure', 'MIT licensed'], - dbMeta: '4,2 TB · in place', + dbMeta: '4.2 TB · in place', } as const; /* --- how it works --------------------------------------------------------- */ @@ -151,15 +152,15 @@ export const how = { /* --- databases ------------------------------------------------------------ */ export const databases = { - eyebrow: 'Nineteen engines', + eyebrow: 'Twenty-seven engines', headline: { lineOne: 'One interface.', gradient: 'Different engines.' }, intro: - "Relational, document, key-value, analytical, search and federated query — the same tree, editor and grid over each of them, and each engine's real limits declared rather than papered over.", + "Relational, document, key-value, graph, vector, time-series, streaming, analytical, search and federated query — the same tree, editor and grid over each of them, and each engine's real limits declared rather than papered over.", notDoTitle: 'What this engine does not do', notDoNote: 'Stated because the alternative is a feature list that quietly breaks on the fourth engine. Capability flags come from the provider itself — a control that cannot work is hidden, not offered and then failed.', footnote: - 'The claim is the span, not a count — nineteen engines, one interface. Per-engine detail lives in the provider docs.', + 'The claim is the span, not a count — twenty-seven engines, one interface, plus LibreDB, the embedded store the playground runs on. Twenty-seven more speak one of these wire protocols and connect through the same drivers, each measured against a live instance: fifty-four named engines in all. Per-engine detail lives in the provider docs.', } as const; /* --- product demo --------------------------------------------------------- */ @@ -353,20 +354,33 @@ export const openSource = { eyebrow: 'Open source', headline: { before: 'Nothing sits behind an ', gradient: 'enterprise wall', after: '' }, intro: - 'LibreDB Studio is MIT because it has to go everywhere. What costs money is someone else running it for you — no capability moves across that line to create a reason to upgrade.', + 'LibreDB Studio is MIT because it has to go everywhere. Nothing is sold: no paid edition, no hosted offering, and no capability held back from the MIT build to create a reason to pay.', tableHead: ['Capability', 'LibreDB Studio', 'Typical community editions'], + /* + * The right-hand column names no product, per the rule in compare.ts, and + * each cell is kept only where a vendor's own page says so (read 2026-10-11): + * - SSO: github.com/dbeaver/cloudbeaver/wiki/Single-Sign-On — "This feature + * is available in Enterprise, AWS, and GCP editions only." + * - MongoDB and Redis: dbeaver.io/about — neither is in the Community list, + * and "We support more SQL, NoSQL, and cloud data sources in DBeaver PRO." + * - AI assistant: dbeaver.com/docs/dbeaver/AI-Smart-Assistance/ — chat and + * the editor AI command are unbadged, with OpenAI and GitHub Copilot only; + * query suggestions, error troubleshooting, object exploration and the + * other six providers carry "Available only in PRO". + * Removed the same day: "ER diagrams — PRO editions" (DBeaver Community ships + * ER diagrams). "AI assistant — Enterprise" was rewritten as above: the + * assistant exists in Community, limited. + */ rows: [ - { cap: 'OIDC single sign-on', us: 'in the MIT build', them: 'Enterprise, AWS only' }, - { cap: 'ER diagrams', us: 'in the MIT build', them: 'PRO editions' }, - { cap: 'AI assistant', us: 'in the MIT build', them: 'Enterprise' }, + { cap: 'OIDC single sign-on', us: 'in the MIT build', them: 'paid editions only' }, + { cap: 'AI assistant', us: 'in the MIT build', them: 'most providers and features in PRO editions' }, { cap: 'MongoDB and Redis', us: 'in the MIT build', them: 'absent from community driver sets' }, ], - tableNote: - 'License and feature scope only, each line with a primary source — the full table lives on the comparison page.', + tableNote: 'License and feature scope only.', stats: [ { v: '100%', l: 'line coverage, enforced as a CI gate' }, - { v: '17', l: 'engines — SQL, NoSQL, analytics and search' }, - { v: '33', l: 'distribution channels, 26 of them live' }, + { v: String(externalEngines.length), l: 'engines — SQL, NoSQL, analytics, graph and vector' }, + { v: '46', l: 'distribution channels, 39 of them live' }, { v: 'MIT', l: 'license. All of it, nothing held back' }, ], } as const; @@ -382,7 +396,7 @@ export const deploy = { id: 'docker', label: 'Docker', command: 'docker run -p 3000:3000 libredb/libredb-studio', - note: '# zero-config first run — secrets generated at boot, printed once', + note: '# zero-config first run — secrets generated at boot, admin password printed once', }, { id: 'npx', @@ -393,7 +407,7 @@ export const deploy = { { id: 'helm', label: 'Helm', - command: 'helm install studio ./charts/libredb-studio', + command: 'helm install libredb oci://ghcr.io/libredb/charts/libredb-studio', note: '# also a certified chart in Rancher Partner Charts', }, { @@ -409,8 +423,8 @@ export const deploy = { note: '# one click on the platform that already runs your database', }, ], - channelsCount: '33', - channelsLabel: 'distribution channels — 26 live', + channelsCount: '46', + channelsLabel: 'distribution channels — 39 live', channels: [ 'Docker Hub', 'Helm chart', @@ -427,7 +441,7 @@ export const deploy = { 'Fly.io', ], channelsNote: - 'Every published artifact carries a build provenance attestation, and native channels bind to localhost by default.', + 'Release archives, Linux packages, container images and the npm package carry a build provenance attestation (the Helm chart does not), and native channels bind to localhost by default.', channelsLink: 'Every channel →', } as const; diff --git a/src/data/open-source.ts b/src/data/open-source.ts index 8cd73df..e2b950d 100644 --- a/src/data/open-source.ts +++ b/src/data/open-source.ts @@ -63,5 +63,5 @@ export const security = { headline: 'Found a vulnerability? Do not open an issue.', body: 'Security reports go to the vendor by email, not through the public tracker, so a fix can ship before the details are public. The policy covers what to include, what to expect and the disclosure timeline.', email: 'info@sekoya.tech', - note: 'Security updates are provided for the current minor line; older lines are not patched. The policy also documents the known security considerations and publishes a software bill of materials.', + note: 'Security fixes are released against the latest published release only; there is no long-term support branch and no backport. The policy also documents the known security considerations and publishes a software bill of materials.', } as const; diff --git a/src/data/security.ts b/src/data/security.ts index 2009d2c..8b06ad8 100644 --- a/src/data/security.ts +++ b/src/data/security.ts @@ -16,7 +16,8 @@ * minutes later, and now they have found them in spite of us. * * SOURCE OF TRUTH: libredb-studio → SECURITY.md, with docs/OIDC.md, - * docs/STORAGE.md and docs/SEED_CONNECTIONS.md for the individual controls. + * docs/STORAGE.md, docs/SEED_CONNECTIONS.md, docs/MFA.md and docs/PASSKEYS.md + * for the individual controls. Re-verified against studio 0.18.2. * Nothing here may claim more than those files claim. */ @@ -37,6 +38,13 @@ export const controls: Control[] = [ href: `${REPO}/blob/main/docs/OIDC.md`, linkLabel: 'OIDC guide', }, + { + control: 'Local sign-in', + detail: + 'Without SSO, each local account can require a TOTP code from any standard authenticator after the password. With a server-side store (sqlite or postgres), local accounts live in an accounts table — any number of admins and users, managed under Admin > Accounts — and can register passkeys, which are off until PASSKEY_ORIGIN names the address users open Studio at. A disabled, deleted or demoted account loses its sessions at the next request.', + href: `${REPO}/blob/main/docs/PASSKEYS.md`, + linkLabel: 'Passkeys', + }, { control: 'Authorization', detail: @@ -64,7 +72,7 @@ export const controls: Control[] = [ { control: 'Request integrity', detail: - 'Every state-changing request must carry an Origin or Referer matching the deployment’s own host, behind the SameSite cookie. Responses carry CSP, HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Permissions-Policy. Logins, denials and rate-limit trips are written to the audit log and emitted as structured JSON on stdout.', + 'Every state-changing request must carry an Origin or Referer matching the deployment’s own host, behind the SameSite cookie — the one carve-out is a request whose Content-Type is exactly application/json and that carries neither header, the shape a cross-site browser cannot forge. Responses carry CSP, HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Permissions-Policy. Logins, denials and rate-limit trips are written to the audit log and emitted as structured JSON on stdout.', href: `${REPO}/blob/main/docs/API_DOCS.md`, linkLabel: 'API reference', }, @@ -86,19 +94,21 @@ export interface Limit { } /** - * Transcribed from SECURITY.md § Known Security Considerations. Each of these + * Transcribed from SECURITY.md § Known Security Considerations, corrected where + * docs/SECURITY.md and docs/STORAGE.md are more precise (the localStorage copy, + * env-mode versus account-table passwords). Each of these * is a real constraint an operator has to design around, not a hypothetical. */ export const limits: Limit[] = [ { title: 'The browser store keeps credentials in the clear', detail: - 'Without a server-side store, connection details — database passwords and SSH private keys included — are held unencrypted in browser localStorage. That is what lets Studio work with no master password, and it means the browser profile is secret material: anyone who can read it can read every configured credential. It is also why cross-site scripting is treated as a top-severity issue here. Set STORAGE_PROVIDER to sqlite or postgres and the encryption above applies instead.', + 'Connection details — database passwords and SSH private keys included — are held unencrypted in browser localStorage, whichever storage provider is set, because localStorage is the copy the interface renders from. That is what lets Studio work with no master password, and it means the browser profile is secret material: anyone who can read it can read every configured credential. It is also why cross-site scripting is treated as a top-severity issue here. With STORAGE_PROVIDER set to sqlite or postgres, the encryption above protects the server’s own copy at rest; the browser’s copy stays in the clear.', }, { - title: 'The built-in local login does not hash', + title: 'The env-mode local login does not hash', detail: - 'ADMIN_PASSWORD and USER_PASSWORD are compared directly. They are not stored in a database and not hashed — protect them the way you protect any other server environment variable, or skip them entirely and use OIDC.', + 'With STORAGE_PROVIDER=local, ADMIN_PASSWORD and USER_PASSWORD are compared directly on every sign-in. They are not stored in a database and not hashed — protect them the way you protect any other server environment variable, or skip them entirely and use OIDC. With sqlite or postgres they only seed the accounts table on first start, and the stored passwords are scrypt hashes.', }, { title: 'Saved passwords are returned to their owner', @@ -115,6 +125,11 @@ export const limits: Limit[] = [ detail: 'The counters live in the application process, so two replicas mean two budgets. Enforce the same budgets at the ingress for any multi-replica deployment; the RATE_LIMIT_* variables document what the application applies.', }, + { + title: 'Not every denial is audited', + detail: + 'Denials based on the session or the Origin are audited; a denial based on role — a non-admin token sent away from /admin, or an admin-only check inside a handler — is not written to the audit log today. And a failed login records the submitted email verbatim as its actor, so a password typed into the email field lands on a retained audit line.', + }, { title: 'A cloud LLM sees the query', detail: @@ -125,6 +140,6 @@ export const limits: Limit[] = [ export const disclosure = { headline: 'Report it privately, and it gets fixed before it is public', body: 'A vulnerability goes to the address in the security policy, not into a public issue — that is what lets a fix ship before the details are known. The policy documents what to include, what response to expect, and the disclosure timeline it commits to.', - note: 'Security updates are provided for the current minor line; older lines are not patched. The policy also publishes a software bill of materials and the known considerations above, in the project’s own words.', + note: 'Security fixes are released against the latest published release only; there is no long-term support branch and no backport. The upgrade path to a fix is the current release. The policy also publishes a software bill of materials and the known considerations above, in the project’s own words.', href: `${REPO}/blob/main/SECURITY.md`, }; diff --git a/src/data/support.ts b/src/data/support.ts index c98f7dc..78344c0 100644 --- a/src/data/support.ts +++ b/src/data/support.ts @@ -94,7 +94,7 @@ export const scope: SupportArea[] = [ { area: 'Configuration', detail: - 'OIDC single sign-on, role mapping, storage backends, seed connections, ingress and TLS, and the hardened chart defaults — non-root, read-only root filesystem, NetworkPolicy, PodDisruptionBudget and HPA.', + 'OIDC single sign-on, role mapping, storage backends, seed connections, ingress and TLS, the chart’s hardened defaults — non-root, read-only root filesystem, all capabilities dropped — and its opt-in NetworkPolicy, PodDisruptionBudget and HPA, which ship disabled.', }, { area: 'Defects', @@ -122,5 +122,15 @@ export const listings = [ name: 'Red Hat OpenShift community catalog', href: 'https://github.com/redhat-openshift-ecosystem/community-operators-prod', }, + { name: 'OperatorHub.io', href: 'https://operatorhub.io/operator/libredb-studio-operator' }, { name: 'Artifact Hub', href: 'https://artifacthub.io/packages/helm/libredb-studio/libredb-studio' }, + { name: 'AWS Marketplace', href: 'https://aws.amazon.com/marketplace/pp/prodview-tsahkrgdqpnws' }, + { + name: 'Microsoft Azure Marketplace', + href: 'https://marketplace.microsoft.com/en-us/product/libredb.libredb-studio', + }, + { + name: 'Google Cloud Marketplace', + href: 'https://console.cloud.google.com/marketplace/product/libredb-public/libredb-studio', + }, ]; diff --git a/src/data/supporters.ts b/src/data/supporters.ts index f160283..0a875fb 100644 --- a/src/data/supporters.ts +++ b/src/data/supporters.ts @@ -84,7 +84,7 @@ export const supportersPage = { eyebrow: 'Supporters', headline: { before: 'Who pays for the parts ', gradient: 'we do not build' }, intro: - 'LibreDB Studio is MIT and self-hosted, but the work around it is not free: a build fleet, sixteen database engines to probe against, a public trial anyone can open, signed Windows binaries. Where a company or an open-source programme covers one of those, it is named here.', + 'LibreDB Studio is MIT and self-hosted, but the work around it is not free: a build fleet, fifty-four database engines to probe against, a public trial anyone can open, and the code-signing certificate the Windows binaries still lack. Where a company or an open-source programme covers one of those, it is named here.', empty: { kicker: 'Current supporters', diff --git a/src/lib/posts.ts b/src/lib/posts.ts index 52ce519..8dab124 100644 --- a/src/lib/posts.ts +++ b/src/lib/posts.ts @@ -66,8 +66,8 @@ export function engineArchives(posts: T[]): Map * Posts to offer at the end of `post`: same engine first, newest first, topped * up with recent posts when that engine has too few to fill the block. * - * Falling back matters — seven of the eighteen engines have four posts or fewer, - * and a post with no engine at all (`counting-databases`) must still get a + * Falling back matters — most engines have four posts or fewer, several have + * none yet, and a post with no engine at all (`counting-databases`) must still get a * block rather than an empty aside. */ export function relatedPosts(post: T, all: T[], limit = 4): T[] { diff --git a/src/pages/blog/engine/[engine].astro b/src/pages/blog/engine/[engine].astro index c5bf964..a2cdc40 100644 --- a/src/pages/blog/engine/[engine].astro +++ b/src/pages/blog/engine/[engine].astro @@ -15,7 +15,7 @@ import { engines } from '../../../data/engines'; * 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. + * is a thin page, and shipping one per engine to cover the few with posts 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 diff --git a/src/pages/databases.astro b/src/pages/databases.astro index bf2c131..04ecfed 100644 --- a/src/pages/databases.astro +++ b/src/pages/databases.astro @@ -1,12 +1,12 @@ --- import BaseLayout from '../layouts/BaseLayout.astro'; -import { engines, engineReference, type EngineCategory } from '../data/engines'; +import { engines, externalEngines, engineReference, type EngineCategory } from '../data/engines'; import { databases } from '../data/home'; import { site } from '../lib/site'; // The homepage shows one engine at a time behind a hexagon tablist — good for a // first impression, useless for the question this page exists to answer: "is my -// engine in here, and what exactly does it not do?" So this renders all nineteen +// engine in here, and what exactly does it not do?" So this renders every engine // at once, grouped, with every limit visible without a click. const ORDER: EngineCategory[] = [ 'SQL', @@ -15,6 +15,8 @@ const ORDER: EngineCategory[] = [ 'Document', 'Key-value', 'Wide-column', + 'Graph', + 'Vector', 'Search', 'Time-series', 'Streaming', @@ -27,23 +29,32 @@ const BLURBS: Record = { Document: 'Collections and nested documents, with the grid flattening what it can.', 'Key-value': 'Ordered keyspaces. Tables and collections are conventions over the keys.', 'Wide-column': 'Partition-and-clustering-key stores, where a scan is a decision, not a default.', - Search: 'Index-first engines queried through their own DSL as well as SQL where offered.', + Graph: 'Nodes and relationships read in their own query language, and drawn as a graph beside the grid.', + Vector: "Collections of vectors and payloads, searched by similarity in the engine's own request format.", + Search: + 'Index-first engines queried through their own SQL endpoint, Elasticsearch SQL or OpenSearch SQL, not the JSON query DSL.', 'Time-series': 'Metric stores read through their own query language; the series is the table.', Streaming: 'Logs of records, browsed by offset or by time rather than queried.', }; +// Every card is shown, but the counters count external engines only, so they +// sum to the same externalEngines.length the heading and description state. An +// embedded store in a group (LibreDB, in Key-value) is named beside the counter +// instead of being added to it. const groups = ORDER.map((cat) => ({ cat, blurb: BLURBS[cat], items: engines.filter((e) => e.cat === cat), + counted: externalEngines.filter((e) => e.cat === cat).length, + embedded: engines.filter((e) => e.cat === cat && e.embedded).map((e) => e.name), })).filter((g) => g.items.length > 0); const schema = [ { '@type': 'ItemList', name: 'Database engines supported by LibreDB Studio', - numberOfItems: engines.length, - itemListElement: engines.map((e, i) => ({ + numberOfItems: externalEngines.length, + itemListElement: externalEngines.map((e, i) => ({ '@type': 'ListItem', position: i + 1, name: e.name, @@ -56,7 +67,7 @@ const schema = [
@@ -73,8 +84,9 @@ const schema = [ groups.map((g) => (
  • - {String(g.items.length).padStart(2, '0')} + {String(g.counted).padStart(2, '0')} {g.cat} + {g.embedded.length > 0 && ` + ${g.embedded.join(', ')}`}
  • )) @@ -176,7 +188,7 @@ const schema = [ text-wrap: pretty; } - /* Category jump strip — nineteen engines is enough to need one. */ + /* Category jump strip — this many engines is enough to need one. */ .dbp__counts { display: flex; flex-wrap: wrap; diff --git a/src/pages/deploy.astro b/src/pages/deploy.astro index 8508df7..7820e42 100644 --- a/src/pages/deploy.astro +++ b/src/pages/deploy.astro @@ -10,7 +10,7 @@ const groups = channelCategories .filter((g) => g.items.length > 0); // The four the homepage leads with, pulled out so someone who just wants to run -// it does not have to read twenty-six cards first. +// it does not have to read thirty-nine cards first. const QUICK = ['docker-ghcr', 'npm', 'helm', 'homebrew']; const quick = QUICK.map((id) => channels.find((c) => c.id === id)).filter((c) => c !== undefined); @@ -34,7 +34,7 @@ const schema = [
    diff --git a/src/pages/docker-compose.astro b/src/pages/docker-compose.astro index 02518dc..8eb7970 100644 --- a/src/pages/docker-compose.astro +++ b/src/pages/docker-compose.astro @@ -6,6 +6,7 @@ import { site } from '../lib/site'; const rawUrl = `${site.url}/docker-compose.example.yml`; const ghUrl = `${site.links.github}/blob/main/docker-compose.example.yml`; +const envExampleUrl = `${site.links.github}/blob/main/.env.example`; /** * A three-token YAML pass, run at build time. @@ -50,7 +51,7 @@ const schema = [ author: { '@type': 'Organization', name: site.name }, headline: 'LibreDB Studio docker-compose.yml', description: - 'The full docker-compose.example.yml for self-hosting LibreDB Studio, with every supported environment variable.', + 'The full docker-compose.example.yml for self-hosting LibreDB Studio, with every variable the compose file sets or lists.', proficiencyLevel: 'Beginner', about: { '@type': 'SoftwareApplication', name: 'LibreDB Studio', applicationCategory: 'DeveloperApplication' }, }, @@ -60,7 +61,7 @@ const schema = [
    @@ -71,9 +72,11 @@ const schema = [ The whole compose file, nothing to assemble

    - One service, the published ghcr.io image, and every environment variable Studio reads — active ones set, - rarely-used ones sitting commented where you can find them. It is the same file that lives in the repository; this - page just saves you the clone. + One service, the published ghcr.io image, and the variables most deployments touch — active ones set, + rarely-used ones sitting commented where you can find them. It is the file that lives in the repository, with a few + comments corrected where they were wrong for this release; this page just saves you the clone. Studio reads more than + the file lists; the full set is in + .env.example.

    @@ -119,9 +122,11 @@ const schema = [ -

    Every environment variable

    +

    Every variable the file sets or lists

    - Grouped as the file groups them. Set means the line ships uncommented and is already in force; + Grouped as the file groups them. This is a subset of what Studio reads; .env.example documents the rest. Set means the line ships uncommented and is already in force; commented means it is there for you to uncomment. Secrets come from .env through {'${VAR}'} interpolation and are never written into the compose file.

    @@ -169,7 +174,7 @@ const schema = [ diff --git a/src/pages/features.astro b/src/pages/features.astro index 676c95f..6ad0200 100644 --- a/src/pages/features.astro +++ b/src/pages/features.astro @@ -40,8 +40,8 @@ const schema = [ {featuresSection.headline.before}{featuresSection.headline.gradient}

    - Six things ship in every build, on every engine that can support them — nothing here is held behind an edition. - Each one below is followed by the limit the product's own documentation states for it. + Six things ship in every standalone build, on every engine that can support them — nothing here is held behind + an edition. Each one below is followed by the limit the product's own documentation states for it.

      diff --git a/src/pages/get-started.astro b/src/pages/get-started.astro index 64f525b..2201c39 100644 --- a/src/pages/get-started.astro +++ b/src/pages/get-started.astro @@ -51,8 +51,9 @@ const commands = deploy.tabs.filter((t): t is CommandTab => 'command' in t);

      - One-click templates for Railway, Dokploy, CapRover, DigitalOcean, Sealos and Fly.io, plus the Helm chart and its - certified Rancher Partner Charts listing, are on the deploy page with the rest. + One-click templates for Railway, Dokploy, CapRover and Sealos, the DigitalOcean Marketplace listing, a fly.toml + for Fly.io, and the Helm chart with its certified Rancher Partner Charts listing are on the deploy page with the + rest. Every channel ↗

      diff --git a/src/pages/helper/index.astro b/src/pages/helper/index.astro index 63fae30..dfdaa9b 100644 --- a/src/pages/helper/index.astro +++ b/src/pages/helper/index.astro @@ -14,7 +14,7 @@ const guides = [ href: '/helper/0.17.0.html', published: '2026-09-28', size: '15.7 MB', - current: true, + latest: true, }, { version: '0.16.2', @@ -30,14 +30,18 @@ const guides = [ }, ]; -const current = guides.find((g) => g.current) ?? guides[0]; +// `latest` marks the newest guide, which is not the same as the current +// release: when the site describes a newer Studio than any guide covers, the +// page says so rather than presenting the guide as matching it. +const current = guides.find((g) => g.latest) ?? guides[0]; +const behind = current.version !== site.version; const schema = [ { '@type': 'TechArticle', headline: `${site.name} installation guide`, description: - 'A single-file installation guide for LibreDB Studio: 22 install routes, a walkthrough of the application, every setting, in 13 languages.', + 'A single-file installation guide for LibreDB Studio, one per release: the install routes, a walkthrough of the application, every setting, in 13 languages.', url: `${site.url}/helper/`, about: { '@type': 'SoftwareApplication', name: site.name, applicationCategory: 'DeveloperApplication' }, author: { '@type': 'Organization', name: site.shortName }, @@ -49,7 +53,7 @@ const schema = [
      @@ -61,18 +65,26 @@ const schema = [

      Someone installed LibreDB Studio on each of these platforms, wrote down what actually happened, and - screenshotted it. That is the whole guide: 22 install routes, a walkthrough of the application, every setting it - exposes, and the notes on what was tested and what was not. It is one file with the images and fonts inside it, - so it opens with no network and nothing to install. + screenshotted it. That is the whole guide: the install routes, a walkthrough of the application, every setting + it exposes, and the notes on what was tested and what was not. It is one file with the images and fonts inside + it, so it opens with no network and nothing to install.

      -

      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', () => {