Skip to content
hekla

Projectors

Entities, the four write statements, and why a projector has no failure channel.

A projector folds the log into rows. hekla serves those rows over HTTP and rebuilds them from position zero when the projector’s definition changes, unless [projectors] auto_rebuild is off.

projector CustomerOrders {
  entity Order {
    order_id: Uuid @key,
    customer_id: Int @index,
    email: String? @max(200),
    shipping_address: String? @max(200),
  }

  on @order.placed { order_id, customer_id, email, shipping_address } {
    put Order { order_id, customer_id, email, shipping_address }
  }
}

The handlers are the subscription. They say which events to read and what to do with each, so there is no separate list of subscribed types to keep in step with the code.

Entities

An entity is a table. Its columns are declared with the annotations the read API needs:

Annotation Effect
@key the primary key. Exactly one per entity, and it is the by-key route
@index makes the column filterable on the scan route
@max(n) bounds a String or String?
= <literal> the value a patch materialises an absent row with, in place of the zero

A composite index is declared at entity level rather than on a column:

entity Booking {
  booking_id: Uuid @key,
  guest_id: Int @index,
  status: Status,

  index (guest_id, status)
}

Only the key and the leftmost column of each declared index are filterable, and only one filter field per request. Anything else is a 400 naming the field, rather than a table scan nobody asked for.

So index (guest_id, status) makes ?guest_id= a filter and leaves status unfilterable: the runtime records a compound index whole but filters on its first column only. A scan is always ordered by the primary key, whatever is indexed.

The four write statements

Statement When the row is absent When it is present
put inserts it replaces it whole
patch materialises it from zero values, then applies applies the given fields
update does nothing applies the given fields
delete does nothing removes it

The question to ask at every write is what should happen when the row is not there yet. put says “this event fully determines the row”. patch says “create it if needed”. update says “only if it already exists”, which is the right answer more often than people expect: an event about a thing you have not seen created is usually one you should ignore.

Reaching the envelope

A handler may bind the whole event with as, which is how it reaches the envelope:

on @order.placed as e { order_id } {
  put Order { order_id, placed_at: e.at }
}

e.at, e.id and e.position are what the envelope carries. This is the only way a projector reaches the moment an event was appended, because now() does not parse in one.

The leading dot

Inside a handler, a leading dot refers to the column’s current stored value:

on @order.shipped { order_id } {
  update Order[order_id] { shipped_count: .shipped_count + 1 }
}

That is a read of the row being written, and it is the only read a projector gets.

No general reads

There is no get(entity, key). A projector cannot look up an arbitrary row, only the one its statement is addressing.

That restriction is what makes a rebuild deterministic. A projector that could read anything would depend on the order it processed events in, and a rebuild would be free to produce something different from the live table.

If you need a value from elsewhere to make a decision, the answer is that the decision belongs in the command, and the event should carry the result.

Zero values

patch materialises an absent row from zero values, so every column type needs one:

Type Zero
Bool false
Int 0
Decimal(n), Money(n) 0 at that scale
String ""
an enum the @default variant
T? absent
Uuid, Timestamp none

Uuid and Timestamp deliberately have none. The nil UUID and epoch zero are not identities, they are placeholders that read as data, and a row containing one is a bug that looks like a row. A patch that would have to invent one is a compile error naming the column.

What a projector may not do

No network, no clock, no decrypt, no append, and no failure channel: there is no fail, no invalid, no reject.

That last one is the important one. A projector that could fail would have states a rebuild could half-reach, and every read of it would have to ask whether it was in one. Because it cannot, “rebuild this from zero” is total, and hekla rebuilds it whenever a definition hash changes, unless [projectors] auto_rebuild is off, in which case the projector goes stale and serves 503 until an operator replays it. See Read models.

Sealed columns

A projector never declares a subject. It receives sealed content from an event and carries it: the column stores ciphertext, the projector never handles plaintext, and the read API decrypts on the way out.

A projector cannot reveal. See Personal data.

Serving them

Each entity gets two routes, a key lookup and a filtered scan, both covered in The HTTP API. What happens when a projector is behind, rebuilding, or broken is in Read models.