Skip to content

SurrealDB v3 patterns

The 0.1 -> 0.2 window rebased the crate on the surrealdb 3.x driver (previously 2.x). Several of the SurrealQL shapes that worked on v2 are parse errors on v3. This page documents every call-site where surql adapted, so consumers porting their own SurrealQL know what to watch for.

1. Subprotocol handshake

DatabaseClient wraps surrealdb::Surreal<surrealdb::engine::any::Any>. The Any engine picks the transport from the URL at runtime:

URL scheme Engine
ws:// / wss:// WebSocket + RPC subprotocol
http:// / https:// HTTP
mem:// In-process
surrealkv:// SurrealKV embedded
file:// / rocksdb:// Local file stores

The v3 driver negotiates an RPC subprotocol on connect (v2 skipped the handshake). DatabaseClient::classify_surrealdb_error specifically recognises subprotocol in the error message and re-tags the failure as [SurqlError::Connection] so retries use the connection back-off schedule rather than the query back-off schedule.

let client = surql::DatabaseClient::new(config)?;
client.connect().await?;   // handshake happens here, not in ::new.

2. type::thing -> type::record

type::thing(table, id) was renamed to type::record(table, id) in v3. The old name emits:

Invalid function/constant path, did you maybe mean `type::record`

surql ships both helpers so existing queries keep working under either server version:

  • type_record - renders type::record(...). Preferred on v3.
  • type_thing - renders type::thing(...) verbatim. Use when a query plan relies on the literal function name matching.

The migration history recorder uses type::record:

// src/migration/history.rs
let surql = format!(
    "CREATE type::record('{table}', $id) SET {set};",
    table = MIGRATION_TABLE_NAME,
);

3. Datetime coercion

v3 rejects bare ISO-8601 strings for datetime-typed fields with:

Expected `datetime` but found '...'

The fix is to keep the cast explicit in SurrealQL:

// src/migration/history.rs
let mut set = String::from(
    "version = $version, description = $description, \
     applied_at = <datetime> $applied_at, checksum = $checksum",
);

types::coerce provides the inverse - coerce arbitrary serde_json::Values into ISO-8601 strings suitable for this pattern.

4. Unrolled graph-traversal depth

v3 rejects the Python port's depth-templated traversal syntax:

SELECT * FROM <from>->edge{d}-> WHERE id = <to>  -- parse error on v3

The trailing -> leaves no target. shortest_path instead iterates depths and unrolls the arrow chain with SurrealDB's ? wildcard:

// src/query/graph.rs
for depth in 1..=max_depth {
    let mut path = String::new();
    for _ in 0..depth {
        write!(path, "->{edge_table}->?").unwrap();
    }
    let surql = format!(
        "SELECT * FROM {from_record}{path} WHERE id = {to_record} LIMIT 1"
    );
    // ...
}

Incoming edges use FROM <record><-<edge> rather than v2's FROM <-edge<-<record>:

// src/query/graph.rs
Direction::In  => format!("SELECT count() FROM {record}<-{edge_table}"),

5. UPSERT INTO <table> [...] rejected

v3 requires a single target after UPSERT, not an array literal. The Python port's bulk pattern:

UPSERT INTO user [{id: 'user:a', ...}, {id: 'user:b', ...}];

is a parse error. batch::upsert_many iterates per record and emits:

UPSERT <target> CONTENT $data

per row, with the payload bound as a variable. build_upsert_query still emits the Python-compatible statement for logging / preview output that needs byte-for-byte parity.

6. Buffered transactions

The v3 driver does not stream BEGIN / COMMIT / CANCEL as separate query() calls - each query() is an isolated request, and the server rejects a bare COMMIT. Transaction::execute buffers statements client-side and Transaction::commit flushes them as a single atomic request:

BEGIN TRANSACTION;
...buffered statements...
COMMIT TRANSACTION;

Transaction::rollback simply drops the buffer without contacting the server. See crate::connection::transaction for the full API.

Usage is unchanged from the v2 port:

let mut tx = client.begin_transaction().await?;
tx.execute("CREATE user SET name = 'Alice'").await?;
tx.execute("CREATE user SET name = 'Bob'").await?;
tx.commit().await?;

7. Structured Token on signin

v2 returned an opaque Jwt; v3 returns a structured Token. surql transparently re-exports via the upstream surrealdb::opt::auth::Token, so the [AuthManager] cache, refresh loop, and the connection::auth credential types all operate on the new type without requiring any caller changes.

8. SurrealValue envelope avoided

The typed-call envelope on v3 is the SurrealValue trait, which would require T: SurrealValue + Serialize + DeserializeOwned bounds on every typed helper. surql deliberately routes typed CRUD through raw SurrealQL + serde_json::Value, keeping the public bound at just serde::Serialize + serde::de::DeserializeOwned:

pub async fn fetch_one<T: DeserializeOwned>(
    client: &DatabaseClient,
    query: &Query,
) -> Result<Option<T>> { /* ... */ }

This keeps caller code identical between v2 and v3 and avoids a SurrealValue derive on every schema record type.

What's next