Reading Aggregates

Last updated about 2 months ago

GET aggregates to fetch current state. j17 replays events and applies handlers on demand. Handlers are declarative rules defined in your spec -- for example, a was_created event might use {"set": {"target": "", "value": "$.data"}} to set initial state, and a had_name_updated event might merge a new name into it.

Basic query

GET /{aggregate_type}/{aggregate_id}
curl https://myapp.j17.dev/order/ord_123 \
  -H "Authorization: Bearer $API_KEY"

Response:

{
  "ok": true,
  "data": {
    "items": [
      { "sku": "WIDGET-1", "quantity": 2, "price": 29.99 }
    ],
    "status": "paid",
    "customer_id": "550e8400-e29b-41d4-a716-446655440003",
    "total": 79.97,
    "placed_at": 1705312800,
    "paid_at": 1705312900
  },
  "metadata": {
    "length": 5,
    "created_at": 1705312800,
    "updated_at": 1705312900
  }
}
Field Description
data Current aggregate state after applying all events through handlers
metadata.length Total number of events in this aggregate
metadata.created_at Timestamp of the first event (Unix seconds)
metadata.updated_at Timestamp of the most recent event (Unix seconds)

Status codes

Status When
200 OK Aggregate exists (metadata.length > 0); or the id is a singleton
404 Not Found Aggregate id is well-formed but no events have ever been written to it
422 Unprocessable Entity Aggregate id is malformed — see Identifiers

Singletons always return 200, even before the first write — they're conceptually "one well-known instance, possibly empty." Branch on metadata.length > 0 rather than status code to detect missingness on a singleton.

Stream length (for OCC)

Get just the stream length without computing the full aggregate. This is an O(1) operation, useful for optimistic concurrency control.

GET /{aggregate_type}/{aggregate_id}/length
curl https://myapp.j17.dev/order/ord_123/length \
  -H "Authorization: Bearer $API_KEY"

Response:

{
  "ok": true,
  "length": 5
}

Use this length as previous_length in your next write:

const { length } = await fetch('/order/ord_123/length').then(r => r.json());
await writeEvent('/order/ord_123/was_updated', {
  data: { ... },
  metadata: { actor: { ... }, previous_length: length }
});

If another write happened between your read and write, you'll get a 409 Conflict.

Query parameters

Synchronous reads

By default, j17 may serve cached aggregate state. To bypass the cache and compute fresh from events:

curl "https://myapp.j17.dev/order/ord_123?synchronous=true"

Use when you need guaranteed-fresh state, such as immediately after a write.

A synchronous read rebuilds the aggregate from its whole event log, so it is the one read that gets more expensive as an aggregate grows. Requests that would have to read more than 32 MB of events are refused with a 422 rather than served; the default cached read has no such limit.

Get events

Get the raw event stream for an aggregate:

GET /{aggregate_type}/{aggregate_id}/events
curl "https://myapp.j17.dev/order/ord_123/events" \
  -H "Authorization: Bearer $API_KEY"

Response:

{
  "ok": true,
  "data": [
    {
      "id": "1234567890123-0",
      "hash": "9f2c...64 hex chars...a1",
      "event": {
        "key": "order:ord_123",
        "type": "was_placed",
        "data": { ... },
        "metadata": {
          "actor": { "type": "user", "id": "..." },
          "timestamp": 1705312800
        }
      }
    },
    {
      "id": "1234567890124-0",
      "event": {
        "key": "order:ord_123",
        "type": "had_item_added",
        "data": { ... },
        "metadata": { ... }
      }
    }
  ],
  "count_limit": 500,
  "truncated": true,
  "truncated_by": "count",
  "next_start": "1234567890124-0"
}

Each element of data pairs the stream ID with the event itself. The id is the cursor value to pass back as start; the event body lives under event; hash is the event's chain hash — with it, every entry on a page is verifiable against its predecessor.

Query parameters:

