Persistence & Serialization¶
Overview¶
Two systems provide application continuity across restarts and across process boundaries:
Persistence = "How do settings and history survive a restart?"
Serialization = "How do payloads cross process boundaries (IPC)?"
Both are dependency-free by default. The Domain Engine provides the storage and serialization logic; the App Engine provides the contracts.
Part 1: Persistence¶
StorageBackend Trait¶
The StorageBackend trait is a generic key-value storage interface:
use app_shell::app_engine::StorageBackend;
pub trait StorageBackend: Send + Sync {
fn load(&self, key: &str) -> Result<Option<String>, PersistenceError>;
fn save(&self, key: &str, value: &str) -> Result<(), PersistenceError>;
fn remove(&self, key: &str) -> Result<(), PersistenceError>;
fn exists(&self, key: &str) -> bool;
fn keys(&self) -> Result<Vec<String>, PersistenceError>;
}
Two implementations are provided:
| Backend | Purpose |
|---|---|
MemoryStorageBackend |
In-memory (default, backward compatible) |
FileStorageBackend |
Persists to a directory (one file per key) |
MemoryStorageBackend¶
use app_shell::app_engine::MemoryStorageBackend;
let backend = MemoryStorageBackend::new();
backend.save("key1", "value1")?;
backend.save("key2", "value2")?;
assert_eq!(backend.load("key1")?, Some("value1".to_string()));
assert!(backend.exists("key1"));
assert!(!backend.exists("missing"));
backend.remove("key1")?;
assert!(!backend.exists("key1"));
let keys = backend.keys()?;
assert_eq!(keys.len(), 1);
FileStorageBackend¶
Persists each key as a separate file in a directory:
use app_shell::app_engine::FileStorageBackend;
use std::env::temp_dir;
let dir = temp_dir().join("my_app_config");
let backend = FileStorageBackend::new(&dir)?;
backend.save("app.name", "MyApp")?;
backend.save("worker_count", "8")?;
assert_eq!(backend.load("app.name")?, Some("MyApp".to_string()));
assert!(backend.exists("worker_count"));
Survives recreation:
// First backend writes
{
let backend = FileStorageBackend::new(&dir)?;
backend.save("persistent", "data")?;
}
// Second backend reads from the same files
let backend2 = FileStorageBackend::new(&dir)?;
assert_eq!(backend2.load("persistent")?, Some("data".to_string()));
Key sanitization: Keys with / or \ are sanitized to _ to prevent directory traversal:
backend.save("app/name", "value")?; // Stored as "app_name"
assert!(backend.exists("app/name"));
assert_eq!(backend.load("app/name")?, Some("value".to_string()));
ConfigStore with Backend¶
ConfigStore can be created with a persistence backend:
use app_shell::app_engine::{
Config, ConfigPropertyDefinition, ConfigSchema, ConfigStore,
FileStorageBackend, MemoryStorageBackend,
};
let schema = ConfigSchema::new()
.with_property(
ConfigPropertyDefinition::new("app.name", "App name")
.with_default("Default")
.required(),
)
.with_property(
ConfigPropertyDefinition::new("worker_count", "Workers")
.with_default("4")
.with_min("1")
.with_max("32"),
);
// With file backend (persists across restarts)
let mut store = ConfigStore::with_backend(
schema,
Box::new(FileStorageBackend::new("/path/to/config")?),
);
// With memory backend (for testing)
let store = ConfigStore::with_memory_backend(schema);
// Without backend (backward compatible — in-memory only)
let store = ConfigStore::new(schema);
How Persistence Works¶
When set() is called:
// ConfigStore::set() — thread-safe
store.set("worker_count", "8")?;
// 1. Validates against schema
// 2. Saves to backend (file/memory)
// 3. Updates in-memory config
// 4. Fires change notification callback
When reset() is called:
// ConfigStore::reset()
store.reset();
// 1. Resets to schema defaults
// 2. Reloads from backend (overrides defaults with persisted values)
When save() is called with a full Config:
let mut new_config = Config::new();
new_config.set("app.name", "NewApp");
new_config.set("worker_count", "16");
store.save(new_config)?;
// 1. Validates entire config against schema
// 2. Persists all keys to backend
// 3. Updates in-memory config
// 4. Fires change notifications for each changed key
Change Notifications¶
ConfigStore can fire callbacks when values change:
use std::sync::{Arc, Mutex};
let store = ConfigStore::new(ConfigSchema::new()
.with_property(ConfigPropertyDefinition::new("key", "Key").with_default("default")));
let received = Arc::new(Mutex::new(None::<(String, Option<String>, Option<String>)>));
let r = received.clone();
store.on_changed(move |key, old, new| {
*r.lock().unwrap() = Some((
key.to_string(),
old.map(|s| s.to_string()),
new.map(|s| s.to_string()),
));
});
store.set("key", "new_value")?;
let received = received.lock().unwrap();
let (key, old, new) = received.as_ref().unwrap();
assert_eq!(key, "key");
assert_eq!(old.as_deref(), Some("default")); // old value was the default
assert_eq!(new.as_deref(), Some("new_value"));
Thread-Safe ConfigStore¶
ConfigStore uses Mutex<Config> internally. All methods take &self:
let store = Arc::new(ConfigStore::new(schema));
// Concurrent reads
let s1 = Arc::clone(&store);
let s2 = Arc::clone(&store);
let h1 = std::thread::spawn(move || { s1.get("key") });
let h2 = std::thread::spawn(move || { s2.set("key", "value").unwrap() });
h1.join().unwrap();
h2.join().unwrap();
HistoryStore with Backend¶
HistoryStore can also persist:
use app_shell::app_engine::{HistoryStore, MemoryStorageBackend};
let backend = Box::new(MemoryStorageBackend::new());
let store = HistoryStore::with_backend(backend);
assert!(store.has_backend());
// Append (entry metadata is persisted)
let id = store.append(
HistoryEntry::new("test.operation").undoable(),
);
assert_eq!(store.entry_count(), 1);
Part 2: Serialization¶
The Problem¶
App Engine containers (CommandInput, TaskInput, Event, HistoryEntry, Resource) use Box<dyn Any + Send> for type-erased payloads. dyn Any cannot be serialized — the engine doesn't know the concrete type.
The Solution¶
A Serializer trait that the Domain Engine implements for its payload types, plus a SerializationRegistry that the engine uses to look up the right serializer by type name.
Serializer Trait¶
use app_shell::app_engine::Serializer;
use std::any::Any;
pub trait Serializer: Send + Sync {
fn type_name(&self) -> &str;
fn serialize(&self, data: &dyn Any) -> Result<Vec<u8>, SerializationError>;
fn deserialize(&self, bytes: &[u8]) -> Result<Box<dyn Any + Send>, SerializationError>;
}
SerializationRegistry¶
Stores serializers by type name. Thread-safe (RwLock internally):
use app_shell::app_engine::SerializationRegistry;
let registry = SerializationRegistry::new();
// Register a serializer for a domain type
registry.register(Box::new(MyInputSerializer));
// Check registration
assert!(registry.has_serializer("my_domain::MyInput"));
// Serialize
let bytes = registry.serialize(&my_input, "my_domain::MyInput")?;
// Deserialize
let data = registry.deserialize(&bytes, "my_domain::MyInput")?;
Domain Serializer Example¶
use app_shell::app_engine::{Serializer, SerializationError};
use std::any::Any;
#[derive(Debug, Clone, PartialEq)]
struct ExportInput {
project_id: String,
format: String,
output_path: String,
}
struct ExportInputSerializer;
impl Serializer for ExportInputSerializer {
fn type_name(&self) -> &str {
"my_domain::ExportInput"
}
fn serialize(&self, data: &dyn Any) -> Result<Vec<u8>, SerializationError> {
let input = data.downcast_ref::<ExportInput>()
.ok_or_else(|| SerializationError::TypeMismatch {
expected: self.type_name().to_string(),
actual: "unknown".to_string(),
})?;
// Simple pipe-delimited serialization
let serialized = format!("{}|{}|{}", input.project_id, input.format, input.output_path);
Ok(serialized.into_bytes())
}
fn deserialize(&self, bytes: &[u8]) -> Result<Box<dyn Any + Send>, SerializationError> {
let s = std::str::from_utf8(bytes)
.map_err(|e| SerializationError::DeserializationFailed {
type_name: self.type_name().to_string(),
reason: e.to_string(),
})?;
let parts: Vec<&str> = s.split('|').collect();
if parts.len() != 3 {
return Err(SerializationError::DeserializationFailed {
type_name: self.type_name().to_string(),
reason: "expected 'project_id|format|output_path'".to_string(),
});
}
Ok(Box::new(ExportInput {
project_id: parts[0].to_string(),
format: parts[1].to_string(),
output_path: parts[2].to_string(),
}))
}
}
Serialized Payloads on Containers¶
All payload-carrying containers support optional serialized bytes alongside typed data:
CommandInput¶
use app_shell::app_engine::CommandInput;
// Create with typed data only
let input = CommandInput::new(ExportInput {
project_id: "proj_42".to_string(),
format: "pdf".to_string(),
output_path: "/tmp/out.pdf".to_string(),
});
// Create from serialized bytes (for IPC)
let input = CommandInput::from_bytes(
"my_domain::ExportInput",
serialized_bytes,
);
// Create with both (typed + serialized)
let input = CommandInput::with_serialized(
ExportInput { project_id: "p1".into(), format: "pdf".into(), output_path: "/tmp".into() },
serialized_bytes,
);
// Access
input.get::<ExportInput>(); // Typed data (in-process)
input.serialized(); // Serialized bytes (for IPC)
input.type_name(); // Type name string
input.has_data(); // Has typed data?
input.has_serialized(); // Has serialized bytes?
TaskInput¶
Same API:
use app_shell::app_engine::TaskInput;
let input = TaskInput::new(ExportInput { /* ... */ });
let input = TaskInput::from_bytes("my_domain::ExportInput", bytes);
let input = TaskInput::with_serialized(ExportInput { /* ... */ }, bytes);
input.ensure_serialized(®istry)?; // Serialize if not already
input.ensure_deserialized(®istry)?; // Deserialize if not already
Event¶
use app_shell::app_engine::Event;
// Typed payload only
let event = Event::new("document.exported")
.with_payload(ExportResult { /* ... */ });
// From serialized bytes (IPC)
let event = Event::from_serialized(
"document.exported",
"my_domain::ExportResult",
serialized_bytes,
);
// Both forms
let event = Event::new("document.exported")
.with_payload_and_serialized(ExportResult { /* ... */ }, bytes);
event.ensure_serialized(®istry)?;
event.ensure_deserialized(®istry)?;
IPC Roundtrip Pattern¶
The serialization system enables sending commands, tasks, and events across process boundaries:
flowchart LR
subgraph SA["Process A (Sender)"]
A["CommandInput<br/>.ensure_serial<br/>.serialized()<br/>.type_name()"]
end
subgraph SB["Process B (Receiver)"]
B["CommandInput<br/>.from_bytes<br/>.ensure_deserial<br/>.get::<T>()"]
end
A -->|"bytes<br/>type_name"| B
Full Roundtrip Example¶
use app_shell::app_engine::*;
// --- Domain type ---
#[derive(Debug, Clone, PartialEq)]
struct ExportInput {
project_id: String,
format: String,
}
// --- Domain serializer ---
struct ExportInputSerializer;
impl Serializer for ExportInputSerializer {
fn type_name(&self) -> &str { "demo::ExportInput" }
fn serialize(&self, data: &dyn Any) -> Result<Vec<u8>, SerializationError> {
let input = data.downcast_ref::<ExportInput>()
.ok_or_else(|| SerializationError::TypeMismatch {
expected: self.type_name().to_string(),
actual: "unknown".to_string(),
})?;
Ok(format!("{}|{}", input.project_id, input.format).into_bytes())
}
fn deserialize(&self, bytes: &[u8]) -> Result<Box<dyn Any + Send>, SerializationError> {
let s = std::str::from_utf8(bytes)
.map_err(|e| SerializationError::DeserializationFailed {
type_name: self.type_name().to_string(),
reason: e.to_string(),
})?;
let parts: Vec<&str> = s.split('|').collect();
Ok(Box::new(ExportInput {
project_id: parts[0].to_string(),
format: parts[1].to_string(),
}))
}
}
// --- Roundtrip ---
fn roundtrip() {
let registry = SerializationRegistry::new();
registry.register(Box::new(ExportInputSerializer));
// Sender creates input with typed data
let mut input = CommandInput::new(ExportInput {
project_id: "proj_42".to_string(),
format: "pdf".to_string(),
});
// Serialize for IPC
input.ensure_serialized(®istry).unwrap();
let type_name = input.type_name();
let bytes = input.serialized().unwrap().to_vec();
// Receiver reconstructs from bytes
let mut received = CommandInput::from_bytes(type_name, bytes);
assert!(!received.has_data()); // Not deserialized yet
// Deserialize back to typed data
received.ensure_deserialized(®istry).unwrap();
assert!(received.has_data());
let data: &ExportInput = received.get::<ExportInput>().unwrap();
assert_eq!(data.project_id, "proj_42");
assert_eq!(data.format, "pdf");
}
Event Roundtrip¶
// Sender creates event with typed payload
let mut event = Event::new("document.exported")
.with_payload(ExportInput {
project_id: "doc_42".to_string(),
format: "pdf".to_string(),
});
// Serialize for IPC
event.ensure_serialized(®istry).unwrap();
let type_name = event.payload_type_name();
let bytes = event.serialized_payload().unwrap().to_vec();
// Receiver reconstructs from bytes
let mut received = Event::from_serialized("document.exported", type_name, bytes);
assert!(!received.has_payload());
// Deserialize
received.ensure_deserialized(®istry).unwrap();
let payload = received.payload::<ExportInput>().unwrap();
assert_eq!(payload.project_id, "doc_42");
Integration with ConfigStore¶
ConfigStore persists key-value strings via StorageBackend. The serialization system is not used for configuration — configuration is always String-based.
For complex configuration objects, serialize them to a string format (JSON, TOML) in the Domain Engine and store the string:
// Domain code serializes config to JSON string
let json = serde_json::to_string(&my_config)?;
engine.config_store().set("my_plugin.config", &json)?;
Thread Safety¶
All systems are thread-safe:
| System | Lock Type | Notes |
|---|---|---|
ConfigStore |
Mutex<Config> |
All methods &self |
HistoryStore |
Mutex<Vec> + Mutex<usize> |
All methods &self |
SerializationRegistry |
RwLock<HashMap> |
register() uses write, serialize()/deserialize() use read |
MemoryStorageBackend |
Mutex<HashMap> |
All methods &self |
FileStorageBackend |
Mutex<HashMap> (cache) |
Files are source of truth, cache for fast reads |
All are Send + Sync.
Quick Reference¶
StorageBackend API¶
backend.load("key")? // → Option<String>
backend.save("key", "value")? // → ()
backend.remove("key")? // → ()
backend.exists("key") // → bool
backend.keys()? // → Vec<String>
ConfigStore with Backend¶
// Create
ConfigStore::new(schema) // No backend
ConfigStore::with_backend(schema, backend) // With backend
ConfigStore::with_memory_backend(schema) // Memory backend
// Persistence
store.set("key", "value")? // Saves to backend
store.save(config)? // Saves all keys
store.remove("key") // Removes from backend
store.reset() // Defaults + reload from backend
// Change notification
store.on_changed(|key, old, new| { ... });
// Queries
store.has_backend() // → bool
store.get("key") // → Option<String>
store.contains("key") // → bool
store.key_count() // → usize
SerializationRegistry API¶
registry.register(serializer) // Register by type name
registry.has_serializer("type_name") // → bool
registry.serialize(&data, "type_name")? // → Vec<u8>
registry.deserialize(&bytes, "type_name")? // → Box<dyn Any + Send>
registry.registered_types() // → Vec<String>
registry.len() // → usize
Container Serialization Methods¶
// CommandInput / TaskInput
input.from_bytes(type_name, bytes) // Construct from IPC
input.with_serialized(data, bytes) // Both forms
input.ensure_serialized(®istry)? // Serialize if not present
input.ensure_deserialized(®istry)? // Deserialize if not present
input.serialized() // → Option<&[u8]>
input.has_serialized() // → bool
// Event
event.from_serialized(event_type, type_name, bytes)
event.with_payload_and_serialized(data, bytes)
event.ensure_serialized(®istry)?
event.ensure_deserialized(®istry)?
event.serialized_payload() // → Option<&[u8]>
event.has_serialized_payload() // → bool
Next Steps¶
- 09-domain-engine-integration.md — How domain code provides serializers.
- 11-handler-capabilities.md — What handlers can access via
EngineRef. - 03-bootstrap-and-lifecycle.md — Loading configuration during bootstrap.
- 16-testing-and-metadata.md — Testing serialization roundtrips.