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.