5-Minute Tutorial

Build a complete todo application with records, events, actions, validation, state machines, and tests -- all in one module.

Prerequisites: Installation, Hello World. What you'll learn: How to model a real entity lifecycle with Grove's core features.

What We're Building

A todo application where items can be created, renamed, started, completed, and deleted. Items follow a state machine: open -> in_progress -> done.

Step 1: Define the Record

Create the project structure:

mkdir -p my-todo/todo

Create my-todo/grove.toml:

project_id = "my-todo"

Create my-todo/todo/module.grove and start with the record:

module todo

enum TodoStatus { open, in_progress, done }

record {
  kind "todo"
  title: String
  notes: String?
  status: TodoStatus = TodoStatus.open
  priority: Int = 1
  assignee_id: Id? @lookup(user)
}

Key concepts:

Step 2: Add Validation and Invariants

Add constraints to the record and a reusable validator:

module todo

enum TodoStatus { open, in_progress, done }

record {
  kind "todo"
  title: String
  notes: String?
  status: TodoStatus = TodoStatus.open
  priority: Int = 1
  assignee_id: Id? @lookup(user)

  invariant title_present {
    record.title != ""
  }

  state status {
    TodoStatus.open -> TodoStatus.in_progress on started
    TodoStatus.in_progress -> TodoStatus.done on completed
  }
}

validate title_not_empty {
  params.title != ""
}

Step 3: Define Events

Events are immutable facts. Each one carries only the data that changed:

event created {
  title: String
  notes: String?
  status: TodoStatus = TodoStatus.open
  priority: Int
  assignee_id: Id?
}

event renamed {
  title: String
}

event started {
  status: TodoStatus = TodoStatus.in_progress
}

event completed {
  status: TodoStatus = TodoStatus.done
}

@delete
event deleted {}

Step 4: Define Actions

Actions are the entry points for state changes. They validate input and emit events:

@create
action create {
  title: String
  notes: String?
  priority: Int = 1
  assignee_id: Id?
  validate title_not_empty
  validate priority_positive {
    params.priority > 0
  }
  apply {
    emit created {
      title: params.title
      notes: params.notes
      priority: params.priority
      assignee_id: params.assignee_id
    }
  }
}

action rename {
  title: String
  validate title_not_empty
  apply {
    emit renamed {
      title: params.title
    }
  }
}

action start_todo {
  apply {
    emit started {}
  }
}

action complete_todo {
  apply {
    emit completed {}
  }
}

action delete {
  apply {
    emit deleted {}
  }
}

Step 5: Check It

manzano check my-todo/todo
OK

The checker validates everything: types, state machine transitions, event-action relationships, validation references, and invariant expressions.

Step 6: Write Tests

Create my-todo/todo/todo_test.grove:

module todo_test

test todo_lifecycle {
  run create {
    request {
      name: "create",
      params: {
        title: "buy milk",
        notes: "2% organic",
        priority: 2,
        assignee_id: "user-1"
      },
      meta: {
        id: "todo-test-1",
        by: "tester",
        timestamp: 1700000000000,
        action_id: "cmd-test-create"
      }
    }
    expect ok
    assert record.status == TodoStatus.open
    assert events.len == 1
    assert events[0].name == "created"
  }

  run start {
    request {
      name: "start_todo",
      params: {},
      meta: {
        id: "todo-test-1",
        by: "tester",
        timestamp: 1700000000100,
        action_id: "cmd-test-start"
      }
    }
    expect ok
    assert record.status == TodoStatus.in_progress
    assert events[0].name == "started"
  }

  run complete {
    request {
      name: "complete_todo",
      params: {},
      meta: {
        id: "todo-test-1",
        by: "tester",
        timestamp: 1700000000200,
        action_id: "cmd-test-complete"
      }
    }
    expect ok
    assert record.status == TodoStatus.done
    assert events[0].name == "completed"
  }
}

Run the tests:

manzano test my-todo/todo

Each run block sends a request, checks the outcome (expect ok or expect error), and asserts on the resulting record state and events.

Step 7: Run with a Database

For persistent storage, use run-db with a SQLite backend:

manzano run-db my-todo/todo \
  --request my-todo/todo/request_create.json \
  --backend sqlite \
  --db /tmp/my-todo.sqlite

The runtime creates the necessary tables, stores events, and maintains the current record state.

The Complete Module

Here's the full module.grove -- 96 lines that define a complete entity lifecycle:

module todo

enum TodoStatus { open, in_progress, done }

record {
  kind "todo"
  title: String
  notes: String?
  status: TodoStatus = TodoStatus.open
  priority: Int = 1
  assignee_id: Id? @lookup(user)

  invariant title_present {
    record.title != ""
  }

  state status {
    TodoStatus.open -> TodoStatus.in_progress on started
    TodoStatus.in_progress -> TodoStatus.done on completed
  }
}

validate title_not_empty {
  params.title != ""
}

event created {
  title: String
  notes: String?
  status: TodoStatus = TodoStatus.open
  priority: Int
  assignee_id: Id?
}

event renamed {
  title: String
}

event started {
  status: TodoStatus = TodoStatus.in_progress
}

event completed {
  status: TodoStatus = TodoStatus.done
}

@delete
event deleted {}

@create
action create {
  title: String
  notes: String?
  priority: Int = 1
  assignee_id: Id?
  validate title_not_empty
  validate priority_positive {
    params.priority > 0
  }
  apply {
    emit created {
      title: params.title
      notes: params.notes
      priority: params.priority
      assignee_id: params.assignee_id
    }
  }
}

action rename {
  title: String
  validate title_not_empty
  apply {
    emit renamed {
      title: params.title
    }
  }
}

action start_todo {
  apply {
    emit started {}
  }
}

action complete_todo {
  apply {
    emit completed {}
  }
}

action delete {
  apply {
    emit deleted {}
  }
}

See Also