Parameter Type Description
start string Cursor — exclusive in both directions
count integer Events per page. Defaults to 500, capped at 5,000.
direction string forwards (default) or backwards
raw boolean raw=true returns each event's stored bytes as a JSON string under raw, in place of the decoded event.

Verifying events as you read them

Chain hashes are computed over the event's bytes as stored. A decoded JSON object re-serialized by your language's encoder will not, in general, reproduce those bytes (key order differs), so hashing a decoded event back into the chain is a trap. raw=true exists for exactly this:

curl "https://myapp.j17.dev/order/ord_123/events?raw=true" \
  -H "Authorization: Bearer $API_KEY"
{
  "id": "1234567890123-0",
  "hash": "9f2c...a1",
  "raw": "{\"key\":\"order:ord_123\",\"type\":\"was_placed\",...}"
}

To verify: the first event's hash is SHA-256(raw); every later event's is SHA-256(previous_hash <> raw) (hex hash string concatenated with the raw bytes). Any page whose hashes recompute is untampered relative to itself, and the last hash ties into the Merkle proofs and signed anchors for verification against a signed root.

Response fields alongside data:

Field Description
count_limit The page size actually applied
truncated true when there are more events in the direction you are reading
truncated_by "count" or "bytes" — which limit ended the page. Absent when not truncated.
next_start Cursor for the next page. Absent when there is none, so its absence is how you know you have reached the end.
count_clamped true when your count exceeded the maximum and was reduced. Absent otherwise.

Cursor semantics:

  • Events are always returned oldest-first, in stream order, whichever direction you ask for
  • start is exclusive — an event whose id equals start is never returned
  • There is always a page size. Omitting count gives you 500 events, not the whole stream. Follow next_start to read the rest.
  • truncated is exact, not inferred: a page that ends precisely on the last event of the stream reports truncated: false, so you never have to fetch an empty page to discover you were done
  • id values are opaque — treat them as cursors, not as data to parse. The format may change, and differs between the managed platform and local development tools.
  • An unusable count (0, negative, non-numeric) or an unrecognised direction is a 400, not a silently ignored parameter

Page size

A request that names no count gets 500 events. A count above 5,000 is reduced to 5,000 and the response sets count_clamped: true — your request is answered, just not at the size you asked for.

There is also a 4 MB ceiling on a single page, and whichever limit binds first ends the page. Event size varies enormously between specs, so a count on its own is a poor description of what a page costs: 500 small events and 500 fat ones are the same number and wildly different responses. When the byte ceiling is what stopped the page, truncated_by reads "bytes" and you will get fewer events than you asked for. Follow next_start exactly as you would otherwise — an event larger than the entire ceiling is still returned on its own, so a stream of large events stays walkable.

Changed in 0.15.0. There was previously no page size at all: omitting count returned the entire stream, and count limited the response without limiting the work behind it. A client that relied on a single unparameterized call returning everything now needs to follow next_start.

Direction

direction selects which window of the stream you get. It does not change the order of the returned array — that is always oldest-first.

Request Returns
?count=3 The oldest 3 events
?direction=backwards&count=3 The newest 3 events, still oldest-first
?start=ID&count=3 The 3 events after ID
?direction=backwards&start=ID&count=3 The 3 events before ID

Keeping the array oldest-first in both directions is deliberate. An event stream is a fold — state is rebuilt by applying events in order — so a page that arrives reversed is a page you cannot replay, and the bug it causes surfaces far from its origin. Direction moves the window; order stays fold order.

# The five most recent events
curl "https://myapp.j17.dev/order/ord_123/events?direction=backwards&count=5"

Paging forwards (catch-up consumer): keep the last id you processed, pass it as start, and you resume exactly where you stopped. next_start gives you that cursor directly.

curl "https://myapp.j17.dev/order/ord_123/events?count=50"
curl "https://myapp.j17.dev/order/ord_123/events?count=50&start=1705312800999-0"

