SurrealDB connector

Write to SurrealDB with support for normal and relation (graph edge) tables, typed values, array record IDs, bounded transactions, and HNSW vector indexes with cosine / euclidean / manhattan distances.

Version
v 1.0.14
Last reviewed
Nov 4, 2026
ExperimentalThe Rust SDK is experimental. It is outside CocoIndex's compatibility promise: any release, including a minor one, may change or remove its API. It is not yet published on crates.io.

The surrealdb connector provides utilities for writing records to SurrealDB databases, with support for normal tables, relation (graph edge) tables, optional schema enforcement, and vector indexes.

use cocoindex::connectors::surrealdb::{self, ColumnDef, Graph, TableSchema, VectorIndexOptions};

The module is compiled with the surrealdb Cargo feature of the cocoindex crate.

Connection setup

Connect a surrealdb::Graph and provide it under a context key. A Graph holds one authenticated WebSocket connection to a namespace and database, shared by all tables and batches.

Note

The key name is load-bearing across runs — it’s the stable identity CocoIndex uses to track managed rows. See ContextKey as stable identity before renaming.

use cocoindex::connectors::surrealdb::Graph;
use cocoindex::prelude::*;

cocoindex::context_key!(pub static GRAPH: Graph);

#[tokio::main]
async fn main() -> Result<()> {
    // Connect SurrealDB: WebSocket address, namespace, database, root user and password.
    let graph = Graph::connect("127.0.0.1:8000", "test", "test", "root", "root").await?;
    let app = Environment::builder()
        .provide_key(&GRAPH, graph)
        .build()
        .await?
        .app("ConversationToKnowledge")
        .await?;
    // ...
    Ok(())
}

Graph::connect(url, ns, db, user, pass) opens one connection and signs in as a root user. url is a WebSocket server, as host:port or with a ws:// or wss:// scheme (an /rpc path is optional), or mem:// for SurrealDB’s embedded in-memory engine (the surrealdb_mem feature): each mem:// connection is a fresh, private database, which suits tests (connect with Credentials::None, below). A Graph has no close() method; builder.on_shutdown(..) in a #[cocoindex::lifespan] function is the place for teardown.

To sign in as another kind of user, use Graph::connect_with_credentials(url, ns, db, credentials):

let graph = Graph::connect_with_credentials(
    &url,
    &ns,
    &db_name,
    surrealdb::Credentials::Database {
        username: "writer".to_string(),
        password: "writer-pass".to_string(),
    },
)
.await?;

surrealdb::Credentials has one variant per sign-in:

  • Root { username, password } — a root user (what Graph::connect uses).
  • Namespace { username, password } — a user of the connection’s namespace.
  • Database { username, password } — a user of the connection’s database.
  • Record { access, params } — a record user, through the access method access of the connection’s database, with the params (a serde_json::Value) that method expects.
  • None — no sign-in, for a server started without authentication or the embedded mem:// engine.

The graph’s stable identity is the context key’s name; the credentials are not part of any stored state. graph.client() returns the underlying surrealdb::Surreal<surrealdb::engine::any::Any> client, for queries of your own.

As target

The surrealdb connector provides target state APIs for writing records to normal tables and relation tables. CocoIndex tracks what records should exist and automatically handles upserts and deletions.

Writes are sent as typed parameters, never inlined in the statement: top-level values of datetime, uuid, decimal, duration and bytes columns arrive as native SurrealDB types, not strings, and a top-level None is sent as NONE.

Transactions and errors. The changes of each table are applied in chunks of at most 500 actions, each chunk one transaction and one round trip (Transaction conflict errors are retried with backoff, five attempts in all). Atomicity is per chunk, not across tables. Any statement error rolls the chunk back and raises, so the update fails and the affected components are retried on the next run.

Record IDs are any IntoRecordId value: an integer (i8 through i64, u8 through u64, isize, usize), a String or a &str. Relation endpoints (from_id, to_id) take the same forms.

Different from Python

Array record IDs (person:['sap', '0001']) are not supported: IntoRecordId has no tuple or list form.

