Skip to content
hekla

Getting started

Build a project from an empty directory, check it, test it, and call it over HTTP.

Everything below runs start to finish. The project is a waitlist that refuses a repeated address, which is small enough to read in one go and still exercises the thing that matters: the slice a command folds is the condition its append is checked against.

Install

curl -fsSL https://hekla.tqwewe.com/install.sh | sh

That detects your architecture, downloads the matching release archive, verifies its published checksum, and puts hekla in /usr/local/bin. Pass HEKLA_INSTALL_DIR to put it somewhere else, HEKLA_VERSION to pin one, and HEKLA_LIBC=gnu if you would rather link against glibc than take the static musl build.

Prebuilt binaries are Linux only, x86_64 and aarch64. On macOS or Windows, and anywhere you would rather not pipe a script into a shell, build from source:

cargo install hekla

Either way the binary is the whole runtime. It serves the API, runs the scenarios and does the key management, with tephra embedded as a library and SQLite bundled, so there is no server to stand up beside it and nothing to point it at.

To run it without installing anything at all:

nix run git+https://git.tqwewe.com/tephra/hekla

You will also want hek, the language’s own checker, formatter and digest tool, for editor integration and pre-commit. It is a separate binary from a separate crate, and the same script installs it:

curl -fsSL https://hekla.tqwewe.com/install.sh | sh -s -- hek

Nothing below needs it. hekla check and hekla test run the compiler’s checks and hekla’s own on top, and hek is what you point an editor at: see Editor setup for highlighting and format on save.

Lay out a project

Three directories decide what a declaration is allowed to be. Everywhere else is yours.

mkdir -p waitlist/{events,commands,projectors,tests}
cd waitlist

There is no manifest and no import. Every .hk file under the directory is compiled together as one program, so a command can name an event declared three directories away without saying so, and file order never matters.

Declare an event

An event is a fact that already happened. It is written whole and never changes.

// events/waitlist.hk

event @waitlist.joined {
  entry_id: Uuid,
  email: String @max(200),
}

Every field is indexed and becomes a store tag unless it opts out with @no_index, so the runtime derives the tags from the declaration and there is no second list to keep in step.

Declare a command

// commands/join-waitlist.hk

refusal AlreadyJoined "that email is already on the list"

command JoinWaitlist(entry_id: Uuid, email: String) {
  fold joined: Bool = false
    on @waitlist.joined(email) => true

  if email.trim() == "" {
    return invalid("an entry needs an email")
  }
  if joined {
    return reject AlreadyJoined
  }

  emit @waitlist.joined { entry_id, email }
}

Three things are happening.

fold names a slice of the log: every @waitlist.joined carrying this address. That slice is also what the append is conditioned on, so if a concurrent request joins the same address first, this append is rejected and the command re-decides against the new log.

invalid and reject are different answers. invalid is about the request and carries no code: a blank address is wrong whoever sent it, and the caller learns nothing about the log. reject is about the world and carries a code, so a client can switch on it.

A refusal is declared once. The wire code is its name in snake_case, which is why the name must be capitalised and carry no underscore.

Declare a read model

// projectors/waitlist.hk

projector Waitlist {
  entity Entry {
    entry_id: Uuid @key,
    email: String @index @max(200),
  }

  on @waitlist.joined { entry_id, email } {
    put Entry { entry_id, email }
  }
}

The handlers are the subscription. They say which events to read and what to do with each, so there is no second list to keep in step with them.

Write the tests

A test is a declaration like any other, so it lives in the same program and runs with the same binary.

// tests/join-waitlist.hk

const ADA: Uuid = "0190d1a1-0000-7000-8000-000000000001"
const GRACE: Uuid = "0190d1a1-0000-7000-8000-000000000002"

test "a new address joins the list" {
  run JoinWaitlist { entry_id: ADA, email: "ada@example.com" }
  expect @waitlist.joined { entry_id: ADA, email: "ada@example.com" }
}

test "the same address twice is refused" {
  given @waitlist.joined { entry_id: ADA, email: "ada@example.com" }
  run JoinWaitlist { entry_id: GRACE, email: "ada@example.com" }
  expect reject AlreadyJoined
}

