From f31b908578073b6684c9890d3ece3087de6fd55a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 27 Aug 2026 19:33:55 +0700 Subject: [PATCH] fix(landing): derive the engine count from the grid, and correct it to 29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site published "25" in seven rendered places and about forty fields of comparisons.json. It was not an arbitrary string: it counted distinct driverGroup values in database-grid.json, while the headline above it promised databases. Two quantities wearing one number, so raising either broke the other. It had also gone stale everywhere at once — the app registered 28 types, the docs said 27, the site said 25 — without a single failing test, because every copy agreed with every other copy and none agreed with the app. Diffing allRegisteredTypeIds() against the grid found the gap was real content, not phrasing: Dameng and Kafka ship as driver plugins and had no tile, no icon and no page, and libSQL and Turso shared one tile while the app registers them separately. Flipping the literals alone would have put "29 databases." above a grid of 26 countable tiles. ENGINE_COUNT now derives from database-grid.json, which is what a reader can actually count on the page, so the claim and the grid cannot drift apart again. DRIVER_PLUGIN_COUNT stays a literal because nothing here can derive it. --- resources/blog/open-source-db-clients-2026.md | 2 +- resources/data/comparisons.json | 84 +++++----- resources/data/database-grid.json | 34 +++- .../js/components/landing/database-grid.tsx | 5 +- resources/js/components/landing/hero.tsx | 3 +- .../js/components/landing/spec-strip.tsx | 3 +- resources/js/data/engines.ts | 56 +++++++ resources/js/data/faqs.ts | 14 +- resources/js/lib/structured-data.ts | 3 +- resources/js/pages/Home.tsx | 7 +- .../Feature/Landing/DatabaseGridDataTest.php | 91 +++++++++-- tests/Feature/Landing/EngineCountTest.php | 147 ++++++++++++++++++ tests/Feature/Landing/HomepageRenderTest.php | 31 +++- tests/Feature/Seo/StaleClaimsTest.php | 26 ++-- 14 files changed, 419 insertions(+), 87 deletions(-) create mode 100644 resources/js/data/engines.ts create mode 100644 tests/Feature/Landing/EngineCountTest.php diff --git a/resources/blog/open-source-db-clients-2026.md b/resources/blog/open-source-db-clients-2026.md index 65131a8..f16e779 100644 --- a/resources/blog/open-source-db-clients-2026.md +++ b/resources/blog/open-source-db-clients-2026.md @@ -76,7 +76,7 @@ Status: actively maintained. Used by enterprise data teams that need schema docu ### TablePro (AGPLv3) -Native macOS app written in Swift and AppKit. 25 databases through a plugin architecture (MySQL, Postgres, MongoDB, Redis, ClickHouse, BigQuery, DynamoDB, Cloudflare D1, Turso, etc.). Free, open source, with paid features (license server, advanced AI usage). +Native macOS app written in Swift and AppKit. 28 databases through a plugin architecture (MySQL, Postgres, MongoDB, Redis, ClickHouse, BigQuery, DynamoDB, Cloudflare D1, Turso, etc.). Free, open source, with paid features (license server, advanced AI usage). Best at: native macOS performance with broad database coverage. ~80 MB at idle, ~1 second cold start. AI assistant in the free tier. iOS app with iCloud sync. diff --git a/resources/data/comparisons.json b/resources/data/comparisons.json index 603c227..d15dd0c 100644 --- a/resources/data/comparisons.json +++ b/resources/data/comparisons.json @@ -3,7 +3,7 @@ "slug": "tableplus", "name": "TablePlus", "tagline": "Free, open source TablePlus alternative for Mac.", - "description": "TablePlus is $99 a device and closed source. TablePro is free, open source under AGPLv3, and covers 25 databases. Both are native Mac apps — that part is a tie.", + "description": "TablePlus is $99 a device and closed source. TablePro is free, open source under AGPLv3, and covers 29 databases. Both are native Mac apps — that part is a tie.", "longDescription": "TablePlus is the closest thing to TablePro on the Mac: a genuinely native AppKit app, fast, well made, and shipping since 2019. It also has an LLM chat panel and, since June 2026, an MCP server — so the usual \"native versus Electron\" and \"we have AI\" arguments do not apply here. What separates them is the licence. TablePlus costs $99 for one device or $129 for two, includes one year of updates, and asks $59 per device to keep receiving them. TablePro is free, AGPLv3, and the source is public.", "rows": [ { @@ -28,7 +28,7 @@ }, { "label": "Databases", - "tablePro": "25 drivers", + "tablePro": "29 drivers", "competitor": "16" }, { @@ -86,7 +86,7 @@ "Free forever, against $99 a device", "Updates never expire", "Open source under AGPLv3", - "25 database drivers, against 16", + "29 database drivers, against 16", "Six Safe Mode levels, set per connection", "XLSX export" ], @@ -108,7 +108,7 @@ "open source tableplus alternative", "tableplus alternative mac" ], - "ogTableProMetaHtml": "FREE · AGPLv3
25 databases
Native (SwiftUI)", + "ogTableProMetaHtml": "FREE · AGPLv3
29 databases
Native (SwiftUI)", "ogCompetitorMetaHtml": "$99 per device
+$59 to keep updates
Closed source", "ogPunchline": "Both native. One is free and open source.", "ogStatValue": "$0", @@ -138,7 +138,7 @@ }, { "question": "Why do you say 16 databases when TablePlus documents 13?", - "answer": "Their documentation lists 13 engines and their homepage shows 14 logos, and the two lists do not match — BigQuery, ClickHouse and Turso appear only on the homepage, Snowflake and Vertica only in the docs. Sixteen is the union of both, which is the most generous reading. TablePro ships 25 drivers." + "answer": "Their documentation lists 13 engines and their homepage shows 14 logos, and the two lists do not match — BigQuery, ClickHouse and Turso appear only on the homepage, Snowflake and Vertica only in the docs. Sixteen is the union of both, which is the most generous reading. TablePro ships 29 drivers." }, { "question": "What does the TablePlus licence actually cost over time?", @@ -154,7 +154,7 @@ "slug": "dbeaver", "name": "DBeaver", "tagline": "Free, Native DBeaver Alternative for Mac.", - "description": "Native DBeaver alternative for Mac. 80 MB RAM, instant startup, no Java. Free and open source. 25 databases.", + "description": "Native DBeaver alternative for Mac. 80 MB RAM, instant startup, no Java. Free and open source. 29 databases.", "longDescription": "DBeaver is a popular cross-platform database tool licensed Apache 2.0. The Community Edition is free; DBeaver PRO starts at $113 per year. It runs on Java 21 via the Eclipse RCP. On Mac you'll see 8+ second cold starts and 800 MB of RAM at idle on the same connections where TablePro starts in 1 second and idles at 80 MB.", "rows": [ { @@ -174,7 +174,7 @@ }, { "label": "Databases", - "tablePro": "25", + "tablePro": "29", "competitor": "100+" }, { @@ -319,7 +319,7 @@ }, { "label": "Databases", - "tablePro": "25", + "tablePro": "29", "competitor": "30+" }, { @@ -444,8 +444,8 @@ "slug": "navicat", "name": "Navicat", "tagline": "Free, Native Navicat Alternative for Mac.", - "description": "Native Navicat alternative for Mac. Free, open source, fast. 25 databases, AI assistant, iOS app.", - "longDescription": "Navicat Premium covers 7+ databases in one app. It costs $399 per year subscription or $1,599 for a perpetual license per user. TablePro covers 25 databases and is free, open source under AGPLv3. Native macOS, no per-database editions.", + "description": "Native Navicat alternative for Mac. Free, open source, fast. 29 databases, AI assistant, iOS app.", + "longDescription": "Navicat Premium covers 7+ databases in one app. It costs $399 per year subscription or $1,599 for a perpetual license per user. TablePro covers 29 databases and is free, open source under AGPLv3. Native macOS, no per-database editions.", "rows": [ { "label": "Price", @@ -464,7 +464,7 @@ }, { "label": "Databases", - "tablePro": "25", + "tablePro": "29", "competitor": "7+ (Premium)" }, { @@ -515,7 +515,7 @@ ], "prosTablePro": [ "Free vs $399/yr or $1,599 perpetual", - "25 databases in one app", + "29 databases in one app", "Native macOS", "AI SQL assistant in the free tier", "iOS app with iCloud sync", @@ -615,7 +615,7 @@ }, { "label": "Databases", - "tablePro": "25", + "tablePro": "29", "competitor": "17+" }, { @@ -745,8 +745,8 @@ "slug": "sequel-pro", "name": "Sequel Pro", "tagline": "Modern Sequel Pro Alternative for Mac.", - "description": "Modern Sequel Pro alternative for Mac. 25 databases, AI assistant, active development. Free and open source.", - "longDescription": "Sequel Pro stopped active development in 2016. The community fork Sequel Ace continues it but stays MySQL and MariaDB only. As of 2026, the latest Sequel Ace release dates back to April 2024. TablePro is a fresh native Mac app with regular releases, AI assistant in the free tier, and 25 databases.", + "description": "Modern Sequel Pro alternative for Mac. 29 databases, AI assistant, active development. Free and open source.", + "longDescription": "Sequel Pro stopped active development in 2016. The community fork Sequel Ace continues it but stays MySQL and MariaDB only. As of 2026, the latest Sequel Ace release dates back to April 2024. TablePro is a fresh native Mac app with regular releases, AI assistant in the free tier, and 29 databases.", "rows": [ { "label": "Price", @@ -775,7 +775,7 @@ }, { "label": "Databases", - "tablePro": "25", + "tablePro": "29", "competitor": "MySQL & MariaDB" }, { @@ -826,7 +826,7 @@ ], "prosTablePro": [ "Active development with regular releases", - "25 databases, not just MySQL", + "29 databases, not just MySQL", "AI SQL assistant in the free tier", "iOS app with iCloud sync", "Modern SwiftUI on macOS Sonoma+", @@ -848,10 +848,10 @@ "sequel pro macos sonoma", "sequel pro alternative 2024" ], - "ogTableProMetaHtml": "Active in 2026
25 databases
SwiftUI · AI · iCloud", + "ogTableProMetaHtml": "Active in 2026
29 databases
SwiftUI · AI · iCloud", "ogCompetitorMetaHtml": "Last release: 2019
MySQL only
Cocoa . unmaintained", "ogPunchline": "Active in 2026. AI, multi-DB, iCloud, iOS.", - "ogStatValue": "25", + "ogStatValue": "29", "ogStatLabel": "DATABASES", "benchmarks": { "tablePro": { @@ -906,8 +906,8 @@ "slug": "postico", "name": "Postico", "tagline": "Multi-Database Postico Alternative for Mac.", - "description": "Native, free, open-source Postico alternative for Mac. Postgres plus MySQL, MongoDB, Redis, SQLite, and 20 more.", - "longDescription": "Postico 2 is a polished native Mac client for PostgreSQL, Redshift, CockroachDB, and Greenplum. It costs $69 for a personal license (3 devices) or $99 per device for commercial use. TablePro is free and open source under AGPLv3, and covers 25 databases beyond Postgres.", + "description": "Native, free, open-source Postico alternative for Mac. Postgres plus MySQL, MongoDB, Redis, SQLite, and 24 more.", + "longDescription": "Postico 2 is a polished native Mac client for PostgreSQL, Redshift, CockroachDB, and Greenplum. It costs $69 for a personal license (3 devices) or $99 per device for commercial use. TablePro is free and open source under AGPLv3, and covers 29 databases beyond Postgres.", "rows": [ { "label": "Price", @@ -926,7 +926,7 @@ }, { "label": "Databases", - "tablePro": "25", + "tablePro": "29", "competitor": "Postgres, Redshift, CockroachDB, Greenplum" }, { @@ -987,7 +987,7 @@ ], "prosTablePro": [ "Free vs $69 personal or $99 commercial", - "25 databases including MySQL, Mongo, Redis", + "29 databases including MySQL, Mongo, Redis", "AI SQL assistant", "iOS app with iCloud sync", "ER diagrams, Vim mode, MCP server", @@ -1001,7 +1001,7 @@ ], "chooseTablePro": "you work with multiple databases or want AI and iOS support in the free tier.", "chooseCompetitor": "Postgres, Redshift, CockroachDB, or Greenplum is your only database and you prefer paying once for a focused tool.", - "verdict": "Postico is excellent for Postgres alone. TablePro covers the same ground for Postgres plus 24 other databases, free.", + "verdict": "Postico is excellent for Postgres alone. TablePro covers the same ground for Postgres plus 28 other databases, free.", "keywords": [ "postico alternative", "tablepro vs postico", @@ -1009,10 +1009,10 @@ "free postico alternative", "multi-database mac client" ], - "ogTableProMetaHtml": "FREE · 25 databases", + "ogTableProMetaHtml": "FREE · 29 databases", "ogCompetitorMetaHtml": "$69 · Postgres only", - "ogPunchline": "Postgres plus 24 more. Free. Native.", - "ogStatValue": "25", + "ogPunchline": "Postgres plus 28 more. Free. Native.", + "ogStatValue": "29", "ogStatLabel": "DATABASES", "benchmarks": { "tablePro": { @@ -1067,8 +1067,8 @@ "slug": "sequel-ace", "name": "Sequel Ace", "tagline": "Modern, Multi-Database Sequel Ace Alternative for Mac.", - "description": "Modern, multi-database Sequel Ace alternative for Mac. Active development, AI assistant, 25 databases. Free and open source.", - "longDescription": "Sequel Ace is the MIT-licensed community fork of Sequel Pro. It keeps a beloved MySQL client alive but stays MySQL and MariaDB only. As of 2026 the latest release is from April 2024. TablePro is a fresh native Mac app with regular releases, 25 databases, and AI in the free tier.", + "description": "Modern, multi-database Sequel Ace alternative for Mac. Active development, AI assistant, 29 databases. Free and open source.", + "longDescription": "Sequel Ace is the MIT-licensed community fork of Sequel Pro. It keeps a beloved MySQL client alive but stays MySQL and MariaDB only. As of 2026 the latest release is from April 2024. TablePro is a fresh native Mac app with regular releases, 29 databases, and AI in the free tier.", "rows": [ { "label": "Price", @@ -1092,7 +1092,7 @@ }, { "label": "Databases", - "tablePro": "25", + "tablePro": "29", "competitor": "MySQL & MariaDB" }, { @@ -1148,7 +1148,7 @@ ], "prosTablePro": [ "Active development with regular releases", - "25 databases, not just MySQL", + "29 databases, not just MySQL", "AI SQL assistant in the free tier", "iOS app with iCloud sync", "Modern SwiftUI on macOS Sonoma+", @@ -1170,9 +1170,9 @@ "modern sequel ace replacement", "multi database mac client" ], - "ogTableProMetaHtml": "25 databases · Active", + "ogTableProMetaHtml": "29 databases · Active", "ogCompetitorMetaHtml": "MySQL only · Slow", - "ogPunchline": "Active. AI. 25 databases. iCloud. Free.", + "ogPunchline": "Active. AI. 29 databases. iCloud. Free.", "ogStatValue": "Active", "ogStatLabel": "DEV", "benchmarks": { @@ -1228,8 +1228,8 @@ "slug": "heidisql", "name": "HeidiSQL", "tagline": "Native HeidiSQL Alternative for Mac.", - "description": "Native HeidiSQL alternative for Mac. No Wine, no VM. Free and open source. 25 databases, AI assistant, iCloud sync.", - "longDescription": "HeidiSQL is a free GPL-2.0 database client built with Delphi for Windows. As of 2026 there is a separate Lazarus-based macOS preview build, but it remains a Windows-first app. TablePro is built natively for macOS in Swift and AppKit and covers MySQL, MariaDB, PostgreSQL, SQL Server, SQLite (the same databases HeidiSQL handles) plus 20 more.", + "description": "Native HeidiSQL alternative for Mac. No Wine, no VM. Free and open source. 29 databases, AI assistant, iCloud sync.", + "longDescription": "HeidiSQL is a free GPL-2.0 database client built with Delphi for Windows. As of 2026 there is a separate Lazarus-based macOS preview build, but it remains a Windows-first app. TablePro is built natively for macOS in Swift and AppKit and covers MySQL, MariaDB, PostgreSQL, SQL Server, SQLite (the same databases HeidiSQL handles) plus 24 more.", "rows": [ { "label": "Price", @@ -1258,7 +1258,7 @@ }, { "label": "Databases", - "tablePro": "25", + "tablePro": "29", "competitor": "MySQL, MariaDB, Postgres, SQL Server, SQLite, Interbase, Firebird" }, { @@ -1384,7 +1384,7 @@ "slug": "azimutt", "name": "Azimutt", "tagline": "Database Client and ER Diagram for Mac.", - "description": "Native database client with ER diagrams for Mac. Free and open source. SQL editor, data grid, schema browser. 25 databases.", + "description": "Native database client with ER diagrams for Mac. Free and open source. SQL editor, data grid, schema browser. 29 databases.", "longDescription": "Azimutt is an MIT-licensed schema visualization tool with a free tier and paid plans from €7/mo (Solo). It's web-based with self-host via Docker. It's not a database client; queries and edits aren't its focus. TablePro is a full database client with built-in ER diagrams. The two solve different problems and run side by side.", "rows": [ { @@ -1409,7 +1409,7 @@ }, { "label": "Databases", - "tablePro": "25", + "tablePro": "29", "competitor": "Postgres, MySQL, SQL Server (read-only)" }, { @@ -1467,7 +1467,7 @@ "Full database client with SQL editor and data grid", "Edit data inline, run queries, manage table structure", "Native macOS, works offline", - "25 databases including NoSQL", + "29 databases including NoSQL", "iOS app with iCloud sync", "AI SQL assistant" ], @@ -1546,7 +1546,7 @@ "slug": "phpmyadmin", "name": "phpMyAdmin", "tagline": "Desktop phpMyAdmin Alternative for Mac.", - "description": "Native phpMyAdmin alternative for Mac. SSH-tunneled, no public web surface. Free and open source. 25 databases.", + "description": "Native phpMyAdmin alternative for Mac. SSH-tunneled, no public web surface. Free and open source. 29 databases.", "longDescription": "phpMyAdmin is a free GPL-licensed PHP web app for MySQL and MariaDB. The current release (5.2.x as of late 2025) ships with most cPanel hosts. It's a recurring CVE target when exposed publicly. TablePro is a desktop client. SSH-tunnel through your existing server access; no public web surface.", "rows": [ { @@ -1586,7 +1586,7 @@ }, { "label": "Databases", - "tablePro": "25", + "tablePro": "29", "competitor": "MySQL & MariaDB" }, { @@ -1634,7 +1634,7 @@ "No public web surface to attack", "SSH-tunneled, not /phpmyadmin?token=...", "Native macOS UX with Touch ID and dark mode", - "25 databases, not just MySQL", + "29 databases, not just MySQL", "AI SQL assistant", "iOS app with iCloud sync" ], diff --git a/resources/data/database-grid.json b/resources/data/database-grid.json index 16064e7..4545fd9 100644 --- a/resources/data/database-grid.json +++ b/resources/data/database-grid.json @@ -119,6 +119,16 @@ "category": "relational", "href": "/oracle-client" }, + { + "name": "Dameng", + "icon": null, + "monogram": "DM", + "port": "5236", + "distribution": "plugin", + "driverGroup": "dameng", + "category": "relational", + "href": null + }, { "name": "DuckDB", "icon": "/images/databases/duckdb.svg", @@ -179,6 +189,16 @@ "category": "document-kv", "href": "/surrealdb-client" }, + { + "name": "Kafka", + "icon": null, + "monogram": "KA", + "port": "9092", + "distribution": "plugin", + "driverGroup": "kafka", + "category": "document-kv", + "href": null + }, { "name": "Cloudflare D1", "icon": "/images/databases/cloudflare-d1.svg", @@ -220,8 +240,8 @@ "href": "/snowflake-client" }, { - "name": "libSQL / Turso", - "icon": "/images/databases/turso.svg", + "name": "libSQL", + "icon": null, "monogram": "LS", "port": "API", "distribution": "plugin", @@ -229,6 +249,16 @@ "category": "file-embedded", "href": "/turso-client" }, + { + "name": "Turso", + "icon": "/images/databases/turso.svg", + "monogram": "TU", + "port": "API", + "distribution": "plugin", + "driverGroup": "libsql", + "category": "file-embedded", + "href": "/turso-client" + }, { "name": "Beancount", "icon": "/images/databases/beancount.svg", diff --git a/resources/js/components/landing/database-grid.tsx b/resources/js/components/landing/database-grid.tsx index e235a10..65e021e 100644 --- a/resources/js/components/landing/database-grid.tsx +++ b/resources/js/components/landing/database-grid.tsx @@ -8,6 +8,7 @@ import DatabaseMark from '@/components/ui/database-mark'; import SectionShell from '@/components/ui/section-shell'; import { CELL_DENSITY, cellBorders, GridCell, type ColumnMap } from '@/components/ui/grid-cell'; import { GITHUB_REPO_URL } from '@/data/links'; +import { BUNDLED_ENGINE_COUNT, ENGINE_COUNT, ON_DEMAND_ENGINE_COUNT } from '@/data/engines'; interface DatabaseTile { name: string; @@ -182,9 +183,9 @@ export default function DatabaseGrid() { tone="raised" id="databases" label="Databases" - headline="25 databases." + headline={`${ENGINE_COUNT} databases.`} headlineMuted="One native driver each. No JDBC." - lede="Nine drivers ship inside the app. The other sixteen download the first time you pick one, signature verified, with no restart." + lede={`${BUNDLED_ENGINE_COUNT} drivers ship inside the app. The other ${ON_DEMAND_ENGINE_COUNT} download the first time you pick one, signature verified, with no restart.`} > diff --git a/resources/js/components/landing/hero.tsx b/resources/js/components/landing/hero.tsx index fd5a2ef..abfcd44 100644 --- a/resources/js/components/landing/hero.tsx +++ b/resources/js/components/landing/hero.tsx @@ -7,6 +7,7 @@ import Button from '@/components/ui/button'; import { AppleGlyph } from '@/components/ui/glyph'; import { trackDownload } from '@/lib/analytics'; import { GITHUB_REPO_URL } from '@/data/links'; +import { ENGINE_COUNT } from '@/data/engines'; interface Props { githubStars?: number | null; @@ -89,7 +90,7 @@ export default function Hero({ githubStars, latestRelease }: Props) { * sections below read as evidence for a claim already made. */}