Ownership. declare_record writes with UPSERT ... SET, one assignment per field, never CONTENT: fields written by other owners survive, and fields you stop declaring are unset. Each declared field’s value is replaced whole, nested objects included. Field names must be identifiers (ASCII letters, digits and _). Use declare_fields when a pipeline contributes only some fields of a record that another pipeline owns. Relation records are single-owner and are replaced whole.

Different from Python

Python writes a record with UPSERT ... MERGE, which merges nested objects. Rust writes SET, one assignment per field, so a nested key a row stops declaring is removed with the rest of the old value.

Server version. A relation record whose fields change is replaced in place. A relation record whose endpoints change (one with an explicit id) is replaced with DELETE and then RELATE in one transaction. SurrealDB 3.2 rejects that with “already exists”; use SurrealDB 3.3.5 or later to move such a relation.

DDL. Table and field definitions use OVERWRITE, so they are idempotent. For a user-managed table (ManagedTargetOptions::user_managed()) CocoIndex never runs DDL on tables (indexes declared with declare_vector_index are still created).

Declaring target states

Normal tables (parent state)

Declares a table as a target state. Returns a TableTarget for declaring records.

pub fn declare_table_target(
    ctx: &Ctx,
    graph: &ContextKey<Graph>,
    table_name: impl Into<String>,
) -> Result<TableTarget>

pub fn declare_table_target_with_schema_and_options(
    ctx: &Ctx,
    graph: &ContextKey<Graph>,
    table_name: impl Into<String>,
    table_schema: Option<TableSchema>,
    options: ManagedTargetOptions,
) -> Result<TableTarget>

declare_table_target declares a schemaless, system-managed table in the current component; its records resolve when the component commits. The mount_* forms (mount_table_target, mount_table_target_with_schema, mount_table_target_with_options, mount_table_target_with_schema_and_options) are async and return a target whose records can be declared at once.

Parameters:

  • graph — The context key of the surrealdb::Graph.
  • table_name — Name of the table.
  • table_schema — Optional schema definition (see Table schema: from a row struct). When provided, the table is SCHEMAFULL; when None, the table is SCHEMALESS.
  • options — ManagedTargetOptions::user_managed() assumes the table exists; the default lets CocoIndex manage its lifecycle.

Returns: A TableTarget. It serializes to its stable key (the graph’s context key and the table name), so it can be an argument of mount!, use_mount!, mount_each! and memoized functions.

Records (child states)

Once a TableTarget is resolved, declare records to be upserted:

pub fn TableTarget::declare_record<R: serde::Serialize>(
    &self,
    ctx: &Ctx,
    id: impl IntoRecordId,
    row: &R,
) -> Result<()>

pub fn TableTarget::declare_row<R: serde::Serialize>(&self, ctx: &Ctx, row: &R) -> Result<()>

declare_record takes the record id separately, so the row struct has no id field. declare_row takes the id from the row’s own scalar id field.

pub fn TableTarget::declare_fields<R: serde::Serialize>(
    &self,
    ctx: &Ctx,
    id: impl IntoRecordId,
    group: &str,
    fields: &R,
) -> Result<()>

fields is a struct or map that serializes to an object of field name to value; it cannot name id. For example:

// Component A owns the record.
players.declare_record(ctx, "ann", &json!({ "name": "Ann" }))?;
// Component B owns the `stats` fields of the same record.
players.declare_fields(ctx, "ann", "stats", &json!({ "score": 7, "rank": 1 }))?;

Declares the fields one owner (group) contributes to a record. It runs UPDATE $id SET … with only these fields and touches nothing else. When the declaration goes away, or a field is dropped from it, those fields are unset. If the record does not exist, the write raises and is retried on the next run, so declare the base record in the same or another pipeline.

Relation tables (parent state)

Declares a relation (graph edge) table. Returns a RelationTarget for declaring relation records.

pub async fn mount_relation_target(
    ctx: &Ctx,
    graph: &ContextKey<Graph>,
    table_name: impl Into<String>,
    from_table: &TableTarget,
    to_table: &TableTarget,
) -> Result<RelationTarget>