given seeds the log, run acts, and expect asserts on what came out. There is no framework to adopt and nothing to mock: each case runs the real command against a real tephra log in a temporary directory, so the slice and the append condition are genuinely exercised.

Check and test

hekla check .
checked 2 module(s): 1 command(s), 1 projector(s), 0 effect(s), 1 event(s)
ok: no errors, 0 warning(s)

check reports the compiler’s diagnostics plus what only hekla knows: that a declaration sits in the directory its kind requires, and that a read model can be keyed and indexed the way the read API needs. It runs nothing, opens no data directory and reads no environment, so it stays usable as a CI gate.

hekla test .
ok: "a new address joins the list"
ok: "the same address twice is refused"

2 passed, 0 failed

Run it

hekla serve .
hekla listening on http://127.0.0.1:8080
  admin console   http://127.0.0.1:8080/admin
  api reference   http://127.0.0.1:8080/docs

The data directory is created beside the project on first run. Nothing here is authenticated, and the bind address is the boundary: it defaults to 127.0.0.1 for that reason. Use --addr to change it, and put a proxy in front before you widen it.

Call it

The route is the declared name, and the body is the parameters as JSON.

curl -X POST localhost:8080/commands/JoinWaitlist \
  -H 'content-type: application/json' \
  -d '{"entry_id":"0190d1a1-0000-7000-8000-000000000001","email":"ada@example.com"}'
{
  "causation_id": "6a4bd410-c971-488a-8243-771847cbe260",
  "correlation_id": "d50eb468-55fb-4411-983f-8400e250dc92",
  "events": [
    {
      "tags": ["email:ada@example.com", "entry_id:0190d1a1-0000-7000-8000-000000000001"],
      "type": "waitlist.joined"
    }
  ],
  "positions": { "first": 1, "last": 1 }
}

Send the same address again under a different id and the fold sees the first one:

{
  "causation_id": "656b4c61-fb47-4fa9-a125-310197b3125a",
  "correlation_id": "0d7d6aba-fcf2-46fc-bffd-73a70cc959bb",
  "error": {
    "code": "already_joined",
    "message": "that email is already on the list"
  }
}

That arrives as 422. A committed command is 200, an invalid is 400, and a concurrency conflict the runtime could not resolve inside its retry budget is 409.

Read it back

The projector gets two routes per entity: one by key, one filtered on a declared index.

curl localhost:8080/read/Waitlist/Entry/0190d1a1-0000-7000-8000-000000000001
{
  "item": {
    "email": "ada@example.com",
    "entry_id": "0190d1a1-0000-7000-8000-000000000001"
  },
  "position": 1
}
curl 'localhost:8080/read/Waitlist/Entry?email=ada@example.com'
{
  "items": [
    {
      "email": "ada@example.com",
      "entry_id": "0190d1a1-0000-7000-8000-000000000001"
    }
  ],
  "next_cursor": null,
  "position": 1
}

Only the key and the leftmost column of each declared index are filterable; anything else is a 400 naming the field. Pagination is a cursor, never an offset.

A read model is eventually consistent by default. If you need to see your own write, pass the positions.last a command returned as ?after=, and the read blocks until the projector reaches that position instead of quietly serving a stale row.

Look at it

Open http://localhost:8080/admin in a browser. It is the same URL as the JSON API, not a second one: a request naming text/html in its Accept header gets the console, and everything else gets the JSON byte for byte unchanged.

The console is compiled into the binary, so it works with no network at all. curl sends */* and so does a bare fetch(), so neither is affected by the negotiation.

http://localhost:8080/docs is a generated OpenAPI 3.1 reference over the same project. That page loads its reference UI from a CDN, so it is the one surface that needs outbound network; /openapi.json itself needs nothing.

Where to go next

Deploying is copying the source tree and restarting, because the deployable artefact is the .hk files plus the master key if any field declares a @subject. Before you do it, hekla plan will tell you what would change, and hekla plan --replay will re-run the recorded invocations of every effect the deploy would change, to tell you whether it would still behave the same.

The language is documented in full at heklang/docs, one document per idea, each paired with a test file of the same name that is the same rules made executable.