Your first document model
In this lesson you'll build a minimal Todo document model from scratch. You'll define the schema in Studio, let codegen scaffold the types, and write the reducer that drives every state transition.
You'll need:
- Node.js ≥ 24
- The
phCLI (npm i -g @powerhousedao/ph-cmdor see Installation)
Set up the project
From an empty directory:
ph init todo-app
cd todo-app
pnpm install
ph vetra
ph vetra launches Vetra Studio at http://localhost:3000 and starts the file watchers. Leave it running in its own terminal throughout this lesson.
Define the schema in Vetra Studio
Open the browser and go to http://localhost:3000. You'll see two drives — open the vetra-<hash> drive (the source drive for your package).
Click Document Model to create a new model specification. Give it:
- Name:
Todo(PascalCase, no spaces) - Document type:
powerhouse/todo
In the GraphQL editor, author the state schema:
type TodoState {
items: [TodoItem!]!
}
type TodoItem {
id: OID!
title: String!
done: Boolean!
}
Add a module called todos, then add three operations: ADD_TODO, COMPLETE_TODO, and REMOVE_TODO. Vetra Studio generates the input types automatically. Once the schema is valid, codegen runs and emits files into your project.
Understand what codegen produces
After codegen finishes, your project contains:
document-models/todo/
├── gen/ # auto-generated — never edit
│ ├── actions.ts # action type unions
│ ├── creators.ts # typed action creators
│ ├── types.ts # TypeScript interfaces
│ └── reducer.ts # reducer dispatcher
├── src/
│ └── reducers/
│ └── todos.ts # yours to implement
├── schema.graphql
└── todo.json
Files inside gen/ are regenerated every time the schema changes — never edit them. Your work lives entirely in src/reducers/.
Write the reducer
Open document-models/todo/src/reducers/todos.ts. Codegen scaffolds a stub that throws "not implemented" for each operation. Replace those with real logic:
import type { TodoTodosOperations } from "todo-app/document-models/todo";
export const todoTodosOperations: TodoTodosOperations = {
addTodoOperation(state, action) {
state.items.push({
id: action.input.id,
title: action.input.title,
done: false,
});
},
completeTodoOperation(state, action) {
const item = state.items.find((t) => t.id === action.input.id);
if (item) item.done = true;
},
removeTodoOperation(state, action) {
state.items = state.items.filter((t) => t.id !== action.input.id);
},
};
wraps reducers in Mutative, so you write direct mutations — no need to return a new object. The framework produces an immutable snapshot from each mutation.
The state argument the operation receives is already the scoped state (global or local). From outside the reducer, you access items as doc.state.global.items.
Test it
Because reducers are pure (deterministic, no I/O), you can test them without a server, a database, or a running Vetra instance:
import { utils, reducer } from "todo-app/document-models/todo";
import { addTodo, completeTodo } from "todo-app/document-models/todo";
import { describe, it, expect } from "vitest";
describe("todo reducer", () => {
it("adds and completes a todo", () => {
let doc = utils.createDocument();
doc = reducer(doc, addTodo({ id: "t-1", title: "Write the tutorial" }));
doc = reducer(doc, addTodo({ id: "t-2", title: "Review it" }));
doc = reducer(doc, completeTodo({ id: "t-1" }));
expect(doc.state.global.items).toHaveLength(2);
expect(doc.state.global.items[0].done).toBe(true);
expect(doc.state.global.items[1].done).toBe(false);
});
});
Run it with pnpm test. The same three operations can be replayed on any peer to reconstruct exactly this state.