Skip to content

WIT: Import/Export inconsistencies #629

@Game4Freak

Description

@Game4Freak

This is from the point of view of using component composition for system design where dynamic linking / composing components is key.

Issue: Asymmetrical import/export resolution

Transitive import/export dependencies are resolved inconsistently for imports and exports:

  • Imports dependencies are resolved as additional imports.
  • Exports dependencies are resolved as additional imports.

This causes worlds with symmetrical definitions to be resolved asymmetrical.
While both worlds look like they would compose perfectly together, once resolved they don't and instead leave a gap behind.

Example:

/// Shared types
interface types {
    type start = string;
    type end = string;

    record path {
        start: start,
        end: end
    }

    resource path-graph {
        constructor();
        push: func(path: path);
        get-ends: func(start: start) -> list<end>;
        get-starts: func(end: end) -> list<start>;
    }
}

/// Validate provided graphs for correctness
interface validation {
    use types.{start, end, path-graph};

    validate: func(graph: path-graph) -> bool;
    validate-path: func(start: start, end: end) -> bool;
}

/// Component that want to use validation
world service {
    import validation;
}

/// Component that provides validation
world validator {
    export validation;
}

This would resolve into these worlds on the components:

/// Component that want to use validation
world service {
    import types;
    import validation;
}

/// Component that provides validation
world validator {
    import types;
    export validation;
}

While the validation interface can be composed, the types interface cannot be composed because it is imported by both components.
This would require an explicit export or a third component or the host to provide these types.

The obvious solution would be to update the resolution to be symmetrical:

  • Imports dependencies are resolved as additional imports.
  • Exports dependencies are resolved as additional exports.

But this isn't a valuable solution either as it forces the exporter to implement the types even tho it has no use to do so.

Solution: Fix inconsistent differentiation between values and signatures (interfaces)

For the sake of less confusion I will call the classic interfaces/traits "signatures"

Wit types can be split into 2 categories.
These categories have different uses cases for wit itself.

Please correct me if I'm wrong

Category type Compilation Runtime
Values string, u32, record, variant, ...
Signatures resource, function

For the runtime, Values don't really matter as the actual implementation is added automatically and doesn't need to be imported or exported.
For the runtime, Signatures matter as the actual implementation is not added automatically and needs to be either imported or implemented to be exported.

So, looking back at the example:

/// Shared types
interface types {
    type start = string;      // <--- import/export doesn't matter during runtime
    type end = string;        // <--- import/export doesn't matter during runtime

    record path {             // <--- import/export doesn't matter during runtime
        start: start,
        end: end
    }

    resource path-graph {     // <--- ❗ import/export does matter during runtime
        constructor();
        push: func(path: path);
        get-ends: func(start: start) -> list<end>;
        get-starts: func(end: end) -> list<start>;
    }
}

This insight alone doesn't change anything as we also need to rethink the import/export behaviour.

  • Values don't need import / export schemantic at all.
    The same definitions can be used for both imports and exports.
    It's same for generated code via bindgen (kind of relates to Split 'use' inside worlds into 'use import' and 'use export' #308).
  • Signatures require import / export schemantic.
    Whenever a signature is taken into scope it needs to be defined if it's an export or import.

This requires changes to the way entries are referenced via the use directive.

Looking at the example again:

/// Validate provided graphs for correctness
interface validation {
    use types.{start, end};             
    
    // use types.{path-graph};
    use import types.{path-graph};      // <--- The validator only wants the signature to validate the graph and doesn't provide the implementation.

    validate: func(graph: path-graph) -> bool;
    validate-path: func(start: start, end: end) -> bool;
}

/// Component that want to use validation
world service {                         // <--- Has to export the path-graph
    import validation;
}

/// Component that provides validation
world validator {                       // <--- Will import the path-graph
    export validation;
}

This would resolve into these worlds on the components:

/// Component that want to use validation
world service {
    use types.{start, end, path};
    use export types.{path-graph};
    import validation;
}

/// Component that provides validation
world validator {
    use types.{start, end, path};
    use import types.{path-graph};
    export validation;
}

Summary

With these changes interfaces are more expressive in what they require / provide and are resolved symmetrical.

I just want to share my point of view from using wit and it's interfaces as contracts in general where a symmetrical definition is important.
I'm open for any discussion :)

Benefits

Changes to WIT

  • Add export keyword to use directive (world and interface)
  • Add import keyword to use directive (world and interface)
  • Require export / import on use for resources and functions

Changes to bindgen (optional)

  • Remove different export / import handling for wit types of the Values category (string, u32, record, variant...)

Handling breaking changes

Make use syntactical suguar for use import on resources and functions.

use types;

translates to

use types.{start, end, path};
use import types.{path-graph};       // <--- Same behaviour

World resolution

Export Side

// Before
import types;

// After
use types.{start, end, path};
use import types.{path-graph};       // <--- Same behaviour

Import Side

// Before
import types;

// After
use types.{start, end, path};
use export types.{path-graph};       // <--- Breaking change

Could be feature gated via adding it by default as import aswell and defing the export to reference the import

use import types.{path-graph};
use export types.{path-graph} with { types = import types };

Resulting in

// Before
import types;

// After
use types.{start, end, path};
use import types.{path-graph};       // <--- Same behaviour
use export types.{path-graph} with { types = import types };

Example uses in WASI

wasi:http@0.3

interface types {
    resource request {}
    resource response {}
    variant error-code {}
}

interface handler {
  use types.{error-code};
  use import types.{request, response};    // <--- As a request handler i only need the signature of request and response

  handle: async func(request: request) -> result<response, error-code>;
}

wasi:keyvalue@0.2

interface types {
    resource bucket {}
    variant error {}
}

interface store {
    use types.{error};
    use export types.{bucket};             // <--- As a store i need to provide the implementation of the bucket signature

    open: func(identifier: string) -> result<bucket, error>;
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions