tabular-center

tabular-center Home · Rust · Kotlin · Swift · Migrating · Compatibility · Cells · Diagnostics · Matrix files · Source

Migrating to tabular-center

Every library below lets you list transitions. tabular-center asks for a grid: one cell for every state and every action, each decided explicitly. That is the whole migration, and it is also the whole gain – the cells a listing never mentions are exactly the ones a grid will not let you forget. Add a state or an action and the build breaks until every new cell has an answer; add an effect and every handler breaks until it performs it.

The “before” sketches are written against each library’s documented API to show the shape of the code you have now. They are not compiled here; check them against the version you use. The “after” code is included from this repository’s examples, which CI builds and runs.

The concepts, mapped

You have You write
a state a row
an event / action a column
a transition to a fixed state, maybe with a side effect GO(Target, Effect...) – no code
a transition whose target is computed, or guarded HANDLE – one method, arguments already narrowed
an event that does nothing in this state (often: silently unlisted) IGNORE, written down
an event that must never arrive in this state UNREACHABLE – a trap, counted by coverage
a target-less / internal transition with a side effect EMIT(Effect...)
side-effect listeners, onTransition, actions an effects enum, and one perform member per effect
a composite / nested state DELEGATE(child) – a child machine, composed, not nested

What has no equivalent

Said up front, because each is a real feature of the libraries below:

From Tinder StateMachine (Kotlin)

A stopwatch – Idle and Running(since), Start and Stop, a Beep on stopping – in Tinder’s DSL:

// Sketch against com.tinder.StateMachine's documented DSL; not compiled here.
val stopwatch = StateMachine.create<State, Event, SideEffect> {
    initialState(State.Idle)
    state<State.Idle> {
        on<Event.Start> { transitionTo(State.Running(clock.now())) }
    }
    state<State.Running> {
        on<Event.Stop> {
            lastElapsed = clock.now() - since
            transitionTo(State.Idle, SideEffect.Beep)
        }
    }
    onTransition {
        val valid = it as? StateMachine.Transition.Valid ?: return@onTransition
        if (valid.sideEffect == SideEffect.Beep) beeps++
    }
}

Stop while Idle and Start while Running appear nowhere: they come back as Transition.Invalid at runtime, and nothing says whether that was meant. The same machine here – the two unlisted pairs are IGNORE, on the page:

@Machine(
    states = [S.Idle::class, S.Running::class],
    actions = [A.Start::class, A.Stop::class],
    effects = [F.Beep::class],
    initial = S.Idle::class,
)
//                         Start                    Stop
@Row(S.Idle::class,      [CellSpec(Kind.HANDLE),  CellSpec(Kind.IGNORE)])
@Row(S.Running::class,   [CellSpec(Kind.IGNORE),  CellSpec(Kind.HANDLE)])
internal interface StopwatchSpec {
    fun Clock.handle(ctx: Ctx, state: S, action: A): Step<S, F>
}

From tabular-center-kotlin/examples/06-generated/src/Stopwatch.tb.kt.

KSP turns the two HANDLE cells and the one effect into three members, each receiving its state and action already narrowed – state.since is a field, not a cast:

internal class StopwatchImpl : Cells {
    override fun Clock.idleStart(ctx: Ctx, state: S.Idle, action: A.Start): Step<S, F> =
        Step.Go(S.Running(now()))

    override fun Clock.runningStop(ctx: Ctx, state: S.Running, action: A.Stop): Step<S, F> {
        ctx.lastElapsed = now() - state.since
        return Step.Go(S.Idle, listOf(F.Beep))
    }

    override fun Clock.beep(ctx: Ctx, effect: F.Beep): A? {
        ctx.beeps++
        return null
    }
}

From tabular-center-kotlin/examples/06-generated/src/StopwatchImpl.kt.

onTransition’s as? and if become beep, which the compiler requires.

From KStateMachine (Kotlin)

// Sketch against KStateMachine's documented DSL; not compiled here.
val machine = createStateMachine(scope) {
    addInitialState(StopwatchState.Idle) {
        transition<Start> {
            targetState = StopwatchState.Running
            onTriggered { since = clock.now() }
        }
    }
    addState(StopwatchState.Running) {
        transition<Stop> {
            targetState = StopwatchState.Idle
            onTriggered {
                lastElapsed = clock.now() - since
                beeps++
            }
        }
    }
}
machine.processEvent(Start)

