Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules/
.pnpm-store/
target/
pnpm-lock.yaml
.wesley-cache/
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ schema facts, and extract operation directive arguments.
cargo wesley doctor
cargo wesley schema lower --schema test/fixtures/ir-parity/small-schema.graphql --json
cargo wesley schema hash --schema test/fixtures/ir-parity/small-schema.graphql
cargo wesley schema operations --schema test/fixtures/consumer-models/jedit-hot-text-runtime.graphql --json
cargo wesley schema operations --schema test/fixtures/consumer-models/jedit-rope.graphql --json
cargo wesley schema diff --old old.graphql --new new.graphql --format summary --exit-code
cargo wesley schema diff --schema schema.graphql --against HEAD --format summary
cargo wesley law validate --schema test/fixtures/weslaw/contract-bundle-shape.graphql --law test/fixtures/weslaw/accepted/footprint-replace-range.weslaw.yaml
Expand Down
49 changes: 46 additions & 3 deletions crates/wesley-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ use wesley_emit_rust::{
GENERATOR_NAME as RUST_GENERATOR_NAME, GENERATOR_VERSION as RUST_GENERATOR_VERSION,
};
use wesley_emit_typescript::{
emit_typescript_with_operations, GENERATOR_NAME as TYPESCRIPT_GENERATOR_NAME,
GENERATOR_VERSION as TYPESCRIPT_GENERATOR_VERSION,
emit_le_binary_typescript, emit_typescript_with_operations, DEFAULT_CODEC_IMPORT,
GENERATOR_NAME as TYPESCRIPT_GENERATOR_NAME, GENERATOR_VERSION as TYPESCRIPT_GENERATOR_VERSION,
LE_BINARY_GENERATOR_NAME as LE_BINARY_TYPESCRIPT_GENERATOR_NAME,
};

const EXIT_OK: u8 = 0;
Expand Down Expand Up @@ -532,6 +533,36 @@ fn run_emit_command(args: &[String]) -> Result<u8, CliError> {
)?;
Ok(EXIT_OK)
}
Some("le-binary-typescript") if wants_help(&args[1..]) => {
print_emit_help();
Ok(EXIT_OK)
}
Some("le-binary-typescript") => {
let options = parse_options(&args[1..], "emit le-binary-typescript")?;
let schema_path = options.required_schema("emit le-binary-typescript")?;
let out_path = options.required_out("emit le-binary-typescript")?;
let sdl = read_file(&schema_path, "schema")?;
let ir = lower_schema_sdl(&sdl)?;
let operations = list_schema_operations_sdl(&sdl)?;
let bundle =
load_contract_bundle_if_requested(options.law.as_deref(), &ir, &operations)?;
let manifest = bundle.as_ref().map(|bundle| &bundle.manifest);
let codec_import = options
.codec_import
.as_deref()
.unwrap_or(DEFAULT_CODEC_IMPORT);
let typescript = emit_le_binary_typescript(&ir, &operations, codec_import);

write_file(&out_path, &typescript, "LE binary TypeScript output")?;
write_emit_metadata_if_requested(
options.metadata_out.as_deref(),
&ir,
manifest,
LE_BINARY_TYPESCRIPT_GENERATOR_NAME,
TYPESCRIPT_GENERATOR_VERSION,
)?;
Ok(EXIT_OK)
}
Some(command) => Err(CliError::usage(format!("unknown emit command '{command}'"))),
}
}
Expand Down Expand Up @@ -769,6 +800,7 @@ struct ParsedOptions {
operation: Option<PathBuf>,
out: Option<PathBuf>,
metadata_out: Option<PathBuf>,
codec_import: Option<String>,
directive: Option<String>,
family: Option<String>,
profile: Option<String>,
Expand Down Expand Up @@ -947,6 +979,15 @@ fn parse_options(args: &[String], command: &str) -> Result<ParsedOptions, CliErr
"unknown option '--metadata-out' for `{command}`"
)));
}
"--codec-import" if command == "emit le-binary-typescript" => {
index += 1;
options.codec_import = Some(required_value(args, index, "--codec-import")?);
}
"--codec-import" => {
return Err(CliError::usage(format!(
"unknown option '--codec-import' for `{command}`"
)));
}
"--directive" | "-d" => {
index += 1;
let name = required_value(args, index, "--directive")?;
Expand Down Expand Up @@ -2098,12 +2139,14 @@ Query, Mutation, or Subscription fields.
Usage:
wesley emit rust --schema <path> --out <path> [--law <path>] [--metadata-out <path>]
wesley emit typescript --schema <path> --out <path> [--law <path>] [--metadata-out <path>]
wesley emit le-binary-typescript --schema <path> --out <path> [--law <path>] [--metadata-out <path>] [--codec-import <path>]

Options:
-s, --schema <path> GraphQL SDL file
--law <path> Optional weslaw/v1 file for bundle hashes
--out <path> Output file
--metadata-out <path> Deterministic metadata JSON sidecar"
--metadata-out <path> Deterministic metadata JSON sidecar
--codec-import <path> Module specifier for Writer/Reader/CodecError (le-binary-typescript only)"
);
}