Read a whole stream by following next_start until it stops coming back:

cursor=""
while :; do
  page=$(curl -s "https://myapp.j17.dev/order/ord_123/events?count=500${cursor:+&start=$cursor}")
  echo "$page" | jq -c '.data[]'
  cursor=$(echo "$page" | jq -r '.next_start // empty')
  [ -z "$cursor" ] && break
done

Paging backwards (walking history from the present): take the first id of each window as the next cursor, since the array is oldest-first. next_start already accounts for direction, so following it works either way.

curl "https://myapp.j17.dev/order/ord_123/events?direction=backwards&count=50"
curl "https://myapp.j17.dev/order/ord_123/events?direction=backwards&count=50&start=1705312800111-0"
# First page
curl "https://myapp.j17.dev/order/ord_123/events?count=50"

# Next page — pass the last id from the previous response
curl "https://myapp.j17.dev/order/ord_123/events?count=50&start=1705312800999-0"

List aggregates

GET /{aggregate_type}
curl https://myapp.j17.dev/order \
  -H "Authorization: Bearer $API_KEY"

Returns known aggregate IDs for a type with cursor-based pagination.

Parameter Type Description
resolve flag Return full aggregate data instead of IDs (?resolve or ?resolve=true)
synchronous boolean Skip cache when resolving, read from event stream (?synchronous=true)
limit integer Page size (default 50, max 200 — a lowered limit is reported via limit_clamped: true). Malformed values are a 400, not silently defaulted.
start string Return IDs after this value (exclusive, lexicographic). cursor is accepted as a legacy alias.

The response speaks the same pagination vocabulary as /events: the applied limit is echoed back, truncated says whether there is more, and next_start is present exactly when there is — its absence alone answers "am I done". (The legacy cursor response field carries the same value as next_start.)

Cursor semantics:

  • IDs are returned in lexicographic (byte-wise) order
  • The cursor is exclusive — an ID equal to the cursor value is not returned
  • To page through, pass next_start from the previous response back as start
  • When the response omits next_start, you've reached the end
  • The cursor is stable across requests as long as no aggregate IDs are deleted; new aggregates with IDs greater than your current cursor will appear on subsequent pages
  • All four identifier kinds — UUIDs, humane codes, singletons (including global), tagged UUIDs — appear in the list once any event has been written to them. They sort lexicographically by raw string, so singleton names (lowercase letters) appear before UUIDs (hex digits) in the natural order.

IDs only (default):

{"ok": true, "data": ["order-123", "order-456", "order-789"], "limit": 50, "truncated": false}

With ?resolve — full aggregate data:

{
  "ok": true,
  "data": [
    {"id": "order-123", "data": {"status": "pending", ...}, "metadata": {...}},
    {"id": "order-456", "data": {"status": "shipped", ...}, "metadata": {...}}
  ]
}

Resolved aggregates use the cache when available, falling back to sync reads from the event stream. Use ?synchronous=true to always read from the stream. Resolved entries are exactly as current as a GET of the same aggregate — a listing and a single read never disagree.

If one aggregate in the page cannot be read (for example, recomputing it would exceed the fold byte budget), its entry carries an error field instead of data — the rest of the page is unaffected:

{"id": "order-789", "error": "fold_budget_exceeded"}

Pagination:

# First page
curl "https://myapp.j17.dev/order?limit=25"
# Response includes cursor: {"ok": true, "data": [...], "cursor": "order-xyz"}

# Next page
curl "https://myapp.j17.dev/order?limit=25&cursor=order-xyz"

Projections

Request a named projection:

curl "https://myapp.j17.dev/_projections/user_dashboard/cust_456" \
  -H "Authorization: Bearer $API_KEY"

Projections are pre-computed views maintained by the platform. Use ?synchronous=true for a fresh computation instead of the cached value.

Export as CSV: GET /_projections/:name/export.csv

