Lenso
lenso is the public Rust facade for Lenso module-authoring and host boot
contracts.
Install it from crates.io:
cargo add lenso@0.3.18
The default facade exposes serializable module manifest declarations:
- module manifests and manifest lints;
- HTTP route metadata;
- runtime function declarations;
- event handler declarations;
- lifecycle declarations;
- Console surface declarations;
- story display metadata.
Enable the host feature for the narrow host boot facade:
cargo add lenso --features host
cargo add async-trait serde_json
cargo add tokio --features macros,rt-multi-thread
Application SQL, repositories, auth/session policy, CRUD shape, and Runtime
Console UI stay in the host application or module code.
Manifest-declared behavior for a host-owned linked Module is authored through
lenso::host::runtime. It exposes the linked loader, binding, function handler,
descriptor, retry, execution context, and standard error types needed to return
a behavior-bearing HostLinkedModule; external Modules do not import a
lenso-platform-* crate. The same public facade exposes Event handlers through
lenso::host::outbox.
use async_trait::async_trait;
use lenso::host::outbox::{ClaimedOutboxEvent, EventHandler};
use lenso::host::runtime::{
AppContext, AppResult, ExecutionContext, FunctionDefinition, FunctionHandler,
LinkedBinding, Module, RetryPolicy, RuntimeDescriptor,
};
use lenso::host::{HostBuilder, HostLinkedModule, Migration};
use lenso::{
EventHandlerDeclaration, EventSurface, ModuleManifest, RuntimeFunctionDeclaration,
RuntimeRetryPolicyDeclaration, RuntimeSurface, ScheduledFunctionDeclaration,
};
use serde_json::{Value, json};
use std::{sync::Arc, time::Duration};
const FUNCTION_NAME: &str = "inventory.reconcile.v1";
const SCHEDULE_NAME: &str = "inventory-reconcile-hourly";
const EVENT_HANDLER_NAME: &str = "inventory.project-item-changed.v1";
const EVENT_NAME: &str = "inventory.item-changed.v1";
const MIGRATIONS: &[Migration] = &[];
#[derive(Debug)]
struct Reconcile;
#[async_trait]
impl FunctionHandler for Reconcile {
async fn call(&self, _context: ExecutionContext, input: Value) -> AppResult<Value> {
Ok(input)
}
}
#[derive(Debug)]
struct ProjectItemChanged;
#[async_trait]
impl EventHandler for ProjectItemChanged {
fn handler_name(&self) -> &str {
EVENT_HANDLER_NAME
}
fn event_name(&self) -> &str {
EVENT_NAME
}
async fn handle(&self, event: &ClaimedOutboxEvent) -> AppResult<()> {
let _stable_delivery_id = &event.id;
Ok(())
}
}
fn manifest() -> ModuleManifest {
ModuleManifest::builder("example/inventory")
.events(EventSurface {
handlers: vec![EventHandlerDeclaration {
name: EVENT_HANDLER_NAME.to_owned(),
event_name: EVENT_NAME.to_owned(),
operation: None,
}],
})
.runtime(RuntimeSurface {
functions: vec![RuntimeFunctionDeclaration {
name: FUNCTION_NAME.to_owned(),
version: 1,
queue: "inventory".to_owned(),
input_schema: Some(FUNCTION_NAME.to_owned()),
retry_policy: Some(RuntimeRetryPolicyDeclaration {
max_attempts: 3,
initial_delay_ms: 5_000,
}),
operation: None,
}],
schedules: vec![ScheduledFunctionDeclaration {
name: SCHEDULE_NAME.to_owned(),
function_name: FUNCTION_NAME.to_owned(),
cron: "0 * * * *".to_owned(),
input: json!({ "reason": "scheduled" }),
}],
workflows: Vec::new(),
})
.build()
}
fn load(_context: &AppContext) -> Module {
Module::linked(
manifest(),
LinkedBinding::builder()
.event_handlers(vec![Arc::new(ProjectItemChanged)])
.runtime(RuntimeDescriptor {
module: "inventory",
functions: vec![FunctionDefinition {
name: FUNCTION_NAME.to_owned(),
version: 1,
queue: "inventory".to_owned(),
retry_policy: RetryPolicy::fixed(3, Duration::from_secs(5)),
handler: Arc::new(Reconcile),
}],
..RuntimeDescriptor::default()
})
.build(),
)
}
fn linked_module() -> HostLinkedModule {
HostLinkedModule::linked("inventory", manifest, load, MIGRATIONS)
}
#[tokio::main]
async fn main() {
HostBuilder::new()
.linked_module(linked_module())
.run_worker_from_env()
.await
.expect("run Lenso worker");
}
If context-bound setup can fail, return an AppResult<Module> and use
HostLinkedModule::try_linked. The Host propagates the structured error during
startup; external Modules do not need to panic or import private platform
crates.
fn try_load(context: &AppContext) -> AppResult<Module> {
std::env::var("INVENTORY_BUCKET").map_err(|_| {
lenso::host::runtime::AppError::new(
lenso::host::runtime::ErrorCode::Validation,
"INVENTORY_BUCKET is required",
)
})?;
Ok(load(context))
}
fn fallible_linked_module() -> HostLinkedModule {
HostLinkedModule::try_linked("inventory", manifest, try_load, MIGRATIONS)
}
The Module owns the schema named by input_schema at
contracts/runtime/functions/inventory.reconcile.v1.schema.json; keep its
$id and title equal to the stable versioned function name.
The Host rejects declarations and bindings whose stable function, handler,
Event, version, or queue identities do not agree.
Event delivery is at least once. Persist ClaimedOutboxEvent::id as an
idempotency key in the owning Module, atomically with the handler's business
side effect, and acknowledge only after commit. In-memory deduplication is not
sufficient because process restarts and retries preserve the same Event id.
Host-owned linked modules can use lenso::host::transaction when one operation
must atomically claim an idempotency key, execute app-owned SQL, and publish an
Outbox event. The application still writes its business query with sqlx; it
does not import lenso-platform-core or address platform tables directly.
Consumers that only need this transaction boundary can avoid the complete Host
boot dependency graph:
lenso = { version = "0.3.19", features = ["host-transactions"] }
use lenso::host::transaction::{
IdempotencyClaim, IdempotencyKey, LinkedTransaction, OutboxEvent,
};
let key = IdempotencyKey::parse("orders:create", request_key)?;
let mut transaction = LinkedTransaction::begin(&context.db).await?;
if transaction.claim_idempotency_key(&key).await? == IdempotencyClaim::Existing {
transaction.rollback().await?;
return Ok(());
}
sqlx::query("insert into orders (id) values ($1)")
.bind(order_id)
.execute(&mut **transaction.sql())
.await?;
transaction.publish_outbox(&event).await?;
transaction.commit().await?;
The same feature exposes the host-owned relay through
lenso::host::outbox. A host implements EventDispatcher and passes it to
OutboxRelay::relay_once; it does not import a lenso-platform-* crate or
address Outbox tables directly. Delivery is at least once. When a dispatcher
returns a retryable AppError, the existing host retry and dead-letter policy
decides when to redeliver or exhaust the event. Consumers must therefore make
effects idempotent using the stable ClaimedOutboxEvent::id.
use lenso::host::outbox::{
AppError, AppResult, ClaimedOutboxEvent, ErrorCode, EventDispatcher,
OutboxRelay,
};
#[derive(Debug)]
struct Consumer;
#[async_trait::async_trait]
impl EventDispatcher for Consumer {
async fn dispatch(&self, event: &ClaimedOutboxEvent) -> AppResult<()> {
consume_idempotently(&event.id, &event.payload)
.await
.map_err(|error| {
AppError::new(ErrorCode::ExternalDependency, "consumer unavailable")
.with_source(error)
.retryable()
})
}
}
let relay = OutboxRelay::new(context.db.clone(), "app-worker");
relay.relay_once(&Consumer, 25).await?;
Example
use lenso::{ModuleManifest, lint_module_manifest};
let manifest = ModuleManifest::builder("example")
.capabilities(vec!["example.records.read".to_owned()])
.build();
let lints = lint_module_manifest(&manifest);
assert!(
lints
.iter()
.all(|lint| !matches!(lint.severity, lenso::ModuleManifestLintSeverity::Error))
);