sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 6785fe03f0cdd518c2ab5aec2ee752f9fba22eea
parent abca9cbaa50f2b90b42410009223d78eb2255e62
Author: triesap <tyson@radroots.org>
Date:   Thu, 11 Jun 2026 14:05:50 -0700

chore: align binding model

Diffstat:
MCargo.lock | 5+++++
MCargo.toml | 1+
Acrates/binding_model/Cargo.toml | 11+++++++++++
Acrates/binding_model/src/lib.rs | 468+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/xtask/Cargo.toml | 1+
Mcrates/xtask/src/output.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
6 files changed, 544 insertions(+), 20 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -677,6 +677,10 @@ dependencies = [ ] [[package]] +name = "radroots_sdk_binding_model" +version = "0.1.0" + +[[package]] name = "radroots_sdk_xtask" version = "0.1.0" dependencies = [ @@ -685,6 +689,7 @@ dependencies = [ "radroots_events_indexed_bindings", "radroots_identity_bindings", "radroots_replica_db_schema_bindings", + "radroots_sdk_binding_model", "radroots_trade_bindings", "radroots_types_bindings", "serde_json", diff --git a/Cargo.toml b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/binding_model", "crates/core_bindings", "crates/events_bindings", "crates/events_indexed_bindings", diff --git a/crates/binding_model/Cargo.toml b/crates/binding_model/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "radroots_sdk_binding_model" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish = false + +[dependencies] diff --git a/crates/binding_model/src/lib.rs b/crates/binding_model/src/lib.rs @@ -0,0 +1,468 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TsModule { + declarations: Vec<TsDeclaration>, +} + +impl TsModule { + pub fn new(declarations: Vec<TsDeclaration>) -> Self { + Self { declarations } + } + + pub fn render(&self) -> String { + self.declarations + .iter() + .map(TsDeclaration::render) + .collect::<Vec<_>>() + .join("\n\n") + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TsDeclaration { + TypeAlias(TsTypeAlias), + Const(TsConst), + ImportType(TsImportType), +} + +impl TsDeclaration { + fn render(&self) -> String { + match self { + Self::TypeAlias(alias) => alias.render(), + Self::Const(constant) => constant.render(), + Self::ImportType(import) => import.render(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TsTypeAlias { + name: String, + params: Vec<String>, + value: TsType, +} + +impl TsTypeAlias { + fn render(&self) -> String { + let params = if self.params.is_empty() { + String::new() + } else { + format!("<{}>", self.params.join(", ")) + }; + format!( + "export type {}{} = {};", + self.name, + params, + self.value.render() + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TsConst { + name: String, + annotation: Option<TsType>, + value: TsValue, +} + +impl TsConst { + fn render(&self) -> String { + let annotation = self + .annotation + .as_ref() + .map(|value| format!(": {}", value.render())) + .unwrap_or_default(); + format!( + "export const {}{} = {};", + self.name, + annotation, + self.value.render() + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TsImportType { + names: Vec<String>, + from: String, +} + +impl TsImportType { + fn render(&self) -> String { + if self.names.len() == 1 { + return format!( + "import type {{ {} }} from {};", + self.names[0], + quote_string(&self.from) + ); + } + let names = self + .names + .iter() + .map(|name| format!(" {name},")) + .collect::<Vec<_>>() + .join("\n"); + format!( + "import type {{\n{}\n}} from {};", + names, + quote_string(&self.from) + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TsType { + Primitive(TsPrimitive), + Reference { name: String, args: Vec<TsType> }, + Array(Box<TsType>), + Tuple { readonly: bool, items: Vec<TsType> }, + Object(Vec<TsField>), + Union(Vec<TsType>), + StringLiteral(String), + NumberLiteral(i64), +} + +impl TsType { + pub fn render(&self) -> String { + match self { + Self::Primitive(value) => value.render().to_owned(), + Self::Reference { name, args } => { + if args.is_empty() { + name.clone() + } else { + format!( + "{}<{}>", + name, + args.iter() + .map(TsType::render) + .collect::<Vec<_>>() + .join(", ") + ) + } + } + Self::Array(item) => format!("Array<{}>", item.render()), + Self::Tuple { readonly, items } => { + let prefix = if *readonly { "readonly " } else { "" }; + format!( + "{}[{}]", + prefix, + items + .iter() + .map(TsType::render) + .collect::<Vec<_>>() + .join(", ") + ) + } + Self::Object(fields) => { + if fields.is_empty() { + return "{}".to_owned(); + } + format!( + "{{ {}, }}", + fields + .iter() + .map(TsField::render) + .collect::<Vec<_>>() + .join(", ") + ) + } + Self::Union(items) => { + if items.is_empty() { + return "never".to_owned(); + } + items + .iter() + .map(TsType::render) + .collect::<Vec<_>>() + .join(" | ") + } + Self::StringLiteral(value) => quote_string(value), + Self::NumberLiteral(value) => value.to_string(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TsPrimitive { + String, + Number, + Boolean, + BigInt, + Null, +} + +impl TsPrimitive { + fn render(&self) -> &'static str { + match self { + Self::String => "string", + Self::Number => "number", + Self::Boolean => "boolean", + Self::BigInt => "bigint", + Self::Null => "null", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TsField { + name: String, + optional: bool, + value: TsType, +} + +impl TsField { + fn render(&self) -> String { + let optional = if self.optional { "?" } else { "" }; + format!( + "{}{}: {}", + render_property_name(&self.name), + optional, + self.value.render() + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TsValue { + Number(i64), + String(String), + Boolean(bool), + Array(Vec<TsValue>), +} + +impl TsValue { + fn render(&self) -> String { + match self { + Self::Number(value) => value.to_string(), + Self::String(value) => quote_string(value), + Self::Boolean(value) => value.to_string(), + Self::Array(values) => format!( + "[{}]", + values + .iter() + .map(TsValue::render) + .collect::<Vec<_>>() + .join(", ") + ), + } + } +} + +pub fn module(declarations: Vec<TsDeclaration>) -> TsModule { + TsModule::new(declarations) +} + +pub fn import_type(names: &[&str], from: &str) -> TsDeclaration { + TsDeclaration::ImportType(TsImportType { + names: names.iter().map(|name| (*name).to_owned()).collect(), + from: from.to_owned(), + }) +} + +pub fn type_alias(name: &str, value: TsType) -> TsDeclaration { + type_alias_params(name, &[], value) +} + +pub fn type_alias_params(name: &str, params: &[&str], value: TsType) -> TsDeclaration { + TsDeclaration::TypeAlias(TsTypeAlias { + name: name.to_owned(), + params: params.iter().map(|param| (*param).to_owned()).collect(), + value, + }) +} + +pub fn const_number(name: &str, value: i64) -> TsDeclaration { + const_decl(name, None, TsValue::Number(value)) +} + +pub fn const_string_array(name: &str, annotation: TsType, values: &[&str]) -> TsDeclaration { + const_decl( + name, + Some(annotation), + TsValue::Array( + values + .iter() + .map(|value| TsValue::String((*value).to_owned())) + .collect(), + ), + ) +} + +pub fn const_decl(name: &str, annotation: Option<TsType>, value: TsValue) -> TsDeclaration { + TsDeclaration::Const(TsConst { + name: name.to_owned(), + annotation, + value, + }) +} + +pub fn string() -> TsType { + TsType::Primitive(TsPrimitive::String) +} + +pub fn number() -> TsType { + TsType::Primitive(TsPrimitive::Number) +} + +pub fn boolean() -> TsType { + TsType::Primitive(TsPrimitive::Boolean) +} + +pub fn bigint() -> TsType { + TsType::Primitive(TsPrimitive::BigInt) +} + +pub fn null() -> TsType { + TsType::Primitive(TsPrimitive::Null) +} + +pub fn reference(name: &str) -> TsType { + TsType::Reference { + name: name.to_owned(), + args: Vec::new(), + } +} + +pub fn generic(name: &str, args: Vec<TsType>) -> TsType { + TsType::Reference { + name: name.to_owned(), + args, + } +} + +pub fn array(item: TsType) -> TsType { + TsType::Array(Box::new(item)) +} + +pub fn tuple(items: Vec<TsType>) -> TsType { + TsType::Tuple { + readonly: false, + items, + } +} + +pub fn readonly_tuple(items: Vec<TsType>) -> TsType { + TsType::Tuple { + readonly: true, + items, + } +} + +pub fn object(fields: Vec<TsField>) -> TsType { + TsType::Object(fields) +} + +pub fn union(items: Vec<TsType>) -> TsType { + let mut flattened = Vec::new(); + for item in items { + match item { + TsType::Union(items) => flattened.extend(items), + item => flattened.push(item), + } + } + TsType::Union(flattened) +} + +pub fn nullable(item: TsType) -> TsType { + union(vec![item, null()]) +} + +pub fn string_literal(value: &str) -> TsType { + TsType::StringLiteral(value.to_owned()) +} + +pub fn number_literal(value: i64) -> TsType { + TsType::NumberLiteral(value) +} + +pub fn field(name: &str, value: TsType) -> TsField { + TsField { + name: name.to_owned(), + optional: false, + value, + } +} + +pub fn optional_field(name: &str, value: TsType) -> TsField { + TsField { + name: name.to_owned(), + optional: true, + value, + } +} + +fn render_property_name(value: &str) -> String { + if is_identifier(value) { + value.to_owned() + } else { + quote_string(value) + } +} + +fn is_identifier(value: &str) -> bool { + let mut chars = value.chars(); + match chars.next() { + Some(first) if first == '_' || first == '$' || first.is_ascii_alphabetic() => {} + _ => return false, + } + chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()) +} + +fn quote_string(value: &str) -> String { + let mut escaped = String::with_capacity(value.len() + 2); + escaped.push('"'); + for ch in value.chars() { + match ch { + '\\' => escaped.push_str("\\\\"), + '"' => escaped.push_str("\\\""), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + ch => escaped.push(ch), + } + } + escaped.push('"'); + escaped +} + +#[cfg(test)] +mod tests { + use super::{ + array, const_number, field, import_type, module, nullable, number, object, reference, + string, string_literal, type_alias, type_alias_params, union, + }; + + #[test] + fn renders_type_aliases() { + let module = module(vec![ + type_alias("Name", string()), + type_alias_params( + "Result", + &["T"], + object(vec![field("result", reference("T"))]), + ), + ]); + assert_eq!( + module.render(), + "export type Name = string;\n\nexport type Result<T> = { result: T, };" + ); + } + + #[test] + fn renders_imports_constants_and_unions() { + let module = module(vec![ + import_type(&["A", "B"], "@radroots/example"), + type_alias( + "Status", + union(vec![string_literal("ready"), nullable(array(number()))]), + ), + const_number("KIND_READY", 1), + ]); + assert!(module.render().contains("import type {\n A,\n B,\n}")); + assert!( + module + .render() + .contains("export type Status = \"ready\" | Array<number> | null;") + ); + assert!(module.render().contains("export const KIND_READY = 1;")); + } +} diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml @@ -13,6 +13,7 @@ name = "radroots_sdk_xtask" path = "src/main.rs" [dependencies] +radroots_sdk_binding_model = { path = "../binding_model" } radroots_core_bindings = { path = "../core_bindings" } radroots_events_bindings = { path = "../events_bindings" } radroots_events_indexed_bindings = { path = "../events_indexed_bindings" } diff --git a/crates/xtask/src/output.rs b/crates/xtask/src/output.rs @@ -7,13 +7,14 @@ use crate::{ strip_legacy_generated_header, }, }; +use radroots_sdk_binding_model::TsModule; pub struct PackageOutput { pub spec: PackageSpec, - pub types_ts: Option<&'static str>, + pub types_ts: Option<TsSource>, pub types_imports_ts: Option<&'static str>, - pub constants_ts: Option<&'static str>, - pub kinds_ts: Option<&'static str>, + pub constants_ts: Option<TsSource>, + pub kinds_ts: Option<TsSource>, } pub struct GeneratedFile { @@ -21,22 +22,41 @@ pub struct GeneratedFile { pub contents: String, } +#[allow(dead_code)] +pub enum TsSource { + Text(&'static str), + Module(TsModule), +} + +impl TsSource { + fn text(value: &'static str) -> Self { + Self::Text(value) + } + + fn render(&self) -> String { + match self { + Self::Text(value) => strip_legacy_generated_header(value), + Self::Module(module) => module.render(), + } + } +} + impl PackageOutput { pub fn files(&self) -> Vec<GeneratedFile> { let mut files = Vec::new(); - if let Some(types_ts) = self.types_ts { + if let Some(types_ts) = &self.types_ts { files.push(GeneratedFile { relative_path: format!("src/generated/{}", generated_types_file()), contents: render_ts(types_ts, self.types_imports_ts), }); } - if let Some(constants_ts) = self.constants_ts { + if let Some(constants_ts) = &self.constants_ts { files.push(GeneratedFile { relative_path: format!("src/generated/{}", generated_constants_file()), contents: render_ts(constants_ts, None), }); } - if let Some(kinds_ts) = self.kinds_ts { + if let Some(kinds_ts) = &self.kinds_ts { files.push(GeneratedFile { relative_path: format!("src/generated/{}", generated_kinds_file()), contents: render_ts(kinds_ts, None), @@ -58,21 +78,21 @@ pub fn package_outputs() -> Vec<PackageOutput> { vec![ PackageOutput { spec: spec_by_key("core"), - types_ts: Some(radroots_core_bindings::TYPES_TS), + types_ts: Some(TsSource::text(radroots_core_bindings::TYPES_TS)), types_imports_ts: None, constants_ts: None, kinds_ts: None, }, PackageOutput { spec: spec_by_key("events"), - types_ts: Some(radroots_events_bindings::TYPES_TS), + types_ts: Some(TsSource::text(radroots_events_bindings::TYPES_TS)), types_imports_ts: Some(EVENTS_TYPES_IMPORTS_TS), - constants_ts: Some(radroots_events_bindings::CONSTANTS_TS), - kinds_ts: Some(radroots_events_bindings::KINDS_TS), + constants_ts: Some(TsSource::text(radroots_events_bindings::CONSTANTS_TS)), + kinds_ts: Some(TsSource::text(radroots_events_bindings::KINDS_TS)), }, PackageOutput { spec: spec_by_key("events_indexed"), - types_ts: Some(radroots_events_indexed_bindings::TYPES_TS), + types_ts: Some(TsSource::text(radroots_events_indexed_bindings::TYPES_TS)), types_imports_ts: None, constants_ts: None, kinds_ts: None, @@ -81,26 +101,28 @@ pub fn package_outputs() -> Vec<PackageOutput> { spec: spec_by_key("identity"), types_ts: None, types_imports_ts: None, - constants_ts: Some(radroots_identity_bindings::CONSTANTS_TS), + constants_ts: Some(TsSource::text(radroots_identity_bindings::CONSTANTS_TS)), kinds_ts: None, }, PackageOutput { spec: spec_by_key("replica_db_schema"), - types_ts: Some(radroots_replica_db_schema_bindings::TYPES_TS), + types_ts: Some(TsSource::text( + radroots_replica_db_schema_bindings::TYPES_TS, + )), types_imports_ts: Some(REPLICA_DB_SCHEMA_TYPES_IMPORTS_TS), constants_ts: None, kinds_ts: None, }, PackageOutput { spec: spec_by_key("trade"), - types_ts: Some(radroots_trade_bindings::TYPES_TS), + types_ts: Some(TsSource::text(radroots_trade_bindings::TYPES_TS)), types_imports_ts: Some(TRADE_TYPES_IMPORTS_TS), constants_ts: None, kinds_ts: None, }, PackageOutput { spec: spec_by_key("types"), - types_ts: Some(radroots_types_bindings::TYPES_TS), + types_ts: Some(TsSource::text(radroots_types_bindings::TYPES_TS)), types_imports_ts: None, constants_ts: None, kinds_ts: None, @@ -116,8 +138,8 @@ fn spec_by_key(key: &str) -> PackageSpec { .unwrap_or_else(|| panic!("missing package spec for {key}")) } -fn render_ts(source: &str, imports: Option<&str>) -> String { - let body = strip_legacy_generated_header(source); +fn render_ts(source: &TsSource, imports: Option<&str>) -> String { + let body = source.render(); let imports = imports.unwrap_or(""); format!("{}{}{}", generated_header(), imports, body.trim_start()) } @@ -196,11 +218,15 @@ fn render_index(output: &PackageOutput) -> String { #[cfg(test)] mod tests { - use super::{package_outputs, render_ts}; + use super::{TsSource, package_outputs, render_ts}; + use radroots_sdk_binding_model::{module, string, type_alias}; #[test] fn renders_sdk_header() { - let output = render_ts("// legacy\n\nexport type A = string;\n", None); + let output = render_ts( + &TsSource::text("// legacy\n\nexport type A = string;\n"), + None, + ); assert!(output.starts_with("// @generated by cargo xtask generate ts")); assert!(output.contains("export type A = string;")); } @@ -208,7 +234,7 @@ mod tests { #[test] fn renders_import_prelude_after_header() { let output = render_ts( - "export type A = B;\n", + &TsSource::text("export type A = B;\n"), Some("import type { B } from \"b\";\n\n"), ); assert!(output.starts_with( @@ -218,6 +244,18 @@ mod tests { } #[test] + fn renders_model_sources() { + let output = render_ts( + &TsSource::Module(module(vec![type_alias("A", string())])), + None, + ); + assert_eq!( + output, + "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nexport type A = string;" + ); + } + + #[test] fn includes_core_and_types_outputs() { let package_names = package_outputs() .into_iter()