Tutorial: a team task tracker
You'll build an API where teams (orgs) track tasks, from the data model up to background notifications. The tutorial covers:
- entities and keys
- org roles
- queries with indexes
- counters and plan limits
- errors with details
- events and email
- a daily digest
1. The app and its roles
import { app, err, t } from "@aleph-lang/sdk";
const a = app("tracker", {
roles: ["owner", "member"],
profile: { email: t.string().max(200) },
});
const orgId = t.string().min(1).max(64);roles lists the org roles routes may require, so a typo in a route's roles is a validation error instead of a route nobody can call. profile declares the user fields the app reads from the caller.
2. Data
const Org = a.entity("Org", {
key: ["orgId"],
fields: { orgId, name: t.string().min(1).max(80), plan: t.enum("free", "pro").default("free") },
});
const Task = a.entity("Task", {
key: ["orgId", "taskId"],
fields: {
orgId,
taskId: t.id().generated(),
title: t.string().min(1).max(200),
assignee: t.string().max(64).optional(),
done: t.bool().default(false),
dueAt: t.timestamp().optional(),
},
indexes: { byAssignee: ["orgId", "assignee", "dueAt"] },
});
const openTasks = a.counter("openTasks", {
of: Task,
groupBy: ["orgId"],
where: (task) => task.done.not(),
});The key ["orgId", "taskId"] stores each org's tasks as one ordered range, so listing them is a single bounded scan. The counter is maintained on every write to Task and read in O(1).
3. Creating tasks, with a plan limit
a.route("POST /orgs/:orgId/tasks")
.params({ orgId })
.member("orgId")
.body({ title: t.string().min(1).max(200), assignee: t.string().max(64).optional() })
.errors({
planLimit: err.forbidden("The free plan allows 100 open tasks", { open: t.int() }),
noOrg: err.notFound("Create the org first"),
})
.status(201)
.handle(($, { params, body, errors }) => {
const org = Org.getOrFail({ orgId: params.orgId }, errors.noOrg);
const open = openTasks.get({ orgId: params.orgId });
$.ensure(org.plan.eq("pro").or(open.lt(100)), errors.planLimit, { open });
return Task.insert({ orgId: params.orgId, title: body.title, assignee: body.assignee });
});.member("orgId")admits only members of the org named by the path parameter.$.ensurefails the request with a declared error, and that error carries typeddetails.- The whole handler is one transaction: the check and the insert can't interleave with another request.
4. Querying
a.route("GET /orgs/:orgId/tasks/mine")
.params({ orgId })
.member("orgId")
.handle(($, { params }) =>
Task.query({ index: "byAssignee", prefix: [params.orgId, $.caller], limit: 50 }),
);Queries always name a key or index prefix and a limit with a static bound. The bytes the query can read follow from the limit and the row size, so the route's worst case is known.
5. Completing tasks, and notifying in the background
const TaskDone = a.event("taskDone", { payload: { orgId, taskId: t.id(), by: t.string().max(64) } });
const mail = a.integration("mail", {
provider: "email",
operations: { send: { mode: "effect", input: { to: t.string().max(200), subject: t.string().max(200) } } },
});
a.route("POST /orgs/:orgId/tasks/:taskId/done")
.params({ orgId, taskId: t.id() })
.member("orgId")
.errors({ missing: err.notFound() })
.handle(($, { params, errors }) => {
const task = Task.getOrFail(params, errors.missing).update({ done: true });
TaskDone.emit({ orgId: params.orgId, taskId: params.taskId, by: $.caller });
return task;
});
a.onEvent("notifyOwner", TaskDone, ($, { payload }) => {
const owner = a.users.get(payload.by);
$.when(owner.email.isSet(), () => {
mail.send("send", { to: owner.email.ifNull(""), subject: "A task was completed" });
});
});The event is written to an outbox in the same commit as the update. If the request fails, nothing is sent; if it succeeds, the handler runs once, as its own bounded job, with retries.
6. A daily digest
a.every("dailyDigest", "weekdays 09:00", ($, { payload }) => {
$.forEach(payload.batch, (org) => {
$.log("open tasks", openTasks.get({ orgId: org.orgId }));
});
}, { tz: "Europe/Berlin", scan: { entity: Org, batch: 100 } });A scheduled job that walks every org in batches of 100. Each batch is a separately bounded run, so the job scales with the number of orgs without any single run being unbounded.
7. Check, test, serve
npx aleph check app.ts
npx aleph dev app.ts --watch --seed seed.jsonAdd .example(...) to the routes you care about, and npx aleph test app.ts keeps them honest. Continue with Concepts, or read Durable workflows for multi-step processes.