1use std::collections::BTreeSet;
4
5use anyhow::Result;
6
7use crate::manifest::{
8 CacheHint, InvalidateTarget, Manifest, ProcedureSchema, ProcedureType, TransportConfig,
9 TransportPreference,
10};
11use crate::rpc_hash::RpcHashMap;
12
13use super::render::{render_top_level, to_pascal_case};
14
15fn quote_key(name: &str) -> String {
17 if name.contains('.') { format!("\"{name}\"") } else { name.to_string() }
18}
19
20fn channel_owned_procedures(manifest: &Manifest) -> BTreeSet<String> {
22 let mut owned = BTreeSet::new();
23 for (ch_name, ch) in &manifest.channels {
24 for msg_name in ch.incoming.keys() {
25 owned.insert(format!("{ch_name}.{msg_name}"));
26 }
27 owned.insert(format!("{ch_name}.events"));
28 }
29 owned
30}
31
32fn generate_procedure_config(manifest: &Manifest) -> String {
34 let mut out = String::from("export const seamProcedureConfig = {\n");
35 for (name, schema) in &manifest.procedures {
36 let key = quote_key(name);
37 let kind = schema.proc_type.to_string();
38
39 let mut fields = vec![format!("kind: \"{kind}\"")];
40
41 if let Some(ref cache) = schema.cache {
43 match cache {
44 CacheHint::Config { ttl } => fields.push(format!("cache: {{ ttl: {ttl} }}")),
45 CacheHint::Disabled(_) => fields.push("cache: false".into()),
46 }
47 }
48
49 if let Some(ref targets) = schema.invalidates
51 && !targets.is_empty()
52 {
53 fields.push(format!("invalidates: {}", format_invalidate_targets(targets)));
54 }
55
56 out.push_str(&format!(" {key}: {{ {} }},\n", fields.join(", ")));
57 }
58 out.push_str("} as const;\n\n");
59 out.push_str("export type SeamProcedureConfig = typeof seamProcedureConfig;\n\n");
60 out
61}
62
63fn format_invalidate_targets(targets: &[InvalidateTarget]) -> String {
65 let items: Vec<String> = targets
66 .iter()
67 .map(|t| {
68 let mut obj = format!("{{ query: \"{}\"", t.query);
69 if let Some(ref mapping) = t.mapping {
70 let entries: Vec<String> = mapping
71 .iter()
72 .map(|(k, v)| {
73 let mut mv = format!("{{ from: \"{}\"", v.from);
74 if v.each == Some(true) {
75 mv.push_str(", each: true");
76 }
77 mv.push_str(" }");
78 format!("{k}: {mv}")
79 })
80 .collect();
81 obj.push_str(&format!(", mapping: {{ {} }}", entries.join(", ")));
82 }
83 obj.push_str(" }");
84 obj
85 })
86 .collect();
87 format!("[{}]", items.join(", "))
88}
89
90fn generate_channel_types(manifest: &Manifest) -> Result<String> {
92 if manifest.channels.is_empty() {
93 return Ok(String::new());
94 }
95
96 let mut out = String::new();
97 let mut channel_entries: Vec<String> = Vec::new();
98
99 for (ch_name, ch) in &manifest.channels {
100 let ch_pascal = to_pascal_case(ch_name);
101
102 let input_type = format!("{ch_pascal}ChannelInput");
104 out.push_str(&render_top_level(&input_type, &ch.input)?);
105 out.push('\n');
106
107 let mut handle_methods: Vec<String> = Vec::new();
109 for (msg_name, msg) in &ch.incoming {
110 let msg_pascal = to_pascal_case(msg_name);
111 let msg_input_type = format!("{ch_pascal}{msg_pascal}Input");
112 let msg_output_type = format!("{ch_pascal}{msg_pascal}Output");
113
114 out.push_str(&render_top_level(&msg_input_type, &msg.input)?);
115 out.push('\n');
116 out.push_str(&render_top_level(&msg_output_type, &msg.output)?);
117 out.push('\n');
118
119 if let Some(ref error_schema) = msg.error {
120 let msg_error_type = format!("{ch_pascal}{msg_pascal}Error");
121 out.push_str(&render_top_level(&msg_error_type, error_schema)?);
122 out.push('\n');
123 }
124
125 handle_methods
126 .push(format!(" {msg_name}(input: {msg_input_type}): Promise<{msg_output_type}>;"));
127 }
128
129 out.push_str(&generate_channel_outgoing(ch, &ch_pascal)?);
131
132 let event_type = format!("{ch_pascal}Event");
134 let handle_type = format!("{ch_pascal}Channel");
135 out.push_str(&format!("export interface {handle_type} {{\n"));
136 for method in &handle_methods {
137 out.push_str(method);
138 out.push('\n');
139 }
140 out.push_str(&format!(
141 " on<E extends {event_type}[\"type\"]>(\n event: E,\n callback: (data: Extract<{event_type}, {{ type: E }}>[\"payload\"]) => void,\n ): void;\n"
142 ));
143 out.push_str(" close(): void;\n");
144 out.push_str("}\n\n");
145
146 channel_entries.push(format!(" {ch_name}: {{ input: {input_type}; handle: {handle_type} }};"));
148 }
149
150 out.push_str("export interface SeamChannels {\n");
152 for entry in &channel_entries {
153 out.push_str(entry);
154 out.push('\n');
155 }
156 out.push_str("}\n\n");
157
158 Ok(out)
159}
160
161fn generate_channel_outgoing(
163 ch: &crate::manifest::ChannelSchema,
164 ch_pascal: &str,
165) -> Result<String> {
166 let mut out = String::new();
167 let mut union_parts: Vec<String> = Vec::new();
168
169 for (evt_name, evt_schema) in &ch.outgoing {
170 let evt_pascal = to_pascal_case(evt_name);
171 let payload_type = format!("{ch_pascal}{evt_pascal}Payload");
172 out.push_str(&render_top_level(&payload_type, evt_schema)?);
173 out.push('\n');
174 union_parts.push(format!(" | {{ type: \"{evt_name}\"; payload: {payload_type} }}"));
175 }
176
177 let event_type = format!("{ch_pascal}Event");
178 out.push_str(&format!("export type {event_type} =\n"));
179 for part in &union_parts {
180 out.push_str(part);
181 out.push('\n');
182 }
183 out.push_str(";\n\n");
184 Ok(out)
185}
186
187fn generate_procedure_meta(manifest: &Manifest) -> String {
189 let channel_event_names: BTreeSet<String> =
192 manifest.channels.keys().map(|ch| format!("{ch}.events")).collect();
193
194 let mut out = String::from("export interface SeamProcedureMeta {\n");
195 for (name, schema) in &manifest.procedures {
196 let pascal = to_pascal_case(name);
197 let key = quote_key(name);
198 let kind = match schema.proc_type {
199 ProcedureType::Query => "query",
200 ProcedureType::Command => "command",
201 ProcedureType::Subscription => "subscription",
202 ProcedureType::Stream => "stream",
203 ProcedureType::Upload => "upload",
204 };
205 let (input_name, output_name) = if channel_event_names.contains(name) {
206 let ch_name = name.strip_suffix(".events").expect("channel event name has .events suffix");
208 let ch_pascal = to_pascal_case(ch_name);
209 (format!("{ch_pascal}ChannelInput"), format!("{ch_pascal}Event"))
210 } else if schema.proc_type == ProcedureType::Stream {
211 (format!("{pascal}Input"), format!("{pascal}Chunk"))
212 } else {
213 (format!("{pascal}Input"), format!("{pascal}Output"))
214 };
215 let mut extra_fields = String::new();
216 if schema.error.is_some() {
217 let error_name = format!("{pascal}Error");
218 extra_fields.push_str(&format!("; error: {error_name}"));
219 }
220 if let Some(targets) = &schema.invalidates
221 && !targets.is_empty()
222 {
223 let names: Vec<String> = targets.iter().map(|t| format!("\"{}\"", t.query)).collect();
224 extra_fields.push_str(&format!("; invalidates: readonly [{}]", names.join(", ")));
225 }
226 if let Some(ctx_keys) = &schema.context
227 && !ctx_keys.is_empty()
228 {
229 let keys_str: Vec<String> = ctx_keys.iter().map(|k| format!("\"{k}\"")).collect();
230 extra_fields.push_str(&format!("; context: readonly [{}]", keys_str.join(", ")));
231 }
232 out.push_str(&format!(
233 " {key}: {{ kind: \"{kind}\"; input: {input_name}; output: {output_name}{extra_fields} }};\n"
234 ));
235 }
236 out.push_str("}\n\n");
237 out
238}
239
240fn format_fallback(fallback: &Option<Vec<TransportPreference>>) -> String {
242 match fallback {
243 Some(v) if !v.is_empty() => {
244 let items: Vec<String> = v.iter().map(|p| format!("\"{p}\"")).collect();
245 format!("[{}] as const", items.join(", "))
246 }
247 _ => "[] as const".to_string(),
248 }
249}
250
251fn resolve_channel_transport(
253 ch: &crate::manifest::ChannelSchema,
254 defaults: &std::collections::BTreeMap<String, TransportConfig>,
255) -> &'static str {
256 if let Some(ref t) = ch.transport {
257 return match t.prefer {
258 TransportPreference::Http => "http",
259 TransportPreference::Sse => "sse",
260 TransportPreference::Ws => "ws",
261 TransportPreference::Ipc => "ipc",
262 };
263 }
264 if let Some(t) = defaults.get("channel") {
265 return match t.prefer {
266 TransportPreference::Http => "http",
267 TransportPreference::Sse => "sse",
268 TransportPreference::Ws => "ws",
269 TransportPreference::Ipc => "ipc",
270 };
271 }
272 "ws"
273}
274
275fn resolve_channel_fallback(
277 ch: &crate::manifest::ChannelSchema,
278 defaults: &std::collections::BTreeMap<String, TransportConfig>,
279) -> Option<Vec<TransportPreference>> {
280 if let Some(ref t) = ch.transport {
281 return t.fallback.clone();
282 }
283 if let Some(t) = defaults.get("channel") {
284 return t.fallback.clone();
285 }
286 Some(vec![TransportPreference::Http])
287}
288
289fn generate_transport_hint(manifest: &Manifest, rpc_hashes: Option<&RpcHashMap>) -> String {
291 let mut out = String::from("export const seamTransportHint = {\n");
292
293 out.push_str(" defaults: {\n");
295 for (kind, tc) in &manifest.transport_defaults {
296 out.push_str(&format!(
297 " {kind}: {{ prefer: \"{prefer}\" as const, fallback: {fallback} }},\n",
298 prefer = tc.prefer,
299 fallback = format_fallback(&tc.fallback),
300 ));
301 }
302 out.push_str(" },\n");
303
304 let proc_overrides: Vec<(&String, &TransportConfig)> = manifest
306 .procedures
307 .iter()
308 .filter_map(|(name, schema)| schema.transport.as_ref().map(|t| (name, t)))
309 .collect();
310 if !proc_overrides.is_empty() {
311 out.push_str(" procedures: {\n");
312 for (name, tc) in &proc_overrides {
313 out.push_str(&format!(
314 " {}: {{ prefer: \"{prefer}\" as const, fallback: {fallback} }},\n",
315 quote_key(name),
316 prefer = tc.prefer,
317 fallback = format_fallback(&tc.fallback),
318 ));
319 }
320 out.push_str(" },\n");
321 }
322
323 if !manifest.channels.is_empty() {
325 out.push_str(" channels: {\n");
326 for (ch_name, ch) in &manifest.channels {
327 let transport = resolve_channel_transport(ch, &manifest.transport_defaults);
328 let fallback = resolve_channel_fallback(ch, &manifest.transport_defaults);
329
330 out.push_str(&format!(" {}: {{\n", quote_key(ch_name)));
331 out.push_str(&format!(" transport: \"{transport}\" as const,\n"));
332 out.push_str(&format!(" fallback: {},\n", format_fallback(&fallback)));
333
334 let incoming: Vec<String> = ch
335 .incoming
336 .keys()
337 .map(|msg_name| {
338 let full_name = format!("{ch_name}.{msg_name}");
339 let wire = rpc_hashes
340 .and_then(|m| m.procedures.get(&full_name))
341 .map(String::as_str)
342 .unwrap_or(full_name.as_str());
343 format!("\"{wire}\"")
344 })
345 .collect();
346 out.push_str(&format!(" incoming: [{}],\n", incoming.join(", ")));
347
348 let events_name = format!("{ch_name}.events");
349 let events_wire = rpc_hashes
350 .and_then(|m| m.procedures.get(&events_name))
351 .map(String::as_str)
352 .unwrap_or(events_name.as_str());
353 out.push_str(&format!(" outgoing: \"{events_wire}\",\n"));
354
355 out.push_str(" },\n");
356 }
357 out.push_str(" },\n");
358 }
359
360 out.push_str("} as const;\n\n");
361 out.push_str("export type SeamTransportHint = typeof seamTransportHint;\n\n");
362 out
363}
364
365pub fn generate_typescript_meta(data_id: &str) -> String {
367 format!("// Auto-generated by seam. Do not edit.\nexport const DATA_ID = \"{data_id}\";\n")
368}
369
370fn resolve_wire_name<'a>(name: &'a str, rpc_hashes: Option<&'a RpcHashMap>) -> &'a str {
372 rpc_hashes.and_then(|m| m.procedures.get(name)).map(String::as_str).unwrap_or(name)
373}
374
375fn generate_imports(has_stream: bool) -> String {
377 let mut out = String::from("import { createClient } from \"@canmi/seam-client\";\n");
378 if has_stream {
379 out.push_str(
380 "import type { SeamClient, SeamClientError, ProcedureKind, Unsubscribe, StreamHandle } from \"@canmi/seam-client\";\n\n",
381 );
382 } else {
383 out.push_str(
384 "import type { SeamClient, SeamClientError, ProcedureKind, Unsubscribe } from \"@canmi/seam-client\";\n\n",
385 );
386 }
387 out
388}
389
390fn generate_procedure_declarations(
392 manifest: &Manifest,
393 rpc_hashes: Option<&RpcHashMap>,
394 channel_owned: &BTreeSet<String>,
395) -> Result<(String, Vec<String>, Vec<String>)> {
396 let mut out = String::new();
397 let mut iface_lines: Vec<String> = Vec::new();
398 let mut factory_lines: Vec<String> = Vec::new();
399
400 for (name, schema) in &manifest.procedures {
401 if channel_owned.contains(name) {
402 continue;
403 }
404
405 let pascal = to_pascal_case(name);
406 let key = quote_key(name);
407
408 let input_name = format!("{pascal}Input");
409 let output_name = if schema.proc_type == ProcedureType::Stream {
411 format!("{pascal}Chunk")
412 } else {
413 format!("{pascal}Output")
414 };
415
416 out.push_str(&render_top_level(&input_name, &schema.input)?);
417 out.push('\n');
418
419 if let Some(output_schema) = schema.effective_output() {
420 out.push_str(&render_top_level(&output_name, output_schema)?);
421 out.push('\n');
422 }
423
424 if let Some(ref error_schema) = schema.error {
425 let error_name = format!("{pascal}Error");
426 out.push_str(&render_top_level(&error_name, error_schema)?);
427 out.push('\n');
428 }
429
430 let wire_name = resolve_wire_name(name, rpc_hashes);
431 let (iface, factory) =
432 procedure_client_lines(&key, &input_name, &output_name, wire_name, schema);
433 iface_lines.push(iface);
434 factory_lines.push(factory);
435 }
436
437 Ok((out, iface_lines, factory_lines))
438}
439
440fn procedure_client_lines(
442 key: &str,
443 input: &str,
444 output: &str,
445 wire: &str,
446 schema: &ProcedureSchema,
447) -> (String, String) {
448 match schema.proc_type {
449 ProcedureType::Stream => (
450 format!(" {key}(input: {input}): StreamHandle<{output}>;"),
451 format!(" {key}: (input) => client.stream(\"{wire}\", input) as StreamHandle<{output}>,"),
452 ),
453 ProcedureType::Subscription => (
454 format!(
455 " {key}(input: {input}, onData: (data: {output}) => void, onError?: (err: SeamClientError) => void): Unsubscribe;"
456 ),
457 format!(
458 " {key}: (input, onData, onError) => client.subscribe(\"{wire}\", input, onData as (data: unknown) => void, onError),"
459 ),
460 ),
461 ProcedureType::Upload => (
462 format!(" {key}(input: {input}, file: File | Blob): Promise<{output}>;"),
463 format!(
464 " {key}: (input, file) => client.upload(\"{wire}\", input, file) as Promise<{output}>,"
465 ),
466 ),
467 _ => {
468 let method = if schema.proc_type == ProcedureType::Command { "command" } else { "query" };
469 (
470 format!(" {key}(input: {input}): Promise<{output}>;"),
471 format!(" {key}: (input) => client.{method}(\"{wire}\", input) as Promise<{output}>,"),
472 )
473 }
474 }
475}
476
477fn generate_client_factory(
479 manifest: &Manifest,
480 rpc_hashes: Option<&RpcHashMap>,
481 factory_lines: &[String],
482 has_channels: bool,
483) -> String {
484 let return_type = if has_channels {
485 "SeamProcedures & {\n channel<K extends keyof SeamChannels>(\n name: K,\n input: SeamChannels[K][\"input\"],\n ): SeamChannels[K][\"handle\"];\n}"
486 } else {
487 "SeamProcedures"
488 };
489
490 let mut out = format!("export function createSeamClient(baseUrl: string): {return_type} {{\n");
491
492 let mut opts_parts = vec![String::from("baseUrl")];
494 if let Some(map) = rpc_hashes {
495 opts_parts.push(format!("batchEndpoint: \"{}\"", map.batch));
496 }
497 if has_channels {
498 let entries: Vec<String> = manifest
499 .channels
500 .iter()
501 .map(|(name, ch)| {
502 let transport = resolve_channel_transport(ch, &manifest.transport_defaults);
503 format!("{}: \"{}\"", quote_key(name), transport)
504 })
505 .collect();
506 opts_parts.push(format!("channelTransports: {{ {} }}", entries.join(", ")));
507 }
508 out.push_str(&format!(
509 " const client: SeamClient = createClient({{ {} }});\n",
510 opts_parts.join(", ")
511 ));
512
513 if has_channels {
514 out.push_str(" function channel<K extends keyof SeamChannels>(name: K, input: SeamChannels[K][\"input\"]): SeamChannels[K][\"handle\"] {\n");
515 out.push_str(&generate_channel_factory(manifest));
516 out.push_str(" throw new Error(`Unknown channel: ${name as string}`);\n");
517 out.push_str(" }\n");
518 }
519
520 out.push_str(" return {\n");
521 for line in factory_lines {
522 out.push_str(line);
523 out.push('\n');
524 }
525 if has_channels {
526 out.push_str(" channel,\n");
527 }
528 out.push_str(" };\n");
529 out.push_str("}\n");
530
531 out
532}
533
534pub fn generate_typescript(
536 manifest: &Manifest,
537 rpc_hashes: Option<&RpcHashMap>,
538 _data_id: &str,
539) -> Result<String> {
540 let mut out = String::from("// Auto-generated by seam. Do not edit.\n");
541
542 let has_channels = !manifest.channels.is_empty();
543 let has_stream = manifest.procedures.values().any(|s| s.proc_type == ProcedureType::Stream);
544
545 out.push_str(&generate_imports(has_stream));
546 out.push_str("export { DATA_ID } from \"./meta.js\";\n\n");
547
548 let channel_owned = channel_owned_procedures(manifest);
549 let (type_decls, iface_lines, factory_lines) =
550 generate_procedure_declarations(manifest, rpc_hashes, &channel_owned)?;
551 out.push_str(&type_decls);
552
553 out.push_str("export interface SeamProcedures {\n");
554 for line in &iface_lines {
555 out.push_str(line);
556 out.push('\n');
557 }
558 out.push_str("}\n\n");
559
560 out.push_str(&generate_procedure_meta(manifest));
561 out.push_str(&generate_procedure_config(manifest));
562
563 if has_channels {
564 out.push_str(&generate_channel_types(manifest)?);
565 }
566
567 out.push_str(&generate_transport_hint(manifest, rpc_hashes));
569
570 out.push_str(&generate_client_factory(manifest, rpc_hashes, &factory_lines, has_channels));
571
572 Ok(out)
573}
574
575fn generate_channel_factory(manifest: &Manifest) -> String {
577 let mut out = String::new();
578
579 for ch_name in manifest.channels.keys() {
580 out.push_str(&format!(" if (name === \"{ch_name}\") {{\n"));
581 out.push_str(
582 " return client.channel(name, input) as unknown as SeamChannels[typeof name][\"handle\"];\n",
583 );
584 out.push_str(" }\n");
585 }
586
587 out
588}