Skip to main content

noxid_execution_ir/
lib.rs

1use noxid_ir::{
2    ComponentDefinition, EndpointCachePolicy, EndpointHandler, EndpointInputSection, EndpointKind,
3    EndpointLimitPolicy, EndpointMethod, ExecutionTarget, FileUploadContract, QueueHandler,
4    SemanticExpr, SemanticId, SemanticProgram, SemanticStatement, TaskHandler,
5};
6use noxid_source::{Span, json_escape};
7
8pub const SCHEMA_VERSION: u32 = 18;
9
10#[derive(Clone, Debug, Default)]
11pub struct ExecutionProgram {
12    pub live_resources: Vec<LiveResourceExecutionContract>,
13    pub presences: Vec<PresenceExecutionContract>,
14    pub boundaries: Vec<ExecutionBoundary>,
15    pub endpoints: Vec<EndpointExecutionBoundary>,
16    pub tasks: Vec<TaskExecutionBoundary>,
17    pub queues: Vec<QueueExecutionBoundary>,
18}
19
20#[derive(Clone, Debug)]
21pub struct PresenceExecutionContract {
22    pub id: SemanticId,
23    pub component: SemanticId,
24    pub component_name: String,
25    pub stream: SemanticId,
26    pub record_type: SemanticId,
27    pub member_type: SemanticId,
28    pub snapshot_type: SemanticId,
29    pub fields: Vec<PresenceExecutionField>,
30    pub capabilities: Vec<String>,
31    pub route_scopes: Vec<ExecutionRouteScope>,
32    pub ttl_ms: u64,
33    pub heartbeat_ms: u64,
34}
35
36#[derive(Clone, Debug)]
37pub struct PresenceExecutionField {
38    pub id: SemanticId,
39    pub name: String,
40    pub ty: String,
41    pub type_id: Option<SemanticId>,
42}
43
44#[derive(Clone, Debug)]
45pub struct LiveResourceExecutionContract {
46    pub id: SemanticId,
47    pub name: String,
48    pub capabilities: Vec<String>,
49    pub route_scopes: Vec<ExecutionRouteScope>,
50}
51
52#[derive(Clone, Debug)]
53pub struct QueueExecutionBoundary {
54    pub id: SemanticId,
55    pub host_key: Option<SemanticId>,
56    pub name: String,
57    pub payload: Vec<QueueExecutionField>,
58    pub retry: u32,
59    pub backoff_ms: u64,
60    pub statements: Vec<SemanticStatement>,
61    pub invalidates: Vec<SemanticId>,
62    pub span: Span,
63}
64
65#[derive(Clone, Debug)]
66pub struct QueueExecutionField {
67    pub id: SemanticId,
68    pub name: String,
69    pub ty: String,
70    pub type_id: Option<SemanticId>,
71    pub type_ids: Vec<(String, SemanticId)>,
72}
73
74#[derive(Clone, Debug)]
75pub struct TaskExecutionBoundary {
76    pub id: SemanticId,
77    pub host_key: Option<SemanticId>,
78    pub name: String,
79    pub schedule: String,
80    pub statements: Vec<SemanticStatement>,
81    pub span: Span,
82}
83
84#[derive(Clone, Debug)]
85pub struct EndpointExecutionBoundary {
86    pub id: SemanticId,
87    pub kind: EndpointKind,
88    pub host_key: Option<SemanticId>,
89    pub name: String,
90    pub version: u32,
91    pub description: Option<String>,
92    pub method: Option<EndpointMethod>,
93    pub path: Option<String>,
94    pub inputs: Vec<EndpointExecutionInput>,
95    pub result: ExecutionResult,
96    pub statements: Vec<SemanticStatement>,
97    pub capabilities: Vec<String>,
98    pub timeout_ms: u64,
99    pub limit: Option<EndpointLimitPolicy>,
100    pub cache: Option<EndpointCachePolicy>,
101    pub idempotent: bool,
102    pub middleware: Vec<String>,
103    pub invalidates: Vec<SemanticId>,
104    pub span: Span,
105}
106
107#[derive(Clone, Debug)]
108pub struct EndpointExecutionInput {
109    pub id: SemanticId,
110    pub section: EndpointInputSection,
111    pub name: String,
112    pub ty: String,
113    pub type_id: Option<SemanticId>,
114    pub file: Option<FileUploadContract>,
115}
116
117#[derive(Clone, Debug)]
118pub struct ExecutionBoundary {
119    pub id: SemanticId,
120    pub action: SemanticId,
121    pub component: SemanticId,
122    pub component_name: String,
123    pub action_name: String,
124    pub target: ExecutionTarget,
125    pub parameters: Vec<ExecutionParameter>,
126    pub result: ExecutionResult,
127    pub body: Option<SemanticExpr>,
128    pub capabilities: Vec<String>,
129    pub route_scopes: Vec<ExecutionRouteScope>,
130    pub invalidates: Vec<SemanticId>,
131    pub span: Span,
132}
133
134#[derive(Clone, Debug)]
135pub struct ExecutionRouteScope {
136    pub route: SemanticId,
137    pub pattern: String,
138    pub parameters: Vec<ExecutionRouteParameter>,
139    pub middleware: Vec<SemanticId>,
140}
141
142#[derive(Clone, Debug)]
143pub struct ExecutionRouteParameter {
144    pub name: String,
145    pub ty: String,
146    pub catch_all: bool,
147}
148
149#[derive(Clone, Debug)]
150pub struct ExecutionParameter {
151    pub id: SemanticId,
152    pub name: String,
153    pub ty: String,
154    pub type_id: Option<SemanticId>,
155}
156
157#[derive(Clone, Debug)]
158pub struct ExecutionResult {
159    pub id: SemanticId,
160    pub ty: String,
161    pub type_id: Option<SemanticId>,
162}
163
164pub fn lower(program: &SemanticProgram) -> ExecutionProgram {
165    let mut lowered = lower_components(&program.components);
166    lowered.live_resources = program
167        .resources
168        .iter()
169        .filter(|resource| resource.live)
170        .map(|resource| LiveResourceExecutionContract {
171            id: resource.id.clone(),
172            name: resource.name.clone(),
173            capabilities: resource
174                .capabilities
175                .iter()
176                .map(|capability| capability.name.clone())
177                .collect(),
178            route_scopes: vec![],
179        })
180        .collect();
181    lowered.endpoints = program
182        .endpoints
183        .iter()
184        .map(|endpoint| EndpointExecutionBoundary {
185            id: endpoint.id.clone(),
186            kind: endpoint.kind,
187            host_key: match &endpoint.handler {
188                EndpointHandler::Host { key } => Some(key.clone()),
189                EndpointHandler::CompilerOwned { .. } => None,
190            },
191            name: endpoint.name.clone(),
192            version: endpoint.version,
193            description: endpoint.description.clone(),
194            method: endpoint.route.as_ref().map(|route| route.method),
195            path: endpoint.route.as_ref().map(|route| route.path.clone()),
196            inputs: endpoint
197                .params
198                .iter()
199                .chain(&endpoint.query)
200                .chain(&endpoint.body)
201                .map(|field| EndpointExecutionInput {
202                    id: field.id.clone(),
203                    section: field.section,
204                    name: field.name.clone(),
205                    ty: field.ty.to_string(),
206                    type_id: field.type_id.clone(),
207                    file: field.file.clone(),
208                })
209                .collect(),
210            result: ExecutionResult {
211                id: endpoint.result.id.clone(),
212                ty: endpoint.result.ty.to_string(),
213                type_id: endpoint.result.type_id.clone(),
214            },
215            statements: endpoint.handler.statements().to_vec(),
216            capabilities: endpoint
217                .capabilities
218                .iter()
219                .map(|capability| capability.name.clone())
220                .collect(),
221            timeout_ms: endpoint.timeout.milliseconds,
222            limit: endpoint.limit,
223            cache: endpoint.cache.clone(),
224            idempotent: endpoint.idempotent,
225            middleware: endpoint
226                .middleware
227                .iter()
228                .map(|middleware| middleware.name.clone())
229                .collect(),
230            invalidates: endpoint.invalidation.resources.clone(),
231            span: endpoint.span,
232        })
233        .collect();
234    lowered
235        .endpoints
236        .sort_by(|left, right| left.id.cmp(&right.id));
237    lowered.tasks = program
238        .tasks
239        .iter()
240        .map(|task| TaskExecutionBoundary {
241            id: task.id.clone(),
242            host_key: match &task.handler {
243                TaskHandler::Host { key } => Some(key.clone()),
244                TaskHandler::CompilerOwned { .. } => None,
245            },
246            name: task.name.clone(),
247            schedule: task.schedule.cron.clone(),
248            statements: task.handler.statements().to_vec(),
249            span: task.span,
250        })
251        .collect();
252    lowered.tasks.sort_by(|left, right| left.id.cmp(&right.id));
253    lowered.queues = program
254        .queues
255        .iter()
256        .map(|queue| QueueExecutionBoundary {
257            id: queue.id.clone(),
258            host_key: match &queue.handler {
259                QueueHandler::Host { key } => Some(key.clone()),
260                QueueHandler::CompilerOwned { .. } => None,
261            },
262            name: queue.name.clone(),
263            payload: queue
264                .payload
265                .iter()
266                .map(|field| QueueExecutionField {
267                    id: field.id.clone(),
268                    name: field.name.clone(),
269                    ty: field.ty.to_string(),
270                    type_id: field.type_id.clone(),
271                    type_ids: field.type_ids.clone(),
272                })
273                .collect(),
274            retry: queue.retry,
275            backoff_ms: queue.backoff_ms,
276            statements: queue.handler.statements().to_vec(),
277            invalidates: queue.invalidation.resources.clone(),
278            span: queue.span,
279        })
280        .collect();
281    lowered.queues.sort_by(|left, right| left.id.cmp(&right.id));
282    lowered
283}
284
285pub fn lower_components<'a>(
286    components: impl IntoIterator<Item = &'a ComponentDefinition>,
287) -> ExecutionProgram {
288    let mut boundaries = Vec::new();
289    let mut presences = Vec::new();
290    for component in components {
291        if let Some(presence) = &component.presence {
292            presences.push(PresenceExecutionContract {
293                id: presence.id.clone(),
294                component: component.id.clone(),
295                component_name: component.name.clone(),
296                stream: presence.stream.clone(),
297                record_type: presence.record_type.clone(),
298                member_type: presence.member_type.clone(),
299                snapshot_type: presence.snapshot_type.clone(),
300                fields: presence
301                    .fields
302                    .iter()
303                    .map(|field| PresenceExecutionField {
304                        id: field.id.clone(),
305                        name: field.name.clone(),
306                        ty: field.ty.to_string(),
307                        type_id: field.type_id.clone(),
308                    })
309                    .collect(),
310                capabilities: component
311                    .capabilities
312                    .iter()
313                    .map(|capability| capability.name.clone())
314                    .collect(),
315                route_scopes: vec![],
316                ttl_ms: presence.ttl_milliseconds,
317                heartbeat_ms: presence.heartbeat_milliseconds,
318            });
319        }
320        for action in component
321            .actions
322            .iter()
323            .filter(|action| action.execution.is_remote())
324        {
325            boundaries.push(ExecutionBoundary {
326                id: SemanticId::execution_boundary(&component.name, &action.name, action.execution),
327                action: action.id.clone(),
328                component: component.id.clone(),
329                component_name: component.name.clone(),
330                action_name: action.name.clone(),
331                target: action.execution,
332                parameters: action
333                    .parameters
334                    .iter()
335                    .map(|parameter| ExecutionParameter {
336                        id: parameter.id.clone(),
337                        name: parameter.name.clone(),
338                        ty: parameter.ty.to_string(),
339                        type_id: parameter.type_id.clone(),
340                    })
341                    .collect(),
342                result: ExecutionResult {
343                    id: action.result.id.clone(),
344                    ty: action.result.ty.to_string(),
345                    type_id: action.result.type_id.clone(),
346                },
347                body: action
348                    .statements
349                    .iter()
350                    .find_map(|statement| match statement {
351                        SemanticStatement::Return { value, .. } => Some(value.clone()),
352                        SemanticStatement::Assignment { .. }
353                        | SemanticStatement::FieldAssignment { .. }
354                        | SemanticStatement::Local { .. }
355                        | SemanticStatement::LocalAssignment { .. }
356                        | SemanticStatement::RemoteAwait { .. }
357                        | SemanticStatement::If { .. }
358                        | SemanticStatement::ActionCall { .. }
359                        | SemanticStatement::Transition { .. }
360                        | SemanticStatement::CollectionMutation { .. }
361                        // A remote action body is one return expression, so a
362                        // principal match cannot be its body (ADR 0137's
363                        // handler `#match` lives in statement-bodied
364                        // endpoint, task, and queue handlers).
365                        | SemanticStatement::PrincipalMatch { .. }
366                        | SemanticStatement::Emit { .. } => None,
367                    }),
368                capabilities: action
369                    .capabilities
370                    .iter()
371                    .map(|capability| capability.name.clone())
372                    .collect(),
373                route_scopes: vec![],
374                invalidates: action.invalidation.resources.clone(),
375                span: action.span,
376            });
377        }
378    }
379    boundaries.sort_by(|left, right| left.id.cmp(&right.id));
380    boundaries.dedup_by(|left, right| left.id == right.id);
381    ExecutionProgram {
382        live_resources: vec![],
383        presences,
384        boundaries,
385        endpoints: vec![],
386        tasks: vec![],
387        queues: vec![],
388    }
389}
390
391impl ExecutionProgram {
392    pub fn to_json(&self) -> String {
393        format!(
394            "{{\n  \"schemaVersion\": {SCHEMA_VERSION},\n  \"liveResources\": [{}],\n  \"presences\": [{}],\n  \"boundaries\": [{}],\n  \"endpoints\": [{}],\n  \"tasks\": [{}],\n  \"queues\": [{}]\n}}",
395            self.live_resources
396                .iter()
397                .map(|resource| {
398                    format!(
399                        "{{\"id\":\"{}\",\"name\":\"{}\",\"capabilities\":[{}],\"routeScopes\":[{}]}}",
400                        resource.id,
401                        json_escape(&resource.name),
402                        resource
403                            .capabilities
404                            .iter()
405                            .map(|capability| format!("\"{}\"", json_escape(capability)))
406                            .collect::<Vec<_>>()
407                            .join(","),
408                        resource
409                            .route_scopes
410                            .iter()
411                            .map(ExecutionRouteScope::to_json)
412                            .collect::<Vec<_>>()
413                            .join(","),
414                    )
415                })
416                .collect::<Vec<_>>()
417                .join(","),
418            self.presences
419                .iter()
420                .map(PresenceExecutionContract::to_json)
421                .collect::<Vec<_>>()
422                .join(","),
423            self.boundaries
424                .iter()
425                .map(ExecutionBoundary::to_json)
426                .collect::<Vec<_>>()
427                .join(","),
428            self.endpoints
429                .iter()
430                .map(EndpointExecutionBoundary::to_json)
431                .collect::<Vec<_>>()
432                .join(","),
433            self.tasks
434                .iter()
435                .map(TaskExecutionBoundary::to_json)
436                .collect::<Vec<_>>()
437                .join(","),
438            self.queues
439                .iter()
440                .map(QueueExecutionBoundary::to_json)
441                .collect::<Vec<_>>()
442                .join(",")
443        )
444    }
445}
446
447impl PresenceExecutionContract {
448    fn to_json(&self) -> String {
449        format!(
450            "{{\"id\":\"{}\",\"component\":\"{}\",\"componentName\":\"{}\",\"stream\":\"{}\",\"recordType\":\"{}\",\"memberType\":\"{}\",\"snapshotType\":\"{}\",\"fields\":[{}],\"capabilities\":[{}],\"routeScopes\":[{}],\"ttlMs\":{},\"heartbeatMs\":{}}}",
451            self.id,
452            self.component,
453            json_escape(&self.component_name),
454            self.stream,
455            self.record_type,
456            self.member_type,
457            self.snapshot_type,
458            self.fields
459                .iter()
460                .map(PresenceExecutionField::to_json)
461                .collect::<Vec<_>>()
462                .join(","),
463            self.capabilities
464                .iter()
465                .map(|capability| format!("\"{}\"", json_escape(capability)))
466                .collect::<Vec<_>>()
467                .join(","),
468            self.route_scopes
469                .iter()
470                .map(ExecutionRouteScope::to_json)
471                .collect::<Vec<_>>()
472                .join(","),
473            self.ttl_ms,
474            self.heartbeat_ms,
475        )
476    }
477}
478
479impl PresenceExecutionField {
480    fn to_json(&self) -> String {
481        format!(
482            "{{\"id\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{}}}",
483            self.id,
484            json_escape(&self.name),
485            json_escape(&self.ty),
486            self.type_id
487                .as_ref()
488                .map(|id| format!("\"{id}\""))
489                .unwrap_or_else(|| "null".into()),
490        )
491    }
492}
493
494impl QueueExecutionBoundary {
495    fn to_json(&self) -> String {
496        format!(
497            "{{\"id\":\"{}\",\"hostKey\":{},\"name\":\"{}\",\"payload\":[{}],\"retry\":{},\"backoffMs\":{},\"invalidates\":{},\"statements\":[{}],\"span\":{{\"start\":{},\"end\":{}}}}}",
498            self.id,
499            optional_id_json(&self.host_key),
500            json_escape(&self.name),
501            self.payload
502                .iter()
503                .map(QueueExecutionField::to_json)
504                .collect::<Vec<_>>()
505                .join(","),
506            self.retry,
507            self.backoff_ms,
508            ids_json(&self.invalidates),
509            self.statements
510                .iter()
511                .map(SemanticStatement::to_json)
512                .collect::<Vec<_>>()
513                .join(","),
514            self.span.start,
515            self.span.end,
516        )
517    }
518}
519
520impl QueueExecutionField {
521    fn to_json(&self) -> String {
522        format!(
523            "{{\"id\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{},\"typeIds\":[{}]}}",
524            self.id,
525            json_escape(&self.name),
526            json_escape(&self.ty),
527            optional_id_json(&self.type_id),
528            self.type_ids
529                .iter()
530                .map(|(name, id)| format!(
531                    "{{\"name\":\"{}\",\"id\":\"{}\"}}",
532                    json_escape(name),
533                    id
534                ))
535                .collect::<Vec<_>>()
536                .join(","),
537        )
538    }
539}
540
541impl TaskExecutionBoundary {
542    fn to_json(&self) -> String {
543        format!(
544            "{{\"id\":\"{}\",\"hostKey\":{},\"name\":\"{}\",\"schedule\":\"{}\",\"statements\":[{}],\"span\":{{\"start\":{},\"end\":{}}}}}",
545            self.id,
546            optional_id_json(&self.host_key),
547            json_escape(&self.name),
548            json_escape(&self.schedule),
549            self.statements
550                .iter()
551                .map(SemanticStatement::to_json)
552                .collect::<Vec<_>>()
553                .join(","),
554            self.span.start,
555            self.span.end,
556        )
557    }
558}
559
560impl EndpointExecutionBoundary {
561    fn to_json(&self) -> String {
562        format!(
563            "{{\"id\":\"{}\",\"hostKey\":{},\"name\":\"{}\",\"version\":{},\"description\":{},\"kind\":\"{}\",\"method\":{},\"path\":{},\"inputs\":[{}],\"result\":{},\"statements\":[{}],\"capabilities\":[{}],\"timeoutMs\":{},\"limit\":{},\"cache\":{},\"idempotent\":{},\"middleware\":[{}],\"invalidates\":{},\"span\":{{\"start\":{},\"end\":{}}}}}",
564            self.id,
565            optional_id_json(&self.host_key),
566            json_escape(&self.name),
567            self.version,
568            self.description
569                .as_ref()
570                .map(|description| format!("\"{}\"", json_escape(description)))
571                .unwrap_or_else(|| "null".into()),
572            self.kind.as_str(),
573            self.method
574                .map(|method| format!("\"{}\"", method.as_str()))
575                .unwrap_or_else(|| "null".into()),
576            self.path
577                .as_ref()
578                .map(|path| format!("\"{}\"", json_escape(path)))
579                .unwrap_or_else(|| "null".into()),
580            self.inputs
581                .iter()
582                .map(EndpointExecutionInput::to_json)
583                .collect::<Vec<_>>()
584                .join(","),
585            self.result.to_json(),
586            self.statements
587                .iter()
588                .map(SemanticStatement::to_json)
589                .collect::<Vec<_>>()
590                .join(","),
591            self.capabilities
592                .iter()
593                .map(|capability| format!("\"{}\"", json_escape(capability)))
594                .collect::<Vec<_>>()
595                .join(","),
596            self.timeout_ms,
597            self.limit
598                .map(|limit| format!(
599                    "{{\"requests\":{},\"window\":\"{}\",\"scope\":\"{}\"}}",
600                    limit.requests,
601                    limit.window.as_str(),
602                    limit.scope.as_str()
603                ))
604                .unwrap_or_else(|| "null".into()),
605            self.cache
606                .as_ref()
607                .map(|cache| format!(
608                    "{{\"id\":\"{}\",\"mode\":\"{}\",\"seconds\":{},\"tags\":[{}]}}",
609                    cache.id,
610                    cache.mode.as_str(),
611                    cache.seconds,
612                    cache
613                        .tags
614                        .iter()
615                        .map(|tag| format!("\"{}\"", json_escape(tag)))
616                        .collect::<Vec<_>>()
617                        .join(",")
618                ))
619                .unwrap_or_else(|| "null".into()),
620            self.idempotent,
621            self.middleware
622                .iter()
623                .map(|middleware| format!("\"{}\"", json_escape(middleware)))
624                .collect::<Vec<_>>()
625                .join(","),
626            ids_json(&self.invalidates),
627            self.span.start,
628            self.span.end,
629        )
630    }
631}
632
633impl EndpointExecutionInput {
634    fn to_json(&self) -> String {
635        format!(
636            "{{\"id\":\"{}\",\"section\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{},\"file\":{}}}",
637            self.id,
638            self.section.as_str(),
639            json_escape(&self.name),
640            json_escape(&self.ty),
641            optional_id_json(&self.type_id),
642            self.file
643                .as_ref()
644                .map(FileUploadContract::to_json)
645                .unwrap_or_else(|| "null".into()),
646        )
647    }
648}
649
650impl ExecutionBoundary {
651    fn to_json(&self) -> String {
652        format!(
653            "{{\"id\":\"{}\",\"action\":\"{}\",\"component\":\"{}\",\"componentName\":\"{}\",\"actionName\":\"{}\",\"target\":\"{}\",\"parameters\":[{}],\"result\":{},\"body\":{},\"capabilities\":[{}],\"routeScopes\":[{}],\"invalidates\":{},\"span\":{{\"start\":{},\"end\":{}}}}}",
654            self.id,
655            self.action,
656            self.component,
657            json_escape(&self.component_name),
658            json_escape(&self.action_name),
659            self.target.as_str(),
660            self.parameters
661                .iter()
662                .map(ExecutionParameter::to_json)
663                .collect::<Vec<_>>()
664                .join(","),
665            self.result.to_json(),
666            self.body
667                .as_ref()
668                .map(|body| body.to_json())
669                .unwrap_or_else(|| "null".into()),
670            self.capabilities
671                .iter()
672                .map(|capability| format!("\"{}\"", json_escape(capability)))
673                .collect::<Vec<_>>()
674                .join(","),
675            self.route_scopes
676                .iter()
677                .map(ExecutionRouteScope::to_json)
678                .collect::<Vec<_>>()
679                .join(","),
680            ids_json(&self.invalidates),
681            self.span.start,
682            self.span.end,
683        )
684    }
685}
686
687fn ids_json(ids: &[SemanticId]) -> String {
688    format!(
689        "[{}]",
690        ids.iter()
691            .map(|id| format!("\"{id}\""))
692            .collect::<Vec<_>>()
693            .join(",")
694    )
695}
696
697impl ExecutionRouteScope {
698    fn to_json(&self) -> String {
699        format!(
700            "{{\"route\":\"{}\",\"pattern\":\"{}\",\"parameters\":[{}],\"middleware\":[{}]}}",
701            self.route,
702            json_escape(&self.pattern),
703            self.parameters
704                .iter()
705                .map(|parameter| format!(
706                    "{{\"name\":\"{}\",\"type\":\"{}\",\"catchAll\":{}}}",
707                    json_escape(&parameter.name),
708                    json_escape(&parameter.ty),
709                    parameter.catch_all,
710                ))
711                .collect::<Vec<_>>()
712                .join(","),
713            self.middleware
714                .iter()
715                .map(|id| format!("\"{}\"", id))
716                .collect::<Vec<_>>()
717                .join(","),
718        )
719    }
720}
721
722impl ExecutionResult {
723    fn to_json(&self) -> String {
724        format!(
725            "{{\"id\":\"{}\",\"type\":\"{}\",\"typeId\":{}}}",
726            self.id,
727            json_escape(&self.ty),
728            optional_id_json(&self.type_id),
729        )
730    }
731}
732
733impl ExecutionParameter {
734    fn to_json(&self) -> String {
735        format!(
736            "{{\"id\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{}}}",
737            self.id,
738            json_escape(&self.name),
739            json_escape(&self.ty),
740            optional_id_json(&self.type_id),
741        )
742    }
743}
744
745fn optional_id_json(id: &Option<SemanticId>) -> String {
746    id.as_ref()
747        .map(|id| format!("\"{}\"", json_escape(&id.to_string())))
748        .unwrap_or_else(|| "null".into())
749}
750
751#[cfg(test)]
752mod tests {
753    use std::process::Command;
754
755    use super::*;
756
757    fn endpoint_boundary(name: &str, host_key: Option<SemanticId>) -> EndpointExecutionBoundary {
758        EndpointExecutionBoundary {
759            id: SemanticId::endpoint(name),
760            kind: EndpointKind::RequestResponse,
761            host_key,
762            name: name.into(),
763            version: 1,
764            description: None,
765            method: None,
766            path: None,
767            inputs: vec![],
768            result: ExecutionResult {
769                id: SemanticId::endpoint_result(name),
770                ty: "String".into(),
771                type_id: None,
772            },
773            statements: vec![],
774            capabilities: vec![],
775            timeout_ms: 30_000,
776            limit: None,
777            cache: None,
778            idempotent: false,
779            middleware: vec![],
780            invalidates: vec![],
781            span: Span::default(),
782        }
783    }
784
785    #[test]
786    fn endpoint_host_keys_are_json_strings_or_null() {
787        let program = ExecutionProgram {
788            live_resources: vec![],
789            presences: vec![],
790            boundaries: vec![],
791            endpoints: vec![
792                endpoint_boundary("LoadProgress", Some(SemanticId::endpoint("LoadProgress"))),
793                endpoint_boundary("AuthCallback", None),
794            ],
795            tasks: vec![],
796            queues: vec![],
797        };
798
799        let json = program.to_json();
800        assert!(json.contains(
801            "\"id\":\"endpoint:LoadProgress@1\",\"hostKey\":\"endpoint:LoadProgress@1\""
802        ));
803        assert!(json.contains("\"id\":\"endpoint:AuthCallback@1\",\"hostKey\":null"));
804        assert!(!json.contains("\"hostKey\":\"null\""));
805        assert!(!json.contains("\"hostKey\":\"\""));
806
807        let parsed = Command::new("node")
808            .args(["-e", "JSON.parse(process.argv[1])", &json])
809            .status()
810            .expect("Node.js is required by the workspace test gate");
811        assert!(parsed.success(), "execution manifest must be valid JSON");
812    }
813}