feat(plugin-surrealdb): add SurrealDB driver plugin (#1862)#1868
Conversation
There was a problem hiding this comment.
💡 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) |
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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 👍 / 👎.
| let variables = parameters.enumerated().map { index, cell in | ||
| (key: "p\(index)", value: SurrealCellCoder.value(from: cell)) |
There was a problem hiding this comment.
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 👍 / 👎.
0b85a3e to
56fbdb2
Compare
There was a problem hiding this comment.
💡 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) |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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) + "`" |
There was a problem hiding this comment.
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 👍 / 👎.
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
…DB shows Namespace not a duplicate Database
There was a problem hiding this comment.
💡 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".
| var payload: [String: String] = ["user": config.username, "pass": config.password] | ||
| payload["ns"] = config.namespace | ||
| payload["db"] = config.database | ||
| payload["ac"] = config.access |
There was a problem hiding this comment.
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 👍 / 👎.
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 /rpcover 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:
/rpcwith CBOR, not/sqlwith JSONThe docs steer you toward
POST /sqlwith URL query params for binding. That is a trap: on 3.x every URL param binds as a string, so the docs' ownSELECT * FROM person WHERE age > $agewith?age=18matches nothing.POST /rpcalso works over plain HTTP (undocumented, but it works on 1.x, 2.x, and 3.x) and takes a typedvarsobject, which is what makes parameter binding real.CBOR rather than JSON because JSON is lossy in ways a data grid cannot paper over:
person:tobie"person:tobie"Tag(8, ["person","tobie"])12.345dec"12.345"Tag(10, "12.345")d"2024-09-15T12:34:56.789Z"Tag(12, [secs, nanos])NONEnullTag(6), distinct fromNULLIn 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::thingwas removed in 3.x in favour oftype::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.
DriverPluginhadcontainerEntityNamebut 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 addsschemaEntityNamewith a default of"Schema", so SurrealDB reads Namespace > Database.The ABI gate reports the diff as additive with zero symbols removed, so no
currentPluginKitVersionbump 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
buildBrowseQuerybecomestab.content.query, which is what the editor shows and what gets persisted. Elasticsearch returns an opaqueELASTICSEARCH_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.tablesyntax, onlyUSE. 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:Verified on the wire: a leading
USEoverrides thesurreal-ns/surreal-dbrequest 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 computedFUTUREfields (surrealist#190) and clobbers concurrent writes to fields the user never touched. This driver emits one parameterizedSETper changed cell:idis immutable. Filter values are escaped literals (the same guarantee every SQL driver here relies on); bound values are typed, so anintcolumn stays anint.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:
tags[*], 3.x astags.*. Filtering only on.breaks 2.x.colsas a string, 3.x as an array.fullon 2.x andschemafullon 3.x; an optional field readsoption<int>on 2.x andnone | inton 3.x.surrealdb-2.6.5butsurrealdb/3.2.1.It also caught two real bugs, both fixed here: parsing a datetime's fraction through
Doubleturned.789into788999969nanoseconds, andperson:`10`(a string id) was re-inferred as the int id10, 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/DBheaders and has nosurreal-auth-*concept).Out of scope for v1
Live queries (needs a WebSocket), the schema editor (
DEFINE FIELD/DEFINE INDEXdo not fit generic ALTER-shaped dialogs), transactions (each RPC call is an independent HTTP request, soBEGINin 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":