Example: a todo list
Example: a todo list
This page describes how Mesh will work, not how it works today. It is a live spec of the developer experience, written before the code. Mesh is not released: nothing here can be installed or run yet, and any detail may change.
This is a complete walk-through of a small project: two resources, one relationship, a validation, a policy, a calculation and an aggregate, and a plain TypeScript script that calls the actions. There is no HTTP server. Mesh serves a command line, a daemon, a worker or a web app equally; the example is a script because that is the shortest path through every idea.
The project is todo-app, the one from Installation. It is called a todo list because a list of lists is the smallest thing that needs a relationship, an ownership rule and a derived value.
What the two resources are
list— a named list with an owner. It has many todos, and it carries a count of them.todo— one item in a list. It belongs to a list, has a title, a done flag and two timestamps.
The vocabulary follows Ash’s DSL in kebab-case with the trailing ? dropped (ADR-0034), and every example is in MX concise syntax, the indentation-based form (ADR-0041). The tags are listed in the Resource file reference.
resources/list.mx
resource="list" table="lists" domain="todos"
attributes
uuid-primary-key="id"
attribute="name" type="string" allow-nil=false
attribute="ownerId" type="uuid" allow-nil=false
create-timestamp="insertedAt"
relationships
has-many="todos" destination="todo"
actions defaults=["read", "destroy"]
create="create" accept=["name"]
change=({ list, actor }) => { list.ownerId = actor.id }
policies
policy=action_type("create")
authorize-if=() => true
policy=action_type(["read", "destroy"])
authorize-if=({ list, actor }) => list.ownerId === actor.id
aggregates
count="todoCount" relationship-path="todos"
Reading it top to bottom:
resource="list"names the resource.table="lists"names the database table.domain="todos"groups the generated files intogenerated/todos/.uuid-primary-key="id"declares the primary key: a UUID Mesh generates, which the caller never sends and never accepts.allow-nil=falsesays the column is not nullable. Attribute types arestring,integer,float,boolean,atom,uuidanddatetime; a uuid arrives in TypeScript as astringand a datetime as aDate.create-timestamp="insertedAt"declares an attribute the database fills on insert and the caller never sets.actions defaults=["read", "destroy"]asks for areadaction and adestroyaction, each named after its type, without declaring them.create="create" accept=["name"]declares a create action namedcreate, which accepts onlyname.ownerIdis filled by the change, not by the caller:changereceives one object argument, destructured here as{ list, actor }, and setslist.ownerIdfrom the actor. The meaning of the record on a create is still open, as noted below.- The policies block declares a check for each action.
action_typeaccepts one name or a list of names, and a list means any of them, soaction_type(["read", "destroy"])gives one policy for both actions. That is different from a list of checks,policy=[a, b], where all conditions in that list must hold. With the policies extension enabled, an action with no matching policy is forbidden.authorize-ifreturns a boolean; the expression is the record and the actor, and nothing else. count="todoCount" relationship-path="todos"declares an aggregate: the number of related todos.
table sits on the resource tag here. In Ash it lives in the data layer’s own section. Mesh’s recommendation is to keep it on resource, because a resource file does not name its adapter (a data adapter is replaceable) and both v1 SQL adapters use the same table name. The vocabulary mapping, exception X1, records the options.
Ash’s belongs_to creates its foreign-key attribute as <name>_id. The roadmap uses listId, matching the fixture. Which name Mesh generates is settled when relationships are built. The vocabulary mapping, row D12.
resources/todo.mx
resource="todo" table="todos" domain="todos"
attributes
uuid-primary-key="id"
attribute="title" type="string" allow-nil=false
attribute="done" type="boolean" allow-nil=false default=false
create-timestamp="insertedAt"
update-timestamp="updatedAt"
relationships
belongs-to="list" destination="list"
actions defaults=["read", "destroy"]
create="create" accept=["title", "listId"]
validate=({ todo }) => todo.title.length > 0 message="title must not be empty"
update="complete"
change=({ todo }) => { todo.done = true }
update="rename" accept=["title"]
read="pending"
filter=({ todo }) => todo.done === false
sort=["insertedAt"]
policies
policy=action_type(["create", "read", "update", "destroy"])
authorize-if=({ todo, actor }) => todo.list.ownerId === actor.id
calculations
calculate="label" type="string"
value({ todo }) {
return (todo.done ? "[x] " : "[ ] ") + todo.title
}
The sort child tag is the current contract form, not a settled v1 design. Ash sorts a read through a prepare build(sort: ...) call; the vocabulary mapping, D17/G1, leaves Mesh’s form to M3/M5.
Reading it:
belongs-to="list" destination="list"declares the relationship, andlistIdis the foreign-key attribute it adds. ThetodoCountaggregate onlistand this relationship are the two sides.createacceptstitleandlistId. Its validation rejects an empty title and carries themessagethe caller sees.update="complete"has noaccept, so it changes nothing the caller sends; its change setsdone = true. Because that change reads no stored value, it folds into theUPDATEstatement and the action stays atomic: one statement, no read first, so two concurrent callers cannot both act on a stale row.update="rename" accept=["title"]acceptstitle.read="pending"filters to undone todos and sorts them oldest first. Afiltermust be translatable to SQL, so only the registered functions may appear in it.- The policy reads
todo.list.ownerId. On an atomic update it becomes part of the statement’s filter; it does not require loading the relationship into the returned record. The create case remains open below. calculate="label"is a derived value.
What an expression’s record parameter holds on a create is not stated. The change that sets list.ownerId reads the attributes already cast from the input; whether a validate on a create sees the same values, and how it reaches the incoming value of an accepted attribute on an update, has no designed form (ADR-0017 and the action lifecycle page both leave it open).
A create policy that reads a related record, as todo.list.ownerId does, has no stated place in the lifecycle. The lifecycle fixes where a policy check runs for reads and writes, and leaves creates undecided. The action lifecycle, the table “Which phase runs each kind of rule”.
The exact semantics of a Mesh expression are still to be ruled, and they decide what todo.done === false means where SQL and JavaScript disagree (nulls, string ordering, division). Mesh’s plan is one expression tree evaluated both ways. ADR-0012 records it.
The label calculation
label is opaque: it is not translatable to SQL. The registry of functions that can be converted holds attribute and parameter references, literals, comparison and boolean operators, numeric + and -, string length, and assignment to an attribute. A conditional operator on a string and a string concatenation are not in it.
An opaque calculation runs in memory, after the rows are loaded. Two things follow, and both are worth knowing before you write one:
- It cannot appear in a
filter, asortor a caller’s filter. The build fails there. - You have to ask for it. A calculation is computed only when the read names it in
load, and reading one that was not loaded is a type error.
src/actor.ts
The actor is your application’s idea of a caller. Mesh does not decide what one is; you register the type once, and every scope.actor is typed from it.
import "@mesh/runtime";
declare module "@mesh/runtime" {
interface Register {
actor: { id: string };
}
}
export const alice = { id: "00000000-0000-4000-8000-000000000001" };
export const bob = { id: "00000000-0000-4000-8000-000000000002" };
The import and exports make this file a module, so the declaration augments Mesh’s existing Register interface. The example actors use UUIDs because list.ownerId has type uuid.
An application with anonymous callers registers User | null. A tenant is not part of the actor: where multitenancy lives is open, and until it is decided a tenant travels in scope.context.
src/main.ts
The whole program. It connects, creates a list as one actor, adds todos, completes one, prints the pending todos, then shows what happens when somebody else tries.
import { InvalidInputError, NotFoundError } from "@mesh/runtime";
import {
connect,
disconnect,
createList,
createTodo,
completeTodo,
pendingTodo,
} from "../generated";
import { alice, bob } from "./actor";
await connect({ file: "todo.db" });
const list = await createList({ name: "Groceries" }, { actor: alice });
const milk = await createTodo(
{ title: "Buy milk", listId: list.id },
{ actor: alice },
);
await createTodo({ title: "Buy bread", listId: list.id }, { actor: alice });
await createTodo({ title: "Buy coffee", listId: list.id }, { actor: alice });
await completeTodo({ id: milk.id }, { actor: alice });
for (const todo of await pendingTodo({ load: ["label"] }, { actor: alice })) {
console.log(todo.label);
}
try {
await completeTodo({ id: milk.id }, { actor: bob });
} catch (error) {
if (!(error instanceof NotFoundError)) throw error;
console.log(error.code); // "not_found"
}
try {
await createTodo({ title: "", listId: list.id }, { actor: alice });
} catch (error) {
if (!(error instanceof InvalidInputError)) throw error;
console.log(error.issues.map((issue) => issue.message).join("; "));
}
await disconnect();
Output:
[ ] Buy bread
[ ] Buy coffee
not_found
title must not be empty
What to notice:
createListtakes onlyname.ownerIdis not accepted, so a caller cannot become the owner of somebody else’s list by sending it.completeTodo({ id })takes an id and nothing else. The change setsdone; the caller does not senddone.load: ["label"]is what makestodo.labeltyped on the returned array. Without it the property is a type error.- Bob gets
NotFoundError, notForbiddenError. The policy reads the stored record, so on an atomic update it folds into the statement as a filter, and a row the caller may not change is reported as not found. That is Mesh’s choice: Ash compiles the same check into the statement but as an expression that raises, and reports forbidden. The asymmetry is the working assumption for v1 and the operator may overrule it.
What a denied write reports on an atomic action. The rule used above is that a record-reading policy folds into the statement and a row the caller may not change reports not found. Ash reports forbidden. ADR-0046 records the choice and the alternative; canCompleteTodo is the way to ask why either way.
- The empty title is an
InvalidInputErrorcarrying themessagefrom thevalidatetag.
How a run-time error carries the position of the .mx tag that failed. Mesh’s working assumption is that the generated code carries the file, line and column as data, which is what InvalidInputError.issues[].source holds above. ADR-0039 records that choice against using source maps.
The generated functions
One exported function per action, named after the action and the resource in PascalCase. The table shows action functions, not their authorization helpers; every action also has a can function, such as canCompleteTodo(input, scope), returning { allowed: boolean; breakdown }.
The return types below are the values the promises resolve to:
| Resource file | Action | Function | Returns |
|---|---|---|---|
list.mx |
read (default) |
readList(input, scope) |
List[] |
list.mx |
create |
createList(input, scope) |
List |
list.mx |
destroy (default) |
destroyList(input, scope) |
void |
todo.mx |
read (default) |
readTodo(input, scope) |
Todo[] |
todo.mx |
read="pending" |
pendingTodo(input, scope) |
Todo[] |
todo.mx |
create |
createTodo(input, scope) |
Todo |
todo.mx |
update="complete" |
completeTodo(input, scope) |
Todo |
todo.mx |
update="rename" |
renameTodo(input, scope) |
Todo |
todo.mx |
destroy (default) |
destroyTodo(input, scope) |
void |
The Todo type is derived from the resource file:
export type Todo = {
id: string;
title: string;
done: boolean;
listId: string;
insertedAt: Date;
updatedAt: Date;
};
Calling actions has every signature, the filter form and the error classes.
Building and running it
bunx mesh build # writes generated/
bunx mesh db push # creates the SQLite file and the two tables
bun run src/main.ts
Next
- Calling actions — signatures, the scope, filters,
load, errors andcan. - Usage — the loop once the first resource exists.
- Configuration — the file that points Mesh at all of this.