Edit this page

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.
  • $.ensure fails the request with a declared error, and that error carries typed details.
  • 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.json

Add .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.