Skip to content

feat(plugin-surrealdb): add SurrealDB driver plugin (#1862)#1868

Merged
datlechin merged 3 commits into
mainfrom
feat/surrealdb-driver
Jul 14, 2026
Merged

feat(plugin-surrealdb): add SurrealDB driver plugin (#1862)#1868
datlechin merged 3 commits into
mainfrom
feat/surrealdb-driver

Conversation

@datlechin

Copy link
Copy Markdown
Member

Closes #1862.

Adds SurrealDB as a registry-only driver plugin: pure Swift, no C bridge, no static libs, no third-party dependency. It talks POST /rpc over plain HTTP with CBOR, browses namespaces and databases in the sidebar, runs SurrealQL, and edits records inline in the data grid.

Surrealist, the official client, has no inline grid editing at all. Of the other clients, only Beekeeper Studio ships SurrealDB support, and it calls its own implementation early. DataGrip, DBeaver, and TablePlus all have open, unanswered requests. The convention here is not set yet.

Transport: /rpc with CBOR, not /sql with JSON

The docs steer you toward POST /sql with URL query params for binding. That is a trap: on 3.x every URL param binds as a string, so the docs' own SELECT * FROM person WHERE age > $age with ?age=18 matches nothing. POST /rpc also works over plain HTTP (undocumented, but it works on 1.x, 2.x, and 3.x) and takes a typed vars object, which is what makes parameter binding real.

CBOR rather than JSON because JSON is lossy in ways a data grid cannot paper over:

SurrealQL JSON CBOR
person:tobie "person:tobie" Tag(8, ["person","tobie"])
12.345dec "12.345" Tag(10, "12.345")
d"2024-09-15T12:34:56.789Z" string Tag(12, [secs, nanos])
NONE null Tag(6), distinct from NULL

In JSON a record link is indistinguishable from a string, so "go to linked record" cannot work on a schemaless table. CBOR also lets a record ID bind as a typed variable, so UPDATE $rid SET ... needs no version branch. That matters: type::thing was removed in 3.x in favour of type::record.

The codec is hand-written (about 20 tags over a simple binary format), which keeps the native-only rule and stays unit-testable.

Hierarchy: namespace to the database level, database to the schema level

SurrealDB nests namespace > database > table. Both levels are genuinely switchable, so both belong in the sidebar, and neither should force a reconnect. That maps onto TablePro's two container levels the same way PostgreSQL and SQL Server already do.

The one thing missing was a name. DriverPlugin had containerEntityName but nothing for the second level, so a SurrealDB database would have been labelled "Schema" in the picker and in "Use as Active Schema". This PR adds schemaEntityName with a default of "Schema", so SurrealDB reads Namespace > Database.

The ABI gate reports the diff as additive with zero symbols removed, so no currentPluginKitVersion bump and no plugin re-release. Existing plugins keep loading because the Swift runtime fills the new requirement from its default.

While threading it through, "Use as Active Database" turned out to be hardcoded, which already mislabelled Cassandra ("Keyspace") and BigQuery ("Dataset"). Both are now correct.

Queries are self-scoping SurrealQL

The string a driver returns from buildBrowseQuery becomes tab.content.query, which is what the editor shows and what gets persisted. Elasticsearch returns an opaque ELASTICSEARCH_SEARCH:<base64> token because it has no textual browse language. SurrealQL does, so copying that would have shown users base64 as their query.

A SurrealDB query also cannot qualify its database: there is no db.table syntax, only USE. If scoping came from driver session state, two tabs open on different databases would silently read the wrong one, which is #1754 in a new costume. So each query carries its own scope:

USE NS test DB test;
SELECT * FROM person ORDER BY id ASC LIMIT 100 START 0;

Verified on the wire: a leading USE overrides the surreal-ns / surreal-db request headers. The query is readable, editable, and pastes straight into the CLI, and a tab cannot read another database's rows by construction.

Writes set one field at a time

Surrealist saves with UPDATE $id CONTENT $body, replacing the whole record. That silently destroys computed FUTURE fields (surrealist#190) and clobbers concurrent writes to fields the user never touched. This driver emits one parameterized SET per changed cell:

UPDATE $p0 SET age = $p1;      -- $p0 is a typed record ID, $p1 a typed int
CREATE person SET name = $p0;  -- id omitted, so the server mints one
DELETE $p0;

id is immutable. Filter values are escaped literals (the same guarantee every SQL driver here relies on); bound values are typed, so an int column stays an int.

Testing

49 unit tests, plus an end-to-end harness that drove the real driver against SurrealDB 2.6.5 and 3.2.1 in Docker: connect, introspect, browse, filter, count, insert, update, delete, and cross-database scoping. Everything passed on both.

Running against real servers is what caught the version skew the docs never mention, all of which would have shipped as bugs:

  • 2.x emits the synthetic nested-array field as tags[*], 3.x as tags.*. Filtering only on . breaks 2.x.
  • 2.x returns index cols as a string, 3.x as an array.
  • The schemafull flag is full on 2.x and schemafull on 3.x; an optional field reads option<int> on 2.x and none | int on 3.x.
  • The version header is surrealdb-2.6.5 but surrealdb/3.2.1.

It also caught two real bugs, both fixed here: parsing a datetime's fraction through Double turned .789 into 788999969 nanoseconds, and person:`10` (a string id) was re-inferred as the int id 10, which would have made an edit target the wrong record.

Injection is covered at both levels: a filter value of x'; REMOVE TABLE person; -- is stored as data, and the table survives.

Baseline: SurrealDB 2.x and 3.x. 1.x is rejected at connect with a clear message (it needs the dead NS/DB headers and has no surreal-auth-* concept).

Out of scope for v1

Live queries (needs a WebSocket), the schema editor (DEFINE FIELD / DEFINE INDEX do not fit generic ALTER-shaped dialogs), transactions (each RPC call is an independent HTTP request, so BEGIN in one cannot affect another, same reason Cloudflare D1 disables them), and editing range-typed cells.

Note for after merge

Merging does not publish the plugin. It still needs a release, or users get "Plugin not found":

gh workflow run build-plugin.yml --repo TableProApp/TablePro -f tags=plugin-surrealdb-v1.0.0

@datlechin datlechin added the abi-additive PluginKit ABI diff reviewed as additive; no version bump needed label Jul 13, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cadc0fec6b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

client.start()
try await client.probeVersion()
try await client.authenticate()
_ = try await client.query("INFO FOR ROOT;", namespace: nil, database: nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Probe with the selected SurrealDB scope

When the connection is configured for namespace, database, record, or token auth, this post-auth probe runs INFO FOR ROOT without any namespace/database scope. SurrealDB only permits that statement to root-level credentials, so those advertised auth modes fail during connect even though validation/authentication succeeded; use a scope-appropriate RETURN 1/INFO FOR NS/INFO FOR DB probe or skip the root probe for scoped auth.

Useful? React with 👍 / 👎.

// MARK: - Namespaces and databases

func fetchDatabases() async throws -> [String] {
let value = try await info("INFO FOR ROOT;", scope: SurrealScope(namespace: nil, database: nil))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fall back before querying root metadata

After connect, the sidebar/schema service calls fetchDatabases; for any scoped credential that cannot run root-level info, this throws before reaching the fallback to settings.namespace, so non-root users still cannot populate the database tree. In the scoped-auth case, return the configured namespace or use a permitted scoped metadata query before trying INFO FOR ROOT.

Useful? React with 👍 / 👎.

Comment on lines +139 to +140
let variables = parameters.enumerated().map { index, cell in
(key: "p\(index)", value: SurrealCellCoder.value(from: cell))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind the placeholders the app actually emits

For user parameter execution, the app converts :name parameters to the driver's native placeholders (? for the loaded plugin default, or $1 from the built-in SurrealDB metadata) and then calls this method with ordered values. These lines always bind RPC variables named p0, p1 without rewriting the SQL placeholders, so normal parameterized queries run with unbound/invalid placeholders unless the user manually wrote $p0; align the variable names with the converted placeholders or rewrite the query before sending it.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 56fbdb2e35

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


func fetchSchemas() async throws -> [String] {
let scope = SurrealScope(namespace: currentNamespace, database: nil)
let value = try await info("INFO FOR NS;", scope: scope)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fall back for DB-scoped schema lists

When the connection uses database or record-level auth, validation already requires settings.database, but this always runs INFO FOR NS to enumerate every database in the namespace. Database-level users cannot run namespace-level metadata queries, so after connect/fetchDatabases is fixed the schema picker still fails instead of showing the one configured database; return settings.database for DB-scoped auth or use a DB-scoped probe before attempting INFO FOR NS.

Useful? React with 👍 / 👎.

table: table,
scope: scope(forSchema: schema),
columns: columns,
kinds: cachedKinds(for: table),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope cached field kinds by schema

When two SurrealDB databases in the same namespace have the same table name, fetchColumns(table:schema:) caches kinds by table name only, and this edit path later reads only cachedKinds(for: table) even though it receives the target schema. Opening or sampling person in another database can overwrite the cache and cause saves in the first database to coerce edited cells with the wrong field types; include the schema/database in the cache key used here.

Useful? React with 👍 / 👎.

case let .uuid(value):
return "`" + value.uuidString.lowercased() + "`"
default:
return "`" + escapeBackticks(id.displayText) + "`"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve structured record ids when displaying

For records whose id part is an object or array, SurrealDB decodes the id into .recordId(... .object/.array), but this default display path wraps the JSON text in backticks. The grid later reparses that displayed id before UPDATE/DELETE and parseRecordId turns the backticked value into a string id, so edits/deletes target a different record instead of the existing structured-id record; emit and parse structured ids without converting them to strings.

Useful? React with 👍 / 👎.

@mintlify

mintlify Bot commented Jul 13, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Jul 13, 2026, 3:44 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2b94a2e3cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +98 to +101
var payload: [String: String] = ["user": config.username, "pass": config.password]
payload["ns"] = config.namespace
payload["db"] = config.database
payload["ac"] = config.access

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Support configured record signin variables

When Auth Level is Record Access, this hard-codes the /signin body to user and pass plus the scope fields. SurrealDB record access methods define arbitrary SIGNIN variables (a common setup uses $email/$pass, and this PR’s docs describe needing “the signin fields”), but the connection form only lets the user choose the access method and built-in username/password, so those record users cannot authenticate even though Record Access is advertised. The driver needs a way to send the configured signin variable names/values, or it should reject/document the narrower $user/$pass shape.

Useful? React with 👍 / 👎.

@datlechin datlechin merged commit 3406a9d into main Jul 14, 2026
4 checks passed
@datlechin datlechin deleted the feat/surrealdb-driver branch July 14, 2026 06:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

abi-additive PluginKit ABI diff reviewed as additive; no version bump needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Database request: SurrealDB

1 participant