Edit this page

How Aleph programs work

Aleph looks like TypeScript, but it runs differently, and that difference is where its guarantees come from. This page explains how your code runs, where state lives, what the usage limits count, and what the language deliberately leaves out.

Your code runs once, at build time

When aleph build or aleph check loads app.ts, each handler runs once, with placeholder values. Nothing in it touches real data. Every SDK call records an operation instead of performing it:

a.route("GET /orgs/:orgId/open")
  .params({ orgId: t.string().max(64) })
  .handle((_, { params }) => {
    const tasks = Task.query({ prefix: [params.orgId], limit: 50 }); // records a query
    return tasks.filter((task) => task.done.not());                  // records a filter
  });
  • params.orgId is an expression meaning "the orgId path parameter", not a string.
  • Task.query(...) returns a placeholder for "the rows this query will return", not rows.
  • task.done.not() returns an expression meaning "not done", not a boolean.

The result is a plan: a tree of built-in operations saved in the manifest. On every request, the core interprets that plan. Your JavaScript never runs in production.

Two consequences follow:

  • Plain if and for run at build time. They run once, while the plan is recorded, so they can't depend on request data. A JavaScript loop over a constant simply records its body that many times. Use the $ helpers for anything decided per request.
  • Request data can't be used as a JavaScript value. for (const t of tasks) or if (task.done) throws at build time, because a placeholder has no items and no truth value. The SDK catches most of these mistakes.

Values, not variables

A const in a handler names a value. It's computed once per request, and it can't be reassigned from one pass of a loop to the next. Most loops that update a variable have a direct replacement:

Instead ofWrite
total += x in a looplist.sum((item) => ...)
a counterlist.count((item) => ...)
best so farlist.max(...), list.min(...), or list.sortBy(...).first()
a "found" flaglist.some(...), list.every(...)
building a new listlist.map(...), list.filter(...), list.concat(...)
stopping at the first matchlist.filter(...).first()
state that must outlive the requesta row, written in $.forEach

When each pass depends on the one before (a running balance, walking a chain until it ends), use a loop that carries state, and $.let to bind values the next steps share:

a.route("GET /tasks/:orgId/streak")
  .params({ orgId: t.string().max(64) })
  .handle(($, { params }) => {
    const tasks = Task.query({ prefix: [params.orgId], limit: 30 });
    const end = $.loop(
      { max: 30, state: { i: 0, streak: 0, best: 0 }, while: (s) => s.i.lt(tasks.length) },
      (s) => {
        const done = $.let(tasks.slice(s.i, s.i.plus(1)).first().done.ifNull(false));
        const streak = $.let($.select(done, s.streak.plus(1), 0));
        return { i: s.i.plus(1), streak, best: $.max(s.best, streak) };
      },
    );
    return { longestStreak: end.best };
  });

The loop is counted as max passes, and its state must keep the same type from pass to pass. See Loops that carry state.

Control flow

Branches and loops nest freely: a loop can hold a branch that holds another loop.

$.if(cond).then(...).else(...), $.when(cond, ...)branch
$.select(cond, a, b), $.match(value, { ... })choose a value
$.ensure(cond, error), $.fail(error)stop with a declared error
.map, .filter, .some, .every, .count, .sum, .min, .max, .sortBy, .takeloops that compute (they may read, never write)
$.loop({ max, state, while }, (state) => next), $.let(value)a loop that carries state from pass to pass, and a shared value
$.forEach(list, ...), .updateAll, .deleteAllloops that write
a.route("POST /orgs/:orgId/close-overdue")
  .params({ orgId: t.string().max(64) })
  .handle(($, { params }) => {
    const tasks = Task.query({ prefix: [params.orgId], limit: 40 });
    $.forEach(tasks, (task) => {
      $.when(task.done.not().and(task.dueAt.isSet()), () => {
        task.update({ status: "closed" });
      });
    });
    return { closed: tasks.count((task) => task.done.not()) };
  });

