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.

9. Full-text index renamed SEARCH -> FULLTEXT

v3 renamed the full-text index keyword. The v1/v2 form:

DEFINE INDEX idx ON TABLE t COLUMNS content SEARCH ANALYZER ascii BM25;  -- parse error on v3

is rejected (Unexpected token, expected Eof at SEARCH). v3 spells it FULLTEXT:

DEFINE INDEX idx ON TABLE t COLUMNS content FULLTEXT ANALYZER ascii BM25 HIGHLIGHTS;

surql emits the FULLTEXT keyword from IndexType::Search / search_index / [bm25_index]; the analyzer is defined separately with [generate_analyzer_sql] (DEFINE ANALYZER ...), which must run before the index that references it. COLUMNS and FIELDS are interchangeable in this statement.

Bare BM25 uses the engine defaults (k1 = 1.2, b = 0.75); the analyzer defaults to like if the clause is omitted (so define + name one explicitly for lexical recall).

search::score and scan ordering

The full-text index decides WHICH rows match. It does not rank them.

search::score(<ref>) returns 0 for every row, and the scan yields matches in insertion order, not relevance order. Measured on 3.2.3 across every form the engine accepts: a bare scan, the score projected, ORDER BY the projected alias, the @@ operator with no reference number, and an index defined BM25 HIGHLIGHTS. None ranks. ORDER BY search::score(1) is a parse error (Missing order idiom search in statement selection).

The way to see it: seed the same two documents in both insertion orders and compare the result order. It mirrors the input.

A consumer that needs relevance has to compute it. The practical shape is the one production search already uses: let the index select a bounded candidate window, then rescore that window locally, matching the analyzer's tokenizer and stemmer so local scoring agrees with what the index matched.

// The sparse leg of hybrid retrieval: matches, NOT yet ranked.
let q = surql::query::helpers::fulltext_search_query(
    "memory", "content", 1, "insider buying", None, "score",
)?
.limit(500)?;   // a rescoring window, not the answer
// Score the window locally, then fuse ranks with the dense leg.

An earlier revision of this document said the scan returned rows in BM25 order. That was wrong.

v3 also ships a native search::rrf([$dense, $sparse], k, 60) function that fuses two result lists server-side, if you prefer in-engine fusion.

10. The KNN operator's second operand picks the plan

<|k,...|> takes a second operand that decides how the search runs:

embedding <|10,64|>     [...]   -- KnnScan: uses the field's vector index
embedding <|10,COSINE|> [...]   -- KnnTopK over TableScan: compares every row

An INTEGER is the HNSW search effort (ef) and selects the index. A metric name makes the engine brute-force the table, which is correct and gets slower with every row inserted. The bare <|k|> form v1/v2 accepted is gone in v3.

[Query::vector_search] renders the metric form, so it remains right for a field with no vector index. Use [Query::vector_search_indexed] whenever the field carries an HNSW or DiskANN index and let the index's own metric apply:

let q = surql::query::Query::new()
    .select(None)
    .from_table("chunk")?
    .vector_search_indexed("embedding", query_vector, 10, 64)?;

There is no distance threshold on this operator in v3. A float in the second position is refused outright (only integers are allowed here), and there is no third position. For a relevance floor, project the real distance and filter on it in the same WHERE:

SELECT * FROM chunk
WHERE embedding <|100,64|> $q AND vector::distance::knn() <= 0.65
LIMIT 10

vector::distance::knn() reads the distance the KNN operator already computed, so the filter costs nothing extra. Cosine distances run 0.0 for identical vectors, 0.2929 at 45 degrees, and 1.0 for orthogonal ones.

11. Surreal::clone opens a new session, and dropping it kills that session's live queries

In v3 the SDK gives every Surreal clone its own session id, and Drop sends that session id away:

fn clone(&self) -> Self {
    let session_id = Uuid::new_v4();
    self.inner.clone_session(self.session_id, session_id);
    // ...
}

A live query belongs to the session that ran the LIVE SELECT. So a subscription opened through a handle that later drops goes quiet: no error, no closed stream, just silence. It is easy to hit without noticing, because a request handler that clones shared state, starts a subscription, and returns the stream has done exactly this.

[LiveQuery] handles it: it clones the client, issues the statement through that clone, and keeps it. Code that talks to the SDK directly has to hold the same handle that ran the statement. Cloning afterwards does not work, because the new clone is a different session.

The session churn also loses events: the SDK announces every clone and drop to the connection router over a side channel, and under multi-threaded request traffic the remote router can process a query before the session event that should precede it, which fails the query with Session not found. DatabaseClient therefore shares one session across its clones and mints new sessions only on request.

12. Sessions carry authority, so one connection can serve many callers

Section 11's session-per-clone behaviour cuts both ways. The same mechanism that silently kills live queries also means one connection can hold sessions with different authority at the same time: clone a handle, authenticate a token on the clone, and the engine evaluates that session against the token's actor while the original handle keeps its own.

For a service that fronts many callers over one root connection, that turns PERMISSIONS clauses from dead weight into a second enforcement layer. DatabaseClient::caller_session packages the pattern:

let caller = client.caller_session(&token).await?;
let rows = caller.query("SELECT * FROM doc;").await?; // engine-filtered
drop(caller); // the session ends with the client

The facts underneath, all engine-verified:

  • Only RECORD sessions are filtered. The token must come from a DEFINE ACCESS ... TYPE RECORD method and carry an id claim; the engine then binds $auth to that record and applies table and field PERMISSIONS. A plain TYPE JWT access method yields a database-level session that bypasses them, which is why caller_session checks $auth and refuses tokens that bind no record identity.
  • Externally minted tokens need no SIGNIN or SIGNUP clause on the access method. Sign the claims with the declared key and the engine verifies them directly.
  • Enforcement follows the actor. Engine credentials decide only what anonymous sessions may do: without them every anonymous session acts as owner, but an authenticated record session is constrained either way. For embedded engines the connection settings' username and password now reach the datastore at build time, so a locked engine is reachable from configuration alone.
  • Engine refusals are silent. A write a table forbids returns empty rows with no error, so the application layer stays the face that explains refusals and the engine acts as a backstop.

What's next