Tick - Declarative Handlers
Tick is j17's declarative handler language. Seventeen operations organized into four categories let you build aggregate state from events using pure JSON — no code to compile, no runtime to manage.
Philosophy
Imperative code has hidden state. Declarative operations have none. When you read a Tick handler, you know exactly what it does — no side effects, no surprises.
Compare:
Imperative (what you might write by hand):
javascript
function applyEvent(state, event) {
if (event.type === 'had_item_added') {
if (!state.items) state.items = [];
state.items.push({
...event.data,
added_at: Date.now()
});
}
}
Declarative (Tick):
json
{
"append": {
"target": "items",
"value": { "$merge": [{ "$": "$.data" }, { "added_at": { "$": "$.metadata.timestamp" } }] }
}
}
The Tick version is explicit, testable, and optimizable. The imperative version has bugs waiting to happen (what if items isn't an array? what if data is null?).
Handler structure
A handler is an array of operations applied sequentially to aggregate state:
{
"was_created": {
"schema": { ... },
"handler": [
{ "set": { "target": "", "value": "$.data" } },
{ "set": { "target": "status", "value": "active" } }
]
}
}
Each operation reads from the event (via JSONPath) and writes to aggregate state. A handler is required for every event type. Use [] (empty array) for events that don't modify aggregate state — useful for audit-only or projection-only events.
Accessing event data
Operations reference event data using JSONPath expressions:
| Path | Resolves to |
|---|---|
$.data |
Event payload |
$.data.field |
Specific field from payload |
$.data.field? |
Optional field (no-op if missing) |
$.metadata.timestamp |
Event timestamp (Unix epoch) |
$.metadata.actor |
Actor object |
$.metadata.actor.id |
Actor ID |
$.key |
Event key (e.g., user:abc123) |
$.type |
Event type (e.g., was_created) |
@.field |
Current aggregate state |
Basic operations
set
Replace the value at a path.
{ "set": { "target": "status", "value": "active" } }
The target is a dot-separated path in aggregate state. The value can be a literal or a JSONPath expression:
{ "set": { "target": "profile.name", "value": "$.data.name" } }
Target "" (empty string) means the root — use this to initialize state from event data:
{ "set": { "target": "", "value": "$.data" } }
Creates intermediate objects automatically. Setting { "target": "a.b.c", "value": 1 } on an empty state produces { "a": { "b": { "c": 1 } } }.
merge
Shallow merge an object into state at a path.
{ "merge": { "target": "profile", "value": "$.data" } }
Given state { "profile": { "name": "Alice" } } and event data { "email": "alice@example.com" }, the result is { "profile": { "name": "Alice", "email": "alice@example.com" } }.
Shallow only — nested objects are replaced, not recursively merged.
append
Add an item to an array.
{ "append": { "target": "items", "value": "$.data.item" } }
Creates the array if it doesn't exist.
remove
Remove elements from an array. Three forms:
By value (primitive arrays):
json
{ "remove": { "target": "tags", "value": "$.data.tag" } }
By field match (object arrays):
json
{ "remove": { "target": "items", "where": { "id": "$.data.item_id" } } }
By predicate (complex conditions):
json
{
"remove": {
"target": "sessions",
"match": {
"expired": {
"timestamp": "$item.created_at",
"maxAgeSeconds": 86400,
"now": "$.metadata.timestamp"
}
}
}
}
Within match, use $item to reference the current array element being tested.
increment
Add to a numeric field.
{ "increment": { "target": "balance", "by": "$.data.amount" } }
Creates the field with value 0 if it doesn't exist, then adds by. The by field accepts a literal number or a JSONPath expression.
decrement
Subtract from a numeric field.
{ "decrement": { "target": "stock", "by": "$.data.quantity" } }
Same behavior as increment but subtracts.
Array operations
filter
Keep only array elements that match a predicate, removing the rest.
{
"filter": {
"target": "sessions",
"keep": {
"not": {
"expired": {
"timestamp": "$item.created_at",
"maxAgeSeconds": 1209600,
"now": "$.metadata.timestamp"
}
}
}
}
}
The keep field takes any predicate. Use $item to reference the current element.
map
Transform every element in an array. Each element becomes a temporary state that the apply operations act on.
{
"map": {
"target": "stages",
"as": "$stage",
"apply": [
{ "set": { "target": "reviewed", "value": true } },
{ "set": { "target": "reviewed_at", "value": "$.metadata.timestamp" } }
]
}
}
The as field (default: $item) names the binding for use in nested expressions. Operations inside apply treat the array element as their state.
update_where
Merge fields into array elements that match a condition.
By field match:
json
{
"update_where": {
"target": "addresses",
"match": { "id": "$.data.address_id" },
"merge": { "verified": true, "verified_at": "$.metadata.timestamp" }
}
}
By predicate:
json
{
"update_where": {
"target": "line_items",
"match": { "equals": ["$item.status", "pending"] },
"merge": { "status": "confirmed" }
}
}
Updates all items where the condition is satisfied.
upsert
Replace a matching element or insert if not found. Effectively "replace if exists, insert if not" for object arrays keyed by a field.
{
"upsert": {
"target": "addresses",
"match": { "id": "$.data.address.id" },
"value": "$.data.address"
}
}
The match field takes a single key-value pair: the field name and the value to match. Removes any existing item where the field equals the value, then appends the new value.
append_unique
Add to an array only if the value isn't already present.
Primitive arrays:
json
{ "append_unique": { "target": "tags", "value": "$.data.tag" } }
Object arrays (check uniqueness by a specific field):
json
{
"append_unique": {
"target": "members",
"value": "$.data.member",
"uniqueField": "id"
}
}
No-op if the value (or an object with a matching uniqueField) already exists.
Dynamic key operations
These operations work on objects where the key is determined at runtime — useful for maps, lookup tables, and per-key counters.
set_at
Set a value at a dynamic key within an object.
{ "set_at": { "target": "preferences", "key": "$.data.setting_name", "value": "$.data.setting_value" } }
Sets target[key] = value. Creates the target object if it doesn't exist. The key must resolve to a string.
merge_at
Shallow merge into a dynamic key within an object.
{
"merge_at": {
"target": "members",
"key": "$.data.user_id",
"value": { "role": "$.data.role", "joined_at": "$.metadata.timestamp" }
}
}
Preserves existing fields at that key. If the key doesn't exist, creates it. Both the existing value and the new value must be objects — use set_at to replace non-object values.
Example:
State: { "members": { "u1": { "role": "viewer", "status": "pending" } } }
Event: { "data": { "user_id": "u1" } }
Op: merge_at target="members" key="$.data.user_id" value={"status": "active"}
Result: { "members": { "u1": { "role": "viewer", "status": "active" } } }
remove_at
Remove a key from an object.
{ "remove_at": { "target": "permissions", "key": "$.data.permission_name" } }
No-op if the key or target doesn't exist.
increment_at
Increment a numeric value at a dynamic key.
{ "increment_at": { "target": "vote_counts", "key": "$.data.option_id", "by": 1 } }
Creates the key with value 0 if it doesn't exist, then adds by.
Control flow
Conditional (if/then/else)
Execute operations conditionally based on a predicate.
{
"if": { "equals": ["$.data.status", "completed"] },
"then": [
{ "set": { "target": "completed_at", "value": "$.metadata.timestamp" } }
],
"else": [
{ "set": { "target": "status", "value": "$.data.status" } }
]
}
The else branch is optional. Both then and else take arrays of operations.
Conditionals nest:
{
"if": { "equals": ["$.data.action", "escalate"] },
"then": [
{
"if": { "equals": ["@.priority", "critical"] },
"then": [
{ "set": { "target": "escalation_level", "value": 2 } }
],
"else": [
{ "set": { "target": "escalation_level", "value": 1 } }
]
}
]
}
let
Bind a variable by finding an element in an array. The variable is available to all subsequent operations in the handler.
{
"let": {
"name": "$address",
"find": {
"in": "addresses",
"where": { "id": "$.data.address_id" }
}
}
}
The find clause searches the array at the given state path. The where clause takes a single key-value pair: the field to match and the value to match against.
Reference the bound variable with its name:
{ "set": { "target": "primary_street", "value": "$address.street" } }
Variables work with $merge expressions:
{
"set": {
"target": "snapshot",
"value": {
"$merge": [
"$address",
{ "verified": true, "verified_at": { "$": "$.metadata.timestamp" } }
]
}
}
}
Predicates
Predicates are boolean conditions used in if/then/else, filter, remove (with match), update_where (with predicate match), every, and some.
equals
Compare two values for equality.
{ "equals": ["$.data.status", "active"] }
{ "equals": ["$item.id", "$.data.item_id"] }
Both arguments can be JSONPath expressions, literals, or item references.
includes
Check if a value exists in an array.
{ "includes": { "array": "@.approved_ids", "value": "$.data.user_id" } }
min_items
Check that an array has at least N elements.
{ "minItems": { "array": "@.items", "min": 1 } }
max_items
Check that an array has at most N elements.
{ "maxItems": { "array": "@.tokens", "max": 5 } }
expired
Check if a timestamp has exceeded a maximum age.
{
"expired": {
"timestamp": "$item.created_at",
"maxAgeSeconds": 1209600,
"now": "$.metadata.timestamp"
}
}
Returns true if now - timestamp > maxAgeSeconds. Useful in filter and remove to clean up stale data.
every
Check that ALL elements in an array match a predicate.
{ "every": { "in": "@.stages", "match": { "equals": ["$item.status", "done"] } } }
Within match, $item refers to the current element.
some
Check that at least one element matches.
{ "some": { "in": "@.approvals", "match": { "equals": ["$item.role", "admin"] } } }
subset_of
Check that every item in one array exists in another.
{ "subset_of": { "items": "@.required_steps", "array": "@.completed_steps" } }
Returns true if every element in items exists in array. Useful for checking completeness (e.g., all required steps finished).
Logical operators
Combine predicates with not, and, and or:
{ "not": { "equals": ["$.data.status", "deleted"] } }
{ "and": [
{ "minItems": { "array": "@.items", "min": 1 } },
{ "equals": ["@.status", "pending"] }
] }
{ "or": [
{ "equals": ["$.data.role", "admin"] },
{ "equals": ["$.data.role", "owner"] }
] }
Predicates summary
| Predicate | JSON key | Description |
|---|---|---|
| equals | equals |
Two values are equal |
| includes | includes |
Value exists in array |
| min_items | minItems |
Array has >= N elements |
| max_items | maxItems |
Array has <= N elements |
| expired | expired |
Timestamp exceeds max age |
| every | every |
All array items match |
| some | some |
Any array item matches |
| subset_of | subset_of |
All items in A exist in B |
| not | not |
Negate a predicate |
| and | and |
All predicates true |
| or | or |
Any predicate true |
$merge expressions
$merge composes objects dynamically anywhere a value is expected.
{
"append": {
"target": "activity_log",
"value": {
"$merge": [
{ "action": "item_added" },
{ "$": "$.data" },
{ "timestamp": { "$": "$.metadata.timestamp" } },
{ "actor_id": { "$": "$.metadata.actor.id" } }
]
}
}
}
$merge takes an array of objects and shallow-merges them left to right. Within the array:
- Plain objects contribute their fields directly
- { "$": "$.path" } evaluates a JSONPath and includes the result
- Variable references like "$found" resolve to bound variables
Later entries overwrite earlier ones for the same key, just like Object.assign in JavaScript.
Automatic timestamps
The engine automatically injects two fields into every aggregate:
created_at— timestamp of the first event (set once, never overwritten)updated_at— timestamp of the most recent event (updated on every event)
These are Unix epoch integers (same format as $.metadata.timestamp). You do not need handlers for these — they are injected after all handlers run. If your handlers also set created_at or updated_at, the engine's values overwrite them.
Reserved field names: Do not use
created_atorupdated_atas handler targets unless you intend the engine to overwrite them.
Complete example: order management
This spec defines an order aggregate with five event types demonstrating most operation categories:
{
"aggregate_types": {
"order": {
"events": {
"was_placed": {
"schema": {
"type": "object",
"properties": {
"customer_id": { "type": "string" },
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sku": { "type": "string" },
"name": { "type": "string" },
"price": { "type": "number" },
"quantity": { "type": "integer", "minimum": 1 }
},
"required": ["sku", "name", "price", "quantity"]
}
}
},
"required": ["customer_id", "items"]
},
"handler": [
{ "set": { "target": "", "value": "$.data" } },
{ "set": { "target": "status", "value": "pending" } },
{ "set": { "target": "placed_at", "value": "$.metadata.timestamp" } }
]
},
"had_item_added": {
"schema": {
"type": "object",
"properties": {
"sku": { "type": "string" },
"name": { "type": "string" },
"price": { "type": "number" },
"quantity": { "type": "integer", "minimum": 1 }
},
"required": ["sku", "name", "price", "quantity"]
},
"handler": [
{
"upsert": {
"target": "items",
"match": { "sku": "$.data.sku" },
"value": "$.data"
}
}
]
},
"had_item_removed": {
"schema": {
"type": "object",
"properties": {
"sku": { "type": "string" }
},
"required": ["sku"]
},
"handler": [
{
"remove": {
"target": "items",
"where": { "sku": "$.data.sku" }
}
}
]
},
"had_status_changed": {
"schema": {
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["confirmed", "shipped", "delivered", "cancelled"] }
},
"required": ["status"]
},
"handler": [
{ "set": { "target": "status", "value": "$.data.status" } },
{
"if": { "equals": ["$.data.status", "shipped"] },
"then": [
{ "set": { "target": "shipped_at", "value": "$.metadata.timestamp" } }
]
},
{
"if": { "equals": ["$.data.status", "delivered"] },
"then": [
{ "set": { "target": "delivered_at", "value": "$.metadata.timestamp" } }
]
}
]
},
"had_note_added": {
"schema": {
"type": "object",
"properties": {
"text": { "type": "string" }
},
"required": ["text"]
},
"handler": [
{
"append": {
"target": "notes",
"value": {
"$merge": [
{ "$": "$.data" },
{ "added_at": { "$": "$.metadata.timestamp" } },
{ "author": { "$": "$.metadata.actor.id" } }
]
}
}
}
]
}
}
}
}
}
After placing an order and adding a note, the aggregate state looks like:
{
"customer_id": "cust_001",
"items": [
{ "sku": "WIDGET-A", "name": "Widget A", "price": 29.99, "quantity": 2 }
],
"status": "pending",
"placed_at": 1710000000,
"notes": [
{ "text": "Rush delivery requested", "added_at": 1710000060, "author": "op_123" }
],
"created_at": 1710000000,
"updated_at": 1710000060
}
Limitations
| Limitation | Workaround |
|---|---|
No type coercion ("123" != 123) |
Ensure event data has correct types via schema |
No arithmetic (price * quantity) |
Use WASM handler |
| No string operations (concat, split) | Use WASM handler |
| No date/time math (add days) | Use WASM handler |
| No cross-aggregate reads | Use implications |
When to escape to WASM
Tick covers the vast majority of state-building patterns. Use WASM handlers when you need:
- Arithmetic beyond increment/decrement
- String manipulation
- Date/time calculations
- Complex business logic
- Cryptographic operations
Performance
Tick handlers are interpreted by the Zig engine and run in-process. No VM overhead, no garbage collection. Typical throughput: 300k+ operations/second per core.
Validation
j17 validates handlers when you upload your spec:
- JSONPath expressions are syntactically valid
- Target paths can be created or already exist
- Required fields are present for each operation
- Predicate structures are well-formed
Invalid handlers are rejected with error details and hints.
See also
- JSONPath reference — Path syntax for accessing event and state data
- WASM reference — Escape hatch for complex logic
- Spec reference — Full spec structure including handler placement
- Implications reference — Cross-aggregate side effects