pub async fn mount_relation_target_many(
    ctx: &Ctx,
    graph: &ContextKey<Graph>,
    table_name: impl Into<String>,
    from_tables: &[&TableTarget],
    to_tables: &[&TableTarget],
    table_schema: Option<TableSchema>,
) -> Result<RelationTarget>

pub async fn mount_relation_target_unconstrained(
    ctx: &Ctx,
    graph: &ContextKey<Graph>,
    table_name: impl Into<String>,
) -> Result<RelationTarget>

mount_relation_target connects one source table to one target table, schemaless. mount_relation_target_many takes slices of source and target tables, for a polymorphic relation, and an optional schema. mount_relation_target_unconstrained accepts any endpoint tables. Each has a _with_options form that takes ManagedTargetOptions, and a declare_relation_target_* form that declares the table in the current component.

Parameters:

  • graph — The context key of the surrealdb::Graph.
  • table_name — Name of the relation table.
  • from_table / from_tables — Source table(s). mount_relation_target_many takes a slice for polymorphic relations.
  • to_table / to_tables — Target table(s). Same rules as the source.
  • table_schema — Optional schema (mount_relation_target_many only). The schema does not require an id field (unlike normal tables).
  • options — ManagedTargetOptions (_with_options forms): whether CocoIndex manages the table lifecycle.

Returns: A RelationTarget, which serializes to its stable key like a TableTarget.

Relations (child states)

Once a RelationTarget is resolved, declare relation records:

pub fn RelationTarget::declare_relation(
    &self,
    ctx: &Ctx,
    from_id: impl IntoRecordId,
    to_id: impl IntoRecordId,
) -> Result<()>

pub fn RelationTarget::declare_relation_record<R: serde::Serialize>(
    &self,
    ctx: &Ctx,
    from_id: impl IntoRecordId,
    to_id: impl IntoRecordId,
    record: &R,
) -> Result<()>

pub fn RelationTarget::declare_relation_between(
    &self,
    ctx: &Ctx,
    from_table: &str,
    from_id: impl IntoRecordId,
    to_table: &str,
    to_id: impl IntoRecordId,
) -> Result<()>

declare_relation and declare_relation_record need a relation with one source and one target table. A polymorphic relation names the endpoint tables per record with declare_relation_between (or declare_relation_record_between with a record):

session_statement.declare_relation(&ctx, 100_i64, 200_i64)?;
statement_mentions.declare_relation_between(
    &ctx,
    "statement",
    200_i64,
    "person",
    "George He",
)?;

Parameters:

  • from_id — ID of the source record.
  • to_id — ID of the target record.
  • record — Data fields for the relation (declare_relation_record and declare_relation_record_between). The id field is optional: when absent, the record id is a string derived from the two endpoint tables and IDs. Each part is length-prefixed and each endpoint ID carries its type, so distinct endpoints always give distinct ids (doc:'chunk_1' and doc_chunk:'1' differ, as do 1 and '1'). Treat the derived id as opaque. Pass an explicit id if other code needs to know it.
  • from_table / to_table — Required when the relation was declared with multiple (polymorphic) source/target tables (the _between methods).

Vector indexes (attachment)

Declare a vector index on a field of the table. CocoIndex tracks the index spec and automatically creates, recreates, or drops the index as needed.

pub fn TableTarget::declare_vector_index(
    &self,
    ctx: &Ctx,
    field: &str,
    dimension: usize,
    options: VectorIndexOptions,
) -> Result<()>

pub struct VectorIndexOptions {
    /// Index name; defaults to `idx_<table>__<field>`.
    pub name: Option<String>,
    /// Distance metric: `"cosine"`, `"euclidean"`, or `"manhattan"`.
    pub metric: &'static str,
    /// Index method: `"hnsw"` (SurrealDB 3 removed `MTREE`).
    pub method: &'static str,
    /// Vector element type: `"f32"`, `"f64"`, `"i16"`, `"i32"`, or `"i64"`.
    pub vector_type: &'static str,
}

For example:

