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.
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.
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 (whatGraph::connectuses).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 methodaccessof the connection’s database, with theparams(aserde_json::Value) that method expects.None— no sign-in, for a server started without authentication or the embeddedmem://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.
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.
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 thesurrealdb::Graph.table_name— Name of the table.table_schema— Optional schema definition (see Table schema: from a row struct). When provided, the table isSCHEMAFULL; whenNone, the table isSCHEMALESS.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 thesurrealdb::Graph.table_name— Name of the relation table.from_table/from_tables— Source table(s).mount_relation_target_manytakes a slice for polymorphic relations.to_table/to_tables— Target table(s). Same rules as the source.table_schema— Optional schema (mount_relation_target_manyonly). The schema does not require anidfield (unlike normal tables).options—ManagedTargetOptions(_with_optionsforms): 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_recordanddeclare_relation_record_between). Theidfield 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'anddoc_chunk:'1'differ, as do1and'1'). Treat the derived id as opaque. Pass an explicitidif other code needs to know it.from_table/to_table— Required when the relation was declared with multiple (polymorphic) source/target tables (the_betweenmethods).
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 toidx_{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 removedMTREE).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 Type | SurrealDB Type |
|---|---|
bool | bool |
i16, i32, i64 (and the unsigned widths) | int |
f32, f64 | float |
rust_decimal::Decimal | decimal |
String | string |
Vec<u8> | bytes |
uuid::Uuid | uuid |
chrono::DateTime, chrono::NaiveDateTime | datetime |
chrono::NaiveDate | datetime (midnight UTC) |
chrono::NaiveTime | string (ISO time) |
std::time::Duration | duration |
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) -> SelfA 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(())
}