commit fedbccb3bdff2d61d721b9db3119182189ecf658
parent 037cf89daabbe3b9314d9f631e5bf84c25b792d9
Author: triesap <tyson@radroots.org>
Date: Sun, 28 Jun 2026 04:00:36 +0000
contracts: align TypeScript package matrix
- replace the stale single package TypeScript contract
- record every public npm package in the contract matrix
- validate TypeScript exports against package_matrix.rs
- cover export and package drift in xtask tests
Diffstat:
1 file changed, 312 insertions(+), 20 deletions(-)
diff --git a/tools/sdk_xtask_import/src/contracts.rs b/tools/sdk_xtask_import/src/contracts.rs
@@ -6,6 +6,8 @@ use std::{
use serde::Deserialize;
+use crate::package_matrix::{PackageSpec, WasmPackageSpec, package_specs, wasm_package_specs};
+
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ExportContract {
@@ -24,6 +26,7 @@ struct PackageContract {
operations: BTreeMap<String, String>,
shared_types: BTreeMap<String, String>,
artifacts: Option<SdkArtifacts>,
+ npm_packages: Option<BTreeMap<String, NpmPackageContract>>,
}
#[derive(Debug, Deserialize)]
@@ -53,7 +56,8 @@ struct ExportArtifacts {
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SdkPackageContract {
- package: String,
+ package: Option<String>,
+ package_family: Option<String>,
module_format: Option<String>,
deterministic_codec: String,
signing: String,
@@ -62,6 +66,29 @@ struct SdkPackageContract {
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
+struct NpmPackageContract {
+ kind: String,
+ crate_name: String,
+ crate_dir: String,
+ package: String,
+ package_dir: String,
+ out_name: Option<String>,
+ out_dir: Option<String>,
+}
+
+#[derive(Debug)]
+struct ExpectedNpmPackage {
+ kind: &'static str,
+ crate_name: &'static str,
+ crate_dir: &'static str,
+ package: &'static str,
+ package_dir: &'static str,
+ out_name: Option<&'static str>,
+ out_dir: Option<&'static str>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
struct RolloutContract {
stage: String,
order: u32,
@@ -118,7 +145,7 @@ pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> {
.values()
.cloned()
.collect::<BTreeSet<String>>();
- if packages.len() != 1 {
+ if export.language.id != "ts" && packages.len() != 1 {
return Err(format!(
"exports {} must resolve to one curated package",
export.language.id
@@ -133,7 +160,12 @@ pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> {
let mut rollout_orders = BTreeMap::new();
for package in &packages {
validate_language(&package.language, "packages")?;
- validate_non_empty(&package.sdk.package, "packages sdk.package")?;
+ if let Some(package_name) = package.sdk.package.as_deref() {
+ validate_non_empty(package_name, "packages sdk.package")?;
+ }
+ if let Some(package_family) = package.sdk.package_family.as_deref() {
+ validate_non_empty(package_family, "packages sdk.package_family")?;
+ }
validate_runtime(
&package.sdk.networking,
&package.sdk.signing,
@@ -143,6 +175,12 @@ pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> {
if let Some(module_format) = package.sdk.module_format.as_deref() {
validate_non_empty(module_format, "packages sdk.module_format")?;
}
+ if package.sdk.package.is_none() && package.sdk.package_family.is_none() {
+ return Err(format!(
+ "packages {} sdk.package or sdk.package_family is required",
+ package.language.id
+ ));
+ }
validate_rollout(&package.language.id, &package.rollout)?;
validate_non_empty_map(&package.operations, "packages operations")?;
validate_non_empty_map(&package.shared_types, "packages shared_types")?;
@@ -174,14 +212,29 @@ pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> {
package.language.id
));
};
- let expected = [package.sdk.package.clone()]
- .into_iter()
- .collect::<BTreeSet<_>>();
- if packages_for_language != &expected {
- return Err(format!(
- "exports {} must resolve to package {}",
- package.language.id, package.sdk.package
- ));
+ if package.language.id == "ts" {
+ let npm_packages = package
+ .npm_packages
+ .as_ref()
+ .ok_or_else(|| "packages ts npm_packages are required".to_owned())?;
+ validate_ts_npm_packages(package, packages_for_language, npm_packages)?;
+ } else {
+ if package.npm_packages.is_some() {
+ return Err(format!(
+ "packages {} npm_packages is only supported for ts",
+ package.language.id
+ ));
+ }
+ let sdk_package = package.sdk.package.as_ref().ok_or_else(|| {
+ format!("packages {} sdk.package is required", package.language.id)
+ })?;
+ let expected = [sdk_package.clone()].into_iter().collect::<BTreeSet<_>>();
+ if packages_for_language != &expected {
+ return Err(format!(
+ "exports {} must resolve to package {}",
+ package.language.id, sdk_package
+ ));
+ }
}
let current_operations = package.operations.keys().cloned().collect::<BTreeSet<_>>();
match &operation_keys {
@@ -221,6 +274,145 @@ pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> {
Ok(())
}
+fn validate_ts_npm_packages(
+ package: &PackageContract,
+ export_packages: &BTreeSet<String>,
+ npm_packages: &BTreeMap<String, NpmPackageContract>,
+) -> Result<(), String> {
+ if package.sdk.package.is_some() {
+ return Err(
+ "packages ts sdk.package must not be set for multi-package npm output".to_owned(),
+ );
+ }
+ let package_family = package
+ .sdk
+ .package_family
+ .as_deref()
+ .ok_or_else(|| "packages ts sdk.package_family is required".to_owned())?;
+ if package_family != "@radroots" {
+ return Err("packages ts sdk.package_family must be @radroots".to_owned());
+ }
+ let expected = expected_ts_npm_packages();
+ let expected_keys = expected.keys().cloned().collect::<BTreeSet<_>>();
+ let actual_keys = npm_packages.keys().cloned().collect::<BTreeSet<_>>();
+ if actual_keys != expected_keys {
+ let missing = expected_keys
+ .difference(&actual_keys)
+ .cloned()
+ .collect::<Vec<_>>();
+ let extra = actual_keys
+ .difference(&expected_keys)
+ .cloned()
+ .collect::<Vec<_>>();
+ return Err(format!(
+ "packages ts npm_packages must match package matrix: missing {:?}, extra {:?}",
+ missing, extra
+ ));
+ }
+ for (key, expected_package) in expected {
+ let actual = npm_packages
+ .get(&key)
+ .ok_or_else(|| format!("packages ts npm package {key} is missing"))?;
+ validate_npm_package(&key, actual, &expected_package)?;
+ }
+ let expected_names = npm_packages
+ .values()
+ .map(|package| package.package.clone())
+ .collect::<BTreeSet<_>>();
+ if export_packages != &expected_names {
+ return Err("exports ts package set must match TypeScript npm package matrix".to_owned());
+ }
+ Ok(())
+}
+
+fn expected_ts_npm_packages() -> BTreeMap<String, ExpectedNpmPackage> {
+ let mut expected = BTreeMap::new();
+ for spec in package_specs() {
+ expected.insert(spec.key.to_owned(), expected_from_package_spec(*spec));
+ }
+ for spec in wasm_package_specs() {
+ expected.insert(spec.key.to_owned(), expected_from_wasm_package_spec(*spec));
+ }
+ expected
+}
+
+fn expected_from_package_spec(spec: PackageSpec) -> ExpectedNpmPackage {
+ ExpectedNpmPackage {
+ kind: "bindings",
+ crate_name: spec.crate_name,
+ crate_dir: spec.crate_dir,
+ package: spec.package_name,
+ package_dir: spec.package_dir,
+ out_name: None,
+ out_dir: None,
+ }
+}
+
+fn expected_from_wasm_package_spec(spec: WasmPackageSpec) -> ExpectedNpmPackage {
+ ExpectedNpmPackage {
+ kind: "wasm",
+ crate_name: spec.crate_name,
+ crate_dir: spec.crate_dir,
+ package: spec.package_name,
+ package_dir: spec.package_dir,
+ out_name: Some(spec.out_name),
+ out_dir: Some(spec.out_dir),
+ }
+}
+
+fn validate_npm_package(
+ key: &str,
+ actual: &NpmPackageContract,
+ expected: &ExpectedNpmPackage,
+) -> Result<(), String> {
+ validate_npm_field(key, "kind", &actual.kind, expected.kind)?;
+ validate_npm_field(key, "crate_name", &actual.crate_name, expected.crate_name)?;
+ validate_npm_field(key, "crate_dir", &actual.crate_dir, expected.crate_dir)?;
+ validate_npm_field(key, "package", &actual.package, expected.package)?;
+ validate_npm_field(
+ key,
+ "package_dir",
+ &actual.package_dir,
+ expected.package_dir,
+ )?;
+ validate_optional_npm_field(
+ key,
+ "out_name",
+ actual.out_name.as_deref(),
+ expected.out_name,
+ )?;
+ validate_optional_npm_field(key, "out_dir", actual.out_dir.as_deref(), expected.out_dir)?;
+ Ok(())
+}
+
+fn validate_npm_field(key: &str, field: &str, actual: &str, expected: &str) -> Result<(), String> {
+ validate_non_empty(actual, &format!("packages ts npm package {key} {field}"))?;
+ if actual != expected {
+ return Err(format!(
+ "packages ts npm package {key} {field} must be {expected}"
+ ));
+ }
+ Ok(())
+}
+
+fn validate_optional_npm_field(
+ key: &str,
+ field: &str,
+ actual: Option<&str>,
+ expected: Option<&str>,
+) -> Result<(), String> {
+ match (actual, expected) {
+ (Some(actual), Some(expected)) => validate_npm_field(key, field, actual, expected),
+ (None, None) => Ok(()),
+ (Some(_), None) => Err(format!(
+ "packages ts npm package {key} {field} must not be set"
+ )),
+ (None, Some(expected)) => Err(format!(
+ "packages ts npm package {key} {field} must be {expected}"
+ )),
+ }
+}
+
fn load_contract_dir<T>(dir: &Path) -> Result<Vec<T>, String>
where
T: for<'de> Deserialize<'de>,
@@ -322,11 +514,7 @@ mod tests {
#[test]
fn rejects_mismatched_language_sets() {
let root = test_root("language_mismatch");
- write_contract(
- &root,
- "contracts/exports/ts.toml",
- EXPORT_TS.replace("@radroots/sdk", "@radroots/sdk").as_str(),
- );
+ write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS);
let error = validate_sdk_contracts(&root).expect_err("missing packages should fail");
assert!(error.contains("contracts/packages"));
let _ = fs::remove_dir_all(root);
@@ -335,16 +523,35 @@ mod tests {
#[test]
fn rejects_package_export_mismatch() {
let root = test_root("package_mismatch");
+ write_contract(
+ &root,
+ "contracts/exports/ts.toml",
+ EXPORT_TS
+ .replace("@radroots/core-bindings", "@radroots/other")
+ .as_str(),
+ );
+ write_contract(&root, "contracts/packages/ts.toml", PACKAGE_TS);
+ let error = validate_sdk_contracts(&root).expect_err("mismatch should fail");
+ assert!(error.contains("exports ts package set"));
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn rejects_ts_package_matrix_drift() {
+ let root = test_root("ts_matrix_drift");
write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS);
write_contract(
&root,
"contracts/packages/ts.toml",
PACKAGE_TS
- .replace("@radroots/sdk", "@radroots/other")
+ .replace(
+ "package = \"@radroots/core-bindings\"",
+ "package = \"@radroots/other\"",
+ )
.as_str(),
);
let error = validate_sdk_contracts(&root).expect_err("mismatch should fail");
- assert!(error.contains("exports ts must resolve"));
+ assert!(error.contains("packages ts npm package core package"));
let _ = fs::remove_dir_all(root);
}
@@ -367,7 +574,16 @@ id = "ts"
repository = "sdk-typescript"
[packages]
-"radroots_core" = "@radroots/sdk"
+"radroots_core" = "@radroots/core-bindings"
+"radroots_events" = "@radroots/events-bindings"
+"radroots_events_indexed" = "@radroots/events-indexed-bindings"
+"radroots_identity" = "@radroots/identity-bindings"
+"radroots_replica_db_schema" = "@radroots/replica-db-schema-bindings"
+"radroots_trade" = "@radroots/trade-bindings"
+"radroots_types" = "@radroots/types-bindings"
+"radroots_events_codec_wasm" = "@radroots/events-codec-wasm"
+"radroots_replica_db_wasm" = "@radroots/replica-db-wasm"
+"radroots_replica_sync_wasm" = "@radroots/replica-sync-wasm"
[artifacts]
models_dir = "src/generated"
@@ -386,12 +602,88 @@ id = "ts"
repository = "sdk-typescript"
[sdk]
-package = "@radroots/sdk"
+package_family = "@radroots"
module_format = "esm"
deterministic_codec = "wasm"
signing = "native"
networking = "native"
+[npm_packages.core]
+kind = "bindings"
+crate_name = "radroots_core_bindings"
+crate_dir = "crates/core_bindings"
+package = "@radroots/core-bindings"
+package_dir = "packages/core-bindings"
+
+[npm_packages.events]
+kind = "bindings"
+crate_name = "radroots_events_bindings"
+crate_dir = "crates/events_bindings"
+package = "@radroots/events-bindings"
+package_dir = "packages/events-bindings"
+
+[npm_packages.events_indexed]
+kind = "bindings"
+crate_name = "radroots_events_indexed_bindings"
+crate_dir = "crates/events_indexed_bindings"
+package = "@radroots/events-indexed-bindings"
+package_dir = "packages/events-indexed-bindings"
+
+[npm_packages.identity]
+kind = "bindings"
+crate_name = "radroots_identity_bindings"
+crate_dir = "crates/identity_bindings"
+package = "@radroots/identity-bindings"
+package_dir = "packages/identity-bindings"
+
+[npm_packages.replica_db_schema]
+kind = "bindings"
+crate_name = "radroots_replica_db_schema_bindings"
+crate_dir = "crates/replica_db_schema_bindings"
+package = "@radroots/replica-db-schema-bindings"
+package_dir = "packages/replica-db-schema-bindings"
+
+[npm_packages.trade]
+kind = "bindings"
+crate_name = "radroots_trade_bindings"
+crate_dir = "crates/trade_bindings"
+package = "@radroots/trade-bindings"
+package_dir = "packages/trade-bindings"
+
+[npm_packages.types]
+kind = "bindings"
+crate_name = "radroots_types_bindings"
+crate_dir = "crates/types_bindings"
+package = "@radroots/types-bindings"
+package_dir = "packages/types-bindings"
+
+[npm_packages.events_codec]
+kind = "wasm"
+crate_name = "radroots_events_codec_wasm"
+crate_dir = "crates/events_codec_wasm"
+package = "@radroots/events-codec-wasm"
+package_dir = "packages/events-codec-wasm"
+out_name = "radroots_events_codec_wasm"
+out_dir = "../../packages/events-codec-wasm/dist"
+
+[npm_packages.replica_db]
+kind = "wasm"
+crate_name = "radroots_replica_db_wasm"
+crate_dir = "crates/replica_db_wasm"
+package = "@radroots/replica-db-wasm"
+package_dir = "packages/replica-db-wasm"
+out_name = "radroots_replica_db_wasm"
+out_dir = "../../packages/replica-db-wasm/dist"
+
+[npm_packages.replica_sync]
+kind = "wasm"
+crate_name = "radroots_replica_sync_wasm"
+crate_dir = "crates/replica_sync_wasm"
+package = "@radroots/replica-sync-wasm"
+package_dir = "packages/replica-sync-wasm"
+out_name = "radroots_replica_sync_wasm"
+out_dir = "../../packages/replica-sync-wasm/dist"
+
[rollout]
stage = "active"
order = 1