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:
surql ships both helpers so existing queries keep working under either server version:
type_record- renderstype::record(...). Preferred on v3.type_thing- renderstype::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:
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:
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>:
5. UPSERT INTO <table> [...] rejected¶
v3 requires a single target after UPSERT, not an array literal. The Python port's bulk pattern:
is a parse error. batch::upsert_many iterates per record and emits:
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:
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:
is rejected (Unexpected token, expected Eof at SEARCH). v3 spells it FULLTEXT:
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:
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 RECORDmethod and carry anidclaim; the engine then binds$authto that record and applies table and fieldPERMISSIONS. A plainTYPE JWTaccess method yields a database-level session that bypasses them, which is whycaller_sessionchecks$authand refuses tokens that bind no record identity. - Externally minted tokens need no
SIGNINorSIGNUPclause 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¶
- Query UX helpers - the 0.2 crate-root additions.
- Migration 0.1 -> 0.2 - upgrade notes.
- API reference - generated rustdoc.