docs.declare_vector_index(
    &ctx,
    "embedding",
    3,
    VectorIndexOptions {
        metric: "cosine",
        method: "hnsw",
        ..Default::default()
    },
)?;

VectorIndexOptions::default() is metric: "cosine", method: "hnsw", vector_type: "f32" and no name.

Parameters:

  • name — Index name (defaults to idx_{table}__{field}).
  • field — Field to index (must be a vector/array field).
  • metric — Distance metric: "cosine", "euclidean", or "manhattan".
  • method — Index method: "hnsw" (SurrealDB 3 removed MTREE).
  • dimension — Vector dimension (required).
  • vector_type — Vector element type: "f32", "f64", "i16", "i32", or "i64".

Table schema: from a row struct

Define the table structure with a row struct:

pub fn TableSchema::from_row<T: SchemaFields>() -> Result<Self>

The row type derives cocoindex::SchemaFields. The record id is passed to declare_record separately, so the struct has no id field:

#[derive(Serialize, SchemaFields)]
struct Session {
    name: String,
    description: Option<String>,        // option<string>
    recorded_at: chrono::DateTime<chrono::Utc>, // datetime
    #[coco(vector = 384)]
    embedding: Vec<f32>,                // array<float, 384>
}

let sessions = surrealdb::mount_table_target_with_schema(
    ctx, &GRAPH, "session", Some(surrealdb::TableSchema::from_row::<Session>()?),
)
.await?;
sessions.declare_record(ctx, session_id, &session)?;

