From 989c6c0b1fceec2af422ef215d646775c92aaa1b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 27 Aug 2026 19:37:19 +0700 Subject: [PATCH] feat(plugins): offer Turso as its own engine and hold the count in docs --- CHANGELOG.md | 1 + ...ginMetadataRegistry+RegistryDefaults.swift | 3 +- ...PluginMetadataRegistry+TursoDefaults.swift | 125 ++++++++++++++++++ ...PluginMetadataRegistryTypeCountTests.swift | 94 +++++++++++++ docs/databases/index.mdx | 11 +- docs/index.mdx | 8 +- docs/scripts/check-docs-against-source.py | 105 ++++++++++++++- docs/snippets/driver-counts.mdx | 5 +- 8 files changed, 336 insertions(+), 16 deletions(-) create mode 100644 TablePro/Core/Plugins/PluginMetadataRegistry+TursoDefaults.swift create mode 100644 TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ee41184dd..c2919d8c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remote File pane for SQLite, opening a read-only copy of a database that lives on an SSH server. (#2474) - Rename on a table's right-click menu, editing the row's label in place. (#2482) - Rename Database and Rename Schema on the sidebar's container rows, where the engine has them. (#2482) +- Turso in the New Connection picker as an engine of its own, offered before the libSQL plugin installs. ### Changed diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index aacec0c8b..1c3b991e8 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -1201,7 +1201,8 @@ extension PluginMetadataRegistry { tagline: String(localized: "Distributed SQLite by Turso") ) )), - ] + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults() + ] + tursoPluginDefaults(dialect: d1Dialect, columnTypes: d1ColumnTypes) + + cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults() + kafkaPluginDefaults() } // swiftlint:enable function_body_length diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+TursoDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+TursoDefaults.swift new file mode 100644 index 000000000..a477b8a34 --- /dev/null +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+TursoDefaults.swift @@ -0,0 +1,125 @@ +// +// PluginMetadataRegistry+TursoDefaults.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + /// The curated snapshot for Turso. + /// + /// Turso is served by the libSQL plugin, which declares it in `additionalDatabaseTypeIds`. + /// Until that plugin is installed there was nothing to answer for the type, so Turso was + /// absent from the New Connection picker and there was no way to create the connection that + /// would trigger the install. Installing libSQL then registered Turso from the plugin's own + /// snapshot, which gave it libSQL's icon and libSQL's tagline: two rows describing the same + /// thing in the same words. + /// + /// ScyllaDB is the precedent. It is an alias of Cassandra in `reverseTypeIndex` and carries a + /// curated entry of its own all the same, because an alias still needs a name, an icon and a + /// tagline that are its own. Every other alias does the same. Turso was the only one that + /// did not, which is why it was the only type missing from `allRegisteredTypeIds()`. + /// + /// The connection fields are libSQL's exactly, including the local-file mode. The driver + /// reads `libsqlMode` and treats anything but `local` as remote, so a narrower list would + /// still connect, but `ConnectionStorage` and `ConnectionExportService` derive Keychain + /// migration and export redaction from this list and a Turso connection saved in local mode + /// already exists in the field. + func tursoPluginDefaults( + dialect: SQLDialectDescriptor, + columnTypes: [String: [String]] + ) -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [ + ("Turso", PluginMetadataSnapshot( + displayName: "Turso", iconName: "libsql-icon", defaultPort: 0, + requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: true, primaryUrlScheme: "turso", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [ + ExplainVariant( + id: "plan", label: "Query Plan", sqlPrefix: "EXPLAIN QUERY PLAN", format: .sqliteQueryPlan + ) + ], + pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["turso"], postConnectActions: [], + brandColorHex: "#4FF8D2", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .apiOnly, supportsDatabaseSwitching: false, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: false, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsModifyColumn: false, + supportsRenameColumn: true, + localFilePathField: .additionalField("libsqlFilePath") + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "main", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .flat, + structureColumnFields: [.name, .type, .nullable, .defaultValue] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: dialect, + statementCompletions: [], + columnTypesByCategory: columnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [ + ConnectionField( + id: "libsqlMode", + label: String(localized: "Connection Mode"), + defaultValue: "remote", + fieldType: .dropdown(options: [ + ConnectionField.DropdownOption( + value: "remote", + label: String(localized: "Remote (Turso)") + ), + ConnectionField.DropdownOption( + value: "local", + label: String(localized: "Local File") + ) + ]), + section: .authentication, + hidesPassword: true + ), + ConnectionField( + id: "databaseUrl", + label: String(localized: "Database URL"), + placeholder: "https://your-db.turso.io", + required: true, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "libsqlMode", values: ["remote"]) + ), + ConnectionField( + id: "libsqlFilePath", + label: String(localized: "Database File"), + placeholder: "/path/to/database.db", + required: true, + section: .authentication, + visibleWhen: FieldVisibilityRule(fieldId: "libsqlMode", values: ["local"]) + ) + ], + category: .cloud, + tagline: String(localized: "Hosted libSQL over HTTP") + ) + )), + ] + } +} diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift new file mode 100644 index 000000000..6d489d2be --- /dev/null +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -0,0 +1,94 @@ +// +// PluginMetadataRegistryTypeCountTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +/// The number of engines TablePro offers is published in three places that cannot see each other: +/// this registry, `docs/snippets/driver-counts.mdx`, and the marketing site. Nothing at runtime +/// reconciles them, and by August 2026 they read 28, 27 and 25 at once. +/// +/// The answer is 29, and the reason it read 28 for a while is worth keeping. Turso is served by +/// the libSQL plugin and was the only alias in `reverseTypeIndex` with no curated entry of its +/// own, so it was the only type the picker could not offer before its plugin was installed. +/// ScyllaDB is the shape every other alias already had: an alias of Cassandra with a curated +/// entry all the same. Turso now matches it, and 29 falls out of the count rather than being +/// asserted on top of it. Do not "correct" this back to 28 by deleting that entry. +/// +/// A failure here means a driver was added or removed, which is a deliberate act. Update +/// `expectedTypeIds`, then `docs/snippets/driver-counts.mdx`, the `and N more` count in the +/// `docs/index.mdx` frontmatter, and the engine count on tablepro.app in the same change. +/// `docs/scripts/check-docs-against-source.py` reads the registry and holds the docs half. +/// +/// The count is taken from the built-in defaults rather than from `allRegisteredTypeIds()`. +/// Both answer 29 under XCTest, where no plugin bundle ever loads, but the registry is a +/// process-global singleton and suites that register a synthetic type run alongside this one. +@MainActor +@Suite("PluginMetadataRegistry engine count") +struct PluginMetadataRegistryTypeCountTests { + private static let expectedTypeIds: Set = [ + "Beancount", "BigQuery", "Cassandra", "ClickHouse", "Cloudflare D1", "CockroachDB", + "Dameng", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", "libSQL", "MariaDB", + "MongoDB", "MySQL", "Oracle", "PGlite", "PostgreSQL", "Redis", "Redshift", "ScyllaDB", + "Snowflake", "SQL Server", "SQLite", "SurrealDB", "Teradata", "Trino", "Turso" + ] + + private static func builtInTypeIds() -> Set { + let curated = PluginMetadataRegistry.curatedDefaults().map(\.typeId) + let registry = PluginMetadataRegistry.shared.registryPluginDefaults().map(\.typeId) + return Set(curated + registry) + } + + @Test("The app ships 29 database types before any plugin loads") + func builtInDefaultsCoverTwentyNineTypes() { + let ids = Self.builtInTypeIds() + #expect(ids.count == 29) + #expect(ids == Self.expectedTypeIds) + } + + @Test("Every built-in type is offered by the registry") + func registrySurfacesEveryBuiltInType() { + let registered = Set(PluginMetadataRegistry.shared.allRegisteredTypeIds()) + #expect(Self.expectedTypeIds.isSubset(of: registered)) + } + + /// An alias is a type of its own to the reader and a route to someone else's plugin to the + /// driver lookup, and it needs both halves. Without a curated entry the picker cannot offer + /// it until the serving plugin is installed, and once installed it inherits the primary's + /// name, icon and tagline. Turso had exactly that gap. + private static let aliasesToTheirPlugin = [ + "MariaDB": "MySQL", + "Redshift": "PostgreSQL", + "CockroachDB": "PostgreSQL", + "PGlite": "PostgreSQL", + "ScyllaDB": "Cassandra", + "Turso": "libSQL" + ] + + @Test("Every alias carries a curated entry of its own") + func everyAliasIsAnEngineInItsOwnRight() { + let builtIn = Self.builtInTypeIds() + for alias in Self.aliasesToTheirPlugin.keys { + #expect(builtIn.contains(alias), "\(alias) has no curated entry") + #expect(PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: alias) != nil) + } + } + + @Test("Every alias still routes to the plugin that serves it") + func everyAliasResolvesToItsPlugin() { + for (alias, plugin) in Self.aliasesToTheirPlugin { + #expect(PluginMetadataRegistry.shared.pluginTypeId(for: alias) == plugin) + } + } + + /// `isDownloadablePlugin` is a fact about the plugin binary, so an alias answers with its + /// serving plugin's flag. Turso is served by libSQL, which is a registry download. + @Test("Turso reports its serving plugin as downloadable") + func tursoIsADownloadablePlugin() { + #expect(DatabaseType.turso.isDownloadablePlugin) + #expect(DatabaseType.turso.isDownloadablePlugin == DatabaseType.libsql.isDownloadablePlugin) + } +} diff --git a/docs/databases/index.mdx b/docs/databases/index.mdx index da8075583..4456c9e54 100644 --- a/docs/databases/index.mdx +++ b/docs/databases/index.mdx @@ -1,11 +1,11 @@ --- title: Supported Databases -description: All 28 engines TablePro connects to, their default ports, and which ones need a plugin +description: All 29 engines TablePro connects to, their default ports, and which ones need a plugin --- import DriverCounts from "/snippets/driver-counts.mdx"; -Twenty-eight engines, and every one of them is free to use. What differs between them is where the +Twenty-nine engines, and every one of them is free to use. What differs between them is where the driver comes from, not what the license covers. @@ -26,7 +26,7 @@ driver comes from, not what the license covers. | [DynamoDB](/databases/dynamodb) | API-based | Plugin | | [Elasticsearch](/databases/elasticsearch) | 9200 | Plugin | | [etcd](/databases/etcd) | 2379 | Plugin | -| [libSQL / Turso](/databases/libsql) | API-based | Plugin | +| [libSQL](/databases/libsql) | API-based | Plugin | | [Kafka](/databases/kafka) | 9092 | Plugin | | [MariaDB](/databases/mariadb) | 3306 | Built-in | | [Microsoft SQL Server](/databases/mssql) | 1433 | Plugin | @@ -42,9 +42,10 @@ driver comes from, not what the license covers. | [SurrealDB](/databases/surrealdb) | 8000 | Plugin | | [Teradata](/databases/teradata) | 1025 | Plugin | | [Trino](/databases/trino) | 8080 | Plugin | +| [Turso](/databases/libsql) | API-based | Plugin | -Rows sharing a page share a driver. MariaDB reads as MySQL, ScyllaDB as Cassandra, and Redshift, -CockroachDB and PGlite all speak the PostgreSQL wire protocol. +Rows sharing a page share a driver. MariaDB reads as MySQL, ScyllaDB as Cassandra, Turso as libSQL, +and Redshift, CockroachDB and PGlite all speak the PostgreSQL wire protocol. ## Built-in against plugin diff --git a/docs/index.mdx b/docs/index.mdx index 129366340..a7adb45f4 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -1,11 +1,11 @@ --- title: Introduction -description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 22 more +description: Native macOS database client for MySQL, PostgreSQL, SQLite, MongoDB, Redis, and 24 more --- import DriverCounts from "/snippets/driver-counts.mdx"; -Using all 27 engines, the SQL editor, the data grid, import, export, and the AI assistant costs nothing, and there is no trial countdown. Paid tiers cover iCloud sync, encrypted connection export, environment variables in connection fields, Linked Folders, Query Insights, result charts, and team sharing ([Licensing](/features/licensing)). Mac is the full app, [iPhone and iPad](/ios) run a smaller one, and there is no Windows or Linux build. +Every engine, the SQL editor, the data grid, import, export, and the AI assistant cost nothing, and there is no trial countdown. Paid tiers cover iCloud sync, encrypted connection export, environment variables in connection fields, Linked Folders, Query Insights, result charts, and team sharing ([Licensing](/features/licensing)). Mac is the full app, [iPhone and iPad](/ios) run a smaller one, and there is no Windows or Linux build. TablePro main interface @@ -28,8 +28,8 @@ Using all 27 engines, the SQL editor, the data grid, import, export, and the AI -Twenty-seven engines, from MySQL and PostgreSQL to DynamoDB, BigQuery and Redis, each with its own -page. [Supported Databases](/databases) lists them all with their default ports and says which ones +From MySQL and PostgreSQL to DynamoDB, BigQuery and Redis, each engine has its own page. +[Supported Databases](/databases) lists them all with their default ports and says which ones arrive as a plugin. ## Open source diff --git a/docs/scripts/check-docs-against-source.py b/docs/scripts/check-docs-against-source.py index 852665691..6aeacf442 100755 --- a/docs/scripts/check-docs-against-source.py +++ b/docs/scripts/check-docs-against-source.py @@ -15,6 +15,7 @@ import re import sys from pathlib import Path +from typing import Optional MENU_FILES = { "TablePro": "AppMenuBuilder.swift", @@ -297,7 +298,6 @@ def check_heading_case(root: Path, docs: Path) -> list[str]: "Oracle": "Oracle Database", "Dameng": "Dameng DM8", "Redshift": "Amazon Redshift", - "libSQL": "libSQL / Turso", } @@ -344,12 +344,108 @@ def check_database_table(root: Path, docs: Path) -> list[str]: f"databases/index.mdx gives {type_id} port {row}, the registry says {port}" ) - counted = re.search(r"\b(\d+|Twenty-seven)\b engines", page.read_text()) - if counted and counted.group(1) not in (str(len(registered)), "Twenty-seven"): - failures.append(f"databases/index.mdx says {counted.group(1)} engines, the registry has {len(registered)}") return failures +NUMBER_WORDS = { + "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, + "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, + "fifteen": 15, "sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19, +} +for _tens_word, _tens in (("twenty", 20), ("thirty", 30), ("forty", 40)): + NUMBER_WORDS[_tens_word] = _tens + for _unit_word, _unit in list(NUMBER_WORDS.items())[:9]: + NUMBER_WORDS[f"{_tens_word}-{_unit_word}"] = _tens + _unit + +ENGINE_COUNT = re.compile( + r"\b(\d+|" + "|".join(sorted(NUMBER_WORDS, key=len, reverse=True)) + r")\s+engines\b", + re.IGNORECASE, +) + + +def spelled(text: str) -> Optional[int]: + return int(text) if text.isdigit() else NUMBER_WORDS.get(text.lower()) + + +def unwrapped(page: Path) -> str: + """A page as one line, so a sentence wrapped at 100 columns still matches.""" + return " ".join(page.read_text().split()) + + +def check_engine_counts(root: Path, docs: Path) -> list[str]: + """Every engine count printed in docs/, against the registry that defines it. + + The count lived on three pages and the marketing site with nothing reconciling them, so + they drifted to 27, 27 and 25 while the app offered 28. STYLE.md 9 gives the count one + owner, `snippets/driver-counts.mdx`; this is what makes that hold. + + `changelog.mdx` is exempt. Its entries state what a past release shipped. + """ + total = len(registered_databases(root)) + if total < 20: + return ["could not read the database types out of PluginMetadataRegistry"] + + failures = [] + for page in sorted(docs.rglob("*.mdx")): + if page.name == "changelog.mdx": + continue + name = page.relative_to(docs) + for match in ENGINE_COUNT.finditer(unwrapped(page)): + counted = spelled(match.group(1)) + if counted is not None and counted != total: + failures.append(f"{name} says {match.group(1)} engines, the registry has {total}") + + failures += check_driver_split(docs, total) + failures += check_introduction_description(docs, total) + return failures + + +def check_driver_split(docs: Path, total: int) -> list[str]: + """The bundled and registry halves in the shared snippet have to add up to the whole. + + They did not: nine bundled databases plus eighteen registry *plugins* reads as 27 engines, + because the two numbers count different things. + """ + text = unwrapped(docs / "snippets/driver-counts.mdx") + bundled = re.search(r"cover (\w+) databases", text) + registry = re.search(r"the other ([\w-]+)", text) + if not bundled or not registry: + return ["snippets/driver-counts.mdx no longer states a bundled and a registry count"] + + halves = [spelled(bundled.group(1)), spelled(registry.group(1))] + if None in halves: + return ["snippets/driver-counts.mdx states a count this script cannot read"] + if sum(halves) != total: + return [ + f"snippets/driver-counts.mdx splits the engines {halves[0]} + {halves[1]}, " + f"the registry has {total}" + ] + return [] + + +def check_introduction_description(docs: Path, total: int) -> list[str]: + """`index.mdx` names five engines in its frontmatter and counts the rest. + + Mintlify prints the description as page metadata, where no snippet can reach, so this is + the one count that has to be typed twice and the one that needs checking directly. + """ + text = (docs / "index.mdx").read_text() + description = re.search(r"^description:\s*(.+)$", text, re.M) + if not description: + return ["index.mdx has no frontmatter description"] + + more = re.search(r"^(.*?) for (.+?),? and (\d+) more\b", description.group(1)) + if not more: + return [] + + named = len([part for part in more.group(2).split(",") if part.strip()]) + if named + int(more.group(3)) != total: + return [ + f"index.mdx names {named} engines and {more.group(3)} more, the registry has {total}" + ] + return [] + + def main() -> int: root = repo_root() docs = root / "docs" @@ -362,6 +458,7 @@ def main() -> int: ("changelog anchors", check_changelog_anchors), ("heading case", check_heading_case), ("database table", check_database_table), + ("engine counts", check_engine_counts), ) total = 0 diff --git a/docs/snippets/driver-counts.mdx b/docs/snippets/driver-counts.mdx index b76694914..1ff5b3dca 100644 --- a/docs/snippets/driver-counts.mdx +++ b/docs/snippets/driver-counts.mdx @@ -1,2 +1,3 @@ -Five drivers ship inside the app and cover nine databases. Another 18 come from the registry and -install on the first connection that needs them. See [Plugins & Themes](/features/plugins). +Five drivers ship inside the app and cover nine databases. Eighteen registry plugins cover the +other twenty and install on the first connection that needs one. See +[Plugins & Themes](/features/plugins).