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_at or updated_at as 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

Can't find what you need? support@j17.app