Sign in

    moon-lander-mediator

    Typed requests, events and middleware for the Moon-Lander framework

    Download zip
    Author
    Version
    0.6.0
    License
    Apache-2.0
    Last updated
    14 hours ago
    Downloads
    201

    #Mediator

    The mediator package (@mediator) lets the parts of a MoonBit application talk through messages instead of direct calls. It follows the mediator pattern of Foundatio.Mediator, with one important difference: registration is explicit, and the compiler is the registry. There is no handler discovery, no text key, and no runtime lookup.

    Import moonrockz/moon-lander-mediator as @mediator. The package is pure and synchronous, so it has the same API and the same behavior on wasm, wasm-gc, js, and native. Each mbt check block on this page runs as a test with mise run test.

    #One send, start to finish

    A caller sends a request through a sender value. The middleware that the application attached runs in attach order, then the one handler runs. The reply goes back out through the same middleware in reverse order.

    graph LR C[Caller] -->|ctx, req| A[Middleware A] A -->|next| B[Middleware B] B -->|next| H[Handler] H -->|reply| B B -->|reply| A A -->|reply| C

    PartValueMake it with
    SenderSender[C, Req, Res]: one handler for one request type@mediator.sender(handler)
    TopicTopic[C, Ev]: one or more handlers for one event type@mediator.topic(handler), then add
    MiddlewareMiddleware[C, Req, Res]: one wrapper step@mediator.middleware(wrap), before, after, finally_
    ErrorMediatorError: a refusal, or the failures of a publishraise @mediator.MediatorError::rejected(...); publish raises PublishFailed

    #Send a request

    A handler is a function that takes a context value and a request, and gives a reply. @mediator.sender takes the handler and gives a Sender[C, Req, Res] value. Keep the value, and send each request through it.

    Type parameterMeaning
    CThe context value that the caller gives to each send
    ReqThe request type. A request of another type does not compile
    ResThe reply type of the handler

    ///|
    struct Ping(String)

    ///|
    test "send a ping" {
    let ping : @mediator.Sender[Unit, Ping, String] = @mediator.sender((_, req) => {
    let Ping(text) = req
    "Pong: " + text
    })
    assert_eq(ping.send((), Ping("Hello")), "Pong: Hello")
    }

    A sender calls its one handler one time for each send. No other handler runs. When the application does not build a sender for a request type, no value can send that request type, so the gap is a compile error and not a runtime error.

    #Publish an event

    A topic sends one event to many handlers. @mediator.topic takes the first event handler and gives a Topic[C, Ev] value, so a topic always has at least one handler. Topic::add gives a new topic with one more handler at the end. Topic::publish calls each handler one time, in add order. An event has no reply: publish gives Unit. A caller that needs a reply uses a sender.

    ///|
    struct Joined(String)

    ///|
    test "publish to two handlers" {
    let log = []
    let joined : @mediator.Topic[Unit, Joined] = @mediator.topic((_, ev) => {
    let Joined(name) = ev
    log.push("welcome " + name)
    })
    let all = joined.add((_, ev) => {
    let Joined(name) = ev
    log.push("count " + name)
    })
    all.publish((), Joined("Ann"))
    assert_eq(log, ["welcome Ann", "count Ann"])
    }

    The handlers of a topic are independent reactions to one event, so a failing handler does not stop the others. This is the Foundatio.Mediator contract. Publish calls every handler, also after a handler raises. After the last handler, publish raises one MediatorError::PublishFailed that holds the number of handlers and each failure, in add order. A failure is a PublishFailure record with the 1-based position of its handler and the error that the handler raised, unchanged. Publish raises PublishFailed also when only one handler fails, so a caller has one error shape to handle.

    Handlers that raiseHandlers that runPublish gives
    NoneAll, in add order()
    One or moreAll, in add orderPublishFailed(handlers~, failures~) after the last handler

    ///|
    suberror CountError {
    CountFull(String)
    }

    ///|
    test "one handler fails and the others still run" {
    let log = []
    let joined : @mediator.Topic[Unit, Joined] = @mediator.topic((_, ev) => {
    let Joined(name) = ev
    log.push("welcome " + name)
    })
    let all = joined
    .add((_, ev) => {
    let Joined(name) = ev
    raise CountFull(name)
    })
    .add((_, ev) => {
    let Joined(name) = ev
    log.push("audit " + name)
    })
    try all.publish((), Joined("Ann")) catch {
    @mediator.PublishFailed(handlers~, failures~) as error => {
    assert_eq(handlers, 3)
    assert_eq(failures.length(), 1)
    guard failures.get(0) is Some(failure) else { fail("missing failure") }
    assert_eq(failure.position, 2)
    assert_true(failure.error is CountFull("Ann"))
    assert_eq(
    error.message(),
    "Publish failed: 1 of 3 handlers raised an error (position 2)",
    )
    }
    other => fail("publish raised another error: \{other}")
    } noraise {
    _ => fail("publish did not raise")
    }
    assert_eq(log, ["welcome Ann", "audit Ann"])
    }

    A topic whose later steps must not run after a failure uses one handler that calls the steps in order.

    #Middleware

    A middleware wraps the next step of a send or a publish. It gets next, the context value, and the request or event. It can run code before and after next, change the request or the reply, give a reply without a call to next, or catch the error of next. Cross-cutting work such as logs, validation, and timing goes in middleware, not in handlers.

    Sender::use_ gives a new sender with one more middleware, and Topic::use_ does the same for a topic. The value that you start from does not change. A topic middleware wraps the whole publish: its next runs all handlers. The first middleware that you attach is the outermost step, so it runs first on the way in and last on the way out.

    ConstructorRuns
    @mediator.middleware(wrap)wrap(next, ctx, req), which gives the reply
    @mediator.before(hook)hook(ctx, req) before the next step
    @mediator.after(hook)hook(ctx, req, reply) after the next step gives a reply
    @mediator.finally_(hook)hook(ctx, req) after the next step, also when it raises

    ///|
    test "log middleware around a send" {
    let log = []
    let ping : @mediator.Sender[Unit, Ping, String] = @mediator.sender((_, req) => {
    log.push("handler")
    let Ping(text) = req
    "Pong: " + text
    })
    let logged = ping
    .use_(
    @mediator.middleware((next, ctx, req) => {
    log.push("log in")
    let reply = next(ctx, req)
    log.push("log out")
    reply
    }),
    )
    .use_(@mediator.after((_, _, reply) => log.push("reply " + reply)))
    assert_eq(logged.send((), Ping("Hello")), "Pong: Hello")
    assert_eq(log, ["log in", "handler", "reply Pong: Hello", "log out"])
    }

    Middleware is typed like the sender: a Middleware[C, Req, Res] fits only a sender with the same three types. To log every message type, make one middleware value for each sender.

    #The context value

    Each send and each publish takes a context value of type C. The mediator gives that exact value to each middleware and to the handler. It does not copy the value, change it, or keep it after the call. The mediator does not know what C holds, so a context can be Unit, or a record that holds the services of the application.

    ///|
    struct Services {
    greeting : String
    }

    ///|
    struct AppContext {
    services : Services
    }

    ///|
    test "a handler reads the services from the context" {
    let ping : @mediator.Sender[AppContext, Ping, String] = @mediator.sender((
    ctx,
    req,
    ) => {
    let Ping(text) = req
    ctx.services.greeting + ": " + text
    })
    let ctx = { services: { greeting: "Pong", }, }
    assert_eq(ping.send(ctx, Ping("Hello")), "Pong: Hello")
    }

    #Errors

    A missing handler is a compile error, and there is no lookup and no I/O, so a send raises no error of its own. A handler or a middleware can raise an error. send raises the same error and gives no reply, unless a middleware catches it. publish collects the errors of its handlers into one PublishFailed. The mediator does not turn an error into a reply.

    MediatorError holds the failures that belong to the mediator pattern:

    CaseRaised bymessage gives
    Rejected(reason~)A handler or a middleware that refuses a request, for example after validationRequest rejected: <reason>
    TimedOut(after~)Async timeout middleware, with a core TimeLimitRequest timed out after <milliseconds> ms
    PublishFailed(handlers~, failures~)publish, when one or more handlers raisePublish failed: 2 of 3 handlers raised an error (positions 2, 3)

    MediatorError::message gives the text for people. It holds only the reason, or the counts and the handler positions. It never holds a request, an event, or the text of a wrapped error, because each one can hold secrets. Read each wrapped error from failures. An application error from a send passes through as itself. A topic middleware wraps the whole publish, so it gets the one PublishFailed from its next and can catch it.

    ///|
    test "a validation middleware rejects a request" {
    let ping : @mediator.Sender[Unit, Ping, String] = @mediator.sender((_, req) => {
    let Ping(text) = req
    "Pong: " + text
    })
    let checked = ping.use_(
    @mediator.before((_, req) => {
    let Ping(text) = req
    if text == "" {
    raise @mediator.MediatorError::rejected("the ping text is empty")
    }
    }),
    )
    try checked.send((), Ping("")) catch {
    @mediator.Rejected(_) as error =>
    assert_eq(error.message(), "Request rejected: the ping text is empty")
    other => fail("send raised another error: \{other}")
    } noraise {
    reply => fail("send gave a reply: \{reply}")
    }
    assert_eq(checked.send((), Ping("Hello")), "Pong: Hello")
    }

    #Migrate error construction and failure access

    Callers can match MediatorError cases, but cannot construct their cases or PublishFailure records. Use MediatorError::rejected(reason) for a refusal and MediatorError::timed_out(limit) for a timeout with a core TimeLimit.

    PublishFailed holds a PublishFailures collection. Use length() for the failure count, get(index) for optional zero-based access, and to_array() for a copy that you can iterate or change. The 1-based handler positions and the original report stay unchanged.

    Adapters that collect handler results use MediatorError::from_publish_errors(errors). Each slot is None for success or Some(error) for failure, in handler add order. The constructor derives the handler count and positions. It gives None for empty or all-success inputs.

    ///|
    test "derive a report and read failures safely" {
    let outcomes : Array[Error?] = [None, Some(CountFull("audit"))]
    guard @mediator.MediatorError::from_publish_errors(outcomes) is Some(error) else {
    fail("expected a publish error")
    }
    guard error is @mediator.PublishFailed(handlers~, failures~) else {
    fail("expected PublishFailed")
    }
    assert_eq(handlers, 2)
    assert_eq(failures.length(), 1)
    guard failures.get(0) is Some(failure) else { fail("expected a failure") }
    assert_eq(failure.position, 2)
    assert_true(failure.error is CountFull("audit"))
    assert_true(failures.get(1) is None)
    let copy = failures.to_array()
    copy.clear()
    outcomes.clear()
    assert_eq(failures.length(), 1)
    }

    MediatorError

    pub suberror MediatorError {
    Rejected(reason~ : String)
    PublishFailed(handlers~ : Int, failures~ : PublishFailures)
    TimedOut(after~ :
    TimeLimit
    )
    }

    A mediator-level failure. Callers can match cases but use the supported constructors to make errors. Application errors from send pass unchanged.

    MediatorError::from_publish_errors

    fn MediatorError::from_publish_errors(errors : Array[Error?]) -> MediatorError?

    Derive a publish error from one optional error per handler in add order. None is a successful handler. Empty and all-success arrays give None. Failed slots derive the count and positive positions. The error retains no caller-owned array, so later changes to errors do not change it.

    MediatorError::message

    fn MediatorError::message(self : MediatorError) -> String

    Text for people. It holds only the reason, counts and positions, or time limit. It never formats request values, events or wrapped error text.

    MediatorError::rejected

    fn MediatorError::rejected(reason : String) -> MediatorError

    Refuse a request or event. Use a reason that holds no request values.

    MediatorError::timed_out

    Describe a timeout with a valid shared core time limit.

    Middleware

    type Middleware[C, Req, Res]

    One step of the pipeline around a handler. A middleware gets the next step as a function value, the context value, and the request. It can run code before and after it calls the next step, change the request or the reply, give a reply without a call to the next step, or catch the error of the next step. It cannot change which handler a sender calls.

    PublishFailure

    pub struct PublishFailure {
    position : Int
    error : Error
    }

    One handler failure in a publish. Only mediator constructs this record.

    PublishFailures

    type PublishFailures

    An immutable failure collection. Accessors do not expose its storage.

    PublishFailures::get

    fn PublishFailures::get(self : PublishFailures, index : Int) -> PublishFailure?

    Give the failure at a zero-based index, or None when the index is outside the collection.

    PublishFailures::length

    fn PublishFailures::length(self : PublishFailures) -> Int

    Give the number of failed handlers.

    PublishFailures::to_array

    Give an array copy. Changes to this array do not change the collection.

    Sender

    type Sender[C, Req, Res]

    A typed sender for one request type. It holds the one handler for that request type and the middleware around it. The context type C, the request type Req, and the reply type Res are fixed at compile time, so a request of another type does not compile. A sender exists only when the application builds it from a handler: the compiler is the registry, and there is no runtime lookup.

    Sender::send

    fn[C, Req, Res] Sender::send(self : Sender[C, Req, Res], ctx : C, req : Req) -> Res raise

    Send a request with a context value. The middleware runs in attach order around the one handler, and the sender gives the reply. An error that no middleware catches passes out unchanged.

    Sender::use_

    fn[C, Req, Res] Sender::use_(self : Sender[C, Req, Res], middleware : Middleware[C, Req, Res]) -> Sender[C, Req, Res]

    Give a new sender with middleware inside the middleware that the sender has. The first middleware attached is the outermost step. The sender itself does not change.

    Topic

    type Topic[C, Ev]

    A typed topic for one event type. It holds one or more event handlers and the middleware around them. A topic starts with one handler, so a topic with no handlers is not a value. An event has no reply.

    Topic::add

    fn[C, Ev] Topic::add(self : Topic[C, Ev], handle : (C, Ev) -> Unit raise) -> Topic[C, Ev]

    Give a new topic with handle after the handlers that the topic has. The topic itself does not change.

    Topic::publish

    fn[C, Ev] Topic::publish(self : Topic[C, Ev], ctx : C, ev : Ev) -> Unit raise

    Publish an event with a context value. The middleware runs in attach order around the handlers. Every handler runs one time, in add order, also after a handler raises. When one or more handlers raise, the publish raises one MediatorError::PublishFailed after the last handler, so a topic middleware gets that one combined error from its next step.

    Topic::use_

    fn[C, Ev] Topic::use_(self : Topic[C, Ev], middleware : Middleware[C, Ev, Unit]) -> Topic[C, Ev]

    Give a new topic with middleware inside the middleware that the topic has. A topic middleware wraps the whole publish: its next step runs all handlers. The first middleware attached is the outermost step. The topic itself does not change.

    after

    fn[C, Req, Res] after(hook : (C, Req, Res) -> Unit raise) -> Middleware[C, Req, Res]

    Build a middleware that calls hook with the reply after the next step gives it. hook does not run when the next step raises an error.

    before

    fn[C, Req, Res] before(hook : (C, Req) -> Unit raise) -> Middleware[C, Req, Res]

    Build a middleware that calls hook before the next step. An error that hook raises ends the call, and the next step does not run.

    finally_

    fn[C, Req, Res] finally_(hook : (C, Req) -> Unit) -> Middleware[C, Req, Res]

    Build a middleware that calls hook after the next step, when the next step gives a reply and when it raises an error. The error then passes out unchanged. hook cannot raise, so it cannot hide the error of the next step.

    middleware

    fn[C, Req, Res] middleware(wrap : ((C, Req) -> Res raise, C, Req) -> Res raise) -> Middleware[C, Req, Res]

    Build a middleware from a wrapper function. The wrapper gets next, the context value, and the request, and gives the reply.

    sender

    fn[C, Req, Res] sender(handle : (C, Req) -> Res raise) -> Sender[C, Req, Res]

    Build a sender from its one handler. The handler takes the context value and the request, and gives the reply or raises an error.

    topic

    fn[C, Ev] topic(handle : (C, Ev) -> Unit raise) -> Topic[C, Ev]

    Build a topic from its first event handler.

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io