moon-lander-actors

    Local typed actors with bounded mailboxes for the Moon-Lander framework

    Download zip
    Author
    Version
    0.11.0
    License
    Apache-2.0
    Last updated
    13 days ago
    Downloads
    60

    #Actors

    moonrockz/moon-lander-actors gives local typed actors: units that own private state and one mailbox and handle one message at a time. A scope owns each actor's lifetime. No actor system, registry or host is necessary.

    The module has two packages:

    PackageAliasTargetsContents
    moonrockz/moon-lander-actors@policywasm, wasm-gc, js, nativeChecked policy values: MailboxCapacity, MessageCount, MailboxPolicy, Admission, PayloadPolicy
    moonrockz/moon-lander-actors/runtime@actorswasm, js, nativeScopes, actors, references, replies and Receive values. See the runtime guide

    The module imports moonrockz/moon-lander-core, moonbitlang/async and moonrockz/ion@0.6.0. Its packages use only the Ion data model, moonrockz/ion/ion, with no text, binary, schema or JSON codec package. It does not depend on hosting, configuration, mediator or logging, and the umbrella module does not import it.

    graph LR core[moon-lander-core] --> policy["actors (policy, portable)"] ion["Ion data model"] --> policy policy --> runtime["actors/runtime"] async[moonbitlang/async] --> runtime

    Each mbt check block on this page runs as a test with mise run test on all four targets.

    #Checked policy values

    A mailbox capacity and a message count are positive numbers from one to the largest Int. Their constructors are the only way to make them, and they raise a typed CountError that holds the input and the broken rule.

    ///|
    test "a capacity is positive" {
    assert_eq(@policy.MailboxCapacity::from_int(1).to_int(), 1)
    let rejected = try {
    ignore(@policy.MailboxCapacity::from_int(0))
    None
    } catch {
    InvalidCapacity(input~, rule=NotPositive) => Some(input)
    InvalidCount(..) => None
    }
    assert_eq(rejected, Some(0))
    }

    The default mailbox policy is bounded at 128 queued messages. An unbounded mailbox is an explicit choice:

    ///|
    test "the default policy is bounded at 128" {
    guard @policy.MailboxPolicy::default() is Bounded(capacity) else {
    fail("the default is unbounded")
    }
    assert_eq(capacity.to_int(), 128)
    assert_true(@policy.MailboxPolicy::Unbounded != @policy.MailboxPolicy::default())
    }

    #Message payloads

    PayloadPolicy[M] selects the data for an undelivered-event payload. The policy is portable on all four targets. omit() is the default and accepts any protocol, including messages with replies or closures. apply(message) returns None for omission, or the encoded Ion value. The runtime applies a policy only for an undelivered event with an active subscriber.

    Messages can hold passwords, tokens and other secrets. ion() and json() encode the whole message. Enable them only when all message fields can be written to an event or log. Use encode() to remove secret fields. Its encoder cannot raise.

    ///|
    enum Change {
    Add(Int)
    } derive(ToJson)

    ///|
    test "omit is the default and JSON bridges a derived protocol" {
    let omitted : @policy.PayloadPolicy[Change] = @policy.PayloadPolicy::default()
    assert_true(omitted.apply(Add(3)) is None)
    assert_true(@policy.PayloadPolicy::omit().apply(Add(3)) is None)
    let json : @policy.PayloadPolicy[Change] = @policy.PayloadPolicy::json()
    guard json.apply(Add(3)) is Some(payload) else { fail("missing payload") }
    assert_true(payload.equals(@ion.Ion(ToJson::to_json(Add(3)))))
    }

    Implement @ion.ToIon for a protocol to preserve Ion types such as decimals and timestamps. ToIon cannot be derived. The JSON bridge above needs only derive(ToJson) and uses @ion.Ion(message.to_json()).

    ///|
    enum Measurement {
    Record(@ion.Decimal, @ion.Timestamp)
    }

    ///|
    impl @ion.ToIon for Measurement with to_ion(self) {
    let Record(amount, time) = self
    @ion.Ion::list([@ion.Ion::decimal(amount), @ion.Ion::timestamp(time)])
    }

    ///|
    test "Ion keeps decimal and timestamp types" {
    let amount = @ion.Decimal::parse("12.340")
    let time = @ion.Timestamp::parse("2026-10-31T12:34:56Z")
    let policy : @policy.PayloadPolicy[Measurement] = @policy.PayloadPolicy::ion()
    guard policy.apply(Record(amount, time)) is Some(payload) else { fail("missing payload") }
    assert_true(payload.equals(@ion.Ion::list([
    @ion.Ion::decimal(amount), @ion.Ion::timestamp(time),
    ])))
    }

    A custom encoder can select data fields and replace a secret:

    ///|
    enum Login {
    Authenticate(String, String)
    }

    ///|
    test "redact a secret before it enters an event" {
    let policy : @policy.PayloadPolicy[Login] = @policy.PayloadPolicy::encode(message => {
    let Authenticate(user, _secret) = message
    @ion.Ion::from_fields([
    @ion.Field("user", @ion.Ion::string(user)),
    @ion.Field("secret", @ion.Ion::string("[redacted]")),
    ])
    })
    guard policy.apply(Authenticate("reader", "private-token")) is Some(payload) else {
    fail("missing payload")
    }
    assert_true(payload.equals(@ion.Ion::from_fields([
    @ion.Field("user", @ion.Ion::string("reader")),
    @ion.Field("secret", @ion.Ion::string("[redacted]")),
    ])))
    }

    let policy : @policy.PayloadPolicy[Change] = @policy.PayloadPolicy::ion() does not compile because Change has no @ion.ToIon implementation. Select json() for that protocol. A protocol with neither codec can use omit() or encode(). The task-script tests in scripts/check-actors.mbtx check wrong codec selection, a raising encoder and private-field construction on all four targets, with a valid consumer as the control.

    CountError

    pub suberror CountError {
    InvalidCapacity(input~ : Int, rule~ : CountRule)
    InvalidCount(input~ : Int, rule~ : CountRule)
    } derive(
    Debug
    )

    An input that is not a valid mailbox capacity or message count. Only the constructors of MailboxCapacity and MessageCount make these errors.

    NameError

    pub suberror NameError {
    InvalidName(input~ : String, rule~ : NameRule)
    } derive(
    Debug
    )

    An input that is not a valid actor name. Only ActorName::parse makes it.

    ActorName

    pub struct ActorName {
    // private fields
    } derive(Eq, Hash,
    Debug
    )

    A label that an actor scope can reserve for one actor at a time: 1 to 128 ASCII characters, a letter first, then letters, digits, _, - or .. Names are case sensitive and are not trimmed or normalized.

    ActorName::equal

    fn ActorName::equal(ActorName, ActorName) -> Bool

    ActorName::hash

    fn ActorName::hash(self : ActorName) -> Int

    ActorName::hash_combine

    fn ActorName::hash_combine(ActorName, Hasher) -> Unit

    ActorName::not_equal

    fn ActorName::not_equal(x : ActorName, y : ActorName) -> Bool

    ActorName::parse

    fn ActorName::parse(text : String) -> ActorName raise NameError

    Parse text as an actor name.

    ActorName::to_string

    fn ActorName::to_string(self : ActorName) -> String

    The name as it was parsed.

    Admission

    pub(all) enum Admission {
    Accepted
    Full
    Closed
    } derive(Eq,
    Debug
    )

    The result of a send that does not wait.

    Admission::equal

    fn Admission::equal(Admission, Admission) -> Bool

    Admission::not_equal

    fn Admission::not_equal(x : Admission, y : Admission) -> Bool

    CountRule

    pub enum CountRule {
    NotPositive
    } derive(Eq,
    Debug
    )

    The rule of a positive count that an input breaks.

    CountRule::equal

    fn CountRule::equal(CountRule, CountRule) -> Bool

    CountRule::not_equal

    fn CountRule::not_equal(x : CountRule, y : CountRule) -> Bool

    MailboxCapacity

    pub struct MailboxCapacity {
    // private fields
    } derive(Eq, Hash,
    Debug
    )

    The largest number of queued messages that a bounded mailbox holds, from 1 to the largest Int. The in-flight message does not count.

    MailboxCapacity::default

    The default capacity: 128 queued messages.

    MailboxCapacity::equal

    MailboxCapacity::from_int

    fn MailboxCapacity::from_int(count : Int) -> MailboxCapacity raise CountError

    Make a capacity of count queued messages.

    MailboxCapacity::hash

    fn MailboxCapacity::hash(self : MailboxCapacity) -> Int

    MailboxCapacity::hash_combine

    fn MailboxCapacity::hash_combine(MailboxCapacity, Hasher) -> Unit

    MailboxCapacity::not_equal

    fn MailboxCapacity::not_equal(x : MailboxCapacity, y : MailboxCapacity) -> Bool

    MailboxCapacity::to_int

    fn MailboxCapacity::to_int(self : MailboxCapacity) -> Int

    The capacity as a count of messages, from 1 to the largest Int.

    MailboxPolicy

    pub(all) enum MailboxPolicy {
    Bounded(MailboxCapacity)
    Unbounded
    } derive(Eq,
    Debug
    )

    How many queued messages a mailbox can hold.

    MailboxPolicy::default

    fn MailboxPolicy::default() -> MailboxPolicy

    The default policy: bounded at MailboxCapacity::default().

    MailboxPolicy::equal

    MailboxPolicy::not_equal

    fn MailboxPolicy::not_equal(x : MailboxPolicy, y : MailboxPolicy) -> Bool

    MessageCount

    pub struct MessageCount {
    // private fields
    } derive(Eq, Hash,
    Debug
    )

    The largest number of messages that a finite receive takes, from 1 to the largest Int.

    MessageCount::equal

    MessageCount::from_int

    fn MessageCount::from_int(count : Int) -> MessageCount raise CountError

    Make a count of count messages.

    MessageCount::hash

    fn MessageCount::hash(self : MessageCount) -> Int

    MessageCount::hash_combine

    fn MessageCount::hash_combine(MessageCount, Hasher) -> Unit

    MessageCount::not_equal

    fn MessageCount::not_equal(x : MessageCount, y : MessageCount) -> Bool

    MessageCount::to_int

    fn MessageCount::to_int(self : MessageCount) -> Int

    The count as an Int, from 1 to the largest Int.

    NameRule

    pub enum NameRule {
    Empty
    TooLong
    FirstNotLetter(Char)
    InvalidCharacter(Char)
    } derive(Eq,
    Debug
    )

    The rule of an actor name that an input breaks.

    NameRule::equal

    fn NameRule::equal(NameRule, NameRule) -> Bool

    NameRule::not_equal

    fn NameRule::not_equal(x : NameRule, y : NameRule) -> Bool

    NameRule::to_repr

    PayloadPolicy

    pub struct PayloadPolicy[M] {
    // private fields
    }

    Select how a message becomes an undelivered-event payload. The default omits the message. Ion and JSON encoding include the whole message; use a custom encoder to remove secrets. Encoders cannot raise.

    PayloadPolicy::apply

    fn[M] PayloadPolicy::apply(self : PayloadPolicy[M], message : M) ->
    Ion
    ?

    Apply the policy to a message. The runtime calls this only for an undelivered event with an active subscriber. None means the policy omits the payload.

    PayloadPolicy::default

    fn[M] PayloadPolicy::default() -> PayloadPolicy[M]

    Omit the message by default, because messages can hold secrets.

    PayloadPolicy::encode

    fn[M] PayloadPolicy::encode(f : (M) ->
    Ion
    ) -> PayloadPolicy[M]

    Use a custom encoder, for example to remove a secret or omit a reply field. The encoder cannot raise.

    PayloadPolicy::ion

    Encode the whole message with its Ion implementation, preserving Ion types.

    PayloadPolicy::json

    fn[M : ToJson] PayloadPolicy::json() -> PayloadPolicy[M]

    Encode the whole message as JSON, then hold that JSON as an Ion value.

    PayloadPolicy::omit

    fn[M] PayloadPolicy::omit() -> PayloadPolicy[M]

    Omit the message from the event payload, with no encoding work.