commit 6785fe03f0cdd518c2ab5aec2ee752f9fba22eea
parent abca9cbaa50f2b90b42410009223d78eb2255e62
Author: triesap <tyson@radroots.org>
Date: Thu, 11 Jun 2026 14:05:50 -0700
chore: align binding model
Diffstat:
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()