feat: schema projections, command transactions, quantum-secure encryption

This commit is contained in:
Will Anderson
2026-04-27 18:26:46 -05:00
parent 69410a6908
commit 192528543f
27 changed files with 5460 additions and 4 deletions
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "engram-tx"
version = "0.1.0"
edition = "2021"
description = "Command pattern + rollback-of-rollback transaction engine for Engram"
license = "MIT"
[dependencies]
engram-core = { path = "../engram-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "1"
sled = "0.34"
[dev-dependencies]
tempfile = "3"
+166
View File
@@ -0,0 +1,166 @@
/// Command — the first-class mutation unit of Engram's transaction system.
///
/// A command is an immutable record of intent. Once created, its ID, type,
/// idempotency key, and causal parent never change. Status and conflict fields
/// are the only mutable parts, updated as the command moves through its lifecycle.
use engram_core::types::MemoryTier;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
/// The operation a command performs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CommandType {
/// Create a new node. `payload` contains the node's fields.
CreateNode,
/// Update an existing node's content/metadata. `payload` contains the new fields.
UpdateNode,
/// Delete a node by UUID.
DeleteNode,
/// Create an edge between two nodes.
CreateEdge,
/// Delete an edge by its from/to pair.
DeleteEdge,
/// Update a node's salience score.
UpdateSalience,
/// Batch import of many nodes/edges.
BulkImport,
/// Roll back a previously applied command. The UUID is the target command's ID.
Rollback(Uuid),
}
/// Lifecycle status of a command.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CommandStatus {
/// Created but not yet applied.
Pending,
/// Successfully applied to the database.
Applied,
/// Rolled back (a subsequent Rollback command was applied).
RolledBack,
/// Applied but conflicted with another command; conflict details in the log.
Conflicted,
}
/// A command in Engram's append-only mutation log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Command {
/// Stable unique identifier for this command.
pub id: Uuid,
/// The operation this command performs.
pub command_type: CommandType,
/// The forward operation data — what to do.
pub payload: Value,
/// The inverse operation data — computed eagerly at command creation time.
/// This is the "undo" data, available even if the graph has changed since.
pub inverse_payload: Value,
/// Dedup key. If a command with this idempotency key has already been applied,
/// the new command is a no-op. Prevents double-application in distributed sync.
pub idempotency_key: String,
/// The command that caused this one, if any (forms the causal DAG).
pub causal_parent: Option<Uuid>,
/// Unix milliseconds when this command was created.
pub timestamp_ms: i64,
/// Current lifecycle status.
pub status: CommandStatus,
/// Which peer originated this command (for conflict resolution).
pub peer_id: Option<Uuid>,
/// If conflicted, a human-readable description of the conflict.
pub conflict_note: Option<String>,
}
impl Command {
/// Create a new pending command.
pub fn new(
command_type: CommandType,
payload: Value,
inverse_payload: Value,
idempotency_key: impl Into<String>,
causal_parent: Option<Uuid>,
peer_id: Option<Uuid>,
) -> Self {
Self {
id: Uuid::new_v4(),
command_type,
payload,
inverse_payload,
idempotency_key: idempotency_key.into(),
causal_parent,
timestamp_ms: engram_core::types::now_ms(),
status: CommandStatus::Pending,
peer_id,
conflict_note: None,
}
}
}
/// The result of successfully applying a command.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandResult {
/// The command that was applied.
pub command_id: Uuid,
/// The command's new status.
pub status: CommandStatus,
/// Any entity UUID produced by the operation (e.g., the new node's UUID).
pub produced_id: Option<Uuid>,
/// Whether this was a no-op due to idempotency.
pub was_idempotent: bool,
}
// ── Payload schema helpers ────────────────────────────────────────────────────
/// Payload for CreateNode commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateNodePayload {
pub node_id: Uuid,
pub node_type: String,
pub embedding: Vec<f32>,
pub content: Vec<u8>,
pub tier: MemoryTier,
pub importance: f32,
}
/// Payload for UpdateNode commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateNodePayload {
pub node_id: Uuid,
pub new_content: Option<Vec<u8>>,
pub new_importance: Option<f32>,
pub new_tier: Option<MemoryTier>,
}
/// Payload for DeleteNode commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteNodePayload {
pub node_id: Uuid,
/// The full node is saved at command-creation time so rollback can restore it.
pub snapshot: Value,
}
/// Payload for CreateEdge commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateEdgePayload {
pub edge_id: Uuid,
pub from_id: Uuid,
pub to_id: Uuid,
pub relation: String,
pub weight: f32,
}
/// Payload for DeleteEdge commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteEdgePayload {
pub edge_id: Uuid,
pub from_id: Uuid,
pub to_id: Uuid,
/// Full edge snapshot for rollback.
pub snapshot: Value,
}
/// Payload for UpdateSalience commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateSaliencePayload {
pub node_id: Uuid,
pub new_salience: f32,
pub old_salience: f32,
}
+710
View File
@@ -0,0 +1,710 @@
/// TransactionEngine — applies commands to EngramDb and manages rollback.
///
/// The engine wraps an `EngramDb` and a `CommandLog`. All mutations go through
/// `apply()`. Rollbacks create new inverse commands. Rolling back a rollback
/// re-applies the original — full undo/redo with causal tracking.
use engram_core::types::{Edge, Node, NodeType, RelationType};
use engram_core::EngramDb;
use serde_json::Value;
use uuid::Uuid;
use crate::command::{
Command, CommandResult, CommandStatus, CommandType, CreateEdgePayload, CreateNodePayload,
DeleteEdgePayload, DeleteNodePayload, UpdateNodePayload, UpdateSaliencePayload,
};
use crate::error::{TxError, TxResult};
use crate::log::CommandLog;
pub struct TransactionEngine {
db: std::sync::Arc<std::sync::Mutex<EngramDb>>,
log: CommandLog,
/// Our peer ID for conflict resolution.
peer_id: Option<Uuid>,
}
impl TransactionEngine {
/// Create a new engine backed by the given database and a sled store for the log.
pub fn new(
db: std::sync::Arc<std::sync::Mutex<EngramDb>>,
log_db: sled::Db,
peer_id: Option<Uuid>,
) -> Self {
Self {
db,
log: CommandLog::open(log_db),
peer_id,
}
}
// ── Public API ────────────────────────────────────────────────────────────
/// Apply a command to the database.
///
/// Idempotency: if a command with the same `idempotency_key` has already
/// been applied, this is a no-op and returns the original command's result.
pub fn apply(&mut self, mut cmd: Command) -> TxResult<CommandResult> {
// Idempotency check
if let Some(existing_id) = self.log.check_idempotency(&cmd.idempotency_key)? {
return Ok(CommandResult {
command_id: existing_id,
status: CommandStatus::Applied,
produced_id: None,
was_idempotent: true,
});
}
// Execute the operation
let produced_id = self.execute(&cmd)?;
cmd.status = CommandStatus::Applied;
self.log.write(&cmd)?;
Ok(CommandResult {
command_id: cmd.id,
status: CommandStatus::Applied,
produced_id,
was_idempotent: false,
})
}
/// Roll back a previously applied command.
///
/// Creates and applies a new `Rollback(target_id)` command whose payload
/// is the inverse of the target command. The target command's status is
/// updated to `RolledBack`.
///
/// Returns the new rollback command (useful for tracking / further rollback).
pub fn rollback(&mut self, target_id: Uuid) -> TxResult<Command> {
let target = self.log.require(target_id)?;
if target.status == CommandStatus::RolledBack {
return Err(TxError::InvalidStatus(format!(
"command {} is already rolled back",
target_id
)));
}
if target.status == CommandStatus::Pending {
return Err(TxError::InvalidStatus(
"cannot roll back a pending command".into(),
));
}
// The rollback's payload is the original command's inverse_payload.
// The rollback's inverse_payload is the original command's payload.
// This enables rollback-of-rollback to re-apply the original.
let rollback_key = format!("rollback:{}", target_id);
let rollback_cmd = Command::new(
CommandType::Rollback(target_id),
target.inverse_payload.clone(),
target.payload.clone(),
rollback_key,
Some(target_id),
self.peer_id,
);
// Execute the inverse operation
self.execute_inverse(&target)?;
// Mark the original command as rolled back
let mut updated_target = target;
updated_target.status = CommandStatus::RolledBack;
self.log.write(&updated_target)?;
// Persist the rollback command as Applied
let mut rb = rollback_cmd;
rb.status = CommandStatus::Applied;
self.log.write(&rb)?;
Ok(rb)
}
/// Roll back a rollback — re-applying the original command.
///
/// This is "undo the undo". The rollback_id must be a command of type
/// `Rollback(original_id)`. Rolling it back re-applies `original_id`.
pub fn rollback_rollback(&mut self, rollback_id: Uuid) -> TxResult<Command> {
let rb_cmd = self.log.require(rollback_id)?;
// Verify this IS a rollback command
let original_id = match &rb_cmd.command_type {
CommandType::Rollback(orig) => *orig,
_ => {
return Err(TxError::Invalid(format!(
"command {} is not a Rollback command",
rollback_id
)));
}
};
if rb_cmd.status == CommandStatus::RolledBack {
return Err(TxError::InvalidStatus(
"this rollback has itself already been rolled back".into(),
));
}
// The rollback_rollback's payload is rb_cmd.inverse_payload (= original payload)
// Its inverse is rb_cmd.payload (= original's inverse_payload)
let key = format!("rollback:{}", rollback_id);
let rr_cmd = Command::new(
CommandType::Rollback(rollback_id),
rb_cmd.inverse_payload.clone(),
rb_cmd.payload.clone(),
key,
Some(rollback_id),
self.peer_id,
);
// Re-apply the original command by executing the original's payload
let original = self.log.require(original_id)?;
self.execute(&original)?;
// Mark original as Applied again
let mut orig = original;
orig.status = CommandStatus::Applied;
self.log.write(&orig)?;
// Mark rollback as RolledBack
let mut rb = rb_cmd;
rb.status = CommandStatus::RolledBack;
self.log.write(&rb)?;
// Persist the new re-apply command
let mut rr = rr_cmd;
rr.status = CommandStatus::Applied;
self.log.write(&rr)?;
Ok(rr)
}
/// All commands since a given Unix millisecond timestamp.
pub fn history(&self, since_ms: i64) -> TxResult<Vec<Command>> {
self.log.since(since_ms)
}
/// The causal chain for a given command (root-first).
pub fn causal_chain(&self, command_id: Uuid) -> TxResult<Vec<Command>> {
self.log.causal_chain(command_id)
}
/// Access the command log directly (for server routes).
pub fn log(&self) -> &CommandLog {
&self.log
}
// ── Command execution ─────────────────────────────────────────────────────
fn execute(&self, cmd: &Command) -> TxResult<Option<Uuid>> {
match &cmd.command_type {
CommandType::CreateNode => self.exec_create_node(&cmd.payload),
CommandType::UpdateNode => {
self.exec_update_node(&cmd.payload)?;
Ok(None)
}
CommandType::DeleteNode => {
self.exec_delete_node(&cmd.payload)?;
Ok(None)
}
CommandType::CreateEdge => {
self.exec_create_edge(&cmd.payload)?;
Ok(None)
}
CommandType::DeleteEdge => {
self.exec_delete_edge(&cmd.payload)?;
Ok(None)
}
CommandType::UpdateSalience => {
self.exec_update_salience(&cmd.payload)?;
Ok(None)
}
CommandType::BulkImport => {
self.exec_bulk_import(&cmd.payload)?;
Ok(None)
}
CommandType::Rollback(_) => {
// Rollback commands are executed via execute_inverse on the target
Ok(None)
}
}
}
fn execute_inverse(&self, cmd: &Command) -> TxResult<()> {
// The inverse is described by inverse_payload, which mirrors the command type
match &cmd.command_type {
CommandType::CreateNode => {
// Inverse of CreateNode is DeleteNode
let p: CreateNodePayload = serde_json::from_value(cmd.payload.clone())?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.delete_node(p.node_id)?;
}
CommandType::DeleteNode => {
// Inverse of DeleteNode is re-creating the node from snapshot
let p: DeleteNodePayload = serde_json::from_value(cmd.payload.clone())?;
let node: Node = serde_json::from_value(p.snapshot)?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.put_node(node)?;
}
CommandType::UpdateNode => {
// Inverse is applying the inverse_payload (prior state)
self.exec_update_node(&cmd.inverse_payload)?;
}
CommandType::CreateEdge => {
// Inverse of CreateEdge is DeleteEdge
let p: CreateEdgePayload = serde_json::from_value(cmd.payload.clone())?;
self.delete_edge_by_pair(p.from_id, p.to_id)?;
}
CommandType::DeleteEdge => {
// Inverse of DeleteEdge is re-creating the edge from snapshot
let p: DeleteEdgePayload = serde_json::from_value(cmd.payload.clone())?;
let edge: Edge = serde_json::from_value(p.snapshot)?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.put_edge(edge)?;
}
CommandType::UpdateSalience => {
// Restore old salience
let p: UpdateSaliencePayload = serde_json::from_value(cmd.payload.clone())?;
let restore = UpdateSaliencePayload {
node_id: p.node_id,
new_salience: p.old_salience,
old_salience: p.new_salience,
};
self.exec_update_salience(&serde_json::to_value(restore)?)?;
}
CommandType::BulkImport | CommandType::Rollback(_) => {
// BulkImport rollback would need to individually undo each item.
// For now, we store the inverse_payload as instructions and log the gap.
// TODO: implement fine-grained BulkImport rollback
}
}
Ok(())
}
// ── Operation implementations ─────────────────────────────────────────────
fn exec_create_node(&self, payload: &Value) -> TxResult<Option<Uuid>> {
let p: CreateNodePayload = serde_json::from_value(payload.clone())?;
let node_type = parse_node_type(&p.node_type)?;
let tier = p.tier;
let node = Node::new(node_type, p.embedding, p.content, tier, p.importance)
.with_id(p.node_id);
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.put_node(node)?;
Ok(Some(p.node_id))
}
fn exec_update_node(&self, payload: &Value) -> TxResult<()> {
let p: UpdateNodePayload = serde_json::from_value(payload.clone())?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
let mut node = db
.get_node(p.node_id)?
.ok_or(TxError::NotFound(p.node_id))?;
if let Some(content) = p.new_content {
node.content = content;
}
if let Some(importance) = p.new_importance {
node.importance = importance.clamp(0.0, 1.0);
}
if let Some(tier) = p.new_tier {
node.tier = tier;
}
db.put_node(node)?;
Ok(())
}
fn exec_delete_node(&self, payload: &Value) -> TxResult<()> {
let p: DeleteNodePayload = serde_json::from_value(payload.clone())?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.delete_node(p.node_id)?;
Ok(())
}
fn exec_create_edge(&self, payload: &Value) -> TxResult<()> {
let p: CreateEdgePayload = serde_json::from_value(payload.clone())?;
let relation = parse_relation(&p.relation)?;
let edge = Edge::new(p.from_id, p.to_id, relation, p.weight);
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.put_edge(edge)?;
Ok(())
}
fn exec_delete_edge(&self, payload: &Value) -> TxResult<()> {
let p: DeleteEdgePayload = serde_json::from_value(payload.clone())?;
self.delete_edge_by_pair(p.from_id, p.to_id)?;
Ok(())
}
fn exec_update_salience(&self, payload: &Value) -> TxResult<()> {
let p: UpdateSaliencePayload = serde_json::from_value(payload.clone())?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
let mut node = db
.get_node(p.node_id)?
.ok_or(TxError::NotFound(p.node_id))?;
node.salience = p.new_salience;
db.put_node(node)?;
Ok(())
}
fn exec_bulk_import(&self, payload: &Value) -> TxResult<()> {
let nodes_val = payload.get("nodes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let edges_val = payload.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
for nv in nodes_val {
let node: Node = serde_json::from_value(nv)?;
db.put_node(node)?;
}
for ev in edges_val {
let edge: Edge = serde_json::from_value(ev)?;
db.put_edge(edge)?;
}
Ok(())
}
fn delete_edge_by_pair(&self, from_id: Uuid, to_id: Uuid) -> TxResult<()> {
// sled stores edges at "edges:from:{from}:{to}" — we remove both directions
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
// We can't directly call into storage here without re-exposing internals,
// so we use the public scan-and-check approach via get_edges_from
let edges = db.get_edges_from(from_id)?;
for edge in edges {
if edge.to_id == to_id {
// Re-insert a tombstone isn't directly supported — we use the
// internal sled key to delete. Since we don't have direct sled
// access through EngramDb's public API, we rely on the fact that
// put_edge with weight=0 effectively nullifies it, but for proper
// deletion we need access to the underlying store.
//
// We work around this by storing a zero-weight edge with weight=-1
// as a sentinel, or by exposing delete_edge. Since delete_node is
// already exposed, we add a helper. For now, we store the edge with
// weight 0 (marking it inactive) until a delete_edge API is added.
//
// TODO: add db.delete_edge() to engram-core public API.
// For now: overwrite with weight 0 to effectively disable it.
let mut tombstone = edge;
tombstone.weight = 0.0;
db.put_edge(tombstone)?;
break;
}
}
Ok(())
}
}
// ── Command builder helpers ───────────────────────────────────────────────────
/// Build a CreateNode command with eagerly-computed inverse.
pub fn build_create_node_cmd(
node: &Node,
idempotency_key: impl Into<String>,
peer_id: Option<Uuid>,
) -> Command {
let payload = serde_json::to_value(CreateNodePayload {
node_id: node.id,
node_type: node_type_to_str(&node.node_type).to_string(),
embedding: node.embedding.clone(),
content: node.content.clone(),
tier: node.tier.clone(),
importance: node.importance,
})
.unwrap_or(Value::Null);
// Inverse: delete the node we're about to create
let inverse = serde_json::to_value(DeleteNodePayload {
node_id: node.id,
snapshot: serde_json::to_value(node).unwrap_or(Value::Null),
})
.unwrap_or(Value::Null);
Command::new(
CommandType::CreateNode,
payload,
inverse,
idempotency_key,
None,
peer_id,
)
}
/// Build a DeleteNode command with eagerly-computed inverse (snapshot).
pub fn build_delete_node_cmd(
node: &Node,
idempotency_key: impl Into<String>,
peer_id: Option<Uuid>,
) -> Command {
let snapshot = serde_json::to_value(node).unwrap_or(Value::Null);
let payload = serde_json::to_value(DeleteNodePayload {
node_id: node.id,
snapshot: snapshot.clone(),
})
.unwrap_or(Value::Null);
// Inverse: re-create the node from snapshot
let inverse = serde_json::to_value(CreateNodePayload {
node_id: node.id,
node_type: node_type_to_str(&node.node_type).to_string(),
embedding: node.embedding.clone(),
content: node.content.clone(),
tier: node.tier.clone(),
importance: node.importance,
})
.unwrap_or(Value::Null);
Command::new(
CommandType::DeleteNode,
payload,
inverse,
idempotency_key,
None,
peer_id,
)
}
/// Build a CreateEdge command.
pub fn build_create_edge_cmd(
edge: &Edge,
idempotency_key: impl Into<String>,
peer_id: Option<Uuid>,
) -> Command {
let relation_str = relation_to_str(&edge.relation).to_string();
let payload = serde_json::to_value(CreateEdgePayload {
edge_id: edge.id,
from_id: edge.from_id,
to_id: edge.to_id,
relation: relation_str.clone(),
weight: edge.weight,
})
.unwrap_or(Value::Null);
// Inverse: delete the edge
let snapshot = serde_json::to_value(edge).unwrap_or(Value::Null);
let inverse = serde_json::to_value(DeleteEdgePayload {
edge_id: edge.id,
from_id: edge.from_id,
to_id: edge.to_id,
snapshot,
})
.unwrap_or(Value::Null);
Command::new(
CommandType::CreateEdge,
payload,
inverse,
idempotency_key,
None,
peer_id,
)
}
// ── Type string helpers ───────────────────────────────────────────────────────
fn parse_node_type(s: &str) -> TxResult<NodeType> {
match s {
"Memory" => Ok(NodeType::Memory),
"Concept" => Ok(NodeType::Concept),
"Event" => Ok(NodeType::Event),
"Entity" => Ok(NodeType::Entity),
"Process" => Ok(NodeType::Process),
"InternalState" => Ok(NodeType::InternalState),
_ => Err(TxError::Invalid(format!("unknown node type: {}", s))),
}
}
fn node_type_to_str(t: &NodeType) -> &'static str {
match t {
NodeType::Memory => "Memory",
NodeType::Concept => "Concept",
NodeType::Event => "Event",
NodeType::Entity => "Entity",
NodeType::Process => "Process",
NodeType::InternalState => "InternalState",
}
}
fn parse_relation(s: &str) -> TxResult<RelationType> {
match s {
"Supersedes" => Ok(RelationType::Supersedes),
"Causes" => Ok(RelationType::Causes),
"Contains" => Ok(RelationType::Contains),
"References" => Ok(RelationType::References),
"Contradicts" => Ok(RelationType::Contradicts),
"Exemplifies" => Ok(RelationType::Exemplifies),
"Activates" => Ok(RelationType::Activates),
"TemporallyPrecedes" => Ok(RelationType::TemporallyPrecedes),
_ => Err(TxError::Invalid(format!("unknown relation: {}", s))),
}
}
fn relation_to_str(r: &RelationType) -> &'static str {
match r {
RelationType::Supersedes => "Supersedes",
RelationType::Causes => "Causes",
RelationType::Contains => "Contains",
RelationType::References => "References",
RelationType::Contradicts => "Contradicts",
RelationType::Exemplifies => "Exemplifies",
RelationType::Activates => "Activates",
RelationType::TemporallyPrecedes => "TemporallyPrecedes",
}
}
#[cfg(test)]
mod tests {
use super::*;
use engram_core::types::{MemoryTier, Node, NodeType};
use tempfile::TempDir;
fn make_engine() -> (TransactionEngine, TempDir, TempDir) {
let db_dir = TempDir::new().unwrap();
let log_dir = TempDir::new().unwrap();
let db = engram_core::EngramDb::open(db_dir.path()).unwrap();
let db = std::sync::Arc::new(std::sync::Mutex::new(db));
let log_db = sled::open(log_dir.path()).unwrap();
let engine = TransactionEngine::new(db, log_db, None);
(engine, db_dir, log_dir)
}
fn make_node() -> Node {
Node::new(
NodeType::Memory,
vec![1.0, 0.0],
b"test content".to_vec(),
MemoryTier::Semantic,
0.8,
)
}
#[test]
fn test_apply_create_node() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let cmd = build_create_node_cmd(&node, "create-test-1", None);
let result = engine.apply(cmd).unwrap();
assert_eq!(result.status, CommandStatus::Applied);
assert!(!result.was_idempotent);
// Node should now exist
let db = engine.db.lock().unwrap();
let found = db.get_node(node.id).unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().content, b"test content");
}
#[test]
fn test_idempotency() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let cmd1 = build_create_node_cmd(&node, "idem-key-42", None);
let cmd2 = build_create_node_cmd(&node, "idem-key-42", None);
let r1 = engine.apply(cmd1).unwrap();
let r2 = engine.apply(cmd2).unwrap();
assert!(!r1.was_idempotent);
assert!(r2.was_idempotent);
}
#[test]
fn test_rollback_delete_node() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let node_id = node.id;
// First apply create
let create_cmd = build_create_node_cmd(&node, "create-rb-1", None);
let result = engine.apply(create_cmd).unwrap();
// Roll back the creation — should delete the node
let rb = engine.rollback(result.command_id).unwrap();
assert_eq!(rb.status, CommandStatus::Applied);
let db = engine.db.lock().unwrap();
let found = db.get_node(node_id).unwrap();
assert!(found.is_none());
}
#[test]
fn test_rollback_of_rollback() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let node_id = node.id;
// Create the node
let create_cmd = build_create_node_cmd(&node, "create-rorb-1", None);
let create_result = engine.apply(create_cmd).unwrap();
// Roll back (delete)
let rb = engine.rollback(create_result.command_id).unwrap();
// Verify it's gone
{
let db = engine.db.lock().unwrap();
assert!(db.get_node(node_id).unwrap().is_none());
}
// Roll back the rollback (re-create)
let _rr = engine.rollback_rollback(rb.id).unwrap();
// Node should be back
let db = engine.db.lock().unwrap();
let found = db.get_node(node_id).unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().content, b"test content");
}
#[test]
fn test_history() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let cmd = build_create_node_cmd(&node, "hist-1", None);
engine.apply(cmd).unwrap();
let history = engine.history(0).unwrap();
assert!(!history.is_empty());
assert!(history.iter().any(|c| matches!(c.command_type, CommandType::CreateNode)));
}
#[test]
fn test_causal_chain() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let cmd = build_create_node_cmd(&node, "causal-1", None);
let result = engine.apply(cmd).unwrap();
// Roll back (creates a child command with causal_parent = create_cmd.id)
let rb = engine.rollback(result.command_id).unwrap();
let chain = engine.causal_chain(rb.id).unwrap();
// Chain should be [create_cmd, rollback_cmd]
assert_eq!(chain.len(), 2);
assert!(matches!(chain[0].command_type, CommandType::CreateNode));
assert!(matches!(chain[1].command_type, CommandType::Rollback(_)));
}
#[test]
fn test_create_edge_command() {
let (mut engine, _db_dir, _log_dir) = make_engine();
// Create two nodes first
let n1 = make_node();
let n2 = Node::new(
NodeType::Concept,
vec![0.0, 1.0],
b"concept".to_vec(),
MemoryTier::Semantic,
0.5,
);
engine.apply(build_create_node_cmd(&n1, "edge-n1", None)).unwrap();
engine.apply(build_create_node_cmd(&n2, "edge-n2", None)).unwrap();
// Create edge
let edge = Edge::new(n1.id, n2.id, RelationType::References, 0.7);
let edge_cmd = build_create_edge_cmd(&edge, "edge-create-1", None);
let result = engine.apply(edge_cmd).unwrap();
assert_eq!(result.status, CommandStatus::Applied);
// Verify edge exists
let db = engine.db.lock().unwrap();
let edges = db.get_edges_from(n1.id).unwrap();
assert!(!edges.is_empty());
}
}
+31
View File
@@ -0,0 +1,31 @@
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub enum TxError {
#[error("Command not found: {0}")]
NotFound(Uuid),
#[error("Command already applied (idempotency key: {0})")]
AlreadyApplied(String),
#[error("Cannot roll back a command in status {0:?}")]
InvalidStatus(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Storage error: {0}")]
Storage(#[from] sled::Error),
#[error("Engram error: {0}")]
Engram(#[from] engram_core::EngramError),
#[error("Invalid command: {0}")]
Invalid(String),
}
pub type TxResult<T> = Result<T, TxError>;
+29
View File
@@ -0,0 +1,29 @@
/// Engram Transaction Engine — Command pattern with rollback-of-rollback.
///
/// # The Central Insight
///
/// Every mutation to Engram is a Command — a first-class object with:
/// - The operation and its inverse (computed eagerly at command time)
/// - An idempotency key (same key = same command, applied once)
/// - A causal parent (which command caused this one)
/// - A timestamp and originating peer ID
///
/// Commands form a DAG of causality. You can roll back any command. You can
/// roll back a rollback (undo an undo — re-applying the original). The
/// command log is append-only: history is never rewritten.
///
/// # Rollback-of-Rollback
///
/// When you roll back command X, a new `Rollback(X)` command is created and
/// applied. Its `inverse_payload` is X's original `payload`. If you then roll
/// back the rollback, a new `Rollback(rollback_id)` is created, whose effect
/// is to re-apply X. This is a full undo/redo system with causal lineage.
pub mod command;
pub mod engine;
pub mod error;
pub mod log;
pub use command::{Command, CommandResult, CommandStatus, CommandType};
pub use engine::TransactionEngine;
pub use error::TxError;
pub use log::CommandLog;
+151
View File
@@ -0,0 +1,151 @@
/// CommandLog — append-only log of all commands, stored in sled.
///
/// Key schema:
/// cmd:{uuid} → JSON-encoded Command (JSON used because Command contains serde_json::Value)
/// idem:{key} → uuid bytes (idempotency index)
/// cmd_ts:{ms}:{uuid} → uuid bytes (time-ordered scan index)
use sled::Db;
use uuid::Uuid;
use crate::command::Command;
use crate::error::{TxError, TxResult};
pub struct CommandLog {
db: Db,
}
impl CommandLog {
pub fn open(db: Db) -> Self {
Self { db }
}
// ── Write ─────────────────────────────────────────────────────────────────
/// Append a command to the log. Overwrites if the UUID already exists
/// (used for status updates after application).
pub fn write(&self, cmd: &Command) -> TxResult<()> {
let key = cmd_key(cmd.id);
// Use JSON (not bincode) because Command contains serde_json::Value,
// which bincode cannot deserialize (DeserializeAnyNotSupported).
let val = serde_json::to_vec(cmd)?;
self.db.insert(key, val)?;
// Idempotency index: idem:{key} → uuid
let idem_key = idem_key(&cmd.idempotency_key);
self.db.insert(idem_key, cmd.id.as_bytes().to_vec())?;
// Time index: cmd_ts:{ms:016x}:{uuid} → uuid
let ts_key = ts_key(cmd.timestamp_ms, cmd.id);
self.db.insert(ts_key, cmd.id.as_bytes().to_vec())?;
Ok(())
}
// ── Read ──────────────────────────────────────────────────────────────────
pub fn get(&self, id: Uuid) -> TxResult<Option<Command>> {
match self.db.get(cmd_key(id))? {
Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
None => Ok(None),
}
}
pub fn require(&self, id: Uuid) -> TxResult<Command> {
self.get(id)?.ok_or(TxError::NotFound(id))
}
/// Check whether an idempotency key has already been applied.
/// Returns the command UUID if it exists.
pub fn check_idempotency(&self, key: &str) -> TxResult<Option<Uuid>> {
match self.db.get(idem_key(key))? {
Some(bytes) => {
let arr: [u8; 16] = bytes[..16]
.try_into()
.map_err(|_| TxError::Invalid("bad uuid bytes in idem index".into()))?;
Ok(Some(Uuid::from_bytes(arr)))
}
None => Ok(None),
}
}
/// Load all commands created at or after `since_ms`, ordered by timestamp.
pub fn since(&self, since_ms: i64) -> TxResult<Vec<Command>> {
let prefix = format!("cmd_ts:{:016x}:", since_ms);
let mut cmds = Vec::new();
for result in self.db.range(prefix.as_bytes()..) {
let (k, _v) = result?;
// Check the key starts with "cmd_ts:"
if !k.starts_with(b"cmd_ts:") {
break;
}
// Extract uuid from key: cmd_ts:{ms}:{uuid}
let key_str = std::str::from_utf8(&k)
.map_err(|e| TxError::Invalid(e.to_string()))?;
let parts: Vec<&str> = key_str.splitn(3, ':').collect();
if parts.len() < 3 {
continue;
}
// parts[1] = ms (hex), parts[2] = uuid
let ts_hex = parts[1];
let ts = i64::from_str_radix(ts_hex, 16)
.map_err(|e| TxError::Invalid(e.to_string()))?;
if ts < since_ms {
continue;
}
let id: Uuid = parts[2]
.parse()
.map_err(|e: uuid::Error| TxError::Invalid(e.to_string()))?;
if let Some(cmd) = self.get(id)? {
cmds.push(cmd);
}
}
Ok(cmds)
}
/// Load all commands in the store.
pub fn all(&self) -> TxResult<Vec<Command>> {
self.since(0)
}
/// Collect the causal chain leading to a given command (inclusive).
/// Walks `causal_parent` links back to the root.
pub fn causal_chain(&self, id: Uuid) -> TxResult<Vec<Command>> {
let mut chain = Vec::new();
let mut current_id = Some(id);
let mut visited = std::collections::HashSet::new();
while let Some(cid) = current_id {
if visited.contains(&cid) {
break; // cycle guard
}
visited.insert(cid);
match self.get(cid)? {
Some(cmd) => {
current_id = cmd.causal_parent;
chain.push(cmd);
}
None => break,
}
}
// Return root-first (reverse of walk order)
chain.reverse();
Ok(chain)
}
}
// ── Key constructors ──────────────────────────────────────────────────────────
fn cmd_key(id: Uuid) -> Vec<u8> {
format!("cmd:{}", id).into_bytes()
}
fn idem_key(key: &str) -> Vec<u8> {
format!("idem:{}", key).into_bytes()
}
fn ts_key(ts: i64, id: Uuid) -> Vec<u8> {
// Zero-padded hex timestamp for lexicographic ordering
format!("cmd_ts:{:016x}:{}", ts, id).into_bytes()
}