10 KiB
ADR-0008: Phase 3b indexer architecture
Status: Accepted Date: 2026-04-18 Supersedes: none Depends on: ADR-0001 (custom_json replacement ops), ADR-0005 (Phase 3 subphase split), ADR-0007 (keygen curve + dblurt package)
Context
Phase 3a shipped the ability to create accounts and broadcast ops.
Phase 3b needs to make those ops readable — specifically, the
orderbook, profiles, feedback, and release-discovery UIs need to
query current state derived from on-chain ops. The Blurt chain
itself is not a good source for that query surface: get_account
returns account data, not arbitrary op history; reading full block
history for every frontend page load would be prohibitively slow
and hammer public RPC nodes.
The indexer is the bridge: it follows the chain's op stream, picks out Morphit-relevant ops, and exposes queryable state over HTTP.
Decision
Ingest: pull, not push
The indexer polls condenser_api.get_block and
condenser_api.get_dynamic_global_properties on a ~3-second
cadence (Blurt's block interval), using the existing relay-style
multi-endpoint rotator pattern.
Why pull over push:
- Blurt's HTTP-JSON-RPC is the only transport dblurt supports post-0.7; WebSocket subscriptions aren't available
- Pull-based indexing is crash-safe: the indexer records the last successfully-indexed block and resumes from there on restart
- Polling latency is bounded and predictable (~3 seconds per block), good enough for an orderbook that isn't latency- sensitive the way an order-matching engine would be
- We can rate-limit ourselves trivially (don't make more than one
get_blockcall per block interval)
Storage: Postgres
PostgreSQL 15+ is the indexer's backing store.
Schema strategy — hybrid event log + materialized views:
- Event log table (
ops): every morphit op we've ever seen, append-only, primary-keyed on(block_num, trx_in_block, op_in_trx). Never updated, never deleted. Audit surface. - Materialized state tables (
orders,profiles,feedback,releases,chat_messages): current derived state. Written by op-handler functions as new ops arrive. - Materialized tables can be fully rebuilt by replaying the event log in order. This is the DR / schema-migration strategy.
Why not SQLite:
- Postgres's concurrency story is better (the indexer writes while the HTTP layer reads)
- Horizontal scale paths exist (read replicas, partitioning)
tsvectorfull-text indexing is ~free; we'll want it for profile display names and search
Why not a graph or document DB:
- Query patterns are simple: "list orders filtered by asset + side
- location + freshness", "get profile by account name", "tally feedback for account X". Relational works.
- No joins we can't express in standard SQL
State model: replace-in-place for logical updates
Consistent with ADR-0001, morphit ops are protocol-immutable on chain. The indexer compresses this to the user-facing view:
morphit_order_v1creates a row inorderswithstatus = 'live'morphit_order_replace_v1looks up the target by permlink, updates the row, preserves the originalcreated_atand bumpsupdated_at. The event log keeps both the original and the replacement; theorderstable shows only the latest state.morphit_order_cancel_v1flipsstatus = 'cancelled', keeps the row for audit (no hard delete)morphit_feedback_response_v1attaches to the original feedback row as a nested response (separate table joined onfeedback_id), not replacing the original feedback
Reorg handling: trail the irreversible block
The indexer maintains two block pointers:
head_block: the highest block we've received from the chainindexed_block: the highest block we've applied to materialized state
We only apply ops from blocks where block_num <= last_irreversible_block_num (provided in every dynamic global
properties response). This means the indexer trails head by ~15-
21 blocks (45-63 seconds), which is the price of never needing to
roll back. DPoS irreversibility is effectively permanent; no reorg
recovery logic needed.
Reconciliation + catch-up
On startup, the indexer:
- Reads its last-applied block from the database
- Polls current
last_irreversible_block_numfrom the chain - Fetches every block in between, applying morphit ops it finds
- Only after the catch-up is complete does it begin serving HTTP queries
This means a fresh deploy has a lengthy initial sync before the HTTP API goes live. Acceptable for a project still in launch.
HTTP API surface — REST + JSON
Endpoints the frontend needs:
GET /v1/health— uptime, chain head, indexed head, lag in blocksGET /v1/orderbook— filter by asset, side, location region, freshness; paginate; returns live orders onlyGET /v1/orders/:account— orders for a specific accountGET /v1/profiles/:account— latest profile for an accountGET /v1/accounts/:account/feedback— feedback records + summary stats (count, weighted avg)GET /v1/release— latest verifiedmorphit_release_v1opGET /v1/chat/:account_a/:account_b— chat messages between two accounts, newest first, paginated
No POST endpoints — all writes go to the chain via Phase 3a's relay or via broadcast from the user's browser. The indexer is strictly read-only from the frontend's perspective.
Authentication
None. Every HTTP endpoint is public. Morphit's whole design premise is that on-chain data is public; the indexer mirrors that publicness. See ADR-0006 for why CSRF/auth concerns don't apply.
Op validation at ingest time
The indexer re-verifies every op's signature against the Blurt chain's public-key records before applying to materialized state. This is strict — an op can fail:
- Structural validation (wrong shape, missing required field)
- Signature validation (signer's posting key doesn't match the account's recorded posting key)
- Authorization (e.g. a
morphit_order_replace_v1targeting an order not owned by the signer)
Ops failing any of these land in the event log tagged as
rejected (so we know they existed) but never update materialized
state. The frontend never sees them.
Pagination + freshness
- All list endpoints return
{ items, next_cursor }cursor-based pagination, not offset-based. Cursors are opaque base64-encoded(block_num, trx_in_block, op_in_trx)tuples ETag+Cache-Control: max-age=3on responses. A 3-second cache window matches the indexer's poll cadence — clients get fresh data exactly as fast as the chain produces it
What's NOT in 3b
- Price feed integration (ADR-0004 is the UI-side fallback; 3b doesn't compute or serve prices)
- Full-text search of order bodies (basic
ILIKEqueries are fine for launch;tsvectoris wired but exposed as a 4+ phase) WebSocket / SSE push to the frontend (polling is fine; upgrade path is clean if we want it later)Shipped in Phase E: SSE endpoints/v1/orderbook/stream,/v1/chat/stream,/v1/instances/streamare now live. Frontend consumers buffer events and flush per-rAF (seeapps/web/src/lib/orderbook/ stream.tsandapps/web/src/lib/chat/stream.ts). Each stream has a 500-event buffer cap (Audit 2026-05 findings 2-11 and NEW-10-2).- Historical reporting / analytics endpoints
- Search-by-region geolocation (strings in, strings out; no geo math)
Alternatives considered
Chain-of-state — no event log, only derived state
Smaller storage footprint, simpler schema. Rejected: rebuilding after a schema change would require re-reading the Blurt chain from launch block, hitting public RPC nodes hard and taking hours.
TimescaleDB instead of plain Postgres
TimescaleDB's hypertables are good for time-series-heavy workloads. Rejected: Morphit's queries aren't time-series-shaped. The orderbook queries "live orders matching filters" — a set operation, not a range scan. Plain Postgres is enough.
Redis for hot materialized state
Performance argument: hot-path reads (orderbook, profile) happen
often; Redis could serve them in <1ms. Rejected for 3b: adds
a second stateful component to operate. Postgres with proper
indexes + max-age=3 Cache-Control handles the traffic volume
Morphit will see in its first year. Revisit in Phase 5 if metrics
justify it.
Writing the indexer in Go instead of Node.js/TypeScript
Go is the traditional choice for a streaming indexer — high
throughput, low memory, easy static binary. Rejected: same
reason as Phase 3a — no actively-maintained Go library for Blurt
means we'd re-implement the signature-verification logic.
@beblurt/dblurt gives us the full verify path in TypeScript, and
Node 22 is fast enough for our op-rate ceiling (we won't exceed
a few hundred morphit ops per hour in the foreseeable future).
Consequences
Positive
- Single language (TypeScript) across relay + indexer + frontend,
with shared libraries (
@beblurt/dblurtprovides the same signing + verification primitives all three need) - Hybrid log + materialized strategy is standard and well-understood
- Trailing irreversibility makes reorg handling a non-issue
- REST + JSON is the broadest-compatibility API shape
Negative
- Database migrations need care. Adding a column to
ordersis easy; changing the primary key triggers a rebuild from the event log. Migration system design is a separate sub-decision (next section). - No websocket push means a 3-second worst-case staleness for UI updates. Acceptable given the orderbook's human-scale decision speed.
Migration strategy (follow-up)
Database schema changes fall into three classes:
- Additive, non-breaking (new column with default, new index):
applied in-place with standard
ALTER TABLE. No downtime. - Materialized-view rebuild: new handler logic, different derivation from event log. Rebuild the affected table from the event log, then switch over atomically in a transaction.
- Event log schema change: rare. Requires a re-read from chain. Documented as an operator task in the indexer README.
Each type gets a separate migration script, versioned sequentially.
Using a bare-minimum migration tool (single numbered .sql files
- a
schema_migrationstable tracking applied versions) rather than a framework — simplicity wins when there are few migrations and no ORM involved.
References
docs/PHASE-3a-DESIGN.md— the design doc this ADR extendsdocs/PHASE-3a-STATUS.md— what shipped in 3a- ADR-0001: custom_json immutability
- ADR-0005: Phase 3 subphase split
apps/web/src/lib/net/config.ts— OP_IDS the indexer must recognize