This is an automated email from the ASF dual-hosted git repository.

hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 6f68d921b feat(connectors): add SurrealDB sink connector (#3453)
6f68d921b is described below

commit 6f68d921b16e757a0e73403bd9ee11b25bad1986
Author: Diaconu Radu-Mihai <[email protected]>
AuthorDate: Thu Jul 9 16:32:06 2026 +0300

    feat(connectors): add SurrealDB sink connector (#3453)
---
 .github/workflows/_build_rust_artifacts.yml        |    2 +-
 .github/workflows/edge-release.yml                 |    1 +
 Cargo.lock                                         |   18 +
 Cargo.toml                                         |    1 +
 core/connectors/README.md                          |    1 +
 core/connectors/sinks/README.md                    |    1 +
 core/connectors/sinks/surrealdb_sink/Cargo.toml    |   49 +
 core/connectors/sinks/surrealdb_sink/README.md     |   99 +
 core/connectors/sinks/surrealdb_sink/config.toml   |   58 +
 core/connectors/sinks/surrealdb_sink/src/lib.rs    | 1970 ++++++++++++++++++++
 core/integration/tests/connectors/fixtures/mod.rs  |    5 +
 .../connectors/fixtures/surrealdb/container.rs     |  330 ++++
 .../tests/connectors/fixtures/surrealdb/mod.rs     |   25 +
 .../tests/connectors/fixtures/surrealdb/sink.rs    |  220 +++
 core/integration/tests/connectors/mod.rs           |    1 +
 core/integration/tests/connectors/surrealdb/mod.rs |   23 +
 .../tests/connectors/surrealdb/sink.toml           |   20 +
 .../tests/connectors/surrealdb/surrealdb_sink.rs   |  354 ++++
 scripts/bump-version.sh                            |    2 +-
 19 files changed, 3178 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/_build_rust_artifacts.yml 
b/.github/workflows/_build_rust_artifacts.yml
index b1de35e89..d7d7d6f11 100644
--- a/.github/workflows/_build_rust_artifacts.yml
+++ b/.github/workflows/_build_rust_artifacts.yml
@@ -46,7 +46,7 @@ on:
       connector_plugins:
         type: string
         required: false
-        default: 
"iggy_connector_elasticsearch_sink,iggy_connector_elasticsearch_source,iggy_connector_iceberg_sink,iggy_connector_postgres_sink,iggy_connector_postgres_source,iggy_connector_quickwit_sink,iggy_connector_random_source,iggy_connector_s3_sink,iggy_connector_stdout_sink"
+        default: 
"iggy_connector_elasticsearch_sink,iggy_connector_elasticsearch_source,iggy_connector_iceberg_sink,iggy_connector_postgres_sink,iggy_connector_postgres_source,iggy_connector_quickwit_sink,iggy_connector_random_source,iggy_connector_s3_sink,iggy_connector_stdout_sink,iggy_connector_surrealdb_sink"
         description: "Comma-separated list of connector plugin crates to build 
as shared libraries"
     outputs:
       artifact_name:
diff --git a/.github/workflows/edge-release.yml 
b/.github/workflows/edge-release.yml
index 96d19e1b5..2572ed7f1 100644
--- a/.github/workflows/edge-release.yml
+++ b/.github/workflows/edge-release.yml
@@ -110,6 +110,7 @@ jobs:
             - `iggy_connector_random_source`
             - `iggy_connector_s3_sink`
             - `iggy_connector_stdout_sink`
+            - `iggy_connector_surrealdb_sink`
 
             ## Downloads
 
diff --git a/Cargo.lock b/Cargo.lock
index ed13f356d..d678d7145 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7161,6 +7161,24 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "iggy_connector_surrealdb_sink"
+version = "0.4.1-edge.1"
+dependencies = [
+ "async-trait",
+ "base64",
+ "bytes",
+ "iggy_common",
+ "iggy_connector_sdk",
+ "reqwest 0.13.4",
+ "secrecy",
+ "serde",
+ "serde_json",
+ "simd-json",
+ "tokio",
+ "tracing",
+]
+
 [[package]]
 name = "iggy_examples"
 version = "0.0.6"
diff --git a/Cargo.toml b/Cargo.toml
index 5ef321639..5990f44e7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -44,6 +44,7 @@ members = [
     "core/connectors/sinks/quickwit_sink",
     "core/connectors/sinks/s3_sink",
     "core/connectors/sinks/stdout_sink",
+    "core/connectors/sinks/surrealdb_sink",
     "core/connectors/sources/elasticsearch_source",
     "core/connectors/sources/influxdb_source",
     "core/connectors/sources/postgres_source",
diff --git a/core/connectors/README.md b/core/connectors/README.md
index 2d26715fa..64bfc9a1f 100644
--- a/core/connectors/README.md
+++ b/core/connectors/README.md
@@ -87,6 +87,7 @@ Each sink should have its own, custom configuration, which is 
passed along with
 - **Quickwit Sink** - indexes messages in Quickwit search engine
 - **S3 Sink** - writes messages to Amazon S3 and S3-compatible stores (MinIO, 
R2, B2, DO Spaces)
 - **Stdout Sink** - prints messages to standard output (useful for 
debugging/development)
+- **SurrealDB Sink** - writes messages into SurrealDB with deterministic 
record IDs for idempotent replay
 
 ## Source
 
diff --git a/core/connectors/sinks/README.md b/core/connectors/sinks/README.md
index 497617f22..57ea05549 100644
--- a/core/connectors/sinks/README.md
+++ b/core/connectors/sinks/README.md
@@ -16,6 +16,7 @@ Sink connectors are responsible for writing data from Iggy 
streams to external s
 | **quickwit_sink** | Indexes messages in Quickwit search engine for log 
analytics |
 | **s3_sink** | Writes messages to Amazon S3 and S3-compatible stores (MinIO, 
R2, B2, DO Spaces) |
 | **stdout_sink** | Prints messages to standard output (useful for debugging 
and development) |
+| **surrealdb_sink** | Writes messages into SurrealDB with deterministic 
record IDs for idempotent replay |
 
 The sink is represented by the single `Sink` trait, which defines the basic 
interface for all sink connectors. It provides methods for initializing the 
sink, writing data to external destination, and closing the sink.
 
diff --git a/core/connectors/sinks/surrealdb_sink/Cargo.toml 
b/core/connectors/sinks/surrealdb_sink/Cargo.toml
new file mode 100644
index 000000000..1ad27d71d
--- /dev/null
+++ b/core/connectors/sinks/surrealdb_sink/Cargo.toml
@@ -0,0 +1,49 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[package]
+name = "iggy_connector_surrealdb_sink"
+version = "0.4.1-edge.1"
+description = "Iggy SurrealDB sink connector for writing stream messages into 
SurrealDB"
+edition = "2024"
+license = "Apache-2.0"
+keywords = ["iggy", "messaging", "streaming", "surrealdb", "sink"]
+categories = ["command-line-utilities", "database", "network-programming"]
+homepage = "https://iggy.apache.org";
+documentation = "https://iggy.apache.org/docs";
+repository = "https://github.com/apache/iggy";
+readme = "../../README.md"
+publish = false
+
+[lib]
+crate-type = ["cdylib", "lib"]
+
+[dependencies]
+async-trait = { workspace = true }
+base64 = { workspace = true }
+bytes = { workspace = true }
+iggy_common = { workspace = true }
+iggy_connector_sdk = { workspace = true }
+reqwest = { workspace = true }
+secrecy = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+
+[dev-dependencies]
+simd-json = { workspace = true }
diff --git a/core/connectors/sinks/surrealdb_sink/README.md 
b/core/connectors/sinks/surrealdb_sink/README.md
new file mode 100644
index 000000000..ba2a5e039
--- /dev/null
+++ b/core/connectors/sinks/surrealdb_sink/README.md
@@ -0,0 +1,99 @@
+# SurrealDB Sink Connector
+
+Writes Apache Iggy stream messages into SurrealDB over the HTTP API.
+
+The sink writes one SurrealQL bulk `INSERT IGNORE` per connector batch. Each
+record uses a deterministic SurrealDB record id derived from stream, topic,
+partition, offset and Iggy message id, so replayed batches are idempotent and
+existing records are left untouched.
+
+Persistent sink failures are at-most-once from the runtime's perspective:
+messages may already be committed in Iggy before this connector exhausts its
+write attempts, so failed writes are logged but not redelivered.
+
+## Configuration
+
+```toml
+type = "sink"
+key = "surrealdb"
+enabled = true
+version = 0
+name = "SurrealDB sink"
+path = "../../target/release/libiggy_connector_surrealdb_sink"
+plugin_config_format = "toml"
+
+[[streams]]
+stream = "example_stream"
+topics = ["example_topic"]
+schema = "json"
+batch_length = 1000
+poll_interval = "5ms"
+consumer_group = "surrealdb_sink_connector"
+
+[plugin_config]
+endpoint = "127.0.0.1:8000"
+namespace = "iggy"
+database = "connectors"
+table = "iggy_messages"
+username = "root"
+password = "root"
+auth_scope = "root"
+use_tls = false
+auto_define_table = true
+define_indexes = true
+batch_size = 1000
+payload_format = "auto"
+include_metadata = true
+include_headers = true
+include_checksum = true
+include_origin_timestamp = true
+query_timeout = "30s"
+max_retries = 3
+retry_delay = "100ms"
+max_retry_delay = "5s"
+verbose_logging = false
+```
+
+### Plugin Fields
+
+| Field | Default | Description |
+| --- | --- | --- |
+| `endpoint` | required | SurrealDB HTTP host and port without scheme, for 
example `127.0.0.1:8000`. Full `http://` or `https://` URLs are also accepted. |
+| `namespace` | required | SurrealDB namespace selected during `open()`. |
+| `database` | required | SurrealDB database selected during `open()`. |
+| `table` | required | Target table. Must be a safe SurrealQL identifier. |
+| `username` / `password` | none | Optional credentials. |
+| `auth_scope` | `root` | `root`, `namespace`, `database`, or `none`. |
+| `use_tls` | `false` | Uses `https://` when true and `endpoint` has no 
scheme, `http://` otherwise. |
+| `auto_define_table` | `false` | Runs `DEFINE TABLE IF NOT EXISTS <table> 
SCHEMALESS`. |
+| `define_indexes` | `false` | Defines an offset index on 
stream/topic/partition/offset. Requires `auto_define_table`. |
+| `batch_size` | `1000` | Maximum number of records per SurrealDB request. |
+| `payload_format` | `auto` | `auto`, `json`, `text`, `base64`, or `binary` 
(`binary` is an alias for `base64`). |
+| `include_metadata` | `true` | Stores 
stream/topic/partition/offset/timestamps/schema fields. |
+| `include_headers` | `true` | Stores Iggy headers as a deterministic object. 
Raw headers are base64 encoded. |
+| `include_checksum` | `true` | Stores `iggy_checksum`. |
+| `include_origin_timestamp` | `true` | Stores `iggy_origin_timestamp`. |
+| `query_timeout` | `30s` | SurrealDB HTTP request timeout. |
+| `max_retries` | `3` | Total attempts for transient write failures. Values 
below `1` are raised to `1`. |
+| `retry_delay` | `100ms` | Base retry delay. |
+| `max_retry_delay` | `5s` | Capped exponential retry delay. |
+| `verbose_logging` | `false` | Emits per-batch success logs at `info`. |
+
+## Stored Shape
+
+With metadata enabled, records contain:
+
+- `id`: deterministic SurrealDB record id key
+- `iggy_message_id`: original Iggy message id as a string
+- `iggy_stream`, `iggy_topic`, `iggy_partition_id`, `iggy_offset`
+- `iggy_timestamp`, `iggy_origin_timestamp`, `iggy_checksum`, `iggy_schema`
+- `iggy_headers`
+- `payload`
+- `payload_encoding`
+
+`payload_format = "auto"` stores decoded JSON payloads as queryable SurrealDB
+values, text payloads as strings, and binary payloads as base64 strings.
+
+The `messages_processed` counter reports valid records submitted to SurrealDB.
+With `INSERT IGNORE`, duplicates can be ignored by SurrealDB while still being
+counted as submitted.
diff --git a/core/connectors/sinks/surrealdb_sink/config.toml 
b/core/connectors/sinks/surrealdb_sink/config.toml
new file mode 100644
index 000000000..dd37160c6
--- /dev/null
+++ b/core/connectors/sinks/surrealdb_sink/config.toml
@@ -0,0 +1,58 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+type = "sink"
+key = "surrealdb"
+enabled = true
+version = 0
+name = "SurrealDB sink"
+path = "../../target/release/libiggy_connector_surrealdb_sink"
+plugin_config_format = "toml"
+verbose = false
+benchmark = false
+
+[[streams]]
+stream = "example_stream"
+topics = ["example_topic"]
+schema = "json"
+batch_length = 1000
+poll_interval = "5ms"
+consumer_group = "surrealdb_sink_connector"
+
+[plugin_config]
+endpoint = "127.0.0.1:8000"
+namespace = "iggy"
+database = "connectors"
+table = "iggy_messages"
+username = "root"
+password = "root"
+auth_scope = "root"
+use_tls = false
+auto_define_table = true
+define_indexes = true
+batch_size = 1000
+payload_format = "auto"
+include_metadata = true
+include_headers = true
+include_checksum = true
+include_origin_timestamp = true
+query_timeout = "30s"
+# Total write attempts for transient failures. Minimum is 1. A value of 0 is 
raised to 1.
+max_retries = 3
+retry_delay = "100ms"
+max_retry_delay = "5s"
+verbose_logging = false
diff --git a/core/connectors/sinks/surrealdb_sink/src/lib.rs 
b/core/connectors/sinks/surrealdb_sink/src/lib.rs
new file mode 100644
index 000000000..6e354e9ae
--- /dev/null
+++ b/core/connectors/sinks/surrealdb_sink/src/lib.rs
@@ -0,0 +1,1970 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use async_trait::async_trait;
+use base64::Engine;
+use base64::engine::general_purpose;
+use bytes::Bytes;
+use iggy_connector_sdk::convert::owned_value_to_serde_json;
+use iggy_connector_sdk::retry::{exponential_backoff, jitter, parse_duration};
+use iggy_connector_sdk::{
+    ConsumedMessage, Error, MessagesMetadata, Payload, Sink, TopicMetadata, 
sink_connector,
+};
+use reqwest::{Body, Client as HttpClient, RequestBuilder, StatusCode, Url};
+use secrecy::{ExposeSecret, SecretString};
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value, json};
+use std::fmt;
+use std::fmt::Write;
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::time::Duration;
+use tokio::sync::Mutex;
+use tracing::{debug, error, info, warn};
+
+sink_connector!(SurrealDbSink);
+
+const DEFAULT_BATCH_SIZE: usize = 1000;
+const DEFAULT_QUERY_TIMEOUT: &str = "30s";
+const DEFAULT_MAX_RETRIES: u32 = 3;
+const DEFAULT_RETRY_DELAY: &str = "100ms";
+const DEFAULT_MAX_RETRY_DELAY: &str = "5s";
+const ENCODING_BASE64: &str = "base64";
+const ENCODING_JSON: &str = "json";
+const ENCODING_TEXT: &str = "text";
+
+type SurrealDbClient = HttpClient;
+
+#[derive(Debug)]
+pub struct SurrealDbSink {
+    id: u32,
+    client: Mutex<Option<SurrealDbClient>>,
+    reconnecting: AtomicBool,
+    base_url: String,
+    endpoint: String,
+    namespace: String,
+    database: String,
+    table: String,
+    username: Option<String>,
+    password: Option<SecretString>,
+    auth_scope_config: Option<String>,
+    auth_scope: AuthScope,
+    payload_format_config: Option<String>,
+    payload_format: PayloadFormat,
+    use_tls: bool,
+    batch_size: usize,
+    query_timeout: Duration,
+    max_retries: u32,
+    retry_delay: Duration,
+    max_retry_delay: Duration,
+    include_metadata: bool,
+    include_headers: bool,
+    include_checksum: bool,
+    include_origin_timestamp: bool,
+    auto_define_table: bool,
+    define_indexes: bool,
+    verbose: bool,
+    messages_processed: AtomicU64,
+    insertion_errors: AtomicU64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SurrealDbSinkConfig {
+    pub endpoint: String,
+    pub namespace: String,
+    pub database: String,
+    pub table: String,
+    pub username: Option<String>,
+    #[serde(serialize_with = 
"iggy_common::serde_secret::serialize_optional_secret")]
+    pub password: Option<SecretString>,
+    pub auth_scope: Option<String>,
+    pub use_tls: Option<bool>,
+    pub auto_define_table: Option<bool>,
+    pub define_indexes: Option<bool>,
+    pub batch_size: Option<u32>,
+    pub payload_format: Option<String>,
+    pub include_metadata: Option<bool>,
+    pub include_headers: Option<bool>,
+    pub include_checksum: Option<bool>,
+    pub include_origin_timestamp: Option<bool>,
+    pub query_timeout: Option<String>,
+    pub max_retries: Option<u32>,
+    pub retry_delay: Option<String>,
+    pub max_retry_delay: Option<String>,
+    pub verbose_logging: Option<bool>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum AuthScope {
+    Root,
+    Namespace,
+    Database,
+    None,
+}
+
+impl AuthScope {
+    fn parse_config(value: Option<&str>) -> Result<Self, Error> {
+        match value {
+            Some(value) if value.eq_ignore_ascii_case("namespace") => 
Ok(AuthScope::Namespace),
+            Some(value) if value.eq_ignore_ascii_case("database") => 
Ok(AuthScope::Database),
+            Some(value) if value.eq_ignore_ascii_case("none") => 
Ok(AuthScope::None),
+            Some(value) if value.eq_ignore_ascii_case("root") => 
Ok(AuthScope::Root),
+            Some(value) => Err(Error::InvalidConfigValue(format!(
+                "SurrealDB auth_scope must be one of root, namespace, 
database, or none: {value}"
+            ))),
+            None => Ok(AuthScope::Root),
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum PayloadFormat {
+    Auto,
+    Json,
+    Text,
+    Base64,
+}
+
+impl PayloadFormat {
+    fn parse_config(value: Option<&str>) -> Result<Self, Error> {
+        match value {
+            Some(value) if value.eq_ignore_ascii_case("json") => 
Ok(PayloadFormat::Json),
+            Some(value) if value.eq_ignore_ascii_case("text") => 
Ok(PayloadFormat::Text),
+            Some(value) if value.eq_ignore_ascii_case("base64") => 
Ok(PayloadFormat::Base64),
+            Some(value) if value.eq_ignore_ascii_case("binary") => 
Ok(PayloadFormat::Base64),
+            Some(value) if value.eq_ignore_ascii_case("auto") => 
Ok(PayloadFormat::Auto),
+            Some(value) => Err(Error::InvalidConfigValue(format!(
+                "SurrealDB payload_format must be one of auto, json, text, 
base64, or binary: {value}"
+            ))),
+            None => Ok(PayloadFormat::Auto),
+        }
+    }
+}
+
+#[derive(Debug)]
+struct PayloadDocument {
+    value: Value,
+    encoding: &'static str,
+}
+
+#[derive(Debug)]
+struct BatchInsertOutcome {
+    inserted_count: u64,
+    error_count: u64,
+    error: Option<Error>,
+}
+
+#[derive(Debug, Deserialize)]
+struct SurrealSqlStatement {
+    status: String,
+    detail: Option<String>,
+    result: Option<Value>,
+}
+
+#[derive(Debug)]
+enum SurrealDbRequestError {
+    Request(reqwest::Error),
+    HttpStatus { status: StatusCode, body: String },
+    Query(String),
+    Decode(String),
+}
+
+impl fmt::Display for SurrealDbRequestError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            SurrealDbRequestError::Request(error) => write!(formatter, 
"{error}"),
+            SurrealDbRequestError::HttpStatus { status, body } => {
+                write!(formatter, "HTTP status {status}: {body}")
+            }
+            SurrealDbRequestError::Query(message) | 
SurrealDbRequestError::Decode(message) => {
+                formatter.write_str(message)
+            }
+        }
+    }
+}
+
+impl SurrealDbSink {
+    pub fn new(id: u32, config: SurrealDbSinkConfig) -> Self {
+        let endpoint = config.endpoint.clone();
+        let namespace = config.namespace.clone();
+        let database = config.database.clone();
+        let table = config.table.clone();
+        let username = config.username.clone();
+        let password = config.password.clone();
+        let auth_scope_config = config.auth_scope.clone();
+        let payload_format_config = config.payload_format.clone();
+        let use_tls = config.use_tls.unwrap_or(false);
+        let base_url = build_base_url(&endpoint, use_tls);
+        let batch_size = config
+            .batch_size
+            .unwrap_or(DEFAULT_BATCH_SIZE as u32)
+            .max(1) as usize;
+        let query_timeout = parse_duration(config.query_timeout.as_deref(), 
DEFAULT_QUERY_TIMEOUT);
+        let retry_delay = parse_duration(config.retry_delay.as_deref(), 
DEFAULT_RETRY_DELAY);
+        let mut max_retry_delay =
+            parse_duration(config.max_retry_delay.as_deref(), 
DEFAULT_MAX_RETRY_DELAY);
+        let max_retries = match config.max_retries {
+            Some(0) => {
+                warn!("SurrealDB sink ID: {id} max_retries must be at least 1. 
Using 1 attempt.");
+                1
+            }
+            Some(max_retries) => max_retries,
+            None => DEFAULT_MAX_RETRIES,
+        };
+        if max_retry_delay < retry_delay {
+            warn!(
+                "SurrealDB sink ID: {id} max_retry_delay is smaller than 
retry_delay. Using retry_delay as max_retry_delay."
+            );
+            max_retry_delay = retry_delay;
+        }
+        let include_metadata = config.include_metadata.unwrap_or(true);
+        let include_headers = config.include_headers.unwrap_or(true);
+        let include_checksum = config.include_checksum.unwrap_or(true);
+        let include_origin_timestamp = 
config.include_origin_timestamp.unwrap_or(true);
+        let auto_define_table = config.auto_define_table.unwrap_or(false);
+        let define_indexes = config.define_indexes.unwrap_or(false);
+        let verbose = config.verbose_logging.unwrap_or(false);
+
+        SurrealDbSink {
+            id,
+            client: Mutex::new(None),
+            reconnecting: AtomicBool::new(false),
+            base_url,
+            endpoint,
+            namespace,
+            database,
+            table,
+            username,
+            password,
+            auth_scope_config,
+            auth_scope: AuthScope::Root,
+            payload_format_config,
+            payload_format: PayloadFormat::Auto,
+            use_tls,
+            batch_size,
+            query_timeout,
+            max_retries,
+            retry_delay,
+            max_retry_delay,
+            include_metadata,
+            include_headers,
+            include_checksum,
+            include_origin_timestamp,
+            auto_define_table,
+            define_indexes,
+            verbose,
+            messages_processed: AtomicU64::new(0),
+            insertion_errors: AtomicU64::new(0),
+        }
+    }
+}
+
+#[async_trait]
+impl Sink for SurrealDbSink {
+    async fn open(&mut self) -> Result<(), Error> {
+        self.auth_scope = 
AuthScope::parse_config(self.auth_scope_config.as_deref())?;
+        self.payload_format = 
PayloadFormat::parse_config(self.payload_format_config.as_deref())?;
+        validate_endpoint_config(&self.endpoint, self.use_tls)?;
+        validate_identifier("namespace", &self.namespace)?;
+        validate_identifier("database", &self.database)?;
+        validate_identifier("table", &self.table)?;
+
+        if self.auto_define_table && self.auth_scope != AuthScope::Root {
+            return Err(Error::InvalidConfigValue(
+                "SurrealDB auto_define_table requires auth_scope=root because 
namespace/database DDL is executed"
+                    .to_string(),
+            ));
+        }
+
+        if self.define_indexes && !self.include_metadata {
+            return Err(Error::InvalidConfigValue(
+                "SurrealDB define_indexes requires include_metadata=true 
because indexes use metadata fields"
+                    .to_string(),
+            ));
+        }
+
+        if self.define_indexes && !self.auto_define_table {
+            warn!(
+                "SurrealDB sink ID: {} define_indexes=true requires 
auto_define_table=true; index DDL will not run.",
+                self.id
+            );
+        }
+
+        info!(
+            "Opening SurrealDB sink connector with ID: {}. Endpoint: {}, 
namespace: {}, database: {}, table: {}",
+            self.id, self.base_url, self.namespace, self.database, self.table
+        );
+
+        let client = self.connect_and_select().await?;
+        *self.client.lock().await = Some(client);
+        info!(
+            "Opened SurrealDB sink connector ID: {} for table: {}",
+            self.id, self.table
+        );
+        Ok(())
+    }
+
+    async fn consume(
+        &self,
+        topic_metadata: &TopicMetadata,
+        messages_metadata: MessagesMetadata,
+        messages: Vec<ConsumedMessage>,
+    ) -> Result<(), Error> {
+        self.process_messages(topic_metadata, &messages_metadata, messages)
+            .await
+    }
+
+    async fn close(&mut self) -> Result<(), Error> {
+        info!("Closing SurrealDB sink connector with ID: {}", self.id);
+        self.client.get_mut().take();
+
+        let messages_processed = 
self.messages_processed.load(Ordering::Relaxed);
+        let insertion_errors = self.insertion_errors.load(Ordering::Relaxed);
+        info!(
+            "SurrealDB sink ID: {} processed {} messages with {} errors",
+            self.id, messages_processed, insertion_errors
+        );
+        Ok(())
+    }
+}
+
+impl SurrealDbSink {
+    async fn connect_and_select(&self) -> Result<SurrealDbClient, Error> {
+        let client = self.connect()?;
+        self.signin_if_configured(&client).await?;
+        self.health_check(&client).await?;
+
+        if self.auto_define_table {
+            self.ensure_namespace_database(&client).await?;
+            self.ensure_table(&client).await?;
+        }
+
+        Ok(client)
+    }
+
+    fn connect(&self) -> Result<SurrealDbClient, Error> {
+        HttpClient::builder()
+            .timeout(self.query_timeout)
+            .build()
+            .map_err(|e| Error::InitError(format!("Failed to create SurrealDB 
HTTP client: {e}")))
+    }
+
+    async fn get_client(&self) -> Result<SurrealDbClient, Error> {
+        self.client
+            .lock()
+            .await
+            .clone()
+            .ok_or_else(|| Error::InitError("SurrealDB sink is not 
connected".to_string()))
+    }
+
+    async fn reconnect(&self) -> Result<bool, Error> {
+        if self
+            .reconnecting
+            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
+            .is_err()
+        {
+            debug!(
+                "Skipping SurrealDB reconnect for connector ID: {} because 
another reconnect is in progress",
+                self.id
+            );
+            tokio::time::sleep(self.retry_delay).await;
+            return Ok(false);
+        }
+
+        warn!("Reconnecting SurrealDB sink connector ID: {}", self.id);
+        let result = async {
+            let client = self.connect_and_select().await?;
+            *self.client.lock().await = Some(client);
+            Ok(())
+        }
+        .await;
+        self.reconnecting.store(false, Ordering::Release);
+        result.map(|()| true)
+    }
+
+    async fn signin_if_configured(&self, client: &SurrealDbClient) -> 
Result<(), Error> {
+        if self.auth_scope == AuthScope::None {
+            return Ok(());
+        }
+
+        let username = self.username.as_ref().ok_or_else(|| {
+            Error::InitError(
+                "SurrealDB username is required when auth_scope is not 
none".to_string(),
+            )
+        })?;
+        let password = self.password.as_ref().ok_or_else(|| {
+            Error::InitError(
+                "SurrealDB password is required when auth_scope is not 
none".to_string(),
+            )
+        })?;
+        let mut payload = Map::new();
+        payload.insert("user".to_string(), Value::String(username.clone()));
+        payload.insert(
+            "pass".to_string(),
+            Value::String(password.expose_secret().to_string()),
+        );
+
+        if matches!(self.auth_scope, AuthScope::Namespace | 
AuthScope::Database) {
+            payload.insert("ns".to_string(), 
Value::String(self.namespace.clone()));
+        }
+        if matches!(self.auth_scope, AuthScope::Database) {
+            payload.insert("db".to_string(), 
Value::String(self.database.clone()));
+        }
+
+        let response = client
+            .post(format!("{}/signin", self.base_url))
+            .json(&Value::Object(payload))
+            .send()
+            .await
+            .map_err(|e| Error::InitError(format!("Failed to authenticate with 
SurrealDB: {e}")))?;
+        let status = response.status();
+        if status.is_success() {
+            return Ok(());
+        }
+
+        let body = response
+            .text()
+            .await
+            .unwrap_or_else(|e| format!("failed to read response body: {e}"));
+        Err(Error::InitError(format!(
+            "Failed to authenticate with SurrealDB: HTTP status {status}: 
{body}"
+        )))
+    }
+
+    async fn ensure_table(&self, client: &SurrealDbClient) -> Result<(), 
Error> {
+        let table = &self.table;
+        let mut query = format!("DEFINE TABLE IF NOT EXISTS {table} 
SCHEMALESS;");
+
+        if self.define_indexes {
+            let offset_index = format!("{table}_iggy_offset_idx");
+            validate_identifier("index", &offset_index)?;
+            query.push_str(&format!(
+                " DEFINE INDEX IF NOT EXISTS {offset_index} ON TABLE {table} 
FIELDS iggy_stream, iggy_topic, iggy_partition_id, iggy_offset;"
+            ));
+        }
+
+        self.execute_sql(client, query)
+            .await
+            .map_err(|e| Error::InitError(format!("Failed to define SurrealDB 
table: {e}")))?;
+
+        Ok(())
+    }
+
+    async fn ensure_namespace_database(&self, client: &SurrealDbClient) -> 
Result<(), Error> {
+        let query = format!(
+            "DEFINE NAMESPACE IF NOT EXISTS {}; USE NS {}; DEFINE DATABASE IF 
NOT EXISTS {};",
+            self.namespace, self.namespace, self.database
+        );
+
+        self.execute_sql_without_scope(client, query)
+            .await
+            .map_err(|e| {
+                Error::InitError(format!(
+                    "Failed to define SurrealDB namespace/database: {e}"
+                ))
+            })?;
+
+        Ok(())
+    }
+
+    async fn health_check(&self, client: &SurrealDbClient) -> Result<(), 
Error> {
+        let response = client
+            .get(format!("{}/health", self.base_url))
+            .send()
+            .await
+            .map_err(|e| Error::InitError(format!("SurrealDB health check 
failed: {e}")))?;
+        let status = response.status();
+        if status.is_success() {
+            return Ok(());
+        }
+
+        let body = response
+            .text()
+            .await
+            .unwrap_or_else(|e| format!("failed to read response body: {e}"));
+        Err(Error::InitError(format!(
+            "SurrealDB health check failed: HTTP status {status}: {body}"
+        )))
+    }
+
+    async fn execute_sql(
+        &self,
+        client: &SurrealDbClient,
+        query: impl Into<Body>,
+    ) -> Result<Vec<SurrealSqlStatement>, SurrealDbRequestError> {
+        self.execute_sql_request(client, query, Some((&self.namespace, 
&self.database)))
+            .await
+    }
+
+    async fn execute_sql_without_scope(
+        &self,
+        client: &SurrealDbClient,
+        query: impl Into<Body>,
+    ) -> Result<Vec<SurrealSqlStatement>, SurrealDbRequestError> {
+        self.execute_sql_request(client, query, None).await
+    }
+
+    async fn execute_sql_request(
+        &self,
+        client: &SurrealDbClient,
+        query: impl Into<Body>,
+        scope: Option<(&str, &str)>,
+    ) -> Result<Vec<SurrealSqlStatement>, SurrealDbRequestError> {
+        let mut request = self
+            .apply_auth(client.post(format!("{}/sql", self.base_url)))
+            .header("Accept", "application/json")
+            .header("Content-Type", "text/plain")
+            .body(query);
+
+        if let Some((namespace, database)) = scope {
+            request = request
+                .header("Surreal-NS", namespace)
+                .header("Surreal-DB", database);
+        }
+
+        let response = request
+            .send()
+            .await
+            .map_err(SurrealDbRequestError::Request)?;
+        let status = response.status();
+        let body = response
+            .text()
+            .await
+            .map_err(SurrealDbRequestError::Request)?;
+
+        if !status.is_success() {
+            return Err(SurrealDbRequestError::HttpStatus { status, body });
+        }
+
+        let statements: Vec<SurrealSqlStatement> = 
serde_json::from_str(&body).map_err(|e| {
+            SurrealDbRequestError::Decode(format!(
+                "Failed to decode SurrealDB SQL response: {e}; response: 
{body}"
+            ))
+        })?;
+
+        if let Some(statement) = statements
+            .iter()
+            .find(|statement| !statement.status.eq_ignore_ascii_case("OK"))
+        {
+            return Err(SurrealDbRequestError::Query(
+                statement
+                    .detail
+                    .clone()
+                    .or_else(|| 
statement.result.as_ref().map(value_to_error_message))
+                    .unwrap_or_else(|| format!("SurrealDB query status: {}", 
statement.status)),
+            ));
+        }
+
+        Ok(statements)
+    }
+
+    fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
+        if self.auth_scope == AuthScope::None {
+            return request;
+        }
+
+        let Some(username) = self.username.as_ref() else {
+            return request;
+        };
+        let Some(password) = self.password.as_ref() else {
+            return request;
+        };
+
+        request.basic_auth(username, Some(password.expose_secret()))
+    }
+
+    async fn process_messages(
+        &self,
+        topic_metadata: &TopicMetadata,
+        messages_metadata: &MessagesMetadata,
+        messages: Vec<ConsumedMessage>,
+    ) -> Result<(), Error> {
+        let mut successful_inserts = 0u64;
+        let mut last_error = None;
+        let record_id_prefix = RecordIdPrefix::new(topic_metadata);
+        let mut batch = Vec::with_capacity(self.batch_size);
+
+        for message in messages {
+            batch.push(message);
+            if batch.len() == self.batch_size {
+                let batch_len = batch.len();
+                let full_batch = std::mem::replace(&mut batch, 
Vec::with_capacity(self.batch_size));
+                let outcome = self
+                    .insert_batch(
+                        full_batch,
+                        &record_id_prefix,
+                        topic_metadata,
+                        messages_metadata,
+                    )
+                    .await;
+                successful_inserts += outcome.inserted_count;
+
+                if let Some(batch_error) = outcome.error {
+                    self.insertion_errors
+                        .fetch_add(outcome.error_count, Ordering::Relaxed);
+                    error!(
+                        "Failed to insert SurrealDB batch of {batch_len} 
messages for connector ID: {}, table: {}, error: {batch_error}",
+                        self.id, self.table
+                    );
+                    last_error = Some(batch_error);
+                }
+            }
+        }
+
+        if !batch.is_empty() {
+            let batch_len = batch.len();
+            let outcome = self
+                .insert_batch(batch, &record_id_prefix, topic_metadata, 
messages_metadata)
+                .await;
+            successful_inserts += outcome.inserted_count;
+
+            if let Some(batch_error) = outcome.error {
+                self.insertion_errors
+                    .fetch_add(outcome.error_count, Ordering::Relaxed);
+                error!(
+                    "Failed to insert SurrealDB batch of {batch_len} messages 
for connector ID: {}, table: {}, error: {batch_error}",
+                    self.id, self.table
+                );
+                last_error = Some(batch_error);
+            }
+        }
+
+        self.messages_processed
+            .fetch_add(successful_inserts, Ordering::Relaxed);
+
+        if self.verbose {
+            info!(
+                "SurrealDB sink ID: {} wrote {successful_inserts} messages to 
table '{}'",
+                self.id, self.table
+            );
+        } else {
+            debug!(
+                "SurrealDB sink ID: {} wrote {successful_inserts} messages to 
table '{}'",
+                self.id, self.table
+            );
+        }
+
+        if let Some(error) = last_error {
+            Err(error)
+        } else {
+            Ok(())
+        }
+    }
+
+    async fn insert_batch(
+        &self,
+        messages: Vec<ConsumedMessage>,
+        record_id_prefix: &RecordIdPrefix,
+        topic_metadata: &TopicMetadata,
+        messages_metadata: &MessagesMetadata,
+    ) -> BatchInsertOutcome {
+        if messages.is_empty() {
+            return BatchInsertOutcome {
+                inserted_count: 0,
+                error_count: 0,
+                error: None,
+            };
+        }
+
+        let mut records = Vec::with_capacity(messages.len());
+        let mut record_error_count = 0u64;
+        let mut last_record_error = None;
+        for message in messages {
+            match self.build_record(record_id_prefix, topic_metadata, 
messages_metadata, message) {
+                Ok(record) => records.push(record),
+                Err(error) => {
+                    record_error_count += 1;
+                    last_record_error = Some(error);
+                }
+            }
+        }
+
+        if records.is_empty() {
+            return BatchInsertOutcome {
+                inserted_count: 0,
+                error_count: record_error_count,
+                error: last_record_error,
+            };
+        }
+
+        let mut outcome = self.insert_records_with_retry(records).await;
+        outcome.error_count += record_error_count;
+        let db_error = outcome.error.take();
+        outcome.error = db_error.or(last_record_error);
+
+        outcome
+    }
+
+    async fn insert_records_with_retry(&self, records: Vec<Value>) -> 
BatchInsertOutcome {
+        let mut attempts = 0u32;
+        let query = match build_insert_query(&self.table, &records) {
+            Ok(query) => query,
+            Err(error) => {
+                return BatchInsertOutcome {
+                    inserted_count: 0,
+                    error_count: records.len() as u64,
+                    error: Some(error),
+                };
+            }
+        };
+        let record_count = records.len() as u64;
+
+        loop {
+            let client = match self.get_client().await {
+                Ok(client) => client,
+                Err(error) => {
+                    return BatchInsertOutcome {
+                        inserted_count: 0,
+                        error_count: record_count,
+                        error: Some(error),
+                    };
+                }
+            };
+            let result = self.execute_sql(&client, query.clone()).await;
+
+            match result {
+                Ok(_) => {
+                    return BatchInsertOutcome {
+                        inserted_count: record_count,
+                        error_count: 0,
+                        error: None,
+                    };
+                }
+                Err(error) => {
+                    let transient = is_transient_error(&error);
+                    attempts += 1;
+
+                    if !transient || attempts >= self.max_retries {
+                        return BatchInsertOutcome {
+                            inserted_count: 0,
+                            error_count: record_count,
+                            error: Some(Error::CannotStoreData(format!(
+                                "SurrealDB batch insert failed after 
{attempts} attempts: {error}"
+                            ))),
+                        };
+                    }
+
+                    if transient && is_connection_error(&error) {
+                        match self.reconnect().await {
+                            Ok(true) => {}
+                            Ok(false) => continue,
+                            Err(reconnect_error) => {
+                                return BatchInsertOutcome {
+                                    inserted_count: 0,
+                                    error_count: record_count,
+                                    error: Some(Error::Connection(format!(
+                                        "Failed to reconnect to SurrealDB 
after transient write error: {reconnect_error}"
+                                    ))),
+                                };
+                            }
+                        }
+                    }
+
+                    let delay = jitter(exponential_backoff(
+                        self.retry_delay,
+                        attempts.saturating_sub(1),
+                        self.max_retry_delay,
+                    ));
+                    warn!(
+                        "Transient SurrealDB write error for connector ID: {} 
(attempt {attempts}/{}): {error}. Retrying in {:?}.",
+                        self.id, self.max_retries, delay
+                    );
+                    tokio::time::sleep(delay).await;
+                }
+            }
+        }
+    }
+
+    fn build_record(
+        &self,
+        record_id_prefix: &RecordIdPrefix,
+        topic_metadata: &TopicMetadata,
+        messages_metadata: &MessagesMetadata,
+        message: ConsumedMessage,
+    ) -> Result<Value, Error> {
+        let mut record = Map::new();
+        record.insert(
+            "id".to_string(),
+            Value::String(build_record_id(
+                record_id_prefix,
+                messages_metadata,
+                message.id,
+                message.offset,
+            )),
+        );
+        record.insert(
+            "iggy_message_id".to_string(),
+            Value::String(message.id.to_string()),
+        );
+
+        if self.include_metadata {
+            record.insert(
+                "iggy_stream".to_string(),
+                Value::String(topic_metadata.stream.clone()),
+            );
+            record.insert(
+                "iggy_topic".to_string(),
+                Value::String(topic_metadata.topic.clone()),
+            );
+            record.insert(
+                "iggy_partition_id".to_string(),
+                Value::String(messages_metadata.partition_id.to_string()),
+            );
+            record.insert(
+                "iggy_offset".to_string(),
+                Value::String(message.offset.to_string()),
+            );
+            record.insert(
+                "iggy_timestamp".to_string(),
+                Value::String(message.timestamp.to_string()),
+            );
+            record.insert(
+                "iggy_schema".to_string(),
+                Value::String(messages_metadata.schema.to_string()),
+            );
+        }
+
+        if self.include_checksum {
+            record.insert(
+                "iggy_checksum".to_string(),
+                Value::String(message.checksum.to_string()),
+            );
+        }
+
+        if self.include_origin_timestamp {
+            record.insert(
+                "iggy_origin_timestamp".to_string(),
+                Value::String(message.origin_timestamp.to_string()),
+            );
+        }
+
+        if self.include_headers
+            && let Some(headers) = &message.headers
+            && !headers.is_empty()
+        {
+            record.insert("iggy_headers".to_string(), 
encode_headers(headers)?);
+        }
+
+        let payload = self.build_payload_document(message.payload)?;
+        record.insert("payload".to_string(), payload.value);
+        record.insert(
+            "payload_encoding".to_string(),
+            Value::String(payload.encoding.to_string()),
+        );
+
+        Ok(Value::Object(record))
+    }
+
+    fn build_payload_document(&self, payload: Payload) -> 
Result<PayloadDocument, Error> {
+        match self.payload_format {
+            PayloadFormat::Auto => build_auto_payload_document(payload),
+            PayloadFormat::Json => build_json_payload_document(payload),
+            PayloadFormat::Text => build_text_payload_document(payload),
+            PayloadFormat::Base64 => build_base64_payload_document(payload),
+        }
+    }
+}
+
+fn build_insert_query(table: &str, records: &[Value]) -> Result<Bytes, Error> {
+    let mut query = Vec::with_capacity(table.len() + records.len() * 128 + 32);
+    query.extend_from_slice(b"INSERT IGNORE INTO ");
+    query.extend_from_slice(table.as_bytes());
+    query.push(b' ');
+    serde_json::to_writer(&mut query, records)
+        .map_err(|e| Error::InvalidRecordValue(format!("Invalid SurrealDB 
records: {e}")))?;
+    query.extend_from_slice(b" RETURN NONE;");
+    Ok(Bytes::from(query))
+}
+
+fn build_auto_payload_document(payload: Payload) -> Result<PayloadDocument, 
Error> {
+    match payload {
+        Payload::Json(value) => Ok(PayloadDocument {
+            value: owned_value_to_serde_json(&value),
+            encoding: ENCODING_JSON,
+        }),
+        Payload::Text(text) | Payload::Proto(text) => Ok(PayloadDocument {
+            value: Value::String(text),
+            encoding: ENCODING_TEXT,
+        }),
+        Payload::Raw(_) | Payload::FlatBuffer(_) | Payload::Avro(_) => {
+            build_base64_payload_document(payload)
+        }
+    }
+}
+
+fn build_json_payload_document(payload: Payload) -> Result<PayloadDocument, 
Error> {
+    match payload {
+        Payload::Json(value) => Ok(PayloadDocument {
+            value: owned_value_to_serde_json(&value),
+            encoding: ENCODING_JSON,
+        }),
+        _ => {
+            let bytes = payload.try_into_vec()?;
+            let value = serde_json::from_slice(&bytes)
+                .map_err(|e| Error::InvalidRecordValue(format!("Invalid JSON 
payload: {e}")))?;
+            Ok(PayloadDocument {
+                value,
+                encoding: ENCODING_JSON,
+            })
+        }
+    }
+}
+
+fn build_text_payload_document(payload: Payload) -> Result<PayloadDocument, 
Error> {
+    match payload {
+        Payload::Text(text) | Payload::Proto(text) => Ok(PayloadDocument {
+            value: Value::String(text),
+            encoding: ENCODING_TEXT,
+        }),
+        _ => {
+            let bytes = payload.try_into_vec()?;
+            let text = String::from_utf8(bytes)
+                .map_err(|e| Error::InvalidRecordValue(format!("Invalid UTF-8 
payload: {e}")))?;
+            Ok(PayloadDocument {
+                value: Value::String(text),
+                encoding: ENCODING_TEXT,
+            })
+        }
+    }
+}
+
+fn build_base64_payload_document(payload: Payload) -> Result<PayloadDocument, 
Error> {
+    let bytes = payload.try_into_vec()?;
+    Ok(PayloadDocument {
+        value: Value::String(general_purpose::STANDARD.encode(bytes)),
+        encoding: ENCODING_BASE64,
+    })
+}
+
+fn encode_headers(
+    headers: &std::collections::BTreeMap<iggy_common::HeaderKey, 
iggy_common::HeaderValue>,
+) -> Result<Value, Error> {
+    let mut encoded = Map::new();
+
+    for (key, value) in headers {
+        let value = if let Ok(raw) = value.as_raw() {
+            json!({
+                "data": general_purpose::STANDARD.encode(raw),
+                "iggy_header_encoding": ENCODING_BASE64
+            })
+        } else {
+            Value::String(value.to_string_value())
+        };
+        encoded.insert(key.to_string_value(), value);
+    }
+
+    Ok(Value::Object(encoded))
+}
+
+#[derive(Debug)]
+struct RecordIdPrefix {
+    stream: String,
+    topic: String,
+}
+
+impl RecordIdPrefix {
+    fn new(topic_metadata: &TopicMetadata) -> Self {
+        let mut stream = String::with_capacity(topic_metadata.stream.len() * 
2);
+        push_hex_component(&mut stream, topic_metadata.stream.as_bytes());
+        let mut topic = String::with_capacity(topic_metadata.topic.len() * 2);
+        push_hex_component(&mut topic, topic_metadata.topic.as_bytes());
+
+        Self { stream, topic }
+    }
+}
+
+fn build_record_id(
+    record_id_prefix: &RecordIdPrefix,
+    messages_metadata: &MessagesMetadata,
+    message_id: u128,
+    offset: u64,
+) -> String {
+    let mut id =
+        String::with_capacity(record_id_prefix.stream.len() + 
record_id_prefix.topic.len() + 72);
+    id.push('s');
+    id.push_str(&record_id_prefix.stream);
+    id.push_str("_t");
+    id.push_str(&record_id_prefix.topic);
+    id.push_str("_p");
+    id.push_str(&messages_metadata.partition_id.to_string());
+    id.push_str("_o");
+    id.push_str(&offset.to_string());
+    id.push_str("_m");
+    let _ = write!(&mut id, "{message_id:032x}");
+    id
+}
+
+fn push_hex_component(out: &mut String, bytes: &[u8]) {
+    const HEX: &[u8; 16] = b"0123456789abcdef";
+
+    for byte in bytes {
+        out.push(HEX[(byte >> 4) as usize] as char);
+        out.push(HEX[(byte & 0x0f) as usize] as char);
+    }
+}
+
+fn validate_identifier(field: &str, value: &str) -> Result<(), Error> {
+    let mut chars = value.chars();
+    let Some(first) = chars.next() else {
+        return Err(Error::InvalidConfigValue(format!(
+            "SurrealDB {field} cannot be empty"
+        )));
+    };
+
+    if !(first == '_' || first.is_ascii_alphabetic()) {
+        return Err(Error::InvalidConfigValue(format!(
+            "SurrealDB {field} must start with an ASCII letter or underscore"
+        )));
+    }
+
+    if chars.any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric())) {
+        return Err(Error::InvalidConfigValue(format!(
+            "SurrealDB {field} must contain only ASCII letters, digits, and 
underscores"
+        )));
+    }
+
+    Ok(())
+}
+
+fn validate_endpoint_config(endpoint: &str, use_tls: bool) -> Result<(), 
Error> {
+    let endpoint = endpoint.trim();
+    if endpoint.is_empty() {
+        return Err(Error::InvalidConfigValue(
+            "SurrealDB endpoint cannot be empty".to_string(),
+        ));
+    }
+
+    let has_scheme = endpoint.starts_with("http://";) || 
endpoint.starts_with("https://";);
+    if use_tls && endpoint.starts_with("http://";) {
+        warn!("SurrealDB use_tls=true is ignored because endpoint has explicit 
http:// scheme.");
+    }
+
+    let parsed_endpoint = if has_scheme {
+        endpoint.to_string()
+    } else {
+        let scheme = if use_tls { "https" } else { "http" };
+        format!("{scheme}://{endpoint}")
+    };
+    let url = Url::parse(&parsed_endpoint).map_err(|e| {
+        Error::InvalidConfigValue(format!("Invalid SurrealDB endpoint 
'{endpoint}': {e}"))
+    })?;
+
+    if url.host_str().is_none() {
+        return Err(Error::InvalidConfigValue(format!(
+            "Invalid SurrealDB endpoint '{endpoint}': host is required"
+        )));
+    }
+
+    if !url.username().is_empty() || url.password().is_some() {
+        return Err(Error::InvalidConfigValue(
+            "SurrealDB endpoint must not include embedded credentials; use 
username/password config fields instead"
+                .to_string(),
+        ));
+    }
+
+    if !matches!(url.path(), "" | "/") || url.query().is_some() || 
url.fragment().is_some() {
+        return Err(Error::InvalidConfigValue(
+            "SurrealDB endpoint must not include a path, query, or 
fragment".to_string(),
+        ));
+    }
+
+    Ok(())
+}
+
+fn build_base_url(endpoint: &str, use_tls: bool) -> String {
+    let endpoint = endpoint.trim().trim_end_matches('/');
+    let has_scheme = endpoint.starts_with("http://";) || 
endpoint.starts_with("https://";);
+    let endpoint = if has_scheme {
+        endpoint.to_string()
+    } else {
+        let scheme = if use_tls { "https" } else { "http" };
+        format!("{scheme}://{endpoint}")
+    };
+
+    if let Ok(mut url) = Url::parse(&endpoint) {
+        let _ = url.set_username("");
+        let _ = url.set_password(None);
+        url.set_path("");
+        url.set_query(None);
+        url.set_fragment(None);
+        return url.as_str().trim_end_matches('/').to_string();
+    }
+
+    endpoint
+}
+
+fn value_to_error_message(value: &Value) -> String {
+    value
+        .as_str()
+        .map(ToString::to_string)
+        .unwrap_or_else(|| value.to_string())
+}
+
+fn is_transient_error(error: &SurrealDbRequestError) -> bool {
+    is_transaction_conflict(error)
+        || is_connection_error(error)
+        || is_timeout_or_service_error(error)
+}
+
+fn is_transaction_conflict(error: &SurrealDbRequestError) -> bool {
+    let message = error.to_string().to_ascii_lowercase();
+    message.contains("transaction conflict") || message.contains("transaction 
can be retried")
+}
+
+fn is_connection_error(error: &SurrealDbRequestError) -> bool {
+    let SurrealDbRequestError::Request(error) = error else {
+        return false;
+    };
+
+    let message = error.to_string().to_ascii_lowercase();
+    error.is_connect()
+        || error.is_timeout()
+        || message.contains("connection")
+        || message.contains("network")
+        || message.contains("broken pipe")
+        || message.contains("reset by peer")
+}
+
+fn is_timeout_or_service_error(error: &SurrealDbRequestError) -> bool {
+    if let SurrealDbRequestError::Request(error) = error
+        && error.is_timeout()
+    {
+        return true;
+    }
+    if let SurrealDbRequestError::HttpStatus { status, .. } = error
+        && matches!(
+            *status,
+            StatusCode::REQUEST_TIMEOUT
+                | StatusCode::TOO_MANY_REQUESTS
+                | StatusCode::INTERNAL_SERVER_ERROR
+                | StatusCode::BAD_GATEWAY
+                | StatusCode::SERVICE_UNAVAILABLE
+                | StatusCode::GATEWAY_TIMEOUT
+        )
+    {
+        return true;
+    }
+
+    false
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use iggy_common::{HeaderKey, HeaderValue};
+    use iggy_connector_sdk::Schema;
+    use std::collections::BTreeMap;
+    use std::str::FromStr;
+    use std::sync::Arc;
+    use std::sync::atomic::AtomicUsize;
+    use tokio::io::{AsyncReadExt, AsyncWriteExt};
+    use tokio::net::{TcpListener, TcpStream};
+    use tokio::sync::Mutex as TokioMutex;
+
+    fn test_config() -> SurrealDbSinkConfig {
+        SurrealDbSinkConfig {
+            endpoint: "127.0.0.1:8000".to_string(),
+            namespace: "iggy".to_string(),
+            database: "connectors".to_string(),
+            table: "iggy_messages".to_string(),
+            username: Some("root".to_string()),
+            password: Some(SecretString::from("root")),
+            auth_scope: None,
+            use_tls: None,
+            auto_define_table: None,
+            define_indexes: None,
+            batch_size: None,
+            payload_format: None,
+            include_metadata: None,
+            include_headers: None,
+            include_checksum: None,
+            include_origin_timestamp: None,
+            query_timeout: None,
+            max_retries: None,
+            retry_delay: None,
+            max_retry_delay: None,
+            verbose_logging: None,
+        }
+    }
+
+    fn test_topic_metadata() -> TopicMetadata {
+        TopicMetadata {
+            stream: "test_stream".to_string(),
+            topic: "test_topic".to_string(),
+        }
+    }
+
+    fn test_messages_metadata() -> MessagesMetadata {
+        MessagesMetadata {
+            partition_id: 7,
+            current_offset: 0,
+            schema: Schema::Json,
+        }
+    }
+
+    fn test_message(payload: Payload) -> ConsumedMessage {
+        ConsumedMessage {
+            id: 42,
+            offset: 9,
+            checksum: 123,
+            timestamp: 1_700_000_000_000_000,
+            origin_timestamp: 1_700_000_000_000_001,
+            headers: None,
+            payload,
+        }
+    }
+
+    fn json_payload(value: serde_json::Value) -> Payload {
+        let mut bytes = serde_json::to_vec(&value).expect("Failed to serialize 
JSON");
+        Payload::Json(simd_json::to_owned_value(&mut bytes).expect("Failed to 
parse JSON"))
+    }
+
+    async fn start_surrealdb_test_server() -> (String, Arc<AtomicUsize>, 
Arc<TokioMutex<String>>) {
+        let listener = TcpListener::bind("127.0.0.1:0")
+            .await
+            .expect("test server should bind");
+        let endpoint = listener
+            .local_addr()
+            .expect("test server should expose local address")
+            .to_string();
+        let sql_requests = Arc::new(AtomicUsize::new(0));
+        let sql_body = Arc::new(TokioMutex::new(String::new()));
+        let server_sql_requests = Arc::clone(&sql_requests);
+        let server_sql_body = Arc::clone(&sql_body);
+
+        tokio::spawn(async move {
+            for _ in 0..2 {
+                let (mut stream, _) = listener.accept().await.expect("request 
should connect");
+                let sql_requests = Arc::clone(&server_sql_requests);
+                let sql_body = Arc::clone(&server_sql_body);
+
+                tokio::spawn(async move {
+                    let (request_line, body) = read_http_request(&mut 
stream).await;
+                    let response_body = if request_line.starts_with("POST /sql 
") {
+                        sql_requests.fetch_add(1, Ordering::Relaxed);
+                        *sql_body.lock().await = body;
+                        r#"[{"status":"OK","result":[]}]"#
+                    } else {
+                        r#"{"status":"OK"}"#
+                    };
+                    let response = format!(
+                        "HTTP/1.1 200 OK\r\ncontent-length: 
{}\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{}",
+                        response_body.len(),
+                        response_body
+                    );
+                    stream
+                        .write_all(response.as_bytes())
+                        .await
+                        .expect("response should be written");
+                });
+            }
+        });
+
+        (endpoint, sql_requests, sql_body)
+    }
+
+    async fn read_http_request(stream: &mut TcpStream) -> (String, String) {
+        let mut buffer = Vec::new();
+        let mut chunk = [0u8; 1024];
+        let mut headers_end = None;
+
+        while headers_end.is_none() {
+            let read = stream.read(&mut chunk).await.expect("request should 
read");
+            assert_ne!(read, 0, "request should include headers");
+            buffer.extend_from_slice(&chunk[..read]);
+            headers_end = buffer.windows(4).position(|window| window == 
b"\r\n\r\n");
+        }
+
+        let headers_end = headers_end.expect("headers should terminate");
+        let body_start = headers_end + 4;
+        let headers = 
String::from_utf8_lossy(&buffer[..headers_end]).to_string();
+        let content_length = headers
+            .lines()
+            .find_map(|line| {
+                line.split_once(':').and_then(|(name, value)| {
+                    name.eq_ignore_ascii_case("content-length")
+                        .then(|| value.trim().parse::<usize>().expect("valid 
content length"))
+                })
+            })
+            .unwrap_or(0);
+
+        while buffer.len() < body_start + content_length {
+            let read = stream.read(&mut chunk).await.expect("body should 
read");
+            assert_ne!(read, 0, "request should include declared body");
+            buffer.extend_from_slice(&chunk[..read]);
+        }
+
+        let request_line = headers
+            .lines()
+            .next()
+            .expect("request line should exist")
+            .to_string();
+        let body =
+            String::from_utf8_lossy(&buffer[body_start..body_start + 
content_length]).to_string();
+
+        (request_line, body)
+    }
+
+    #[test]
+    fn given_default_config_should_apply_expected_runtime_values() {
+        let sink = SurrealDbSink::new(1, test_config());
+
+        assert_eq!(sink.batch_size, DEFAULT_BATCH_SIZE);
+        assert_eq!(sink.auth_scope, AuthScope::Root);
+        assert_eq!(sink.payload_format, PayloadFormat::Auto);
+        assert_eq!(sink.query_timeout, Duration::from_secs(30));
+        assert_eq!(sink.max_retries, DEFAULT_MAX_RETRIES);
+        assert_eq!(sink.retry_delay, Duration::from_millis(100));
+        assert_eq!(sink.max_retry_delay, Duration::from_secs(5));
+        assert!(sink.include_metadata);
+        assert!(sink.include_headers);
+        assert!(sink.include_checksum);
+        assert!(sink.include_origin_timestamp);
+        assert!(!sink.auto_define_table);
+        assert!(!sink.define_indexes);
+    }
+
+    #[test]
+    fn given_config_overrides_should_apply_expected_values() {
+        let mut config = test_config();
+        config.auth_scope = Some("database".to_string());
+        config.payload_format = Some("base64".to_string());
+        config.batch_size = Some(10);
+        config.query_timeout = Some("5s".to_string());
+        config.max_retries = Some(5);
+        config.retry_delay = Some("250ms".to_string());
+        config.max_retry_delay = Some("2s".to_string());
+        config.include_metadata = Some(false);
+        config.include_headers = Some(false);
+        config.include_checksum = Some(false);
+        config.include_origin_timestamp = Some(false);
+        config.auto_define_table = Some(true);
+        config.define_indexes = Some(true);
+        config.verbose_logging = Some(true);
+
+        let sink = SurrealDbSink::new(1, config);
+
+        assert_eq!(sink.auth_scope_config.as_deref(), Some("database"));
+        assert_eq!(sink.payload_format_config.as_deref(), Some("base64"));
+        assert_eq!(sink.auth_scope, AuthScope::Root);
+        assert_eq!(sink.payload_format, PayloadFormat::Auto);
+        assert_eq!(sink.batch_size, 10);
+        assert_eq!(sink.query_timeout, Duration::from_secs(5));
+        assert_eq!(sink.max_retries, 5);
+        assert_eq!(sink.retry_delay, Duration::from_millis(250));
+        assert_eq!(sink.max_retry_delay, Duration::from_secs(2));
+        assert!(!sink.include_metadata);
+        assert!(!sink.include_headers);
+        assert!(!sink.include_checksum);
+        assert!(!sink.include_origin_timestamp);
+        assert!(sink.auto_define_table);
+        assert!(sink.define_indexes);
+        assert!(sink.verbose);
+    }
+
+    #[test]
+    fn given_zero_max_retries_should_use_minimum_one_attempt() {
+        let mut config = test_config();
+        config.max_retries = Some(0);
+
+        let mut sink = SurrealDbSink::new(1, config);
+        sink.payload_format = PayloadFormat::Json;
+
+        assert_eq!(sink.max_retries, 1);
+    }
+
+    #[test]
+    fn given_reversed_retry_delays_should_clamp_max_retry_delay() {
+        let mut config = test_config();
+        config.retry_delay = Some("5s".to_string());
+        config.max_retry_delay = Some("100ms".to_string());
+
+        let mut sink = SurrealDbSink::new(1, config);
+        sink.payload_format = PayloadFormat::Json;
+
+        assert_eq!(sink.retry_delay, Duration::from_secs(5));
+        assert_eq!(sink.max_retry_delay, Duration::from_secs(5));
+    }
+
+    #[test]
+    fn given_payload_format_inputs_should_map_expected_variant() {
+        let cases = [
+            (None, PayloadFormat::Auto),
+            (Some("auto"), PayloadFormat::Auto),
+            (Some("json"), PayloadFormat::Json),
+            (Some("text"), PayloadFormat::Text),
+            (Some("base64"), PayloadFormat::Base64),
+            (Some("binary"), PayloadFormat::Base64),
+        ];
+
+        for (input, expected) in cases {
+            assert_eq!(PayloadFormat::parse_config(input).unwrap(), expected);
+        }
+
+        assert!(matches!(
+            PayloadFormat::parse_config(Some("unknown")),
+            Err(Error::InvalidConfigValue(_))
+        ));
+    }
+
+    #[test]
+    fn given_auth_scope_inputs_should_map_expected_variant() {
+        let cases = [
+            (None, AuthScope::Root),
+            (Some("root"), AuthScope::Root),
+            (Some("namespace"), AuthScope::Namespace),
+            (Some("database"), AuthScope::Database),
+            (Some("none"), AuthScope::None),
+        ];
+
+        for (input, expected) in cases {
+            assert_eq!(AuthScope::parse_config(input).unwrap(), expected);
+        }
+
+        assert!(matches!(
+            AuthScope::parse_config(Some("unknown")),
+            Err(Error::InvalidConfigValue(_))
+        ));
+    }
+
+    #[test]
+    fn given_unknown_auth_scope_when_opening_should_fail_validation() {
+        let mut config = test_config();
+        config.auth_scope = Some("roo".to_string());
+        let mut sink = SurrealDbSink::new(1, config);
+
+        tokio::runtime::Runtime::new()
+            .expect("runtime should start")
+            .block_on(async {
+                let result = sink.open().await;
+
+                assert!(matches!(result, Err(Error::InvalidConfigValue(_))));
+            });
+    }
+
+    #[test]
+    fn given_unknown_payload_format_when_opening_should_fail_validation() {
+        let mut config = test_config();
+        config.payload_format = Some("jsn".to_string());
+        let mut sink = SurrealDbSink::new(1, config);
+
+        tokio::runtime::Runtime::new()
+            .expect("runtime should start")
+            .block_on(async {
+                let result = sink.open().await;
+
+                assert!(matches!(result, Err(Error::InvalidConfigValue(_))));
+            });
+    }
+
+    #[test]
+    fn 
given_auto_define_table_with_scoped_auth_when_opening_should_fail_validation() {
+        let mut config = test_config();
+        config.auth_scope = Some("database".to_string());
+        config.auto_define_table = Some(true);
+        let mut sink = SurrealDbSink::new(1, config);
+
+        tokio::runtime::Runtime::new()
+            .expect("runtime should start")
+            .block_on(async {
+                let result = sink.open().await;
+
+                assert!(matches!(result, Err(Error::InvalidConfigValue(_))));
+            });
+    }
+
+    #[test]
+    fn 
given_define_indexes_without_metadata_when_opening_should_fail_validation() {
+        let mut config = test_config();
+        config.define_indexes = Some(true);
+        config.include_metadata = Some(false);
+        let mut sink = SurrealDbSink::new(1, config);
+
+        tokio::runtime::Runtime::new()
+            .expect("runtime should start")
+            .block_on(async {
+                let result = sink.open().await;
+
+                assert!(matches!(result, Err(Error::InvalidConfigValue(_))));
+            });
+    }
+
+    #[test]
+    fn given_endpoint_path_when_opening_should_fail_validation() {
+        let mut config = test_config();
+        config.endpoint = "http://127.0.0.1:8000/extra/path".to_string();
+        let mut sink = SurrealDbSink::new(1, config);
+
+        tokio::runtime::Runtime::new()
+            .expect("runtime should start")
+            .block_on(async {
+                let result = sink.open().await;
+
+                assert!(matches!(result, Err(Error::InvalidConfigValue(_))));
+            });
+    }
+
+    #[test]
+    fn given_invalid_namespace_when_opening_should_fail_validation() {
+        let mut config = test_config();
+        config.namespace = "bad-namespace".to_string();
+        let mut sink = SurrealDbSink::new(1, config);
+
+        tokio::runtime::Runtime::new()
+            .expect("runtime should start")
+            .block_on(async {
+                let result = sink.open().await;
+
+                assert!(matches!(result, Err(Error::InvalidConfigValue(_))));
+            });
+    }
+
+    #[test]
+    fn given_identifier_values_should_validate_expected_shapes() {
+        assert!(validate_identifier("table", "iggy_messages").is_ok());
+        assert!(validate_identifier("table", "_messages9").is_ok());
+        assert!(validate_identifier("table", "").is_err());
+        assert!(validate_identifier("table", "9messages").is_err());
+        assert!(validate_identifier("table", "messages-name").is_err());
+        assert!(validate_identifier("table", "messages]").is_err());
+        assert!(validate_identifier("table", "messages\"").is_err());
+        assert!(validate_identifier("table", "messages; DROP TABLE 
x").is_err());
+    }
+
+    #[test]
+    fn given_topic_metadata_should_build_deterministic_record_id() {
+        let topic_metadata = test_topic_metadata();
+        let record_id_prefix = RecordIdPrefix::new(&topic_metadata);
+        let id = build_record_id(&record_id_prefix, &test_messages_metadata(), 
42, 9);
+
+        assert_eq!(
+            id,
+            
"s746573745f73747265616d_t746573745f746f706963_p7_o9_m0000000000000000000000000000002a"
+        );
+    }
+
+    #[test]
+    fn given_table_name_should_build_bulk_insert_query() {
+        let records = [json!({
+            "id": "record_1",
+            "payload": {"message": "hello"}
+        })];
+
+        assert_eq!(
+            String::from_utf8(
+                build_insert_query("iggy_messages", &records)
+                    .expect("query should build")
+                    .to_vec()
+            )
+            .expect("query should be valid UTF-8"),
+            r#"INSERT IGNORE INTO iggy_messages 
[{"id":"record_1","payload":{"message":"hello"}}] RETURN NONE;"#
+        );
+    }
+
+    #[test]
+    fn given_adversarial_record_values_should_build_escaped_insert_query() {
+        let records = [json!({
+            "id": "record_\"[]",
+            "payload": {
+                "text": "quote \" bracket ] brace } semi ; newline \n"
+            }
+        })];
+        let query = String::from_utf8(
+            build_insert_query("iggy_messages", &records)
+                .expect("query should build")
+                .to_vec(),
+        )
+        .expect("query should be valid UTF-8");
+        let json_start = "INSERT IGNORE INTO iggy_messages ";
+        let json_end = " RETURN NONE;";
+        assert!(query.starts_with(json_start));
+        assert!(query.ends_with(json_end));
+
+        let encoded_records = &query[json_start.len()..query.len() - 
json_end.len()];
+        let decoded_records: Vec<Value> =
+            serde_json::from_str(encoded_records).expect("records should stay 
valid JSON");
+        assert_eq!(decoded_records, records.to_vec());
+    }
+
+    #[test]
+    fn given_auto_payload_json_should_store_queryable_json() {
+        let payload = json_payload(json!({"name": "Alice", "active": true}));
+        let document = build_auto_payload_document(payload).expect("Failed to 
build payload");
+
+        assert_eq!(document.encoding, ENCODING_JSON);
+        assert_eq!(document.value, json!({"name": "Alice", "active": true}));
+    }
+
+    #[test]
+    fn given_auto_payload_text_should_store_text() {
+        let payload = Payload::Text("hello".to_string());
+        let document = build_auto_payload_document(payload).expect("Failed to 
build payload");
+
+        assert_eq!(document.encoding, ENCODING_TEXT);
+        assert_eq!(document.value, Value::String("hello".to_string()));
+    }
+
+    #[test]
+    fn given_auto_payload_raw_should_store_base64() {
+        let payload = Payload::Raw(vec![0, 1, 2, 255]);
+        let document = build_auto_payload_document(payload).expect("Failed to 
build payload");
+
+        assert_eq!(document.encoding, ENCODING_BASE64);
+        assert_eq!(document.value, Value::String("AAEC/w==".to_string()));
+    }
+
+    #[test]
+    fn given_json_payload_format_should_parse_raw_json() {
+        let payload = Payload::Raw(br#"{"count":3}"#.to_vec());
+        let document = build_json_payload_document(payload).expect("Failed to 
build payload");
+
+        assert_eq!(document.encoding, ENCODING_JSON);
+        assert_eq!(document.value, json!({"count": 3}));
+    }
+
+    #[test]
+    fn given_json_payload_format_when_invalid_should_fail() {
+        let payload = Payload::Raw(b"not-json".to_vec());
+        let result = build_json_payload_document(payload);
+
+        assert!(matches!(result, Err(Error::InvalidRecordValue(_))));
+    }
+
+    #[test]
+    fn given_text_payload_format_when_invalid_utf8_should_fail() {
+        let payload = Payload::Raw(vec![0xff, 0xfe]);
+        let result = build_text_payload_document(payload);
+
+        assert!(matches!(result, Err(Error::InvalidRecordValue(_))));
+    }
+
+    #[test]
+    fn given_headers_should_encode_raw_as_base64_and_values_as_strings() {
+        let mut headers = BTreeMap::new();
+        headers.insert(
+            HeaderKey::try_from("trace-id").expect("valid key"),
+            HeaderValue::from_str("abc").expect("valid value"),
+        );
+        headers.insert(
+            HeaderKey::try_from("binary").expect("valid key"),
+            HeaderValue::try_from(vec![1_u8, 2, 3]).expect("valid raw"),
+        );
+
+        let encoded = encode_headers(&headers).expect("Failed to encode 
headers");
+
+        assert_eq!(
+            encoded,
+            json!({
+                "binary": {
+                    "data": "AQID",
+                    "iggy_header_encoding": "base64"
+                },
+                "trace-id": "abc"
+            })
+        );
+    }
+
+    #[test]
+    fn given_message_should_build_full_record() {
+        let mut message = test_message(json_payload(json!({"event": 
"created"})));
+        let mut headers = BTreeMap::new();
+        headers.insert(
+            HeaderKey::try_from("source").expect("valid key"),
+            HeaderValue::from_str("unit-test").expect("valid value"),
+        );
+        message.headers = Some(headers);
+
+        let sink = SurrealDbSink::new(1, test_config());
+        let topic_metadata = test_topic_metadata();
+        let record_id_prefix = RecordIdPrefix::new(&topic_metadata);
+        let record = sink
+            .build_record(
+                &record_id_prefix,
+                &topic_metadata,
+                &test_messages_metadata(),
+                message,
+            )
+            .expect("Failed to build record");
+        let object = record.as_object().expect("record should be object");
+
+        assert_eq!(
+            object.get("id"),
+            Some(&Value::String(
+                
"s746573745f73747265616d_t746573745f746f706963_p7_o9_m0000000000000000000000000000002a"
+                    .to_string()
+            ))
+        );
+        assert_eq!(object.get("iggy_message_id"), Some(&json!("42")));
+        assert_eq!(object.get("iggy_stream"), Some(&json!("test_stream")));
+        assert_eq!(object.get("iggy_topic"), Some(&json!("test_topic")));
+        assert_eq!(object.get("iggy_partition_id"), Some(&json!("7")));
+        assert_eq!(object.get("iggy_offset"), Some(&json!("9")));
+        assert_eq!(
+            object.get("iggy_timestamp"),
+            Some(&json!("1700000000000000"))
+        );
+        assert_eq!(object.get("iggy_checksum"), Some(&json!("123")));
+        assert_eq!(
+            object.get("iggy_origin_timestamp"),
+            Some(&json!("1700000000000001"))
+        );
+        assert_eq!(object.get("payload"), Some(&json!({"event": "created"})));
+        assert_eq!(object.get("payload_encoding"), Some(&json!("json")));
+        assert!(object.contains_key("iggy_headers"));
+    }
+
+    #[test]
+    fn given_large_u64_metadata_should_build_record_with_lossless_strings() {
+        let sink = SurrealDbSink::new(1, test_config());
+        let mut message = 
test_message(Payload::Text("large-metadata".to_string()));
+        message.offset = u64::MAX;
+        message.timestamp = u64::MAX;
+        message.checksum = u64::MAX;
+        message.origin_timestamp = u64::MAX;
+        let topic_metadata = test_topic_metadata();
+        let record_id_prefix = RecordIdPrefix::new(&topic_metadata);
+
+        let record = sink
+            .build_record(
+                &record_id_prefix,
+                &topic_metadata,
+                &test_messages_metadata(),
+                message,
+            )
+            .expect("Failed to build record");
+        let object = record.as_object().expect("record should be object");
+
+        assert_eq!(
+            object.get("iggy_offset"),
+            Some(&json!("18446744073709551615"))
+        );
+        assert_eq!(
+            object.get("iggy_timestamp"),
+            Some(&json!("18446744073709551615"))
+        );
+        assert_eq!(
+            object.get("iggy_checksum"),
+            Some(&json!("18446744073709551615"))
+        );
+        assert_eq!(
+            object.get("iggy_origin_timestamp"),
+            Some(&json!("18446744073709551615"))
+        );
+    }
+
+    #[test]
+    fn 
given_invalid_batch_when_processing_messages_should_record_error_and_return_error()
 {
+        let mut config = test_config();
+        config.payload_format = Some("json".to_string());
+        let mut sink = SurrealDbSink::new(1, config);
+        sink.payload_format = PayloadFormat::Json;
+        let message = test_message(Payload::Raw(b"not-json".to_vec()));
+
+        tokio::runtime::Runtime::new()
+            .expect("runtime should start")
+            .block_on(async {
+                let result = sink
+                    .process_messages(
+                        &test_topic_metadata(),
+                        &test_messages_metadata(),
+                        vec![message],
+                    )
+                    .await;
+
+                assert!(
+                    matches!(result, Err(Error::InvalidRecordValue(_))),
+                    "batch failures should be observable by direct plugin 
callers"
+                );
+            });
+
+        assert_eq!(sink.messages_processed.load(Ordering::Relaxed), 0);
+        assert_eq!(sink.insertion_errors.load(Ordering::Relaxed), 1);
+    }
+
+    #[test]
+    fn 
given_invalid_chunks_when_processing_messages_should_process_all_chunks() {
+        let mut config = test_config();
+        config.payload_format = Some("json".to_string());
+        config.batch_size = Some(1);
+        let mut sink = SurrealDbSink::new(1, config);
+        sink.payload_format = PayloadFormat::Json;
+        let messages = vec![
+            test_message(Payload::Raw(b"not-json".to_vec())),
+            test_message(Payload::Raw(b"also-not-json".to_vec())),
+        ];
+
+        tokio::runtime::Runtime::new()
+            .expect("runtime should start")
+            .block_on(async {
+                let result = sink
+                    .process_messages(&test_topic_metadata(), 
&test_messages_metadata(), messages)
+                    .await;
+
+                assert!(matches!(result, Err(Error::InvalidRecordValue(_))));
+            });
+
+        assert_eq!(sink.messages_processed.load(Ordering::Relaxed), 0);
+        assert_eq!(sink.insertion_errors.load(Ordering::Relaxed), 2);
+    }
+
+    #[test]
+    fn given_malformed_record_in_batch_should_still_attempt_valid_records() {
+        tokio::runtime::Runtime::new()
+            .expect("runtime should start")
+            .block_on(async {
+                let (endpoint, sql_requests, sql_body) = 
start_surrealdb_test_server().await;
+                let mut config = test_config();
+                config.endpoint = endpoint;
+                config.auth_scope = Some("none".to_string());
+                config.payload_format = Some("json".to_string());
+                let mut sink = SurrealDbSink::new(1, config);
+                sink.open().await.expect("sink should open");
+
+                let messages = vec![
+                    test_message(Payload::Raw(b"not-json".to_vec())),
+                    test_message(Payload::Raw(br#"{"valid":true}"#.to_vec())),
+                ];
+                let result = sink
+                    .process_messages(&test_topic_metadata(), 
&test_messages_metadata(), messages)
+                    .await;
+
+                assert!(matches!(result, Err(Error::InvalidRecordValue(_))));
+                assert_eq!(sink.messages_processed.load(Ordering::Relaxed), 1);
+                assert_eq!(sink.insertion_errors.load(Ordering::Relaxed), 1);
+                assert_eq!(sql_requests.load(Ordering::Relaxed), 1);
+
+                let body = sql_body.lock().await;
+                assert!(body.contains(r#""valid":true"#));
+                assert!(!body.contains("not-json"));
+            });
+    }
+
+    #[test]
+    fn given_endpoint_should_build_http_base_url() {
+        assert_eq!(
+            build_base_url("127.0.0.1:8000", false),
+            "http://127.0.0.1:8000";
+        );
+        assert_eq!(
+            build_base_url("127.0.0.1:8000", true),
+            "https://127.0.0.1:8000";
+        );
+        assert_eq!(
+            build_base_url("http://127.0.0.1:8000/";, true),
+            "http://127.0.0.1:8000";
+        );
+    }
+
+    #[test]
+    fn given_endpoint_credentials_should_build_sanitized_base_url() {
+        assert_eq!(
+            build_base_url("http://user:[email protected]:8000/";, false),
+            "http://127.0.0.1:8000";
+        );
+    }
+
+    #[test]
+    fn given_endpoint_shapes_should_validate_expected_values() {
+        assert!(validate_endpoint_config("127.0.0.1:8000", false).is_ok());
+        assert!(validate_endpoint_config("http://127.0.0.1:8000";, 
true).is_ok());
+        assert!(validate_endpoint_config("http://user:[email protected]:8000";, 
false).is_err());
+        assert!(validate_endpoint_config("http://127.0.0.1:8000/path";, 
false).is_err());
+        assert!(validate_endpoint_config("http://127.0.0.1:8000?x=1";, 
false).is_err());
+    }
+
+    #[test]
+    fn given_http_status_service_error_should_be_transient() {
+        let error = SurrealDbRequestError::HttpStatus {
+            status: StatusCode::SERVICE_UNAVAILABLE,
+            body: "retry later".to_string(),
+        };
+
+        assert!(is_transient_error(&error));
+        assert!(is_timeout_or_service_error(&error));
+    }
+
+    #[test]
+    fn given_transaction_conflict_error_should_be_transient() {
+        let error = SurrealDbRequestError::Query("Transaction 
conflict".to_string());
+
+        assert!(is_transient_error(&error));
+        assert!(is_transaction_conflict(&error));
+    }
+
+    #[test]
+    fn given_timeout_text_in_query_error_should_not_be_transient() {
+        let error = SurrealDbRequestError::Query("Query timed 
out".to_string());
+
+        assert!(!is_transient_error(&error));
+        assert!(!is_timeout_or_service_error(&error));
+    }
+
+    #[test]
+    fn given_transient_text_in_bad_request_body_should_not_be_transient() {
+        let error = SurrealDbRequestError::HttpStatus {
+            status: StatusCode::BAD_REQUEST,
+            body: "service unavailable timeout".to_string(),
+        };
+
+        assert!(!is_transient_error(&error));
+        assert!(!is_timeout_or_service_error(&error));
+    }
+
+    #[test]
+    fn given_connection_text_in_query_error_should_not_be_connection_error() {
+        let error = SurrealDbRequestError::Query("connection pool size 
exceeded".to_string());
+
+        assert!(!is_connection_error(&error));
+    }
+
+    #[test]
+    fn given_non_transient_query_error_should_not_be_transient() {
+        let error = SurrealDbRequestError::Query("syntax error".to_string());
+
+        assert!(!is_transient_error(&error));
+    }
+
+    #[test]
+    fn given_metadata_disabled_should_build_minimal_record() {
+        let mut config = test_config();
+        config.include_metadata = Some(false);
+        config.include_headers = Some(false);
+        config.include_checksum = Some(false);
+        config.include_origin_timestamp = Some(false);
+        let sink = SurrealDbSink::new(1, config);
+        let message = test_message(Payload::Text("minimal".to_string()));
+        let topic_metadata = test_topic_metadata();
+        let record_id_prefix = RecordIdPrefix::new(&topic_metadata);
+
+        let record = sink
+            .build_record(
+                &record_id_prefix,
+                &topic_metadata,
+                &test_messages_metadata(),
+                message,
+            )
+            .expect("Failed to build record");
+        let object = record.as_object().expect("record should be object");
+
+        assert!(object.contains_key("id"));
+        assert!(object.contains_key("iggy_message_id"));
+        assert!(object.contains_key("payload"));
+        assert!(!object.contains_key("iggy_stream"));
+        assert!(!object.contains_key("iggy_checksum"));
+        assert!(!object.contains_key("iggy_origin_timestamp"));
+        assert!(!object.contains_key("iggy_headers"));
+    }
+}
diff --git a/core/integration/tests/connectors/fixtures/mod.rs 
b/core/integration/tests/connectors/fixtures/mod.rs
index 369cde392..68ff7598b 100644
--- a/core/integration/tests/connectors/fixtures/mod.rs
+++ b/core/integration/tests/connectors/fixtures/mod.rs
@@ -27,6 +27,7 @@ mod mongodb;
 mod postgres;
 mod quickwit;
 mod s3;
+mod surrealdb;
 mod wiremock;
 
 /// Prefix on every test container name so `just clean-test-containers` reaps
@@ -74,4 +75,8 @@ pub use postgres::{
 };
 pub use quickwit::{QuickwitFixture, QuickwitOps, QuickwitPreCreatedFixture};
 pub use s3::{S3SinkFixture, S3SinkOps, S3SinkRotationFixture};
+pub use surrealdb::{
+    SurrealDbOps, SurrealDbSinkBatchFixture, SurrealDbSinkFixture, 
SurrealDbSinkJsonFixture,
+    SurrealDbSinkRawFixture,
+};
 pub use wiremock::{WireMockDirectFixture, WireMockWrappedFixture};
diff --git a/core/integration/tests/connectors/fixtures/surrealdb/container.rs 
b/core/integration/tests/connectors/fixtures/surrealdb/container.rs
new file mode 100644
index 000000000..07a733bfb
--- /dev/null
+++ b/core/integration/tests/connectors/fixtures/surrealdb/container.rs
@@ -0,0 +1,330 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::connectors::fixtures;
+use integration::harness::TestBinaryError;
+use serde::Deserialize;
+use serde_json::{Value, json};
+use std::time::Duration;
+use testcontainers_modules::testcontainers::core::{IntoContainerPort, WaitFor};
+use testcontainers_modules::testcontainers::runners::AsyncRunner;
+use testcontainers_modules::testcontainers::{ContainerAsync, GenericImage, 
ImageExt};
+use tokio::time::sleep;
+use tracing::info;
+
+const SURREALDB_IMAGE: &str = "docker.io/surrealdb/surrealdb";
+const SURREALDB_TAG: &str = "v3.1.4";
+const SURREALDB_PORT: u16 = 8000;
+const SURREALDB_READY_MSG: &str = "Started web server on";
+const SURREALDB_BOOT_ATTEMPTS: usize = 120;
+const SURREALDB_BOOT_INTERVAL_MS: u64 = 250;
+
+pub(super) const DEFAULT_TEST_STREAM: &str = "test_stream";
+pub(super) const DEFAULT_TEST_TOPIC: &str = "test_topic";
+pub(super) const DEFAULT_NAMESPACE: &str = "iggy";
+pub(super) const DEFAULT_DATABASE: &str = "connectors";
+pub(super) const DEFAULT_TABLE: &str = "iggy_messages";
+pub(super) const ROOT_USERNAME: &str = "root";
+pub(super) const ROOT_PASSWORD: &str = "root";
+
+pub(super) const DEFAULT_POLL_ATTEMPTS: usize = 120;
+pub(super) const DEFAULT_POLL_INTERVAL_MS: u64 = 50;
+
+pub(super) const ENV_SINK_ENDPOINT: &str = 
"IGGY_CONNECTORS_SINK_SURREALDB_PLUGIN_CONFIG_ENDPOINT";
+pub(super) const ENV_SINK_NAMESPACE: &str =
+    "IGGY_CONNECTORS_SINK_SURREALDB_PLUGIN_CONFIG_NAMESPACE";
+pub(super) const ENV_SINK_DATABASE: &str = 
"IGGY_CONNECTORS_SINK_SURREALDB_PLUGIN_CONFIG_DATABASE";
+pub(super) const ENV_SINK_TABLE: &str = 
"IGGY_CONNECTORS_SINK_SURREALDB_PLUGIN_CONFIG_TABLE";
+pub(super) const ENV_SINK_USERNAME: &str = 
"IGGY_CONNECTORS_SINK_SURREALDB_PLUGIN_CONFIG_USERNAME";
+pub(super) const ENV_SINK_PASSWORD: &str = 
"IGGY_CONNECTORS_SINK_SURREALDB_PLUGIN_CONFIG_PASSWORD";
+pub(super) const ENV_SINK_AUTH_SCOPE: &str =
+    "IGGY_CONNECTORS_SINK_SURREALDB_PLUGIN_CONFIG_AUTH_SCOPE";
+pub(super) const ENV_SINK_AUTO_DEFINE_TABLE: &str =
+    "IGGY_CONNECTORS_SINK_SURREALDB_PLUGIN_CONFIG_AUTO_DEFINE_TABLE";
+pub(super) const ENV_SINK_DEFINE_INDEXES: &str =
+    "IGGY_CONNECTORS_SINK_SURREALDB_PLUGIN_CONFIG_DEFINE_INDEXES";
+pub(super) const ENV_SINK_BATCH_SIZE: &str =
+    "IGGY_CONNECTORS_SINK_SURREALDB_PLUGIN_CONFIG_BATCH_SIZE";
+pub(super) const ENV_SINK_PAYLOAD_FORMAT: &str =
+    "IGGY_CONNECTORS_SINK_SURREALDB_PLUGIN_CONFIG_PAYLOAD_FORMAT";
+pub(super) const ENV_SINK_STREAMS_0_STREAM: &str =
+    "IGGY_CONNECTORS_SINK_SURREALDB_STREAMS_0_STREAM";
+pub(super) const ENV_SINK_STREAMS_0_TOPICS: &str =
+    "IGGY_CONNECTORS_SINK_SURREALDB_STREAMS_0_TOPICS";
+pub(super) const ENV_SINK_STREAMS_0_SCHEMA: &str =
+    "IGGY_CONNECTORS_SINK_SURREALDB_STREAMS_0_SCHEMA";
+pub(super) const ENV_SINK_STREAMS_0_CONSUMER_GROUP: &str =
+    "IGGY_CONNECTORS_SINK_SURREALDB_STREAMS_0_CONSUMER_GROUP";
+pub(super) const ENV_SINK_PATH: &str = "IGGY_CONNECTORS_SINK_SURREALDB_PATH";
+
+#[derive(Clone)]
+pub struct SurrealDbClient {
+    client: reqwest::Client,
+    base_url: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct SurrealSqlStatement {
+    status: String,
+    detail: Option<String>,
+    result: Value,
+}
+
+impl SurrealDbClient {
+    async fn new(endpoint: &str) -> Result<Self, TestBinaryError> {
+        let client = reqwest::Client::builder()
+            .timeout(Duration::from_secs(30))
+            .build()
+            .map_err(|e| TestBinaryError::FixtureSetup {
+                fixture_type: "SurrealDbContainer".to_string(),
+                message: format!("Failed to create SurrealDB HTTP client: 
{e}"),
+            })?;
+        let client = Self {
+            client,
+            base_url: format!("http://{endpoint}";),
+        };
+        client.signin().await?;
+        client.health().await?;
+        client.ensure_namespace_database().await?;
+        Ok(client)
+    }
+
+    pub async fn health(&self) -> Result<(), TestBinaryError> {
+        let response = self
+            .client
+            .get(format!("{}/health", self.base_url))
+            .send()
+            .await
+            .map_err(|e| TestBinaryError::FixtureSetup {
+                fixture_type: "SurrealDbContainer".to_string(),
+                message: format!("Failed to check SurrealDB health: {e}"),
+            })?;
+        let status = response.status();
+        if status.is_success() {
+            return Ok(());
+        }
+
+        let body = response
+            .text()
+            .await
+            .unwrap_or_else(|e| format!("failed to read response body: {e}"));
+        Err(TestBinaryError::FixtureSetup {
+            fixture_type: "SurrealDbContainer".to_string(),
+            message: format!("SurrealDB health check failed with HTTP status 
{status}: {body}"),
+        })
+    }
+
+    pub async fn query_result(&self, query: &str) -> Result<Value, 
TestBinaryError> {
+        let statements = self.execute_sql(query).await?;
+        statements
+            .into_iter()
+            .next()
+            .map(|statement| statement.result)
+            .ok_or_else(|| TestBinaryError::InvalidState {
+                message: "SurrealDB returned no SQL statements".to_string(),
+            })
+    }
+
+    async fn signin(&self) -> Result<(), TestBinaryError> {
+        let response = self
+            .client
+            .post(format!("{}/signin", self.base_url))
+            .json(&json!({
+                "user": ROOT_USERNAME,
+                "pass": ROOT_PASSWORD
+            }))
+            .send()
+            .await
+            .map_err(|e| TestBinaryError::FixtureSetup {
+                fixture_type: "SurrealDbContainer".to_string(),
+                message: format!("Failed to authenticate with SurrealDB: {e}"),
+            })?;
+        let status = response.status();
+        if status.is_success() {
+            return Ok(());
+        }
+
+        let body = response
+            .text()
+            .await
+            .unwrap_or_else(|e| format!("failed to read response body: {e}"));
+        Err(TestBinaryError::FixtureSetup {
+            fixture_type: "SurrealDbContainer".to_string(),
+            message: format!("Failed to authenticate with SurrealDB: HTTP 
status {status}: {body}"),
+        })
+    }
+
+    async fn ensure_namespace_database(&self) -> Result<(), TestBinaryError> {
+        let query = format!(
+            "DEFINE NAMESPACE IF NOT EXISTS {DEFAULT_NAMESPACE}; USE NS 
{DEFAULT_NAMESPACE}; DEFINE DATABASE IF NOT EXISTS {DEFAULT_DATABASE};"
+        );
+        self.execute_sql_request(&query, false).await.map(|_| ())
+    }
+
+    async fn execute_sql(&self, query: &str) -> 
Result<Vec<SurrealSqlStatement>, TestBinaryError> {
+        self.execute_sql_request(query, true).await
+    }
+
+    async fn execute_sql_request(
+        &self,
+        query: &str,
+        include_scope: bool,
+    ) -> Result<Vec<SurrealSqlStatement>, TestBinaryError> {
+        let mut request = self
+            .client
+            .post(format!("{}/sql", self.base_url))
+            .basic_auth(ROOT_USERNAME, Some(ROOT_PASSWORD))
+            .header("Accept", "application/json")
+            .header("Content-Type", "text/plain")
+            .body(query.to_string());
+
+        if include_scope {
+            request = request
+                .header("Surreal-NS", DEFAULT_NAMESPACE)
+                .header("Surreal-DB", DEFAULT_DATABASE);
+        }
+
+        let response = request
+            .send()
+            .await
+            .map_err(|e| TestBinaryError::InvalidState {
+                message: format!("Failed to execute SurrealDB query: {e}"),
+            })?;
+
+        let status = response.status();
+        let body = response
+            .text()
+            .await
+            .map_err(|e| TestBinaryError::InvalidState {
+                message: format!("Failed to read SurrealDB response: {e}"),
+            })?;
+
+        if !status.is_success() {
+            return Err(TestBinaryError::InvalidState {
+                message: format!("SurrealDB query failed with HTTP status 
{status}: {body}"),
+            });
+        }
+
+        let statements: Vec<SurrealSqlStatement> =
+            serde_json::from_str(&body).map_err(|e| 
TestBinaryError::InvalidState {
+                message: format!("Failed to decode SurrealDB response: {e}; 
response: {body}"),
+            })?;
+
+        if let Some(statement) = statements
+            .iter()
+            .find(|statement| !statement.status.eq_ignore_ascii_case("OK"))
+        {
+            return Err(TestBinaryError::InvalidState {
+                message: statement
+                    .detail
+                    .clone()
+                    .unwrap_or_else(|| format!("SurrealDB query status: {}", 
statement.status)),
+            });
+        }
+
+        Ok(statements)
+    }
+}
+
+pub struct SurrealDbContainer {
+    #[allow(dead_code)]
+    container: ContainerAsync<GenericImage>,
+    pub(super) endpoint: String,
+}
+
+impl SurrealDbContainer {
+    pub(super) async fn start() -> Result<Self, TestBinaryError> {
+        let container = GenericImage::new(SURREALDB_IMAGE, SURREALDB_TAG)
+            .with_exposed_port(SURREALDB_PORT.tcp())
+            .with_wait_for(WaitFor::message_on_stdout(SURREALDB_READY_MSG))
+            .with_mapped_port(0, SURREALDB_PORT.tcp())
+            .with_container_name(fixtures::unique_container_name("surrealdb"))
+            .with_cmd([
+                "start",
+                "--log",
+                "info",
+                "--user",
+                ROOT_USERNAME,
+                "--pass",
+                ROOT_PASSWORD,
+                "memory",
+            ])
+            .start()
+            .await
+            .map_err(|e| TestBinaryError::FixtureSetup {
+                fixture_type: "SurrealDbContainer".to_string(),
+                message: format!("Failed to start container: {e}"),
+            })?;
+
+        let mapped_port = container
+            .ports()
+            .await
+            .map_err(|e| TestBinaryError::FixtureSetup {
+                fixture_type: "SurrealDbContainer".to_string(),
+                message: format!("Failed to get ports: {e}"),
+            })?
+            .map_to_host_port_ipv4(SURREALDB_PORT)
+            .ok_or_else(|| TestBinaryError::FixtureSetup {
+                fixture_type: "SurrealDbContainer".to_string(),
+                message: "No mapping for SurrealDB port".to_string(),
+            })?;
+
+        let endpoint = format!("127.0.0.1:{mapped_port}");
+        let instance = Self {
+            container,
+            endpoint,
+        };
+        instance.wait_until_ready().await?;
+
+        info!("SurrealDB container available at {}", instance.endpoint);
+        Ok(instance)
+    }
+
+    pub async fn create_client(&self) -> Result<SurrealDbClient, 
TestBinaryError> {
+        SurrealDbClient::new(&self.endpoint).await
+    }
+
+    async fn wait_until_ready(&self) -> Result<(), TestBinaryError> {
+        let mut last_error = None;
+
+        for _ in 0..SURREALDB_BOOT_ATTEMPTS {
+            match self.create_client().await {
+                Ok(_) => return Ok(()),
+                Err(error) => last_error = Some(error.to_string()),
+            }
+            sleep(Duration::from_millis(SURREALDB_BOOT_INTERVAL_MS)).await;
+        }
+
+        let detail = last_error
+            .map(|error| format!(" Last error: {error}"))
+            .unwrap_or_default();
+        Err(TestBinaryError::FixtureSetup {
+            fixture_type: "SurrealDbContainer".to_string(),
+            message: format!("SurrealDB did not become ready.{detail}"),
+        })
+    }
+}
+
+pub trait SurrealDbOps: Sync {
+    fn container(&self) -> &SurrealDbContainer;
+
+    fn create_client(
+        &self,
+    ) -> impl std::future::Future<Output = Result<SurrealDbClient, 
TestBinaryError>> + Send {
+        self.container().create_client()
+    }
+}
diff --git a/core/integration/tests/connectors/fixtures/surrealdb/mod.rs 
b/core/integration/tests/connectors/fixtures/surrealdb/mod.rs
new file mode 100644
index 000000000..7e8e5fdad
--- /dev/null
+++ b/core/integration/tests/connectors/fixtures/surrealdb/mod.rs
@@ -0,0 +1,25 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod container;
+mod sink;
+
+pub use container::SurrealDbOps;
+pub use sink::{
+    SurrealDbSinkBatchFixture, SurrealDbSinkFixture, SurrealDbSinkJsonFixture,
+    SurrealDbSinkRawFixture,
+};
diff --git a/core/integration/tests/connectors/fixtures/surrealdb/sink.rs 
b/core/integration/tests/connectors/fixtures/surrealdb/sink.rs
new file mode 100644
index 000000000..59f972933
--- /dev/null
+++ b/core/integration/tests/connectors/fixtures/surrealdb/sink.rs
@@ -0,0 +1,220 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::container::{
+    DEFAULT_DATABASE, DEFAULT_NAMESPACE, DEFAULT_POLL_ATTEMPTS, 
DEFAULT_POLL_INTERVAL_MS,
+    DEFAULT_TABLE, DEFAULT_TEST_STREAM, DEFAULT_TEST_TOPIC, 
ENV_SINK_AUTH_SCOPE,
+    ENV_SINK_AUTO_DEFINE_TABLE, ENV_SINK_BATCH_SIZE, ENV_SINK_DATABASE, 
ENV_SINK_DEFINE_INDEXES,
+    ENV_SINK_ENDPOINT, ENV_SINK_NAMESPACE, ENV_SINK_PASSWORD, ENV_SINK_PATH,
+    ENV_SINK_PAYLOAD_FORMAT, ENV_SINK_STREAMS_0_CONSUMER_GROUP, 
ENV_SINK_STREAMS_0_SCHEMA,
+    ENV_SINK_STREAMS_0_STREAM, ENV_SINK_STREAMS_0_TOPICS, ENV_SINK_TABLE, 
ENV_SINK_USERNAME,
+    ROOT_PASSWORD, ROOT_USERNAME, SurrealDbClient, SurrealDbContainer, 
SurrealDbOps,
+};
+use async_trait::async_trait;
+use integration::harness::{TestBinaryError, TestFixture};
+use serde_json::{Value, json};
+use std::collections::HashMap;
+use std::marker::PhantomData;
+use std::time::Duration;
+use tokio::time::sleep;
+use tracing::info;
+
+pub trait SurrealDbSinkProfile {
+    const SCHEMA: &'static str;
+    const BATCH_SIZE: Option<usize>;
+}
+
+pub struct SurrealDbSinkJsonProfile;
+pub struct SurrealDbSinkRawProfile;
+pub struct SurrealDbSinkBatchProfile;
+
+impl SurrealDbSinkProfile for SurrealDbSinkJsonProfile {
+    const SCHEMA: &'static str = "json";
+    const BATCH_SIZE: Option<usize> = None;
+}
+
+impl SurrealDbSinkProfile for SurrealDbSinkRawProfile {
+    const SCHEMA: &'static str = "raw";
+    const BATCH_SIZE: Option<usize> = None;
+}
+
+impl SurrealDbSinkProfile for SurrealDbSinkBatchProfile {
+    const SCHEMA: &'static str = "json";
+    const BATCH_SIZE: Option<usize> = Some(10);
+}
+
+pub type SurrealDbSinkJsonFixture = 
SurrealDbSinkFixture<SurrealDbSinkJsonProfile>;
+pub type SurrealDbSinkRawFixture = 
SurrealDbSinkFixture<SurrealDbSinkRawProfile>;
+pub type SurrealDbSinkBatchFixture = 
SurrealDbSinkFixture<SurrealDbSinkBatchProfile>;
+
+pub struct SurrealDbSinkFixture<P = SurrealDbSinkJsonProfile> {
+    container: SurrealDbContainer,
+    profile: PhantomData<P>,
+}
+
+impl<P> SurrealDbOps for SurrealDbSinkFixture<P>
+where
+    P: Sync,
+{
+    fn container(&self) -> &SurrealDbContainer {
+        &self.container
+    }
+}
+
+impl<P> SurrealDbSinkFixture<P> {
+    pub async fn wait_for_records(
+        &self,
+        client: &SurrealDbClient,
+        expected: usize,
+    ) -> Result<Vec<Value>, TestBinaryError> {
+        for _ in 0..DEFAULT_POLL_ATTEMPTS {
+            let records = self.select_all_records(client).await?;
+            if records.len() >= expected {
+                info!(
+                    "Found {} records in SurrealDB table '{DEFAULT_TABLE}'",
+                    records.len()
+                );
+                return Ok(records);
+            }
+            sleep(Duration::from_millis(DEFAULT_POLL_INTERVAL_MS)).await;
+        }
+
+        Err(TestBinaryError::InvalidState {
+            message: format!(
+                "Expected at least {expected} SurrealDB records after 
{DEFAULT_POLL_ATTEMPTS} attempts"
+            ),
+        })
+    }
+
+    pub async fn select_all_records(
+        &self,
+        client: &SurrealDbClient,
+    ) -> Result<Vec<Value>, TestBinaryError> {
+        let query = format!("SELECT * FROM {DEFAULT_TABLE};");
+        let value = client.query_result(&query).await?;
+        decode_records_sorted_by_offset(value)
+    }
+
+    pub async fn select_records_by_message_id(
+        &self,
+        client: &SurrealDbClient,
+        message_id: u128,
+    ) -> Result<Vec<Value>, TestBinaryError> {
+        let message_id = 
serde_json::to_string(&message_id.to_string()).map_err(|e| {
+            TestBinaryError::InvalidState {
+                message: format!("Failed to encode SurrealDB message id: {e}"),
+            }
+        })?;
+        let query = format!("SELECT * FROM {DEFAULT_TABLE} WHERE 
iggy_message_id = {message_id};");
+        let value = client.query_result(&query).await?;
+        decode_records_sorted_by_offset(value)
+    }
+
+    pub async fn insert_preseeded_record(
+        &self,
+        client: &SurrealDbClient,
+        record_id: &str,
+        message_id: u128,
+    ) -> Result<(), TestBinaryError> {
+        let records = serde_json::to_string(&json!([
+            {
+                "id": record_id,
+                "iggy_message_id": message_id.to_string(),
+                "seed_marker": "preseed-unchanged",
+                "payload": "preseeded"
+            }
+        ]))
+        .map_err(|e| TestBinaryError::InvalidState {
+            message: format!("Failed to encode SurrealDB preseed record: {e}"),
+        })?;
+        let query = format!("INSERT INTO {DEFAULT_TABLE} {records} RETURN 
NONE;");
+        client.query_result(&query).await.map(|_| ())
+    }
+}
+
+fn decode_records_sorted_by_offset(value: Value) -> Result<Vec<Value>, 
TestBinaryError> {
+    let mut records: Vec<Value> =
+        serde_json::from_value(value).map_err(|e| 
TestBinaryError::InvalidState {
+            message: format!("Failed to decode SurrealDB records: {e}"),
+        })?;
+    records.sort_by_key(|record| {
+        record
+            .get("iggy_offset")
+            .and_then(Value::as_str)
+            .and_then(|offset| offset.parse::<u64>().ok())
+            .unwrap_or(u64::MAX)
+    });
+
+    Ok(records)
+}
+
+#[async_trait]
+impl<P> TestFixture for SurrealDbSinkFixture<P>
+where
+    P: SurrealDbSinkProfile + Send + Sync,
+{
+    async fn setup() -> Result<Self, TestBinaryError> {
+        let container = SurrealDbContainer::start().await?;
+        Ok(Self {
+            container,
+            profile: PhantomData,
+        })
+    }
+
+    fn connectors_runtime_envs(&self) -> HashMap<String, String> {
+        let mut envs = HashMap::new();
+        envs.insert(
+            ENV_SINK_ENDPOINT.to_string(),
+            self.container.endpoint.clone(),
+        );
+        envs.insert(
+            ENV_SINK_NAMESPACE.to_string(),
+            DEFAULT_NAMESPACE.to_string(),
+        );
+        envs.insert(ENV_SINK_DATABASE.to_string(), 
DEFAULT_DATABASE.to_string());
+        envs.insert(ENV_SINK_TABLE.to_string(), DEFAULT_TABLE.to_string());
+        envs.insert(ENV_SINK_USERNAME.to_string(), ROOT_USERNAME.to_string());
+        envs.insert(ENV_SINK_PASSWORD.to_string(), ROOT_PASSWORD.to_string());
+        envs.insert(ENV_SINK_AUTH_SCOPE.to_string(), "root".to_string());
+        envs.insert(ENV_SINK_AUTO_DEFINE_TABLE.to_string(), 
"true".to_string());
+        envs.insert(ENV_SINK_DEFINE_INDEXES.to_string(), "true".to_string());
+        envs.insert(ENV_SINK_PAYLOAD_FORMAT.to_string(), "auto".to_string());
+        envs.insert(
+            ENV_SINK_STREAMS_0_STREAM.to_string(),
+            DEFAULT_TEST_STREAM.to_string(),
+        );
+        envs.insert(
+            ENV_SINK_STREAMS_0_TOPICS.to_string(),
+            format!("[{}]", DEFAULT_TEST_TOPIC),
+        );
+        envs.insert(ENV_SINK_STREAMS_0_SCHEMA.to_string(), 
P::SCHEMA.to_string());
+        envs.insert(
+            ENV_SINK_STREAMS_0_CONSUMER_GROUP.to_string(),
+            format!("surrealdb_sink_{}_cg", P::SCHEMA),
+        );
+        envs.insert(
+            ENV_SINK_PATH.to_string(),
+            "../../target/debug/libiggy_connector_surrealdb_sink".to_string(),
+        );
+
+        if let Some(batch_size) = P::BATCH_SIZE {
+            envs.insert(ENV_SINK_BATCH_SIZE.to_string(), 
batch_size.to_string());
+        }
+
+        envs
+    }
+}
diff --git a/core/integration/tests/connectors/mod.rs 
b/core/integration/tests/connectors/mod.rs
index 865712175..0991b75dd 100644
--- a/core/integration/tests/connectors/mod.rs
+++ b/core/integration/tests/connectors/mod.rs
@@ -32,6 +32,7 @@ mod random_source_liveness;
 mod runtime;
 mod s3;
 mod stdout;
+mod surrealdb;
 
 use iggy_common::IggyTimestamp;
 use serde::{Deserialize, Serialize};
diff --git a/core/integration/tests/connectors/surrealdb/mod.rs 
b/core/integration/tests/connectors/surrealdb/mod.rs
new file mode 100644
index 000000000..7bbb19039
--- /dev/null
+++ b/core/integration/tests/connectors/surrealdb/mod.rs
@@ -0,0 +1,23 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod surrealdb_sink;
+
+const TEST_MESSAGE_COUNT: usize = 3;
+const LARGE_BATCH_COUNT: usize = 50;
+const POLL_ATTEMPTS: usize = 120;
+const POLL_INTERVAL_MS: u64 = 50;
diff --git a/core/integration/tests/connectors/surrealdb/sink.toml 
b/core/integration/tests/connectors/surrealdb/sink.toml
new file mode 100644
index 000000000..4f980f882
--- /dev/null
+++ b/core/integration/tests/connectors/surrealdb/sink.toml
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[connectors]
+config_type = "local"
+config_dir = "../connectors/sinks/surrealdb_sink"
diff --git a/core/integration/tests/connectors/surrealdb/surrealdb_sink.rs 
b/core/integration/tests/connectors/surrealdb/surrealdb_sink.rs
new file mode 100644
index 000000000..9205760c4
--- /dev/null
+++ b/core/integration/tests/connectors/surrealdb/surrealdb_sink.rs
@@ -0,0 +1,354 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::{LARGE_BATCH_COUNT, POLL_ATTEMPTS, POLL_INTERVAL_MS, 
TEST_MESSAGE_COUNT};
+use crate::connectors::fixtures::{
+    SurrealDbOps, SurrealDbSinkBatchFixture, SurrealDbSinkFixture, 
SurrealDbSinkJsonFixture,
+    SurrealDbSinkRawFixture,
+};
+use bytes::Bytes;
+use iggy::prelude::{IggyMessage, Partitioning};
+use iggy_common::Identifier;
+use iggy_common::MessageClient;
+use integration::harness::seeds;
+use integration::iggy_harness;
+use serde_json::Value;
+use std::time::Duration;
+use tokio::time::sleep;
+
+fn build_expected_record_id(message_id: u128, offset: u64) -> String {
+    let mut id = String::new();
+    id.push('s');
+    push_hex_component(&mut id, seeds::names::STREAM.as_bytes());
+    id.push_str("_t");
+    push_hex_component(&mut id, seeds::names::TOPIC.as_bytes());
+    id.push_str("_p0_o");
+    id.push_str(&offset.to_string());
+    id.push_str("_m");
+    id.push_str(&format!("{message_id:032x}"));
+    id
+}
+
+fn push_hex_component(out: &mut String, bytes: &[u8]) {
+    const HEX: &[u8; 16] = b"0123456789abcdef";
+
+    for byte in bytes {
+        out.push(HEX[(byte >> 4) as usize] as char);
+        out.push(HEX[(byte & 0x0f) as usize] as char);
+    }
+}
+
+#[iggy_harness(
+    server(connectors_runtime(config_path = 
"tests/connectors/surrealdb/sink.toml")),
+    seed = seeds::connector_stream
+)]
+async fn json_messages_sink_to_surrealdb(harness: &TestHarness, fixture: 
SurrealDbSinkJsonFixture) {
+    let client = harness.root_client().await.unwrap();
+    let surreal_client = fixture
+        .create_client()
+        .await
+        .expect("Failed to create SurrealDB client");
+
+    let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+    let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+
+    let payloads = [
+        serde_json::json!({"name": "Alice", "score": 10}),
+        serde_json::json!({"name": "Bob", "score": 20}),
+        serde_json::json!({"name": "Carol", "score": 30}),
+    ];
+    let mut messages: Vec<IggyMessage> = payloads
+        .iter()
+        .enumerate()
+        .map(|(idx, payload)| {
+            IggyMessage::builder()
+                .id((idx + 1) as u128)
+                .payload(Bytes::from(
+                    serde_json::to_vec(payload).expect("Failed to serialize 
payload"),
+                ))
+                .build()
+                .expect("Failed to build message")
+        })
+        .collect();
+
+    client
+        .send_messages(
+            &stream_id,
+            &topic_id,
+            &Partitioning::partition_id(0),
+            &mut messages,
+        )
+        .await
+        .expect("Failed to send messages");
+
+    let records = fixture
+        .wait_for_records(&surreal_client, TEST_MESSAGE_COUNT)
+        .await
+        .expect("Records did not appear in SurrealDB");
+
+    assert_eq!(records.len(), TEST_MESSAGE_COUNT);
+    for (idx, record) in records.iter().enumerate() {
+        assert_eq!(
+            record["iggy_message_id"],
+            Value::String((idx + 1).to_string())
+        );
+        assert_eq!(
+            record["iggy_stream"],
+            Value::String(seeds::names::STREAM.to_string())
+        );
+        assert_eq!(
+            record["iggy_topic"],
+            Value::String(seeds::names::TOPIC.to_string())
+        );
+        assert_eq!(record["iggy_partition_id"], 
Value::String("0".to_string()));
+        assert_eq!(record["iggy_offset"], Value::String(idx.to_string()));
+        assert_eq!(
+            record["payload_encoding"],
+            Value::String("json".to_string())
+        );
+        assert_eq!(record["payload"], payloads[idx]);
+        assert_eq!(
+            record["id"].as_str().expect("record id should be string"),
+            format!(
+                "iggy_messages:{}",
+                build_expected_record_id((idx + 1) as u128, idx as u64)
+            )
+        );
+    }
+}
+
+#[iggy_harness(
+    server(connectors_runtime(config_path = 
"tests/connectors/surrealdb/sink.toml")),
+    seed = seeds::connector_stream
+)]
+async fn raw_messages_sink_as_base64(harness: &TestHarness, fixture: 
SurrealDbSinkRawFixture) {
+    let client = harness.root_client().await.unwrap();
+    let surreal_client = fixture
+        .create_client()
+        .await
+        .expect("Failed to create SurrealDB client");
+
+    let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+    let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+
+    let payloads: Vec<Vec<u8>> = vec![
+        b"plain text".to_vec(),
+        vec![0x00, 0x01, 0x02, 0xff],
+        vec![0xde, 0xad, 0xbe, 0xef],
+    ];
+    let mut messages: Vec<IggyMessage> = payloads
+        .iter()
+        .enumerate()
+        .map(|(idx, payload)| {
+            IggyMessage::builder()
+                .id((idx + 1) as u128)
+                .payload(Bytes::from(payload.clone()))
+                .build()
+                .expect("Failed to build message")
+        })
+        .collect();
+
+    client
+        .send_messages(
+            &stream_id,
+            &topic_id,
+            &Partitioning::partition_id(0),
+            &mut messages,
+        )
+        .await
+        .expect("Failed to send messages");
+
+    let records = fixture
+        .wait_for_records(&surreal_client, payloads.len())
+        .await
+        .expect("Records did not appear in SurrealDB");
+
+    assert_eq!(records.len(), payloads.len());
+    let expected_payloads = ["cGxhaW4gdGV4dA==", "AAEC/w==", "3q2+7w=="];
+    for (idx, record) in records.iter().enumerate() {
+        assert_eq!(
+            record["payload_encoding"],
+            Value::String("base64".to_string())
+        );
+        assert_eq!(
+            record["payload"],
+            Value::String(expected_payloads[idx].to_string())
+        );
+    }
+}
+
+#[iggy_harness(
+    server(connectors_runtime(config_path = 
"tests/connectors/surrealdb/sink.toml")),
+    seed = seeds::connector_stream
+)]
+async fn large_batch_processed_in_chunks(
+    harness: &TestHarness,
+    fixture: SurrealDbSinkBatchFixture,
+) {
+    let client = harness.root_client().await.unwrap();
+    let surreal_client = fixture
+        .create_client()
+        .await
+        .expect("Failed to create SurrealDB client");
+
+    let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+    let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+
+    let mut messages: Vec<IggyMessage> = (0..LARGE_BATCH_COUNT)
+        .map(|idx| {
+            IggyMessage::builder()
+                .id((idx + 1) as u128)
+                .payload(Bytes::from(
+                    serde_json::to_vec(&serde_json::json!({"idx": idx}))
+                        .expect("Failed to serialize payload"),
+                ))
+                .build()
+                .expect("Failed to build message")
+        })
+        .collect();
+
+    client
+        .send_messages(
+            &stream_id,
+            &topic_id,
+            &Partitioning::partition_id(0),
+            &mut messages,
+        )
+        .await
+        .expect("Failed to send messages");
+
+    let records = fixture
+        .wait_for_records(&surreal_client, LARGE_BATCH_COUNT)
+        .await
+        .expect("Records did not appear in SurrealDB");
+
+    assert_eq!(records.len(), LARGE_BATCH_COUNT);
+    for (idx, record) in records.iter().enumerate() {
+        assert_eq!(record["iggy_offset"], Value::String(idx.to_string()));
+        assert_eq!(record["payload"], serde_json::json!({"idx": idx}));
+    }
+}
+
+#[iggy_harness(
+    server(connectors_runtime(config_path = 
"tests/connectors/surrealdb/sink.toml")),
+    seed = seeds::connector_stream
+)]
+async fn duplicate_record_id_is_idempotent_replay_not_overwrite(
+    harness: &TestHarness,
+    fixture: SurrealDbSinkFixture,
+) {
+    let client = harness.root_client().await.unwrap();
+    let surreal_client = fixture
+        .create_client()
+        .await
+        .expect("Failed to create SurrealDB client");
+
+    let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+    let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+
+    fixture
+        .insert_preseeded_record(&surreal_client, &build_expected_record_id(2, 
1), 2)
+        .await
+        .expect("Failed to preseed duplicate record");
+
+    let mut messages: Vec<IggyMessage> = vec![
+        IggyMessage::builder()
+            .id(1)
+            .payload(Bytes::from_static(br#"{"message":"one"}"#))
+            .build()
+            .expect("Failed to build message 1"),
+        IggyMessage::builder()
+            .id(2)
+            .payload(Bytes::from_static(br#"{"message":"two"}"#))
+            .build()
+            .expect("Failed to build message 2"),
+        IggyMessage::builder()
+            .id(3)
+            .payload(Bytes::from_static(br#"{"message":"three"}"#))
+            .build()
+            .expect("Failed to build message 3"),
+    ];
+
+    client
+        .send_messages(
+            &stream_id,
+            &topic_id,
+            &Partitioning::partition_id(0),
+            &mut messages,
+        )
+        .await
+        .expect("Failed to send duplicate batch");
+
+    let mut id1_inserted = false;
+    let mut id3_inserted = false;
+
+    for _ in 0..POLL_ATTEMPTS {
+        id1_inserted = !fixture
+            .select_records_by_message_id(&surreal_client, 1)
+            .await
+            .expect("Failed to query id 1")
+            .is_empty();
+        id3_inserted = !fixture
+            .select_records_by_message_id(&surreal_client, 3)
+            .await
+            .expect("Failed to query id 3")
+            .is_empty();
+
+        if id1_inserted && id3_inserted {
+            break;
+        }
+
+        sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
+    }
+
+    assert!(
+        id1_inserted,
+        "Expected first non-duplicate record to be inserted"
+    );
+    assert!(
+        id3_inserted,
+        "Expected suffix record after duplicate to be inserted"
+    );
+
+    let duplicate_records = fixture
+        .select_records_by_message_id(&surreal_client, 2)
+        .await
+        .expect("Failed to query duplicate record");
+    assert_eq!(
+        duplicate_records.len(),
+        1,
+        "Duplicate replay should not create extra records"
+    );
+    assert_eq!(
+        duplicate_records[0]["seed_marker"],
+        Value::String("preseed-unchanged".to_string()),
+        "Existing record must not be overwritten by replay"
+    );
+    assert_eq!(
+        duplicate_records[0]["payload"],
+        Value::String("preseeded".to_string()),
+        "Existing record payload must remain unchanged"
+    );
+    assert_eq!(
+        fixture
+            .select_all_records(&surreal_client)
+            .await
+            .expect("Failed to select all records")
+            .len(),
+        3
+    );
+}
diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh
index aa4b08651..d1e44e334 100755
--- a/scripts/bump-version.sh
+++ b/scripts/bump-version.sh
@@ -87,7 +87,7 @@ EOF
 }
 
 RUST_COMPONENTS="rust-sdk rust-common rust-binary-protocol rust-server 
rust-cli rust-connector-sdk rust-mcp rust-bench rust-bench-dashboard-frontend 
rust-bench-dashboard-server rust-bench-report"
-CONNECTOR_SINK_COMPONENTS="rust-connector-delta-sink 
rust-connector-elasticsearch-sink rust-connector-http-sink 
rust-connector-iceberg-sink rust-connector-influxdb-sink 
rust-connector-mongodb-sink rust-connector-postgres-sink 
rust-connector-quickwit-sink rust-connector-stdout-sink"
+CONNECTOR_SINK_COMPONENTS="rust-connector-delta-sink 
rust-connector-elasticsearch-sink rust-connector-http-sink 
rust-connector-iceberg-sink rust-connector-influxdb-sink 
rust-connector-mongodb-sink rust-connector-postgres-sink 
rust-connector-quickwit-sink rust-connector-stdout-sink 
rust-connector-surrealdb-sink"
 CONNECTOR_SOURCE_COMPONENTS="rust-connector-elasticsearch-source 
rust-connector-influxdb-source rust-connector-postgres-source 
rust-connector-random-source"
 CONNECTOR_COMPONENTS="rust-connector-runtime ${CONNECTOR_SINK_COMPONENTS} 
${CONNECTOR_SOURCE_COMPONENTS}"
 SDK_COMPONENTS="sdk-python sdk-node sdk-go sdk-csharp sdk-java"

Reply via email to