Browser Persistence

Multi-tab shared FSM

Open this page in two browser tabs. On Scala.js the demo uses IndexedDB plus BroadcastChannel so both tabs share one FSM instance (docs-order-1). Unique aliases (SharedFSMRuntime.lookup(Alias.of[S].campaign(id), …)) resolve against the same IndexedDB aliases store. A live mermoid state diagram (via specular-mermoid) highlights the current state — click the next node, or use Pay / Ship.

JVM DocSpec SSR uses an in-memory store for the first paint; the live remount in the browser is the IndexedDB path (mechanoid-web) with interactive diagram selection and reflow.

import mechanoid.*
import mechanoid.web.*
import ascent.squawk.Squawk

enum OrderState derives Finite, JsonCodec:
  case Pending, Paid, Shipped

enum OrderEvent derives Finite, JsonCodec:
  case Pay, Ship

val machine = Machine(
  assembly[OrderState, OrderEvent](
    Pending via Pay to Paid,
    Paid via Ship to Shipped,
  )
)

for
  shared <- SharedFSMRuntime.stores[OrderState, OrderEvent]("mechanoid-docs")
  state  <- sq[OrderState](Pending)
  fsm    <- SharedFSMRuntime.start(
              "docs-order-1",
              machine,
              Pending,
              shared,
              onState = state.set,
            )
  refresh =
    fsm.currentState.flatMap(state.set) *>
      shared.sync.publish("docs-order-1")
  panel <- OrderDemoUi.panel(
    state = state,
    note  = ...,
    onPay = fsm.send(Pay).ignore *> refresh,
    onShip = fsm.send(Ship).ignore *> refresh,
    onSelectNode = id =>
      state.get.flatMap { cur =>
        eventTo(cur, id) match
          case Some(ev) => fsm.send(ev).ignore *> refresh
          case None     => ZIO.unit
      },
  )
yield panel

JVM preview (in-memory). Open the published site in two tabs for IndexedDB sync + live remount.

viewport 560px
PayShip

Now: Pending · click the next state on the diagram, or use the buttons

Multi-role publishing flow

Same IndexedDB + BroadcastChannel stack for the hierarchical Document Workflow machine (Draft → review → approval → Published). Role panels (Writer, Reviewer, Approver, Ops) only enable actions that are legal in the current state — illegal buttons stay disabled.

Reset to Draft is a normal DocumentEvent.Reset transition from every non-Draft leaf (gated like the other role actions). Peers reconstruct from IndexedDB when BroadcastChannel notifies.

import mechanoid.*
import mechanoid.web.*

sealed trait DocumentState derives Finite, JsonCodec
case object Draft extends DocumentState
sealed trait InReview extends DocumentState derives Finite, JsonCodec
case object PendingReview extends InReview
case object UnderReview extends InReview
case object ChangesRequested extends InReview
sealed trait Approval extends DocumentState derives Finite, JsonCodec
case object PendingApproval extends Approval
case object Rejected extends Approval
case object Published extends DocumentState
case object Archived extends DocumentState
case object Cancelled extends DocumentState

enum DocumentEvent derives Finite, JsonCodec:
  case SubmitForReview, AssignReviewer, RequestChanges, ResubmitAfterChanges
  case ApproveReview, ApprovePublication, RejectPublication, Archive
  case CancelReview, Abandon, Reset

val machine = Machine(
  assembly[DocumentState, DocumentEvent](
    all[InReview] via CancelReview to Draft,
    all[InReview] via Reset to Draft,
    all[Approval] via Abandon to Cancelled,
    all[Approval] via Reset to Draft,
    Published via Reset to Draft,
    Archived via Reset to Draft,
    Cancelled via Reset to Draft,
  ) ++ assembly[DocumentState, DocumentEvent](
    Draft via SubmitForReview to PendingReview,
    PendingReview via AssignReviewer to UnderReview,
    UnderReview via RequestChanges to ChangesRequested,
    UnderReview via ApproveReview to PendingApproval,
    ChangesRequested via ResubmitAfterChanges to PendingReview,
    PendingApproval via ApprovePublication to Published,
    PendingApproval via RejectPublication to Rejected,
    Rejected via SubmitForReview to PendingReview,
    Published via Archive to Archived,
  )
)

def canFire(from: DocumentState, event: DocumentEvent): Boolean =
  MachineGraph.hasEdge(machine, from, event)

// Role buttons: A.disabled(state.map(s => !canFire(s, event)))

for
  shared <- SharedFSMRuntime.stores[DocumentState, DocumentEvent](
              "mechanoid-docs-publish",
              channelName = "mechanoid-publish-sync",
            )
  state  <- sq[DocumentState](Draft)
  fsm    <- SharedFSMRuntime.start(
              "docs-publish-1",
              machine,
              Draft,
              shared,
              onState = state.set,
            )
  refresh =
    fsm.currentState.flatMap(state.set) *>
      shared.sync.publish("docs-publish-1")
  panel <- PublishDemoUi.panel(
    state = state,
    note  = ...,
    send  = ev => fsm.send(ev).ignore *> refresh,
  )
yield panel

JVM preview (in-memory). Role buttons (including Reset) are gated by the machine transition map.

viewport 720px
CancelReviewCancelReviewCancelReviewResetResetResetAbandonAbandonResetResetResetResetResetSubmitForReviewAssignReviewerRequestChangesApproveReviewResubmitAfterChangesApprovePublicationRejectPublicationSubmitForReviewArchive

Document: Draft · gated role actions below (disabled when illegal)

Writer

Author the draft and respond to review

Reviewer

Assign, request changes, or approve the review

Approver

Publication gate

Ops

Archive or reset the document