Skip to main content

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 ph CLI (npm i -g @powerhousedao/ph-cmd or 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:

document-models/todo/src/reducers/todos.ts
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.

QuizWhich of these would break the reducer's purity contract?

Test it

Because reducers are pure (deterministic, no I/O), you can test them without a server, a database, or a running Vetra instance:

document-models/todo/src/tests/todos.test.ts
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.