use noxid_ir::{
ComponentDefinition, EndpointCachePolicy, EndpointHandler, EndpointInputSection, EndpointKind,
EndpointLimitPolicy, EndpointMethod, ExecutionTarget, FileUploadContract, QueueHandler,
SemanticExpr, SemanticId, SemanticProgram, SemanticStatement, TaskHandler,
};
use noxid_source::{Span, json_escape};
pub const SCHEMA_VERSION: u32 = 18;
#[derive(Clone, Debug, Default)]
pub struct ExecutionProgram {
pub live_resources: Vec<LiveResourceExecutionContract>,
pub presences: Vec<PresenceExecutionContract>,
pub boundaries: Vec<ExecutionBoundary>,
pub endpoints: Vec<EndpointExecutionBoundary>,
pub tasks: Vec<TaskExecutionBoundary>,
pub queues: Vec<QueueExecutionBoundary>,
}
#[derive(Clone, Debug)]
pub struct PresenceExecutionContract {
pub id: SemanticId,
pub component: SemanticId,
pub component_name: String,
pub stream: SemanticId,
pub record_type: SemanticId,
pub member_type: SemanticId,
pub snapshot_type: SemanticId,
pub fields: Vec<PresenceExecutionField>,
pub capabilities: Vec<String>,
pub route_scopes: Vec<ExecutionRouteScope>,
pub ttl_ms: u64,
pub heartbeat_ms: u64,
}
#[derive(Clone, Debug)]
pub struct PresenceExecutionField {
pub id: SemanticId,
pub name: String,
pub ty: String,
pub type_id: Option<SemanticId>,
}
#[derive(Clone, Debug)]
pub struct LiveResourceExecutionContract {
pub id: SemanticId,
pub name: String,
pub capabilities: Vec<String>,
pub route_scopes: Vec<ExecutionRouteScope>,
}
#[derive(Clone, Debug)]
pub struct QueueExecutionBoundary {
pub id: SemanticId,
pub host_key: Option<SemanticId>,
pub name: String,
pub payload: Vec<QueueExecutionField>,
pub retry: u32,
pub backoff_ms: u64,
pub statements: Vec<SemanticStatement>,
pub invalidates: Vec<SemanticId>,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct QueueExecutionField {
pub id: SemanticId,
pub name: String,
pub ty: String,
pub type_id: Option<SemanticId>,
pub type_ids: Vec<(String, SemanticId)>,
}
#[derive(Clone, Debug)]
pub struct TaskExecutionBoundary {
pub id: SemanticId,
pub host_key: Option<SemanticId>,
pub name: String,
pub schedule: String,
pub statements: Vec<SemanticStatement>,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct EndpointExecutionBoundary {
pub id: SemanticId,
pub kind: EndpointKind,
pub host_key: Option<SemanticId>,
pub name: String,
pub version: u32,
pub description: Option<String>,
pub method: Option<EndpointMethod>,
pub path: Option<String>,
pub inputs: Vec<EndpointExecutionInput>,
pub result: ExecutionResult,
pub statements: Vec<SemanticStatement>,
pub capabilities: Vec<String>,
pub timeout_ms: u64,
pub limit: Option<EndpointLimitPolicy>,
pub cache: Option<EndpointCachePolicy>,
pub idempotent: bool,
pub middleware: Vec<String>,
pub invalidates: Vec<SemanticId>,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct EndpointExecutionInput {
pub id: SemanticId,
pub section: EndpointInputSection,
pub name: String,
pub ty: String,
pub type_id: Option<SemanticId>,
pub file: Option<FileUploadContract>,
}
#[derive(Clone, Debug)]
pub struct ExecutionBoundary {
pub id: SemanticId,
pub action: SemanticId,
pub component: SemanticId,
pub component_name: String,
pub action_name: String,
pub target: ExecutionTarget,
pub parameters: Vec<ExecutionParameter>,
pub result: ExecutionResult,
pub body: Option<SemanticExpr>,
pub capabilities: Vec<String>,
pub route_scopes: Vec<ExecutionRouteScope>,
pub invalidates: Vec<SemanticId>,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct ExecutionRouteScope {
pub route: SemanticId,
pub pattern: String,
pub parameters: Vec<ExecutionRouteParameter>,
pub middleware: Vec<SemanticId>,
}
#[derive(Clone, Debug)]
pub struct ExecutionRouteParameter {
pub name: String,
pub ty: String,
pub catch_all: bool,
}
#[derive(Clone, Debug)]
pub struct ExecutionParameter {
pub id: SemanticId,
pub name: String,
pub ty: String,
pub type_id: Option<SemanticId>,
}
#[derive(Clone, Debug)]
pub struct ExecutionResult {
pub id: SemanticId,
pub ty: String,
pub type_id: Option<SemanticId>,
}
pub fn lower(program: &SemanticProgram) -> ExecutionProgram {
let mut lowered = lower_components(&program.components);
lowered.live_resources = program
.resources
.iter()
.filter(|resource| resource.live)
.map(|resource| LiveResourceExecutionContract {
id: resource.id.clone(),
name: resource.name.clone(),
capabilities: resource
.capabilities
.iter()
.map(|capability| capability.name.clone())
.collect(),
route_scopes: vec![],
})
.collect();
lowered.endpoints = program
.endpoints
.iter()
.map(|endpoint| EndpointExecutionBoundary {
id: endpoint.id.clone(),
kind: endpoint.kind,
host_key: match &endpoint.handler {
EndpointHandler::Host { key } => Some(key.clone()),
EndpointHandler::CompilerOwned { .. } => None,
},
name: endpoint.name.clone(),
version: endpoint.version,
description: endpoint.description.clone(),
method: endpoint.route.as_ref().map(|route| route.method),
path: endpoint.route.as_ref().map(|route| route.path.clone()),
inputs: endpoint
.params
.iter()
.chain(&endpoint.query)
.chain(&endpoint.body)
.map(|field| EndpointExecutionInput {
id: field.id.clone(),
section: field.section,
name: field.name.clone(),
ty: field.ty.to_string(),
type_id: field.type_id.clone(),
file: field.file.clone(),
})
.collect(),
result: ExecutionResult {
id: endpoint.result.id.clone(),
ty: endpoint.result.ty.to_string(),
type_id: endpoint.result.type_id.clone(),
},
statements: endpoint.handler.statements().to_vec(),
capabilities: endpoint
.capabilities
.iter()
.map(|capability| capability.name.clone())
.collect(),
timeout_ms: endpoint.timeout.milliseconds,
limit: endpoint.limit,
cache: endpoint.cache.clone(),
idempotent: endpoint.idempotent,
middleware: endpoint
.middleware
.iter()
.map(|middleware| middleware.name.clone())
.collect(),
invalidates: endpoint.invalidation.resources.clone(),
span: endpoint.span,
})
.collect();
lowered
.endpoints
.sort_by(|left, right| left.id.cmp(&right.id));
lowered.tasks = program
.tasks
.iter()
.map(|task| TaskExecutionBoundary {
id: task.id.clone(),
host_key: match &task.handler {
TaskHandler::Host { key } => Some(key.clone()),
TaskHandler::CompilerOwned { .. } => None,
},
name: task.name.clone(),
schedule: task.schedule.cron.clone(),
statements: task.handler.statements().to_vec(),
span: task.span,
})
.collect();
lowered.tasks.sort_by(|left, right| left.id.cmp(&right.id));
lowered.queues = program
.queues
.iter()
.map(|queue| QueueExecutionBoundary {
id: queue.id.clone(),
host_key: match &queue.handler {
QueueHandler::Host { key } => Some(key.clone()),
QueueHandler::CompilerOwned { .. } => None,
},
name: queue.name.clone(),
payload: queue
.payload
.iter()
.map(|field| QueueExecutionField {
id: field.id.clone(),
name: field.name.clone(),
ty: field.ty.to_string(),
type_id: field.type_id.clone(),
type_ids: field.type_ids.clone(),
})
.collect(),
retry: queue.retry,
backoff_ms: queue.backoff_ms,
statements: queue.handler.statements().to_vec(),
invalidates: queue.invalidation.resources.clone(),
span: queue.span,
})
.collect();
lowered.queues.sort_by(|left, right| left.id.cmp(&right.id));
lowered
}
pub fn lower_components<'a>(
components: impl IntoIterator<Item = &'a ComponentDefinition>,
) -> ExecutionProgram {
let mut boundaries = Vec::new();
let mut presences = Vec::new();
for component in components {
if let Some(presence) = &component.presence {
presences.push(PresenceExecutionContract {
id: presence.id.clone(),
component: component.id.clone(),
component_name: component.name.clone(),
stream: presence.stream.clone(),
record_type: presence.record_type.clone(),
member_type: presence.member_type.clone(),
snapshot_type: presence.snapshot_type.clone(),
fields: presence
.fields
.iter()
.map(|field| PresenceExecutionField {
id: field.id.clone(),
name: field.name.clone(),
ty: field.ty.to_string(),
type_id: field.type_id.clone(),
})
.collect(),
capabilities: component
.capabilities
.iter()
.map(|capability| capability.name.clone())
.collect(),
route_scopes: vec![],
ttl_ms: presence.ttl_milliseconds,
heartbeat_ms: presence.heartbeat_milliseconds,
});
}
for action in component
.actions
.iter()
.filter(|action| action.execution.is_remote())
{
boundaries.push(ExecutionBoundary {
id: SemanticId::execution_boundary(&component.name, &action.name, action.execution),
action: action.id.clone(),
component: component.id.clone(),
component_name: component.name.clone(),
action_name: action.name.clone(),
target: action.execution,
parameters: action
.parameters
.iter()
.map(|parameter| ExecutionParameter {
id: parameter.id.clone(),
name: parameter.name.clone(),
ty: parameter.ty.to_string(),
type_id: parameter.type_id.clone(),
})
.collect(),
result: ExecutionResult {
id: action.result.id.clone(),
ty: action.result.ty.to_string(),
type_id: action.result.type_id.clone(),
},
body: action
.statements
.iter()
.find_map(|statement| match statement {
SemanticStatement::Return { value, .. } => Some(value.clone()),
SemanticStatement::Assignment { .. }
| SemanticStatement::FieldAssignment { .. }
| SemanticStatement::Local { .. }
| SemanticStatement::LocalAssignment { .. }
| SemanticStatement::RemoteAwait { .. }
| SemanticStatement::If { .. }
| SemanticStatement::ActionCall { .. }
| SemanticStatement::Transition { .. }
| SemanticStatement::CollectionMutation { .. }
| SemanticStatement::PrincipalMatch { .. }
| SemanticStatement::Emit { .. } => None,
}),
capabilities: action
.capabilities
.iter()
.map(|capability| capability.name.clone())
.collect(),
route_scopes: vec![],
invalidates: action.invalidation.resources.clone(),
span: action.span,
});
}
}
boundaries.sort_by(|left, right| left.id.cmp(&right.id));
boundaries.dedup_by(|left, right| left.id == right.id);
ExecutionProgram {
live_resources: vec![],
presences,
boundaries,
endpoints: vec![],
tasks: vec![],
queues: vec![],
}
}
impl ExecutionProgram {
pub fn to_json(&self) -> String {
format!(
"{{\n \"schemaVersion\": {SCHEMA_VERSION},\n \"liveResources\": [{}],\n \"presences\": [{}],\n \"boundaries\": [{}],\n \"endpoints\": [{}],\n \"tasks\": [{}],\n \"queues\": [{}]\n}}",
self.live_resources
.iter()
.map(|resource| {
format!(
"{{\"id\":\"{}\",\"name\":\"{}\",\"capabilities\":[{}],\"routeScopes\":[{}]}}",
resource.id,
json_escape(&resource.name),
resource
.capabilities
.iter()
.map(|capability| format!("\"{}\"", json_escape(capability)))
.collect::<Vec<_>>()
.join(","),
resource
.route_scopes
.iter()
.map(ExecutionRouteScope::to_json)
.collect::<Vec<_>>()
.join(","),
)
})
.collect::<Vec<_>>()
.join(","),
self.presences
.iter()
.map(PresenceExecutionContract::to_json)
.collect::<Vec<_>>()
.join(","),
self.boundaries
.iter()
.map(ExecutionBoundary::to_json)
.collect::<Vec<_>>()
.join(","),
self.endpoints
.iter()
.map(EndpointExecutionBoundary::to_json)
.collect::<Vec<_>>()
.join(","),
self.tasks
.iter()
.map(TaskExecutionBoundary::to_json)
.collect::<Vec<_>>()
.join(","),
self.queues
.iter()
.map(QueueExecutionBoundary::to_json)
.collect::<Vec<_>>()
.join(",")
)
}
}
impl PresenceExecutionContract {
fn to_json(&self) -> String {
format!(
"{{\"id\":\"{}\",\"component\":\"{}\",\"componentName\":\"{}\",\"stream\":\"{}\",\"recordType\":\"{}\",\"memberType\":\"{}\",\"snapshotType\":\"{}\",\"fields\":[{}],\"capabilities\":[{}],\"routeScopes\":[{}],\"ttlMs\":{},\"heartbeatMs\":{}}}",
self.id,
self.component,
json_escape(&self.component_name),
self.stream,
self.record_type,
self.member_type,
self.snapshot_type,
self.fields
.iter()
.map(PresenceExecutionField::to_json)
.collect::<Vec<_>>()
.join(","),
self.capabilities
.iter()
.map(|capability| format!("\"{}\"", json_escape(capability)))
.collect::<Vec<_>>()
.join(","),
self.route_scopes
.iter()
.map(ExecutionRouteScope::to_json)
.collect::<Vec<_>>()
.join(","),
self.ttl_ms,
self.heartbeat_ms,
)
}
}
impl PresenceExecutionField {
fn to_json(&self) -> String {
format!(
"{{\"id\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{}}}",
self.id,
json_escape(&self.name),
json_escape(&self.ty),
self.type_id
.as_ref()
.map(|id| format!("\"{id}\""))
.unwrap_or_else(|| "null".into()),
)
}
}
impl QueueExecutionBoundary {
fn to_json(&self) -> String {
format!(
"{{\"id\":\"{}\",\"hostKey\":{},\"name\":\"{}\",\"payload\":[{}],\"retry\":{},\"backoffMs\":{},\"invalidates\":{},\"statements\":[{}],\"span\":{{\"start\":{},\"end\":{}}}}}",
self.id,
optional_id_json(&self.host_key),
json_escape(&self.name),
self.payload
.iter()
.map(QueueExecutionField::to_json)
.collect::<Vec<_>>()
.join(","),
self.retry,
self.backoff_ms,
ids_json(&self.invalidates),
self.statements
.iter()
.map(SemanticStatement::to_json)
.collect::<Vec<_>>()
.join(","),
self.span.start,
self.span.end,
)
}
}
impl QueueExecutionField {
fn to_json(&self) -> String {
format!(
"{{\"id\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{},\"typeIds\":[{}]}}",
self.id,
json_escape(&self.name),
json_escape(&self.ty),
optional_id_json(&self.type_id),
self.type_ids
.iter()
.map(|(name, id)| format!(
"{{\"name\":\"{}\",\"id\":\"{}\"}}",
json_escape(name),
id
))
.collect::<Vec<_>>()
.join(","),
)
}
}
impl TaskExecutionBoundary {
fn to_json(&self) -> String {
format!(
"{{\"id\":\"{}\",\"hostKey\":{},\"name\":\"{}\",\"schedule\":\"{}\",\"statements\":[{}],\"span\":{{\"start\":{},\"end\":{}}}}}",
self.id,
optional_id_json(&self.host_key),
json_escape(&self.name),
json_escape(&self.schedule),
self.statements
.iter()
.map(SemanticStatement::to_json)
.collect::<Vec<_>>()
.join(","),
self.span.start,
self.span.end,
)
}
}
impl EndpointExecutionBoundary {
fn to_json(&self) -> String {
format!(
"{{\"id\":\"{}\",\"hostKey\":{},\"name\":\"{}\",\"version\":{},\"description\":{},\"kind\":\"{}\",\"method\":{},\"path\":{},\"inputs\":[{}],\"result\":{},\"statements\":[{}],\"capabilities\":[{}],\"timeoutMs\":{},\"limit\":{},\"cache\":{},\"idempotent\":{},\"middleware\":[{}],\"invalidates\":{},\"span\":{{\"start\":{},\"end\":{}}}}}",
self.id,
optional_id_json(&self.host_key),
json_escape(&self.name),
self.version,
self.description
.as_ref()
.map(|description| format!("\"{}\"", json_escape(description)))
.unwrap_or_else(|| "null".into()),
self.kind.as_str(),
self.method
.map(|method| format!("\"{}\"", method.as_str()))
.unwrap_or_else(|| "null".into()),
self.path
.as_ref()
.map(|path| format!("\"{}\"", json_escape(path)))
.unwrap_or_else(|| "null".into()),
self.inputs
.iter()
.map(EndpointExecutionInput::to_json)
.collect::<Vec<_>>()
.join(","),
self.result.to_json(),
self.statements
.iter()
.map(SemanticStatement::to_json)
.collect::<Vec<_>>()
.join(","),
self.capabilities
.iter()
.map(|capability| format!("\"{}\"", json_escape(capability)))
.collect::<Vec<_>>()
.join(","),
self.timeout_ms,
self.limit
.map(|limit| format!(
"{{\"requests\":{},\"window\":\"{}\",\"scope\":\"{}\"}}",
limit.requests,
limit.window.as_str(),
limit.scope.as_str()
))
.unwrap_or_else(|| "null".into()),
self.cache
.as_ref()
.map(|cache| format!(
"{{\"id\":\"{}\",\"mode\":\"{}\",\"seconds\":{},\"tags\":[{}]}}",
cache.id,
cache.mode.as_str(),
cache.seconds,
cache
.tags
.iter()
.map(|tag| format!("\"{}\"", json_escape(tag)))
.collect::<Vec<_>>()
.join(",")
))
.unwrap_or_else(|| "null".into()),
self.idempotent,
self.middleware
.iter()
.map(|middleware| format!("\"{}\"", json_escape(middleware)))
.collect::<Vec<_>>()
.join(","),
ids_json(&self.invalidates),
self.span.start,
self.span.end,
)
}
}
impl EndpointExecutionInput {
fn to_json(&self) -> String {
format!(
"{{\"id\":\"{}\",\"section\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{},\"file\":{}}}",
self.id,
self.section.as_str(),
json_escape(&self.name),
json_escape(&self.ty),
optional_id_json(&self.type_id),
self.file
.as_ref()
.map(FileUploadContract::to_json)
.unwrap_or_else(|| "null".into()),
)
}
}
impl ExecutionBoundary {
fn to_json(&self) -> String {
format!(
"{{\"id\":\"{}\",\"action\":\"{}\",\"component\":\"{}\",\"componentName\":\"{}\",\"actionName\":\"{}\",\"target\":\"{}\",\"parameters\":[{}],\"result\":{},\"body\":{},\"capabilities\":[{}],\"routeScopes\":[{}],\"invalidates\":{},\"span\":{{\"start\":{},\"end\":{}}}}}",
self.id,
self.action,
self.component,
json_escape(&self.component_name),
json_escape(&self.action_name),
self.target.as_str(),
self.parameters
.iter()
.map(ExecutionParameter::to_json)
.collect::<Vec<_>>()
.join(","),
self.result.to_json(),
self.body
.as_ref()
.map(|body| body.to_json())
.unwrap_or_else(|| "null".into()),
self.capabilities
.iter()
.map(|capability| format!("\"{}\"", json_escape(capability)))
.collect::<Vec<_>>()
.join(","),
self.route_scopes
.iter()
.map(ExecutionRouteScope::to_json)
.collect::<Vec<_>>()
.join(","),
ids_json(&self.invalidates),
self.span.start,
self.span.end,
)
}
}
fn ids_json(ids: &[SemanticId]) -> String {
format!(
"[{}]",
ids.iter()
.map(|id| format!("\"{id}\""))
.collect::<Vec<_>>()
.join(",")
)
}
impl ExecutionRouteScope {
fn to_json(&self) -> String {
format!(
"{{\"route\":\"{}\",\"pattern\":\"{}\",\"parameters\":[{}],\"middleware\":[{}]}}",
self.route,
json_escape(&self.pattern),
self.parameters
.iter()
.map(|parameter| format!(
"{{\"name\":\"{}\",\"type\":\"{}\",\"catchAll\":{}}}",
json_escape(¶meter.name),
json_escape(¶meter.ty),
parameter.catch_all,
))
.collect::<Vec<_>>()
.join(","),
self.middleware
.iter()
.map(|id| format!("\"{}\"", id))
.collect::<Vec<_>>()
.join(","),
)
}
}
impl ExecutionResult {
fn to_json(&self) -> String {
format!(
"{{\"id\":\"{}\",\"type\":\"{}\",\"typeId\":{}}}",
self.id,
json_escape(&self.ty),
optional_id_json(&self.type_id),
)
}
}
impl ExecutionParameter {
fn to_json(&self) -> String {
format!(
"{{\"id\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{}}}",
self.id,
json_escape(&self.name),
json_escape(&self.ty),
optional_id_json(&self.type_id),
)
}
}
fn optional_id_json(id: &Option<SemanticId>) -> String {
id.as_ref()
.map(|id| format!("\"{}\"", json_escape(&id.to_string())))
.unwrap_or_else(|| "null".into())
}
#[cfg(test)]
mod tests {
use std::process::Command;
use super::*;
fn endpoint_boundary(name: &str, host_key: Option<SemanticId>) -> EndpointExecutionBoundary {
EndpointExecutionBoundary {
id: SemanticId::endpoint(name),
kind: EndpointKind::RequestResponse,
host_key,
name: name.into(),
version: 1,
description: None,
method: None,
path: None,
inputs: vec![],
result: ExecutionResult {
id: SemanticId::endpoint_result(name),
ty: "String".into(),
type_id: None,
},
statements: vec![],
capabilities: vec![],
timeout_ms: 30_000,
limit: None,
cache: None,
idempotent: false,
middleware: vec![],
invalidates: vec![],
span: Span::default(),
}
}
#[test]
fn endpoint_host_keys_are_json_strings_or_null() {
let program = ExecutionProgram {
live_resources: vec![],
presences: vec![],
boundaries: vec![],
endpoints: vec![
endpoint_boundary("LoadProgress", Some(SemanticId::endpoint("LoadProgress"))),
endpoint_boundary("AuthCallback", None),
],
tasks: vec![],
queues: vec![],
};
let json = program.to_json();
assert!(json.contains(
"\"id\":\"endpoint:LoadProgress@1\",\"hostKey\":\"endpoint:LoadProgress@1\""
));
assert!(json.contains("\"id\":\"endpoint:AuthCallback@1\",\"hostKey\":null"));
assert!(!json.contains("\"hostKey\":\"null\""));
assert!(!json.contains("\"hostKey\":\"\""));
let parsed = Command::new("node")
.args(["-e", "JSON.parse(process.argv[1])", &json])
.status()
.expect("Node.js is required by the workspace test gate");
assert!(parsed.success(), "execution manifest must be valid JSON");
}
}