- TablePro is a free, open source database client for macOS. 25 engines through native drivers, and + TablePro is a free, open source database client for macOS. {ENGINE_COUNT} engines through native drivers, and nothing runs against your database that you have not read first.

diff --git a/resources/js/components/landing/spec-strip.tsx b/resources/js/components/landing/spec-strip.tsx index d7b2274..be6af80 100644 --- a/resources/js/components/landing/spec-strip.tsx +++ b/resources/js/components/landing/spec-strip.tsx @@ -4,6 +4,7 @@ import DataTable, { TABLE_COLUMN_RULE, TABLE_ROW_RULE } from '@/components/ui/da import FootNote from '@/components/ui/footnote'; import { FullLine } from '@/components/ui/full-line'; import { GITHUB_REPO_URL } from '@/data/links'; +import { BUNDLED_ENGINE_COUNT, ENGINE_COUNT, ON_DEMAND_ENGINE_COUNT } from '@/data/engines'; interface Props { latestRelease?: { version: string | null; publishedAt: string | null; countLast30Days: number | null } | null; @@ -55,7 +56,7 @@ function formatReleaseDate(iso: string): string { */ export default function SpecStrip({ latestRelease, downloads }: Props) { const specs: Spec[] = [ - { label: 'databases', value: '25', sub: '9 bundled, 16 on demand', numeric: true }, + { label: 'databases', value: String(ENGINE_COUNT), sub: `${BUNDLED_ENGINE_COUNT} bundled, ${ON_DEMAND_ENGINE_COUNT} on demand`, numeric: true }, { label: 'cold_start', value: 'Under 1s', sub: 'Cold, to first window', numeric: true }, { label: 'idle_rss', value: '~80 MB', sub: 'One connection, open and idle', numeric: true }, downloads?.total diff --git a/resources/js/data/engines.ts b/resources/js/data/engines.ts new file mode 100644 index 0000000..14bb5c3 --- /dev/null +++ b/resources/js/data/engines.ts @@ -0,0 +1,56 @@ +import gridData from '../../data/database-grid.json'; + +/** + * Every engine-count claim on the site, derived from one dataset. + * + * The figure was written out as "25" in eight places — the hero, the grid + * headline and its lede, the spec strip, the licence block, an FAQ answer, the + * JSON-LD publisher blurb and two fields on Home.tsx — plus roughly thirty more + * across `comparisons.json`. Three of those were stale in a way nobody could + * see from the source: the app had reached 28 registered types while the site + * still said 25 and the docs said 27. + * + * The trap in the old copy was that "25" was not arbitrary. It counted distinct + * `driverGroup` values in `database-grid.json`, which is a *driver* count, while + * the headline above it promised *databases*. Two different quantities wearing + * the same number, so raising one silently broke the other. + * + * These derive from the grid instead. The grid is what a reader can actually + * count on the page, so a claim that disagrees with it is a claim the page + * disproves two rows down. `EngineCountTest` pins the derived value, so adding + * a tile is a deliberate change to the marketing copy rather than a side effect. + */ +interface DatabaseTile { + distribution: 'builtin' | 'plugin'; +} + +const tiles = gridData as DatabaseTile[]; + +/** + * Engines a connection can be opened against — the app's registered type ids, + * one grid tile each. + * + * Turso was the open question and is now counted. It had its own `DatabaseType` + * and its own `/turso-client` page but no snapshot, resolving through + * `reverseTypeIndex["Turso"] = "libSQL"`, so it shared a `libSQL / Turso` tile + * and the app returned 28. It now follows the ScyllaDB precedent — an alias that + * also carries an entry of its own — so libSQL and Turso are two tiles over one + * driver, exactly as Cassandra and ScyllaDB are. + */ +export const ENGINE_COUNT: number = tiles.length; + +/** Drivers compiled into the app, ready on first launch. */ +export const BUNDLED_ENGINE_COUNT: number = tiles.filter((tile) => tile.distribution === 'builtin').length; + +/** The rest, fetched and signature-verified the first time one is picked. */ +export const ON_DEMAND_ENGINE_COUNT: number = ENGINE_COUNT - BUNDLED_ENGINE_COUNT; + +/** + * Driver plugin targets in the app repository, which is fewer than the engines + * they serve: PostgreSQL also drives Redshift, CockroachDB and PGlite, Cassandra + * drives ScyllaDB, and libSQL drives Turso. + * + * Nothing in this repository can derive this — it is a count of build targets in + * an application this site cannot see — so it is a literal, and the only one. + */ +export const DRIVER_PLUGIN_COUNT = 23; diff --git a/resources/js/data/faqs.ts b/resources/js/data/faqs.ts index 1ec311e..80434c2 100644 --- a/resources/js/data/faqs.ts +++ b/resources/js/data/faqs.ts @@ -1,3 +1,5 @@ +import { DRIVER_PLUGIN_COUNT, ENGINE_COUNT } from '@/data/engines'; + export interface FaqItem { question: string; answer: string; @@ -23,7 +25,7 @@ export interface FaqItem { export const faqs: FaqItem[] = [ { question: 'Is it really free, or free for now?', - answer: 'Free, permanently. TablePro is AGPLv3 and the whole app works without a license, with no trial countdown and no per-Mac limit. All 25 databases, the SQL editor, the data grid, the AI assistant, the MCP server, Safe Mode with Touch ID, SSH tunnels, ER diagrams and XLSX export cost nothing. Starter adds seven: Compare & Sync, Query Insights, result charts, iCloud Sync, Linked Folders, encrypted connection export, and environment variables in connection fields. Team adds two more, the Team Catalog and the Team Library.', + answer: `Free, permanently. TablePro is AGPLv3 and the whole app works without a license, with no trial countdown and no per-Mac limit. All ${ENGINE_COUNT} databases, the SQL editor, the data grid, the AI assistant, the MCP server, Safe Mode with Touch ID, SSH tunnels, ER diagrams and XLSX export cost nothing. Starter adds seven: Compare & Sync, Query Insights, result charts, iCloud Sync, Linked Folders, encrypted connection export, and environment variables in connection fields. Team adds two more, the Team Catalog and the Team Library.`, }, { question: 'Can I use TablePro at work under AGPLv3?', @@ -42,8 +44,14 @@ export const faqs: FaqItem[] = [ answer: 'In the macOS Keychain. Connection details live in a plain JSON file with no secrets in it. A connection can also skip storage entirely and resolve its password at connect time from a file, an environment variable, a shell command, 1Password, HashiCorp Vault or AWS Secrets Manager.', }, { - question: 'Why do Cassandra and ScyllaDB appear separately when you say 25?', - answer: 'The grid shows 26 tiles because Cassandra and ScyllaDB are two entries in the connection chooser. They share one driver, which is why the driver count is 25.', + /* + * This used to reconcile 26 tiles against a claim of 25, because the + * headline quoted driver groups while the grid rendered entries. The + * grid now renders one tile per engine, so that gap is gone and the + * only one left is the honest one: a driver can serve several engines. + */ + question: `Why do Cassandra and ScyllaDB appear separately when you say ${ENGINE_COUNT}?`, + answer: `Because they are two entries in the connection chooser, and the grid shows one tile per entry. The ${ENGINE_COUNT} engines run on ${DRIVER_PLUGIN_COUNT} native driver plugins, because one driver can serve several engines: PostgreSQL also drives Redshift, CockroachDB and PGlite, Cassandra drives ScyllaDB, and libSQL drives Turso.`, }, { question: 'Is this abandoned like Sequel Pro?', diff --git a/resources/js/lib/structured-data.ts b/resources/js/lib/structured-data.ts index c246515..b8e42c0 100644 --- a/resources/js/lib/structured-data.ts +++ b/resources/js/lib/structured-data.ts @@ -1,4 +1,5 @@ import { GITHUB_REPO_URL } from '@/data/links'; +import { ENGINE_COUNT } from '@/data/engines'; export interface BreadcrumbCrumb { name: string; @@ -42,7 +43,7 @@ export function buildOrganizationJsonLd(baseUrl: string): object { height: 256, }, description: - 'TablePro builds a native database client for macOS, iPadOS and iOS covering 25 engines, released as open source under AGPLv3.', + `TablePro builds a native database client for macOS, iPadOS and iOS covering ${ENGINE_COUNT} engines, released as open source under AGPLv3.`, // All five profiles the footer links, not two of them. sameAs: [ GITHUB_REPO_URL, diff --git a/resources/js/pages/Home.tsx b/resources/js/pages/Home.tsx index 99d2295..44d5cd5 100644 --- a/resources/js/pages/Home.tsx +++ b/resources/js/pages/Home.tsx @@ -15,6 +15,7 @@ import SEOHead from '@/components/seo/seo-head'; import { buildOrganizationJsonLd } from '@/lib/structured-data'; import { GITHUB_REPO_URL } from '@/data/links'; import { STARTER_PRICE, TEAM_SEAT_PRICE } from '@/data/pricing'; +import { ENGINE_COUNT } from '@/data/engines'; interface LatestRelease { version: string | null; @@ -61,10 +62,10 @@ const HOME_TITLE = 'TablePro - Free, Open Source Database Client for Mac'; * answer engine lifts for "what is TablePro". */ const HOME_DESCRIPTION = - 'TablePro is a free, open source database client for macOS. 25 engines through native drivers, in a 20 MB Swift app that opens in under a second.'; + `TablePro is a free, open source database client for macOS. ${ENGINE_COUNT} engines through native drivers, in a 20 MB Swift app that opens in under a second.`; const FEATURE_LIST = [ - '25 databases through native drivers', + `${ENGINE_COUNT} databases through native drivers`, 'SQL editor with tree-sitter highlighting and Vim mode', 'Editable data grid with deferred commit', '13 AI providers, bring your own key', @@ -121,7 +122,7 @@ function buildAppJsonLd( softwareRequirements: 'macOS 14.0 or later, Apple Silicon or Intel', description: HOME_DESCRIPTION, disambiguatingDescription: - 'TablePro is a native macOS GUI client for 25 database engines, written in Swift rather than Electron or Java, and released as open source under AGPLv3.', + `TablePro is a native macOS GUI client for ${ENGINE_COUNT} database engines, written in Swift rather than Electron or Java, and released as open source under AGPLv3.`, // Every URL from canonicalBaseUrl. Four of these used to hardcode the // production origin while `screenshot` did not, so one object carried // two origins on any non-production host. diff --git a/tests/Feature/Landing/DatabaseGridDataTest.php b/tests/Feature/Landing/DatabaseGridDataTest.php index 69c90bb..84066e3 100644 --- a/tests/Feature/Landing/DatabaseGridDataTest.php +++ b/tests/Feature/Landing/DatabaseGridDataTest.php @@ -8,9 +8,14 @@ }); /** - * The homepage grid advertises "25 databases" while rendering 26 tiles, because - * Cassandra and ScyllaDB share a driver. These assertions are what keep that - * claim honest as engines are added. + * The grid is the source every engine-count claim on the site derives from, so + * these assertions are what keep the headline honest as engines are added. + * + * It used to advertise "25 databases" above 26 tiles: the copy counted driver + * groups and the grid rendered entries. Dameng and Kafka then shipped in the app + * without ever getting a tile, so the site under-sold two engines while quoting a + * number that matched neither, and libSQL and Turso shared a single tile while + * the app registered them separately. One tile per engine now, counted here. */ function gridTiles(): array { @@ -22,17 +27,24 @@ function databaseEntries(): array return json_decode(file_get_contents(base_path('resources/data/databases.json')), true); } -it('ships 26 tiles covering 25 distinct drivers', function (): void { +it('ships 29 tiles covering 27 distinct drivers', function (): void { $tiles = gridTiles(); - expect($tiles)->toHaveCount(26); - expect(array_unique(array_column($tiles, 'driverGroup')))->toHaveCount(25); + expect($tiles)->toHaveCount(29); + + /* + * Two fewer groups than tiles, and both pairs are deliberate: Cassandra with + * ScyllaDB, and libSQL with Turso. Each pair is two engines a user picks + * separately over one driver, which is why the engine count and + * `DRIVER_PLUGIN_COUNT` are different numbers rather than a discrepancy. + */ + expect(array_unique(array_column($tiles, 'driverGroup')))->toHaveCount(27); }); -it('splits distribution as 9 bundled and 17 plugin tiles', function (): void { +it('splits distribution as 9 bundled and 20 plugin tiles', function (): void { $distribution = array_count_values(array_column(gridTiles(), 'distribution')); - expect($distribution)->toBe(['builtin' => 9, 'plugin' => 17]); + expect($distribution)->toBe(['builtin' => 9, 'plugin' => 20]); }); it('gives every tile a complete shape', function (): void { @@ -49,42 +61,91 @@ function databaseEntries(): array it('points every tile icon at a file that exists', function (): void { foreach (gridTiles() as $tile) { + if ($tile['icon'] === null) { + continue; + } + expect($tile['icon'])->toStartWith('/images/databases/'); expect(public_path($tile['icon'])) ->toBeReadableFile("Missing icon for {$tile['name']}: {$tile['icon']}"); } }); -it('assigns every tile to a known category and accounts for all 26', function (): void { +/** + * `DatabaseMark` renders a two-letter monogram when a tile has no icon, which is + * what lets an engine reach the grid before its artwork does. That fallback is a + * deliberate exception per engine, not a way to skip the icon: naming the tiles + * here means a third one cannot appear without someone deciding it should. + */ +it('lets only the engines still awaiting artwork fall back to a monogram', function (): void { + $iconless = array_column( + array_filter(gridTiles(), static fn(array $tile): bool => $tile['icon'] === null), + 'name', + ); + + expect($iconless)->toBe(['Dameng', 'Kafka', 'libSQL']); +}); + +it('assigns every tile to a known category and accounts for all 29', function (): void { $counts = array_count_values(array_column(gridTiles(), 'category')); expect($counts)->toBe([ - 'relational' => 8, - 'file-embedded' => 4, - 'document-kv' => 7, + 'relational' => 9, + 'file-embedded' => 5, + 'document-kv' => 8, 'analytics' => 3, 'cloud-api' => 4, ]); - expect(array_sum($counts))->toBe(26); + expect(array_sum($counts))->toBe(29); }); it('keeps every tile link resolvable and consistent with its marketing page', function (): void { $entries = collect(databaseEntries())->keyBy(fn(array $entry): string => '/' . $entry['slug']); foreach (gridTiles() as $tile) { + if ($tile['href'] === null) { + continue; + } + expect($tile['href'])->toBeString()->toStartWith('/'); $entry = $entries->get($tile['href']); expect($entry)->not->toBeNull("No databases.json entry for grid href {$tile['href']}"); - expect($entry['name'])->toBe($tile['name'], "Name drift on {$tile['href']}"); - expect($entry['icon'])->toBe($tile['icon'], "Icon drift on {$tile['href']}"); + + /* + * Containment, not equality: libSQL and Turso are two tiles over one + * page, which is still titled "libSQL / Turso" because it documents both. + * A tile whose name the page never mentions is still drift. + */ + expect($entry['name'])->toContain($tile['name']); + + if ($tile['icon'] !== null) { + expect($entry['icon'])->toBe($tile['icon'], "Icon drift on {$tile['href']}"); + } } }); it('serves a page for every tile link', function (): void { foreach (gridTiles() as $tile) { + if ($tile['href'] === null) { + continue; + } + test()->get('http://' . config('app.web_domain') . $tile['href']) ->assertOk(); } }); + +/** + * A tile with no href has no marketing page yet. That is allowed, but it is the + * kind of gap that quietly becomes permanent, so the exceptions are named. + */ +it('lets only the engines still awaiting a page go unlinked', function (): void { + $unlinked = array_column( + array_filter(gridTiles(), static fn(array $tile): bool => $tile['href'] === null), + 'name', + ); + + expect($unlinked)->toBe(['Dameng', 'Kafka']); +}); diff --git a/tests/Feature/Landing/EngineCountTest.php b/tests/Feature/Landing/EngineCountTest.php new file mode 100644 index 0000000..4b444d1 --- /dev/null +++ b/tests/Feature/Landing/EngineCountTest.php @@ -0,0 +1,147 @@ +toBeReadableFile("Missing source: {$relative}"); + + return (string) file_get_contents($path); +}; + +/** + * Sources whose rendered copy quotes the engine count. + * + * @var list + */ +$claimSources = [ + 'resources/js/data/faqs.ts', + 'resources/js/pages/Home.tsx', + 'resources/js/lib/structured-data.ts', + 'resources/js/components/landing/hero.tsx', + 'resources/js/components/landing/database-grid.tsx', + 'resources/js/components/landing/spec-strip.tsx', + 'resources/js/components/landing/license.tsx', +]; + +/** + * Comments are history, not copy. `license.tsx` still quotes the retired + * "25 databases, SQL editor, data grid…" row it replaced, and `database-grid` + * explains itself in terms of "twenty six tiles". Both are accurate records of + * what the file used to say and neither reaches a reader, so the scan reads what + * ships rather than what is remembered. + */ +function renderedCopy(string $contents): string +{ + return (string) preg_replace( + ['#/\*.*?\*/#s', '#//[^\n]*#'], + '', + $contents, + ); +} + +it('pins the engine count every marketing claim derives from', function (): void { + /* + * 29 is the number of keys in the app's `allRegisteredTypeIds()`: 7 curated, + * 15 registry, 3 cloud, one each for Elasticsearch, Kafka and SurrealDB, and + * a snapshot of its own for Turso. + * + * Turso was the alias that made this ambiguous — it had a `DatabaseType` and + * a `/turso-client` page but resolved to libSQL, so the app counted 28 while + * the site showed one `libSQL / Turso` tile. It now carries an entry of its + * own on the ScyllaDB precedent, so libSQL and Turso are two tiles sharing + * one driver and the two repositories agree on 29. + */ + $tiles = json_decode(file_get_contents(base_path('resources/data/database-grid.json')), true); + + expect($tiles)->toHaveCount(29); +}); + +it('states the engine count in one module and never as a literal', function () use ($claimSources, $readSource): void { + /* + * Any digit standing next to "databases", "engines" or "drivers" in rendered + * copy is a second copy of the figure, which is how the site came to publish + * a number the app had not used in three releases. The count is interpolated + * from `engines.ts` or it is not stated. + */ + $offenders = []; + + foreach ($claimSources as $source) { + $copy = renderedCopy($readSource($source)); + + if (preg_match_all('/\b\d+\s+(?:database engines?|driver plugins?|databases?|engines?|drivers?)\b/i', $copy, $matches) === 0) { + continue; + } + + foreach ($matches[0] as $match) { + $offenders[] = "{$source}: \"{$match}\""; + } + } + + expect($offenders)->toBe([], "Hardcoded engine counts, import from @/data/engines instead:\n " . implode("\n ", $offenders)); +}); + +it('derives the count and its split from the grid rather than restating them', function () use ($readSource): void { + $engines = $readSource('resources/js/data/engines.ts'); + + expect($engines) + ->toContain("import gridData from '../../data/database-grid.json'") + ->toContain('export const ENGINE_COUNT: number = tiles.length;'); + + /* + * The lede reads "9 drivers ship inside the app. The other 20 download…". + * Spelling either half out is how it last drifted: the grid gained a tile, + * "nine and sixteen" stayed, and the two halves stopped summing to the + * headline directly above them. + */ + expect($engines)->toContain('export const ON_DEMAND_ENGINE_COUNT: number = ENGINE_COUNT - BUNDLED_ENGINE_COUNT;'); +}); + +it('quotes one engine count across every comparison page', function (): void { + /* + * `comparisons.json` cannot import the constant, so its literals are pinned + * against it here — every "Databases" row, and the OG card figures, which are + * generated from their own fields and so drift silently from the table they + * advertise. + */ + $entries = json_decode(file_get_contents(base_path('resources/data/comparisons.json')), true); + + $offenders = []; + + foreach ($entries as $entry) { + foreach ($entry['rows'] ?? [] as $row) { + if (($row['label'] ?? null) !== 'Databases') { + continue; + } + + if (! str_starts_with((string) $row['tablePro'], '29')) { + $offenders[] = "{$entry['slug']}: Databases row says \"{$row['tablePro']}\""; + } + } + + /* + * Only the cards whose stat *is* the engine count. Most quote RAM or a + * price instead, and "$0 PER DEVICE" is not a number that moves with the + * driver list. + */ + if (($entry['ogStatLabel'] ?? null) === 'DATABASES' && ($entry['ogStatValue'] ?? null) !== '29') { + $offenders[] = "{$entry['slug']}: ogStatValue says \"{$entry['ogStatValue']}\""; + } + } + + expect($offenders)->toBe([], "Comparison pages disagreeing with ENGINE_COUNT:\n " . implode("\n ", $offenders)); +}); diff --git a/tests/Feature/Landing/HomepageRenderTest.php b/tests/Feature/Landing/HomepageRenderTest.php index f7fa4d8..a5c71ec 100644 --- a/tests/Feature/Landing/HomepageRenderTest.php +++ b/tests/Feature/Landing/HomepageRenderTest.php @@ -67,14 +67,33 @@ function ssrHomepageHtml(): string } }); -it('server-renders all 26 database tiles', function (): void { +it('server-renders all 29 database tiles', function (): void { $html = ssrHomepageHtml(); - expect(substr_count($html, '/images/databases/'))->toBe(26); + /* + * Counted on the tile, not on the icon. Dameng and Kafka render through + * `DatabaseMark`'s monogram fallback because they have no artwork yet, so + * counting `/images/databases/` returns 26 whether the grid holds 26 tiles + * or 29 — an assertion that would have kept passing, under this name, while + * three engines were missing from the page. + */ + $tiles = json_decode(file_get_contents(base_path('resources/data/database-grid.json')), true); - foreach (['PGlite', 'SurrealDB', 'Teradata', 'Trino', 'Beancount', 'libSQL / Turso', 'etcd'] as $name) { - expect($html)->toContain($name); - } + expect($tiles)->toHaveCount(29); + + /* + * Collected, not asserted in the loop. `toContain()` is `(mixed ...$needles)` + * with no message parameter, so a message passed there becomes a second + * needle and the failure reports the message as the missing string. + */ + $missing = array_values(array_filter( + array_column($tiles, 'name'), + static fn(string $name): bool => ! str_contains($html, $name), + )); + + expect($missing)->toBe([], 'Grid tiles missing from SSR output: ' . implode(', ', $missing)); + + expect(substr_count($html, '/images/databases/'))->toBe(26); }); it('server-renders the verified claims and none of the retired ones', function (): void { @@ -84,7 +103,7 @@ function ssrHomepageHtml(): string ->toContain('Every database.') // "client", not "app": the head noun of every query this page targets. ->toContain('One native Mac client.') - ->toContain('25 databases.') + ->toContain('29 databases.') ->toContain('Starter'); foreach (['15+ databases', '21+ supported', 'iOS 17+', 'free forever on one Mac', '9 built-in themes'] as $stale) { diff --git a/tests/Feature/Seo/StaleClaimsTest.php b/tests/Feature/Seo/StaleClaimsTest.php index 20cc8fd..97ffd0b 100644 --- a/tests/Feature/Seo/StaleClaimsTest.php +++ b/tests/Feature/Seo/StaleClaimsTest.php @@ -21,9 +21,9 @@ $banned = [ // Not bare '18+': that also matches 'macOS 14+, iOS 18+', which is the // correct deployment target. The stale claim was always '18+ databases'. - '18+ databases' => 'the database count is 25', - '15+ databases' => 'the database count is 25', - '21+' => 'the database count is 25', + '18+ databases' => 'the database count is 29', + '15+ databases' => 'the database count is 29', + '21+' => 'the database count is 29', '9 built-in themes' => 'there are 4 built-in themes', 'iOS 17' => 'the iOS deployment target is 18.0', 'free forever on one Mac' => 'the unlicensed app has no per-Mac limit', @@ -118,8 +118,13 @@ * already answered in prose elsewhere on the homepage, and `faqs.ts` spread * `home-faqs.ts` in wholesale, so /faq answered each of them a third time. * The questions all survived the move; only the duplicate render sites went. + * + * Counted on the key, not on the quote character. This matched `question: '` + * until one question began interpolating the engine count and switched to a + * template literal — at which point a file that still held all fourteen + * questions reported thirteen. */ - expect(substr_count($readSource('resources/js/data/faqs.ts'), "question: '"))->toBe(14); + expect(preg_match_all("/\bquestion: ['`]/", $readSource('resources/js/data/faqs.ts')))->toBe(14); expect(file_exists(base_path('resources/js/data/home-faqs.ts'))) ->toBeFalse('home-faqs.ts is gone; /faq is the only place a question is asked'); @@ -220,7 +225,7 @@ expect($routed)->toBe($authored); }); -it('never implies a database count other than 25', function (): void { +it('never implies a database count other than 29', function (): void { /* * Four entries carried counts from the era when TablePro shipped 18 — * Postico promised "Postgres plus 17 other databases" and named five @@ -229,7 +234,8 @@ * * Relative phrasing is what rots: "plus N more" has to be re-derived every * time the driver count moves, and nobody remembers to. This catches the - * ones that no longer add up. + * ones that no longer add up — it caught all four when the count went to 28, + * and all four again at 29. */ $entries = json_decode(file_get_contents(base_path('resources/data/comparisons.json')), true); @@ -249,8 +255,8 @@ /* * Only the engines named in the same sentence count. Postico's * verdict reads "excellent for Postgres alone. TablePro covers the - * same ground for Postgres plus 24 other databases" — counting the - * whole string sees Postgres twice and makes 25 look like 26. + * same ground for Postgres plus 28 other databases" — counting the + * whole string sees Postgres twice and makes 29 look like 30. */ $before = substr($value, 0, strpos($value, $m[0])); $sentence = preg_split('/(?<=[.!?])\s+/', $before); @@ -260,13 +266,13 @@ (string) end($sentence), ); - if ($named + (int) $m[1] !== 25) { + if ($named + (int) $m[1] !== 29) { $offenders[] = "{$entry['slug']}: \"{$m[0]}\" after {$named} named engines = " . ($named + (int) $m[1]); } }); } - expect($offenders)->toBe([], "Database counts that do not total 25:\n " . implode("\n ", $offenders)); + expect($offenders)->toBe([], "Database counts that do not total 29:\n " . implode("\n ", $offenders)); }); it('quotes one price per competitor claim, consistently within an entry', function (): void {