Expand Down
6 changes: 2 additions & 4 deletions crates/wesley-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -714,9 +714,7 @@ fn schema_hash_matches_l1_hash_fixture() {
fn schema_operations_emit_root_operation_catalog_as_json() {
let output = wesley()
.args(["schema", "operations", "--schema"])
.arg(fixture(
"test/fixtures/consumer-models/jedit-hot-text-runtime.graphql",
))
.arg(fixture("test/fixtures/consumer-models/jedit-rope.graphql"))
.arg("--json")
.output()
.expect("wesley should run");
Expand Down Expand Up @@ -1294,7 +1292,7 @@ fn emit_commands_include_jedit_operation_bindings() {
let dir = temp_dir("emit-jedit-operation-bindings");
let rust_out = dir.join("generated").join("model.rs");
let typescript_out = dir.join("generated").join("types.ts");
let schema = fixture("test/fixtures/consumer-models/jedit-hot-text-runtime.graphql");
let schema = fixture("test/fixtures/consumer-models/jedit-rope.graphql");

let rust_output = wesley()
.args(["emit", "rust", "--schema"])
Expand Down
115 changes: 115 additions & 0 deletions crates/wesley-core/src/domain/operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,49 @@ pub struct OperationArgument {
pub directives: IndexMap<String, serde_json::Value>,
}

/// Compute a stable u32 op id from a root operation's kind and field name.
///
/// The algorithm is FNV-1a 32-bit with the canonical seed `0x811c9dc5`:
///
/// 1. Seed `hash = 0x811c9dc5`.
/// 2. Step `hash` with [`operation_type_rank`] of `operation_type`.
/// 3. Step `hash` with each UTF-8 byte of `field_name` in order.
///
/// Each step is `hash = (hash * 0x01000193) ^ byte`, computed modulo 2³² (wrapping).
///
/// This is the cross-language identifier consumed by EINT envelopes:
/// every encoder/decoder that derives op ids from a Wesley schema must
/// produce identical values here, regardless of host language. Changing
/// the algorithm — even reordering the input bytes — is a breaking change
/// to every deployed contract.
#[must_use]
pub fn stable_op_id(operation_type: OperationType, field_name: &str) -> u32 {
let mut hash: u32 = 0x811c_9dc5;
hash = fnv1a_step(hash, operation_type_rank(operation_type));
for byte in field_name.as_bytes() {
hash = fnv1a_step(hash, *byte);
}
hash
}

/// Canonical numeric rank for [`OperationType`].
///
/// `Query = 0`, `Mutation = 1`, `Subscription = 2`. These ranks are part of
/// the [`stable_op_id`] preimage and must never change.
#[must_use]
pub fn operation_type_rank(operation_type: OperationType) -> u8 {
match operation_type {
OperationType::Query => 0,
OperationType::Mutation => 1,
OperationType::Subscription => 2,
}
}

#[inline]
fn fnv1a_step(hash: u32, byte: u8) -> u32 {
hash.wrapping_mul(0x0100_0193) ^ u32::from(byte)
}

/// A root schema operation field described as domain-empty data.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
Expand All @@ -59,3 +102,75 @@ pub struct SchemaOperation {
/// Generic map of directives attached to the root field.
pub directives: IndexMap<String, serde_json::Value>,
}

#[cfg(test)]
mod tests {
use super::{operation_type_rank, stable_op_id, OperationType};

#[test]
fn operation_type_rank_is_pinned() {
assert_eq!(operation_type_rank(OperationType::Query), 0);
assert_eq!(operation_type_rank(OperationType::Mutation), 1);
assert_eq!(operation_type_rank(OperationType::Subscription), 2);
}

#[test]
fn stable_op_id_matches_canonical_seed() {
// Empty field name with each rank must produce a single FNV-1a step
// from the seed 0x811c9dc5.
let seed: u32 = 0x811c_9dc5;
let multiplier: u32 = 0x0100_0193;

let expected_query = seed.wrapping_mul(multiplier) ^ 0_u32;
let expected_mutation = seed.wrapping_mul(multiplier) ^ 1_u32;
let expected_subscription = seed.wrapping_mul(multiplier) ^ 2_u32;

assert_eq!(stable_op_id(OperationType::Query, ""), expected_query);
assert_eq!(stable_op_id(OperationType::Mutation, ""), expected_mutation);
assert_eq!(
stable_op_id(OperationType::Subscription, ""),
expected_subscription
);
}

#[test]
fn stable_op_id_is_pinned_for_domain_neutral_operations() {
// These exact u32s are the contract surface between Rust and TypeScript
// emitters; any change here is a breaking change to every consumer that
// routes EINT envelopes by op_id. Pin them.
assert_eq!(
stable_op_id(OperationType::Mutation, "createRecord"),
1_670_356_121
);
assert_eq!(
stable_op_id(OperationType::Mutation, "updateRecord"),
3_583_386_294
);
assert_eq!(
stable_op_id(OperationType::Mutation, "createCheckpoint"),
3_744_251_216
);
assert_eq!(
stable_op_id(OperationType::Query, "recordSnapshot"),
3_126_837_072
);
assert_eq!(
stable_op_id(OperationType::Query, "recordWindow"),
3_315_867_368
);
}

#[test]
fn stable_op_id_separates_query_from_mutation_for_same_name() {
let q = stable_op_id(OperationType::Query, "createRecord");
let m = stable_op_id(OperationType::Mutation, "createRecord");
assert_ne!(q, m);
}

#[test]
fn stable_op_id_is_byte_order_sensitive() {
let ab = stable_op_id(OperationType::Query, "ab");
let ba = stable_op_id(OperationType::Query, "ba");
assert_ne!(ab, ba);
}
}
6 changes: 3 additions & 3 deletions crates/wesley-core/tests/operation_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,11 @@ fn lists_schema_operations_with_arguments_results_and_directives() {
}

#[test]
fn lists_jedit_hot_text_runtime_schema_operations() {
fn lists_jedit_rope_fixture_schema_operations() {
let operations = list_schema_operations_sdl(include_str!(
"../../../test/fixtures/consumer-models/jedit-hot-text-runtime.graphql"
"../../../test/fixtures/consumer-models/jedit-rope.graphql"
))
.expect("jedit hot text runtime operations should resolve");
.expect("jedit rope fixture operations should resolve");

assert_eq!(operations.len(), 5);

Expand Down
3 changes: 1 addition & 2 deletions crates/wesley-emit-rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -976,8 +976,7 @@ pub struct UserFilter {

#[test]
fn emits_jedit_operation_bindings() {
let sdl =
include_str!("../../../test/fixtures/consumer-models/jedit-hot-text-runtime.graphql");
let sdl = include_str!("../../../test/fixtures/consumer-models/jedit-rope.graphql");
let ir = lower_schema_sdl(sdl).expect("jedit runtime fixture should lower");
let operations =
list_schema_operations_sdl(sdl).expect("jedit runtime operations should resolve");
Expand Down
Loading
Loading