The declaration is the same as above: transition<Start> { targetState = ... } with onTriggered work is a HANDLE cell; one with no work is GO; a target-less one is a HANDLE that stays – or EMIT, when its only work is an effect. KStateMachine’s states are instances, so since lives outside them; here it is the payload of Running, and a cell cannot see it from any other state. Its coroutine support maps to a suspend prototype – suspend fun handle(...) – and SuspendDriver, with no coroutine library needed by the runtime.

From Spring Statemachine (Java or Kotlin)

// Sketch against Spring Statemachine's documented configurer; not compiled here.
@Override
public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
        throws Exception {
    transitions
        .withExternal().source(States.IDLE).target(States.RUNNING).event(Events.START)
            .action(ctx -> since = clock.now())
        .and()
        .withExternal().source(States.RUNNING).target(States.IDLE).event(Events.STOP)
            .action(ctx -> { lastElapsed = clock.now() - since; beeps++; });
}

Move to the Kotlin surface above: each withExternal() is a cell, an .action(...) makes it HANDLE, a .guard(...) belongs inside that same HANDLE as an if, and every source/event pair you did not configure becomes a cell you decide. What does not come along is the container: there is no bean, no @EnableStateMachine, no message channel. A machine is a function from a state and an action to a step, and Driver or SuspendDriver runs it.

From TCA (Swift)

The Composable Architecture is not a state-machine library, but a reducer that switches on its state and action is a state machine – with a default: that decides every pair nobody wrote down:

// Sketch against TCA 1.x's documented reducer; not compiled here.
@Reducer
struct Timer {
    @ObservableState
    enum State: Equatable { case idle, running(since: Int), done }
    enum Action { case start, tick(now: Int), cancel }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch (state, action) {
            case (.idle, .start):
                state = .running(since: 0)
                return .run { _ in await clock.start() }
            case let (.running(since), .tick(now)):
                guard now - since >= limit else { return .none }
                state = .done
                return .run { _ in await clock.stop() }
            case (.running, .cancel):
                state = .idle
                return .run { _ in await clock.stop() }
            case (.done, .start):
                state = .running(since: 0)
                return .run { _ in await clock.start() }
            default:
                return .none
            }
        }
    }
}

The same Timer as a matrix. The default: is gone; its five cases are IGNORE cells you can see, and the two static transitions need no code at all:

extension Timer {
    static let TABLE = Table(
        machine: "Timer",
        states: ["Idle", "Running", "Done"],
        actions: ["Start", "Tick", "Cancel"],
        cells: [
            [.handle,                                         .ignore, .ignore],
            [.ignore,                                         .handle, .go(target: "Idle", effects: ["StopClock"])],
            [.go(target: "Running", effects: ["StartClock"]), .ignore, .ignore],
        ],
        initial: "Idle"
    )
}

From tabular-center-swift/examples/Sources/Timer/Timer.tb.swift.

The two computed cells, and the effects that replace the .run closures:

    struct Impl: Cells {
        func idleStart(_ ctx: Ctx) -> Step<S, F> {
            .go(.running(since: 0), effects: [.startClock])
        }

        func runningTick(_ ctx: Ctx, _ state: Running, _ action: Tick) -> Step<S, F> {
            if action.now - state.since >= ctx.limit {
                return .go(.done, effects: [.stopClock(reason: .elapsed)])
            }
            return .stay(effects: [])
        }

        func startClock(_ ctx: Ctx) -> A? {
            ctx.log.append("start")
            return nil
        }

        func stopClock(_ ctx: Ctx, _ reason: Reason) -> A? {
            ctx.log.append("stop:\(reason)")
            return nil
        }
    }

From tabular-center-swift/examples/Sources/Timer/Timer.swift.

@ObservableState maps to ObservableStore, a @MainActor store SwiftUI can observe. TestStore’s role is shared: exhaustive behaviour tests stay yours, and TabularCenterTesting’s .tbl fixtures pin the matrix itself, one reviewable line per row. See Swift for what is available today and what waits on the @Machine macro.