A vector field needs its dimension: give it in the attribute (#[coco(vector = N)]), or mark the field #[coco(vector)] and resolve the dimension with with_vector_schema (see Column overrides). Declaring a table with a vector dimension still unresolved is an error.

Rust types are automatically mapped to SurrealDB types. An Option<T> field becomes option<…>:

Rust TypeSurrealDB Type
boolbool
i16, i32, i64 (and the unsigned widths)int
f32, f64float
rust_decimal::Decimaldecimal
Stringstring
Vec<u8>bytes
uuid::Uuiduuid
chrono::DateTime, chrono::NaiveDateTimedatetime
chrono::NaiveDatedatetime (midnight UTC)
chrono::NaiveTimestring (ISO time)
std::time::Durationduration
other Vec<T>, maps, nested structs, #[coco(json)]any
#[coco(vector = N)] Vec<f32>array<float, N>

Column overrides

Use a #[coco(..)] attribute on the field to override the default type mapping:

#[derive(Serialize, SchemaFields)]
struct MyRow {
    #[coco(type = "decimal")]
    value: f64,
}

#[coco(type = "…")] sets the SurrealDB type verbatim.

Or override a column of the derived schema. with_column_type(field, surreal_type) replaces the type of one column and keeps its nullability. with_vector_schema(field, provider) types a #[coco(vector)] column as array<float, N> from a VectorSchemaProvider, typically the embedder that fills it (for an embedder held in the context, pass ctx.get_key(&EMBEDDER)?):

#[derive(cocoindex::SchemaFields)]
struct Doc {
    title: String,
    rank: Option<i64>,
    #[coco(vector)]
    embedding: Vec<f32>,
}

let embedder = cocoindex::VectorSchema {
    element_type: cocoindex::VectorElementType::Float32,
    size: 4,
};
let schema = TableSchema::from_row::<Doc>()?
    .with_vector_schema("embedding", &embedder)
    .await?                          // embedding: array<float, 4>
    .with_column_type("rank", "float")?; // rank: option<float>

Both fail if the schema has no column of that name; with_vector_schema also fails on a column that is not a vector.

Table schema: explicit column definitions

Define columns directly using ColumnDef:

pub fn TableSchema::new(
    columns: impl IntoIterator<Item = (impl Into<String>, ColumnDef)>,
) -> Result<Self>

pub fn ColumnDef::new(surreal_type: impl Into<String>) -> Self
pub fn ColumnDef::nullable(self) -> Self

A ColumnDef is not nullable unless .nullable() is called; a nullable column is defined as option<T>. There is no id column: the record id is passed to declare_record.

Example:

let schema = TableSchema::new([
    ("name", ColumnDef::new("string")),
    ("price", ColumnDef::new("float").nullable()),
])?;

Example: Normal tables

use cocoindex::connectors::surrealdb::{self, Graph, TableSchema, VectorIndexOptions};
use cocoindex::prelude::*;

cocoindex::context_key!(pub static GRAPH: Graph);

#[derive(Clone, Debug, Serialize, cocoindex::SchemaFields)]
pub struct SessionRow {
    pub name: String,
    pub description: Option<String>,
    pub transcript: String,
    #[coco(vector = 384)]
    pub embedding: Vec<f32>,
}

pub async fn create_knowledge_base(ctx: &Ctx, processed: &[ProcessedSession]) -> Result<()> {
    // Declare table target state
    let session_target = surrealdb::mount_table_target_with_schema(
        ctx,
        &GRAPH,
        "session",
        Some(surrealdb::TableSchema::from_row::<SessionRow>()?),
    )
    .await?;

    // Declare records
    for ps in processed {
        session_target.declare_record(
            ctx,
            ps.session_id,
            &SessionRow {
                name: ps.name.clone(),
                description: ps.description.clone(),
                transcript: ps.transcript.clone(),
                embedding: ps.embedding.clone(),
            },
        )?;
    }

    // Declare a vector index
    session_target.declare_vector_index(
        ctx,
        "embedding",
        384,
        VectorIndexOptions {
            metric: "cosine",
            method: "hnsw",
            ..Default::default()
        },
    )?;
    Ok(())
}

The full program is examples/rust/conversation_to_knowledge.

Example: Relation tables

#[derive(Clone, Debug, Serialize, cocoindex::SchemaFields)]
pub struct StatementRow {
    pub statement: String,
}

/// A person, tech or org node.
#[derive(Clone, Debug, Serialize, cocoindex::SchemaFields)]
pub struct EntityRow {
    pub name: String,
}

pub async fn create_knowledge_base(ctx: &Ctx, processed: &[ProcessedSession]) -> Result<()> {
    let session_target = surrealdb::mount_table_target_with_schema(
        ctx,
        &GRAPH,
        "session",
        Some(surrealdb::TableSchema::from_row::<SessionRow>()?),
    )
    .await?;
    let statement_target = surrealdb::mount_table_target_with_schema(
        ctx,
        &GRAPH,
        "statement",
        Some(surrealdb::TableSchema::from_row::<StatementRow>()?),
    )
    .await?;
    let person_target = surrealdb::mount_table_target_with_schema(
        ctx,
        &GRAPH,
        "person",
        Some(surrealdb::TableSchema::from_row::<EntityRow>()?),
    )
    .await?;
    let tech_target = surrealdb::mount_table_target_with_schema(
        ctx,
        &GRAPH,
        "tech",
        Some(surrealdb::TableSchema::from_row::<EntityRow>()?),
    )
    .await?;
    // Declare a relation table (schemaless, no id needed)
    let session_statement_target = surrealdb::mount_relation_target(
        ctx,
        &GRAPH,
        "session_statement",
        &session_target,
        &statement_target,
    )
    .await?;
    // A polymorphic relation: a statement mentions a person or a tech.
    let statement_mentions_target = surrealdb::mount_relation_target_many(
        ctx,
        &GRAPH,
        "statement_mentions",
        &[&statement_target],
        &[&person_target, &tech_target],
        None,
    )
    .await?;

    for ps in processed {
        for st in &ps.statements {
            statement_target.declare_record(
                ctx,
                st.id,
                &StatementRow {
                    statement: st.raw.statement.clone(),
                },
            )?;
            // Declare relations — id is auto-derived from endpoints
            session_statement_target.declare_relation(ctx, ps.session_id, st.id)?;
            for name in st.raw.mentioned("person") {
                statement_mentions_target.declare_relation_between(
                    ctx,
                    "statement",
                    st.id,
                    "person",
                    name,
                )?;
            }
        }
    }
    Ok(())
}