Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/plugin-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,20 @@
"category": "database-driver",
"homepage": "https://docs.tablepro.app/databases/trino"
},
"typesense": {
"target": "TypesenseDriverPlugin",
"bundleName": "TypesenseDriverPlugin",
"bundleId": "com.TablePro.TypesenseDriverPlugin",
"bundled": false,
"displayName": "Typesense Driver",
"summary": "Typesense driver over the REST API with a request console",
"databaseTypeIds": [
"Typesense"
],
"icon": "typesense-icon",
"category": "database-driver",
"homepage": "https://docs.tablepro.app/databases/typesense"
},
"xlsx": {
"target": "XLSXExport",
"bundleName": "XLSXExport",
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Database type change from inside the connection editor.
- The reason Save is unavailable, next to the Save button in the connection editor.
- Approval setting for MCP connection access, with the list of approved connections and a Forget action. (#2640)
- Typesense driver plugin, with collection browsing, document editing and a REST request console. (#2629)
- Export, Delete and Empty for a Typesense collection. (#2629)
- Typesense API keys in Users & Roles, with the collections and actions each key holds. (#2629)
- Server Dashboard metrics for Typesense, from `/metrics.json` and `/stats.json`. (#2629)

### Changed

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ TablePro is a native macOS database client (SwiftUI + AppKit), a fast, lightweig
- **Source**: `TablePro/` holds `Core/` (business logic, services), `Views/` (UI), `Models/` (data structures), `ViewModels/`, `Extensions/` and `Theme/`
- **Plugins**: `Plugins/` holds the `.tableplugin` bundles plus the `TableProPluginKit` shared framework.
- **Bundled in app** (the 18 targets in the app's `copy: { destination: plugins }` phase in `project.yml`): MySQL, PostgreSQL, SQLite, ClickHouse, Redis, CSV export, JSON export, SQL export, XLSX export, Markdown export, HTML export, XML export, MQL export, SQL import, JSON import, CSV import, XLSX import, CSV inspector. These ship inside the app bundle and their updates normally ride with the next app release. Six of them (`sqlite`, `clickhouse`, `redis`, `xlsx`, `mql`, `sqlimport`) also have registry arms in `build-plugin.yml`, so a bundled plugin can be published when users on an already-shipped app need the fix sooner. `scripts/build-plugin.sh:10` explains the flag that makes that work.
- **Registry-only** (the other 19): MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, DynamoDB, BigQuery, LibSQL, Snowflake, Elasticsearch, Beancount, SurrealDB, Teradata, Trino, Dameng, Kafka, Parquet export. Parquet is registry-only because it links its own copy of DuckDB, which does the encoding, and that is too large to ship in the app for one format. Distributed via [TableProApp/plugins](https://github.com/TableProApp/plugins) `plugins.json`, installed into the user plugins directory.
- **Registry-only** (the other 20): MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, DynamoDB, BigQuery, LibSQL, Snowflake, Elasticsearch, Typesense, Beancount, SurrealDB, Teradata, Trino, Dameng, Kafka, Parquet export. Parquet is registry-only because it links its own copy of DuckDB, which does the encoding, and that is too large to ship in the app for one format. Distributed via [TableProApp/plugins](https://github.com/TableProApp/plugins) `plugins.json`, installed into the user plugins directory.
- **C bridges**: Each plugin contains its own C bridge module (e.g., `Plugins/MySQLDriverPlugin/CMariaDB/`, `Plugins/PostgreSQLDriverPlugin/CLibPQ/`)
- **Static libs**: `Libs/` holds pre-built `.a` files and `Libs/ios/` holds the iOS xcframeworks. Both are downloaded by `scripts/download-libs.sh` and are not in git.
- **SPM deps**: declared in `project.yml`. Vendored local packages under `LocalPackages/` (CodeEditSourceEditor, CodeEditTextView, CodeEditLanguages) and `Packages/` (TableProCore, TableProOracle); remote packages are Sparkle, swift-certificates and Yams. Revisions are pinned by the tracked `Package.resolved` inside each generated `.xcodeproj`.
Expand Down Expand Up @@ -103,7 +103,7 @@ git add Libs/ios/checksums.sha256 && git commit -m "build: update iOS xcframewor

Run `scripts/generate-project.sh` after editing any of those, and after adding, moving, or deleting a source file: XcodeGen globs sources at generation time, so a new file is not in the project until you regenerate. Changing signing in the Xcode UI is pointless, because the next generate discards it; set `TABLEPRO_DEVELOPMENT_TEAM` and `TABLEPRO_APP_BUNDLE_IDENTIFIER` in `Configs/Secrets.xcconfig` instead.

The 37 plugin bundles share one `DriverPlugin` target template; a plugin declares only its folder, principal class, and any C-library link flags. Every target gets a shared scheme named after it, which is what `scripts/build-plugin.sh <PluginTarget> [arm64|x86_64|both] [version]` builds. The `AllPlugins` aggregate target compile-checks all 37, including the registry-only ones the app does not embed, and PR CI runs it: the `Compile every plugin` step in the `app-tests` job of `.github/workflows/macos-tests.yml` builds that scheme whenever the change touches `Plugins/` or any other watched path. What PR CI still does not cover is plugin packaging, signing and notarization, which only `build-plugin.yml` does and only on a release tag.
The 38 plugin bundles share one `DriverPlugin` target template; a plugin declares only its folder, principal class, and any C-library link flags. Every target gets a shared scheme named after it, which is what `scripts/build-plugin.sh <PluginTarget> [arm64|x86_64|both] [version]` builds. The `AllPlugins` aggregate target compile-checks all 38, including the registry-only ones the app does not embed, and PR CI runs it: the `Compile every plugin` step in the `app-tests` job of `.github/workflows/macos-tests.yml` builds that scheme whenever the change touches `Plugins/` or any other watched path. What PR CI still does not cover is plugin packaging, signing and notarization, which only `build-plugin.yml` does and only on a release tag.

### Plugin System

Expand Down
14 changes: 14 additions & 0 deletions Plugins/TypesenseDriverPlugin/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>TableProMinAppVersion</key>
<string>0.73.0</string>
<key>TableProPluginKitVersion</key>
<integer>21</integer>
<key>TableProProvidesDatabaseTypeIds</key>
<array>
<string>Typesense</string>
</array>
</dict>
</plist>
154 changes: 154 additions & 0 deletions Plugins/TypesenseDriverPlugin/TypesenseApiKeys.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//
// TypesenseApiKeys.swift
// TypesenseDriverPlugin
//
// Typesense API keys, projected onto the app's principal and privilege model.
//

import Foundation
import TableProPluginKit

struct TypesenseApiKey: Equatable {
let id: Int
let description: String
let actions: [String]
let collections: [String]
let valuePrefix: String?
let expiresAt: Int?

/// A key has no name of its own. The description is what the person typed, and it is the only
/// thing that reads as a name in a list; the id keeps two keys with the same description apart.
var displayName: String {
description.isEmpty ? "Key #\(id)" : "\(description) (#\(id))"
}
}

enum TypesenseApiKeys {
static let allCollections = "*"

/// The actions Typesense documents, grouped the way the privilege editor shows them. `*` is a
/// real action value meaning every action, not a wildcard the app expands.
static let actionCatalog: [(name: String, label: String, category: String)] = [
("*", "All actions", "General"),
("documents:search", "Search documents", "Documents"),
("documents:get", "Get a document", "Documents"),
("documents:create", "Create documents", "Documents"),
("documents:upsert", "Upsert documents", "Documents"),
("documents:update", "Update documents", "Documents"),
("documents:delete", "Delete documents", "Documents"),
("documents:import", "Import documents", "Documents"),
("documents:export", "Export documents", "Documents"),
("documents:*", "All document actions", "Documents"),
("collections:list", "List collections", "Collections"),
("collections:get", "Get a collection", "Collections"),
("collections:create", "Create collections", "Collections"),
("collections:delete", "Delete collections", "Collections"),
("collections:*", "All collection actions", "Collections"),
("aliases:*", "All alias actions", "Aliases"),
("synonyms:*", "All synonym actions", "Synonyms"),
("overrides:*", "All override actions", "Curation"),
("keys:*", "All key actions", "Keys"),
("metrics.json:list", "Read metrics", "Operations"),
("stats.json:list", "Read stats", "Operations"),
("debug:list", "Read debug info", "Operations"),
]

// MARK: - Decoding

static func keys(from json: Any?) -> [TypesenseApiKey] {
guard let object = json as? [String: Any],
let raw = object["keys"] as? [[String: Any]] else { return [] }
return raw.compactMap(key(from:))
}

static func key(from json: [String: Any]) -> TypesenseApiKey? {
guard let id = (json["id"] as? NSNumber)?.intValue else { return nil }
return TypesenseApiKey(
id: id,
description: (json["description"] as? String) ?? "",
actions: (json["actions"] as? [String]) ?? [],
collections: (json["collections"] as? [String]) ?? [],
valuePrefix: json["value_prefix"] as? String,
expiresAt: (json["expires_at"] as? NSNumber)?.intValue
)
}

/// The key id has to survive the round trip through `PluginPrincipalRef`, which carries only a
/// name, so it is parsed back out of the display name rather than tracked on the side.
static func id(fromDisplayName name: String) -> Int? {
guard let open = name.lastIndex(of: "#") else { return Int(name) }
let digits = name[name.index(after: open)...].prefix { $0.isNumber }
return Int(digits)
}

// MARK: - Projection

static func principal(for key: TypesenseApiKey) -> PluginPrincipalInfo {
PluginPrincipalInfo(
ref: PluginPrincipalRef(name: key.displayName),
isRole: false,
canLogin: true,
attributes: [],
memberOf: [],
connectionLimit: nil,
comment: comment(for: key)
)
}

static func comment(for key: TypesenseApiKey) -> String? {
var parts: [String] = []
if let prefix = key.valuePrefix, !prefix.isEmpty {
parts.append(String(format: String(localized: "Starts with %@"), prefix))
}
if key.collections.isEmpty || key.collections == [allCollections] {
parts.append(String(localized: "All collections"))
} else {
parts.append(key.collections.joined(separator: ", "))
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}

/// A Typesense key carries actions and a collection list, and the pair is the grant: every
/// action applies to every collection the key names. `*` means the whole server.
static func grants(for key: TypesenseApiKey, database: String) -> [PluginGrantInfo] {
let scopes: [PluginPrivilegeScope] = key.collections.isEmpty || key.collections == [allCollections]
? [.server]
: key.collections.map { .table(database: database, schema: nil, table: $0) }
return key.actions.flatMap { action in
scopes.map { PluginGrantInfo(privilege: action, scope: $0, isGrantable: false) }
}
}

static var catalog: PluginPrivilegeCatalog {
let descriptors = actionCatalog.map {
PluginPrivilegeDescriptor(name: $0.name, label: $0.label, category: $0.category)
}
return PluginPrivilegeCatalog(
serverPrivileges: descriptors,
databasePrivileges: descriptors,
schemaPrivileges: [],
tablePrivileges: descriptors,
columnPrivileges: [],
supportsDynamicPrivileges: false
)
}

// MARK: - Requests

static func createRequest(description: String, actions: [String], collections: [String]) -> TypesenseWriteRequest? {
let payload: [String: Any] = [
"description": description,
"actions": actions.isEmpty ? ["documents:search"] : actions,
"collections": collections.isEmpty ? [allCollections] : collections,
]
guard JSONSerialization.isValidJSONObject(payload),
let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]),
let body = String(data: data, encoding: .utf8)
else { return nil }
return TypesenseWriteRequest(method: "POST", path: "/keys", body: body)
}

static func deleteRequest(id: Int) -> TypesenseWriteRequest {
TypesenseWriteRequest(method: "DELETE", path: "/keys/\(id)", body: nil)
}
}
Loading
Loading