242 lines
7.0 KiB
Markdown
242 lines
7.0 KiB
Markdown
|
|
# ADR-006: SurrealDB 3.0 Engine Abstraction
|
||
|
|
|
||
|
|
**Status**: Accepted
|
||
|
|
|
||
|
|
**Date**: 2026-02-21
|
||
|
|
|
||
|
|
**Deciders**: Architecture Team
|
||
|
|
|
||
|
|
**Supersedes**: [ADR-003: Hybrid Storage Strategy](003-hybrid-storage.md) (SurrealDB connection model)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Context
|
||
|
|
|
||
|
|
SurrealDB 2.x required a concrete connection type at compile time (`Surreal<Ws>`, `Surreal<Mem>`,
|
||
|
|
etc.), forcing a single engine choice per binary. This created three problems:
|
||
|
|
|
||
|
|
1. **No runtime engine selection**: switching from embedded to remote required recompilation
|
||
|
|
2. **Tight test coupling**: tests depended on whichever engine was compiled in
|
||
|
|
3. **No dual-layout support**: using SurrealKV for graph data and RocksDB for hot data required
|
||
|
|
separate crates or unsafe casting
|
||
|
|
|
||
|
|
SurrealDB 3.0 introduces `surrealdb::engine::any::connect(url)` returning `Surreal<Any>` — a
|
||
|
|
type-erased connection dispatched at runtime by URL scheme:
|
||
|
|
|
||
|
|
| URL scheme | Engine | Characteristics |
|
||
|
|
|---|---|---|
|
||
|
|
| `mem://` | In-memory | Ephemeral, test isolation |
|
||
|
|
| `surrealkv://path` | SurrealKV (B-tree) | Embedded, relational/graph data |
|
||
|
|
| `rocksdb://path` | RocksDB (LSM) | Embedded, append-heavy hot data |
|
||
|
|
| `ws://host:port` | WebSocket | Remote, team/shared deployment |
|
||
|
|
|
||
|
|
This makes the engine a pure config concern.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Decision
|
||
|
|
|
||
|
|
**Use `Surreal<Any>` throughout `SurrealDbStorage`, selecting the engine from `SurrealEngineConfig`
|
||
|
|
at runtime via URL dispatch.**
|
||
|
|
|
||
|
|
### Config Schema
|
||
|
|
|
||
|
|
`SurrealEngineConfig` is a serde-tagged enum serialized with `tag = "engine"`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
#[serde(tag = "engine", rename_all = "snake_case")]
|
||
|
|
pub enum SurrealEngineConfig {
|
||
|
|
Mem,
|
||
|
|
SurrealKv { path: String },
|
||
|
|
RocksDb { path: String },
|
||
|
|
Ws { url: String },
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`to_url()` maps variants to the URL scheme `engine::any::connect` expects:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
impl SurrealEngineConfig {
|
||
|
|
pub fn to_url(&self) -> String {
|
||
|
|
match self {
|
||
|
|
Self::Mem => "mem://".to_string(),
|
||
|
|
Self::SurrealKv { path } => format!("surrealkv://{path}"),
|
||
|
|
Self::RocksDb { path } => format!("rocksdb://{path}"),
|
||
|
|
Self::Ws { url } => url.clone(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Dual Hot/Cold Layout
|
||
|
|
|
||
|
|
`SurrealDbStorage` holds two independent `Surreal<Any>` connections:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
pub struct SurrealDbStorage {
|
||
|
|
graph_db: Surreal<Any>, // SurrealKV default — nodes, edges, metadata
|
||
|
|
hot_db: Surreal<Any>, // RocksDB default — embeddings, session logs
|
||
|
|
namespace: String,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Default production layout:
|
||
|
|
|
||
|
|
```nickel
|
||
|
|
secondary.surrealdb = {
|
||
|
|
graph = { engine = "surreal_kv", path = ".kogral/db/graph" },
|
||
|
|
hot = { engine = "rocks_db", path = ".kogral/db/hot" },
|
||
|
|
namespace = "kogral",
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Test layout (in-memory, no filesystem side effects):
|
||
|
|
|
||
|
|
```nickel
|
||
|
|
secondary.surrealdb = {
|
||
|
|
graph = { engine = "mem" },
|
||
|
|
hot = { engine = "mem" },
|
||
|
|
namespace = "test",
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Remote team deployment (single SurrealDB instance, two databases):
|
||
|
|
|
||
|
|
```nickel
|
||
|
|
secondary.surrealdb = {
|
||
|
|
graph = { engine = "ws", url = "ws://kb.company.com:8000" },
|
||
|
|
hot = { engine = "ws", url = "ws://kb.company.com:8000" },
|
||
|
|
namespace = "engineering",
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Storage Factory
|
||
|
|
|
||
|
|
`storage::factory::build()` performs the runtime dispatch, keeping SurrealDB-specific logic
|
||
|
|
behind the `surrealdb-backend` feature gate:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
#[allow(clippy::unused_async)]
|
||
|
|
pub async fn build(config: &StorageConfig, base_path: PathBuf) -> Result<Box<dyn Storage>> {
|
||
|
|
#[cfg(feature = "surrealdb-backend")]
|
||
|
|
if config.secondary.enabled && config.secondary.storage_type == SecondaryStorageType::Surrealdb {
|
||
|
|
let db = SurrealDbStorage::from_config(&config.secondary.surrealdb).await?;
|
||
|
|
return Ok(Box::new(db));
|
||
|
|
}
|
||
|
|
Ok(match config.primary {
|
||
|
|
StorageType::Memory => Box::new(MemoryStorage::new()),
|
||
|
|
StorageType::Filesystem => Box::new(FilesystemStorage::new(base_path)),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Type Erasure Composition
|
||
|
|
|
||
|
|
`impl Storage for Box<dyn Storage>` enables `EventingStorage<Box<dyn Storage>>` without
|
||
|
|
knowing the concrete backend at compile time:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
#[async_trait]
|
||
|
|
impl Storage for Box<dyn Storage> {
|
||
|
|
async fn save_graph(&mut self, graph: &Graph) -> Result<()> {
|
||
|
|
(**self).save_graph(graph).await
|
||
|
|
}
|
||
|
|
// ... full delegation for all methods
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### CRUD via `serde_json::Value`
|
||
|
|
|
||
|
|
SurrealDB 3.0 removed the `IntoSurrealValue`/`SurrealValue` traits. All CRUD goes through
|
||
|
|
`serde_json::Value` as the intermediary:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// save_node
|
||
|
|
let row = serde_json::to_value(node)
|
||
|
|
.map_err(|e| KbError::Storage(e.to_string()))?;
|
||
|
|
let _: Option<serde_json::Value> = self.graph_db
|
||
|
|
.upsert(("nodes", format!("{graph_name}__{}", node.id)))
|
||
|
|
.content(row)
|
||
|
|
.await
|
||
|
|
.map_err(|e| KbError::Storage(e.to_string()))?;
|
||
|
|
```
|
||
|
|
|
||
|
|
`.bind()` parameters require `'static` values — `&str` arguments must be `.to_string()`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
let nodes: Vec<Node> = self.graph_db
|
||
|
|
.query("SELECT * FROM nodes WHERE project = $g")
|
||
|
|
.bind(("g", graph_name.to_string())) // .to_string() required
|
||
|
|
.await...
|
||
|
|
```
|
||
|
|
|
||
|
|
### Error Handling
|
||
|
|
|
||
|
|
`KbError::Database(#[from] surrealdb::Error)` was removed. All SurrealDB errors convert via
|
||
|
|
`map_err(|e| KbError::Storage(e.to_string()))`, avoiding `#[from]` coupling to a feature-gated
|
||
|
|
type that would break compilation on default features.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Consequences
|
||
|
|
|
||
|
|
### Positive
|
||
|
|
|
||
|
|
- Engine selection is a config value, not a compile-time decision
|
||
|
|
- Tests run fully in-memory (`engine = "mem"`) with zero filesystem side effects
|
||
|
|
- Dual layout (SurrealKV + RocksDB) is the embedded production default
|
||
|
|
- Remote deployment (WebSocket) requires only a config change
|
||
|
|
- `Storage` trait consumers never see SurrealDB types — `Box<dyn Storage>` is the boundary
|
||
|
|
|
||
|
|
### Negative
|
||
|
|
|
||
|
|
- `Surreal<Any>` has slightly higher dispatch overhead than concrete types (negligible vs. I/O)
|
||
|
|
- `serde_json::Value` intermediary adds one extra allocation per CRUD call
|
||
|
|
- `engine::any` requires all four engine feature flags compiled in when `surrealdb-backend` is
|
||
|
|
enabled (larger binary)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Feature Matrix
|
||
|
|
|
||
|
|
```toml
|
||
|
|
[features]
|
||
|
|
default = ["filesystem"]
|
||
|
|
filesystem = []
|
||
|
|
surrealdb-backend = ["dep:surrealdb"]
|
||
|
|
nats-events = ["dep:platform-nats", "dep:bytes"]
|
||
|
|
orchestration = ["nats-events", "dep:stratum-orchestrator"]
|
||
|
|
full = ["surrealdb-backend", "nats-events", "orchestration"]
|
||
|
|
```
|
||
|
|
|
||
|
|
SurrealDB dependency activates all four engines:
|
||
|
|
|
||
|
|
```toml
|
||
|
|
surrealdb = { workspace = true, optional = true,
|
||
|
|
features = ["kv-surrealkv", "kv-rocksdb", "protocol-ws", "rustls"] }
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## References
|
||
|
|
|
||
|
|
- [SurrealDB `engine::any` docs](https://surrealdb.com/docs/sdk/rust/setup)
|
||
|
|
- [SurrealDbStorage](../../../crates/kogral-core/src/storage/surrealdb.rs)
|
||
|
|
- [Storage Factory](../../../crates/kogral-core/src/storage/factory.rs)
|
||
|
|
- [Config Schema](../../../crates/kogral-core/src/config/schema.rs)
|
||
|
|
- [Nickel Defaults](../../../schemas/kogral/defaults.ncl)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Revision History
|
||
|
|
|
||
|
|
| Date | Author | Change |
|
||
|
|
|---|---|---|
|
||
|
|
| 2026-02-21 | Architecture Team | Initial decision — SurrealDB 3.0 + engine::any |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
**Previous ADR**: [ADR-005: MCP Protocol](005-mcp-protocol.md)
|
||
|
|
|
||
|
|
**Next ADR**: [ADR-007: NATS Event Publishing](007-nats-event-publishing.md)
|