Query with SQL: POST /_projections/query with {"sql": "SELECT ...", "params": [...]}

See the projections guide for full details including limits and restrictions.

Singleton aggregates

Use the literal global as the aggregate ID for one-per-type aggregates:

curl https://myapp.j17.dev/config/global \
  -H "Authorization: Bearer $API_KEY"

If you've defined custom singletons in your spec ("singletons": ["all"]), use them the same way:

curl https://myapp.j17.dev/company_audit/all \
  -H "Authorization: Bearer $API_KEY"

Reads of singletons return 200 OK even before the first write — branch on metadata.length > 0 if you need missingness detection.

See Identifiers § Singletons for the full reference and Aggregates: Singleton aggregates for the conceptual overview.

Response codes

200 OK

Aggregate exists. Returns state.

404 Not Found

Aggregate doesn't exist (no events yet) or aggregate type not in spec.

{
  "ok": false,
  "error": "Aggregate not found"
}

An aggregate with no events returns 404, not empty state.

422 Unprocessable Entity

The request is valid, but answering it this particular way would cost more than a single request is allowed to spend. Returned by ?synchronous=true on an aggregate whose event log exceeds the rebuild budget.

{
  "ok": false,
  "error_type": "fold_budget_exceeded",
  "error": "Recomputing this aggregate from its event log would read 41943040 bytes, over the 33554432 byte budget for a single request. ...",
  "bytes": 41943040,
  "limit": 33554432
}

The aggregate itself is fine. Drop ?synchronous=true to read the cached state, or use GET /:type/:id/events to page through the log — neither is affected by this budget.

401 Unauthorized

Invalid or missing API key.

403 Forbidden

Valid key, but wrong environment (staging key on prod) or insufficient scope.

Performance

A default read is served from cached state and costs the same regardless of how many events an aggregate has. The cache is kept current in the background by folding on only the events added since it was last written, so neither reads nor writes get slower as an aggregate accumulates history.

Rebuilding from the full log is the operation that scales with size, at roughly 5–7 µs per event. It happens on ?synchronous=true, and in the background after a spec change:

  • 1,000 events: ~5ms
  • 10,000 events: ~60ms
  • 100,000 events: ~600ms

Requests that would rebuild more than 32 MB of events are refused (422); background rebuilds are not, because they run in bounded chunks. Checkpoints snapshot state periodically and are still useful for recovery and export.

Optimistic reads

For optimistic UI patterns:

// 1. Get stream length (O(1))
const { length } = await fetch('/order/ord_123/length').then(r => r.json());

// 2. Fetch current state
const { data } = await fetch('/order/ord_123').then(r => r.json());

// 3. Render UI
renderOrder(data);

// 4. User makes change -- write with OCC
try {
  await writeEvent('/order/ord_123/was_shipped', {
    data: { ... },
    metadata: { actor: { ... }, previous_length: length }
  });
} catch (err) {
  // 409 Conflict -- refetch and retry
  const fresh = await fetch('/order/ord_123').then(r => r.json());
  renderOrder(fresh.data);
}

Comparing to traditional databases

Operation SQL j17
Read by ID SELECT * FROM orders WHERE id = ? GET /order/ord_123
Read length SELECT COUNT(*) FROM events WHERE key = ? GET /order/ord_123/length
Read full history Multiple queries/joins GET /order/ord_123/events
List/filter SELECT * FROM orders WHERE status = 'paid' Not directly (use projections)

The trade-off: you lose ad-hoc queries, you gain immutable history.

Best practices

Store aggregate IDs. When you create an order, save the ID in your database. You'll need it to query later.

Use projections for lists. Don't try to "list all orders." Create a projection that maintains the list.

Use ?synchronous=true sparingly. Default reads may use cached state, which is faster. Only force synchronous when you need guaranteed freshness.

Handle 404 gracefully. A 404 just means no events yet. It's not an error condition.

See also

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