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.