order-state-machine
v0.0.1
Published
A generic, fully-typed state machine for food delivery, logistics, ride-hailing, e-commerce, or any order lifecycle. Zero runtime dependencies.
Maintainers
Readme
order-state-machine
A generic, fully-typed state machine for food delivery, logistics, ride-hailing, e-commerce, or any order lifecycle. Bring your own states and events, or use the built-in delivery defaults. Zero runtime dependencies.
Installation
npm install order-state-machine
# or
yarn add order-state-machineTwo ways to use it
Option A: Built-in delivery defaults (zero config)
import { DeliveryStateMachine } from "order-state-machine";
const order = new DeliveryStateMachine({ orderId: "ord_001" });
await order.dispatch("CONFIRM");
await order.dispatch("START_PREPARING");
await order.dispatch("MARK_READY");
await order.dispatch("PICKUP");
await order.dispatch("START_TRANSIT");
await order.dispatch("DELIVER");
console.log(order.state); // "DELIVERED"
console.log(order.isTerminal()); // trueOption B: Fully generic — your own states and events
import { StateMachine } from "order-state-machine";
type RideState = "REQUESTED" | "ACCEPTED" | "IN_RIDE" | "COMPLETED" | "CANCELLED";
type RideEvent = "ACCEPT" | "START_RIDE" | "END_RIDE" | "CANCEL";
const ride = new StateMachine<RideState, RideEvent>({
orderId: "ride_42",
initialState: "REQUESTED",
transitions: {
REQUESTED: { ACCEPT: "ACCEPTED", CANCEL: "CANCELLED" },
ACCEPTED: { START_RIDE: "IN_RIDE", CANCEL: "CANCELLED" },
IN_RIDE: { END_RIDE: "COMPLETED" },
},
terminalStates: ["COMPLETED", "CANCELLED"],
activeStates: ["ACCEPTED", "IN_RIDE"],
});
await ride.dispatch("ACCEPT");
await ride.dispatch("START_RIDE");
await ride.dispatch("END_RIDE");
console.log(ride.state); // "COMPLETED"Built-in Delivery Lifecycle
PENDING
├─ CONFIRM ──────────► CONFIRMED ──START_PREPARING──► PREPARING ──MARK_READY──► READY_FOR_PICKUP
│ │
└─ CANCEL ──► CANCELLED ──REFUND──► REFUNDED PICKUP
│
PICKED_UP
│
START_TRANSIT
│
IN_TRANSIT
├─ DELIVER ──► DELIVERED ──REFUND──► REFUNDED
└─ FAIL ──► FAILED ──REFUND──► REFUNDEDAny state up to READY_FOR_PICKUP can also be cancelled.
Hooks
Run side effects without coupling them to your business logic.
const order = new DeliveryStateMachine({
orderId: "ord_001",
hooks: {
onTransition: async ({ from, to, event, orderId, metadata }) => {
await auditLog.record({ orderId, from, to, event });
},
onEnter: {
DELIVERED: async ({ orderId }) => {
await notifications.send(orderId, "Your order has been delivered!");
},
IN_TRANSIT: async ({ orderId, metadata }) => {
await sms.send(orderId, `Rider on the way. ETA: ${metadata?.eta} mins`);
},
},
onExit: {
PREPARING: async () => {
await kitchenDisplay.markOrderReady();
},
},
},
});Hooks fire in order: onExit (old state) → onEnter (new state) → onTransition (global).
Metadata
Pass contextual data with any event. Stored in history and forwarded to all hooks.
await order.dispatch("CONFIRM", { confirmedBy: "ops-admin", channel: "web" });
await order.dispatch("PICKUP", { riderId: "rider_42", vehicleType: "motorcycle" });
await order.dispatch("DELIVER", { signedBy: "Jane D.", photoUrl: "https://cdn.example.com/pod.jpg" });Guard Checks
Use can() and availableEvents() to drive UI state without coupling your components to transition logic.
order.can("DELIVER"); // false — not in transit yet
order.availableEvents(); // ["START_PREPARING", "CANCEL"]
order.isActive(); // true
order.isTerminal(); // falseTransition History
Every transition is recorded automatically.
console.log(order.history);
// [
// { from: "PENDING", to: "CONFIRMED", event: "CONFIRM", timestamp: Date, metadata: {...} },
// { from: "CONFIRMED", to: "PREPARING", event: "START_PREPARING", timestamp: Date },
// ...
// ]Persistence
Save to a database or Redis and restore later without re-running hooks.
// Save
const snapshot = order.toJSON();
await db.orders.update({ id: order.orderId }, { snapshot: JSON.stringify(snapshot) });
// Restore — DeliveryStateMachine
const saved = await db.orders.findById("ord_001");
const order = DeliveryStateMachine.fromDeliverySnapshot(JSON.parse(saved.snapshot));
// Restore — Generic StateMachine
const order = StateMachine.fromSnapshot<RideState, RideEvent>(snapshot, {
transitions: RIDE_TRANSITIONS,
terminalStates: ["COMPLETED", "CANCELLED"],
activeStates: ["ACCEPTED", "IN_RIDE"],
});Custom Transitions
Override or extend the default delivery map. Useful for ghost kitchens, self-pickup, or 3PL integrations.
// Ghost kitchen: customer collects from counter, no rider step
const order = new DeliveryStateMachine({
orderId: "ghost_001",
transitions: {
PREPARING: { MARK_READY: "DELIVERED" },
},
});Error Handling
import { InvalidTransitionError } from "order-state-machine";
try {
await order.dispatch("DELIVER"); // order is still PENDING
} catch (err) {
if (err instanceof InvalidTransitionError) {
console.error(err.message);
// "Invalid transition: event "DELIVER" is not allowed from state "PENDING""
console.log(err.from); // "PENDING"
console.log(err.event); // "DELIVER"
}
}API Reference
new StateMachine<S, E>(config)
| Option | Type | Required | Description |
|---|---|---|---|
| orderId | string | yes | Unique identifier for this entity |
| initialState | S | yes | Starting state |
| transitions | TransitionMap<S, E> | yes | Full transition map |
| hooks | StateMachineHooks<S, E> | no | Lifecycle hooks |
| terminalStates | S[] | no | States with no further transitions |
| activeStates | S[] | no | States representing active in-progress lifecycle |
new DeliveryStateMachine(config)
Same as above but initialState defaults to "PENDING" and transitions defaults to the built-in delivery map. All options are optional.
Instance Methods
| Method | Returns | Description |
|---|---|---|
| dispatch(event, metadata?) | Promise<S> | Trigger a transition |
| can(event) | boolean | Check if event is valid from current state |
| availableEvents() | E[] | All valid events from current state |
| isTerminal() | boolean | True if no further transitions possible |
| isActive() | boolean | True if in a configured active state |
| toJSON() | object | Serialize for persistence |
| StateMachine.fromSnapshot(snap, config) | StateMachine<S,E> | Restore from snapshot |
| DeliveryStateMachine.fromDeliverySnapshot(snap, config?) | DeliveryStateMachine | Restore delivery machine |
Built-in Types
States
PENDING CONFIRMED PREPARING READY_FOR_PICKUP PICKED_UP IN_TRANSIT DELIVERED CANCELLED FAILED REFUNDED
Events
CONFIRM START_PREPARING MARK_READY ASSIGN_RIDER PICKUP START_TRANSIT DELIVER CANCEL FAIL REFUND
Domain Examples
| Domain | Example States | Example Events |
|---|---|---|
| Food delivery | PENDING, PREPARING, IN_TRANSIT, DELIVERED | CONFIRM, PICKUP, DELIVER |
| Ride-hailing | REQUESTED, ACCEPTED, IN_RIDE, COMPLETED | ACCEPT, START_RIDE, END_RIDE |
| E-commerce | CART, CHECKOUT, PAID, SHIPPED, RETURNED | PAY, SHIP, RETURN |
| Laundry SaaS | DROPPED_OFF, WASHING, DRYING, READY | WASH, DRY, COLLECT |
| Ghost kitchen | Override PREPARING > DELIVERED directly | same delivery events |
License
MIT — Badmus Sulaimon