Every loop has a known maximum: a list loop runs over a query's limit, an array input's maximum, or the maximum parts of a .split, and $.loop declares its max. There's no unbounded while and no recursion.

Where state lives

  • Nothing stays in memory between requests. There are no global variables, in-process caches, or sessions. Each request starts from its inputs and the data.
  • Durable state is data you declared:
    • rows of entities (with their indexes)
    • counters, which are kept up to date as rows change
    • a workflow instance's typed state, carried from step to step
    • the payloads of queued jobs and events
    • files in buckets: plans see their metadata, and clients move the bytes through signed URLs
  • A request's inputs are its path parameters, query, body, and headers, plus the authenticated caller ($.caller). A handler's input is its trigger's payload.
  • Time and ids come from the runtime. $.now() gives the request's time, and fields declared t.id().generated() get new ids, so tests can pin both.

Transactions and effects

Every request, job, and workflow step is one serializable transaction. It sees a consistent snapshot, and its writes all commit or none do. A conflicting transaction is re-run automatically.

Effects (emails, events, jobs, channel messages) are recorded during the run and released only after the commit, exactly once. A request that fails halfway sends nothing. See Transactions and schema changes.

Usage and limits

Before an app deploys, the checker computes the worst case of every route and handler on seven resources: reads, writes, operations, bytes read, memory, effects, and integration calls.

  • A sequence of steps adds up.
  • A branch counts its more expensive side.
  • A loop counts one pass times the list's maximum size, so nested loops multiply.
  • A query reads at most its limit rows.

The result is compared with the limits: for a route, 250 reads, 50 writes, and 2,000 operations. A plan that could go over is rejected, with the line and the bound to change:

✖ over_budget at app.ts:44:4 (routes[1].plan)
  route "create_post" can use up to 40809 ops in the worst case, over the limit of 2000.
  Lower the bounds that drive it (query limits, list sizes) or split the work.

At run time, a meter counts the same usage as it happens and stops any run at its first charge over a limit. The checker says the meter will never trip; the meter makes sure. See Bounded by construction.

Complex logic is fine. What matters is the worst case, so a tighter bound (a smaller page, fewer items per request, .take(n)) is usually the fix.

Where work goes

The workUseWhy
Logic that fits one request's limits, however complexa routeone transaction: all or nothing
Reacting to a change or an eventan entity trigger or event handlerruns after the commit, with its own limits
More than one request can doa queue jobhigher limits (500 reads, 200 writes)
Every row of an entitya scheduled scanwalks the data in bounded batches
Steps over time: waits, outside calls, undoa workflowdurable state, retries, compensation

Keep work in a route when you can. Each job or workflow step is its own transaction, so splitting trades all-or-nothing for scale or durability. See Background work and workflows.

Chains of handlers

Each handler run is bounded, and the checker also rejects trigger cycles: handlers that set each other off without end, like a trigger that inserts a row of its own entity. A guarded, intended cycle declares maxDepth, which the runtime enforces. A queue job that re-enqueues its own queue to work in batches is allowed, up to 10,000 times in a row. See Trigger cycles.

What you can't do

These limits are what buy the guarantees:

  • No arbitrary code at request time. No npm packages, no fetch, no filesystem. The outside world is reached through declared integrations, and incoming calls arrive as webhooks.
  • No unbounded loops or recursion. Every loop has a declared maximum.
  • No mutable variables. State that changes from pass to pass goes through $.loop (see above).
  • No access to another app's data. Every key is built inside the app's own keyspace, and no expression can name another's. See Shared infrastructure and isolation.
  • No work beyond the limits in one run. Bigger work moves to jobs, scans, and workflows.

What you can expect

  • Every route and handler has a known worst case before it deploys, and that worst case is enforced while it runs.
  • Every request is atomic, and its effects go out exactly once, after commit.
  • No request, bug, or loop in your app can take capacity from another app on the same platform.
  • Schema changes are compared with what's deployed, and breaking ones are blocked.
  • The same manifest runs the same way in aleph dev, in aleph test, and in production, because it's the same core.