kogral/docs/architecture/adrs/006-surrealdb-v3-engine-abstraction.md
Jesús Pérez 1329eb509f
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
feat(core): add SurrealDB v3 engine abstraction, NATS event publishing, and storage factory
Key changes: new events.rs (NATS EventingStorage decorator), storage/factory.rs (backend selection), orchestration.rs, SurrealDB v3
  engine upgrade, expanded Nickel schemas, and two new ADRs (006, 007).
2026-02-22 21:51:53 +00:00

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)