nineveh init reads it to pin the Move layouts it needs, and nineveh validate
reports every problem at the line it's on.
name: vault
network: mainnet
start_version: auto
sources:
deposits: { event: 0xabc::vault::DepositEvent }
withdrawals: { event: 0xabc::vault::WithdrawEvent }
vaults: { resource: 0xabc::vault::Vault }
positions: { table: 0xabc::vault::Vault.positions }
state:
balances:
key: [user]
columns:
user: address
balance: { type: u128, default: 0 }
reduce:
- { on: deposits, set: { balance: "balance + amount" } }
- { on: withdrawals, set: { balance: "balance - amount" } }
vaults: { mirror: vaults }
positions: { mirror: positions }
deposit_log: { log: deposits }
api: { rest: true, graphql: true }
webhooks:
my_backend:
url: https://myapp.example/hooks/nineveh
on: [balances.changed]
Top level
| Key | Required | Meaning |
|---|---|---|
name |
yes | The project's name. |
network |
yes | mainnet, testnet or devnet. |
start_version |
no | auto (default) or a transaction version. nineveh init resolves auto to the first transaction that touched any of the sources' contract addresses, which is at or before their modules were published, and pins it in nineveh.lock. So nothing relevant is missed, and every build starts at the same place. |
sources |
yes | At least one source. |
state |
yes* | At least one state table. Optional when reducers declares them. |
reducers |
no | A reducers file holding the reduce tables and the handlers that write them, named relative to this file. |
api |
no | rest and graphql, both true by default. |
webhooks |
no | Where state changes are delivered. |
Names of the project, sources, tables and columns are lower snake case: a–z,
0–9 and _, starting with a letter, at most 63 characters. Names starting with _
are reserved for Nineveh's own columns.
Sources
Each source is one of:
| Kind | Example | Records |
|---|---|---|
event |
{ event: 0xabc::vault::DepositEvent } |
each event of that type |
resource |
{ resource: 0xabc::vault::Vault } |
each write and delete of that resource |
table |
{ table: 0xabc::vault::Vault.positions } |
each item written to or deleted from the Table, SmartTable or BigOrderedMap in that field |
A generic struct named without type arguments (0x1::coin::CoinStore) matches every
instantiation. With arguments (0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>), it
matches exactly that one. A table: source on a generic struct needs its type arguments.
A table: source follows exactly the tables held in that field. Nineveh learns each
table's handle when the struct holding it is written, so two tables with the same key
and value types, or another contract's table of the same types, never mix. The holding
struct must be stored as a resource or as a table value. BigOrderedMap fields aren't
supported yet: small maps keep their entries inside the struct itself.
Resources are needed alongside events: many contracts expose their real state only as resource writes, and state kept in tables never appears as a resource write at all.
A project whose sources are all event: sources streams only the transactions that emit
those events. Any resource: or table: source means streaming every transaction,
because the stream can't filter on resource or table writes.
State tables
Every table is built one way: reduce, mirror or log.
mirror: the latest value
vaults: { mirror: vaults }
Keeps the latest value of each resource (by address) or table item (by table and key)
from a resource or table source, and deletes it when the chain does. Columns come
from the source's layout: the key columns first, then one column per field of the value's
struct, typed as in Column types.
| Source | Key columns |
|---|---|
resource |
address, plus type for a generic struct named without type arguments |
table |
handle, key |
A table value that isn't a struct or an enum is stored in one value column. A field
that isn't a valid column name, or has the same name as a key column, is an error.
Build that table with reduce instead.
Enums. A Move enum value, such as a versioned V1/V2 event or resource, gets a
column per field that any of its variants declares, in the order the fields first
appear:
- A field every variant declares with the same type is typed like a struct's field.
- A field only some variants declare is nullable, and null for the others.
- A field declared with different types in different variants is
json.
An enum with more than one variant also gets a _variant column holding the value's
variant, such as "V2". Names starting with _ are Nineveh's, so no field clashes
with it.
log: every event
deposit_log: { log: deposits }
One append-only row per event from an event source. The key is version and
event_index (the event's position in its transaction), followed by one column per
field of the event. An enum event gets its columns as described for
mirror.
reduce: your own fold
A reduce table can be written here, or in the reducers file — event-first, with the handler for one event in one place. The two build the same thing; this section describes the YAML form.
balances:
key: [user]
columns:
user: address
balance: { type: u128, default: 0 }
memo: { type: string, nullable: true }
reduce:
- on: deposits
set: { balance: "balance + amount" }
- on: withdrawals
when: "amount > 0"
set: { balance: "balance - amount" }
- on: vaults.deleted
key: { user: "address" }
delete: true
key: the columns that identify a row.columns: each column's type, written either asname: typeor asname: { type, default, nullable }. A column isnullableif its value may be absent.defaultis its value when a new row is created by a rule that doesn't set it.reduce: rules applied to each record, in version order:on: a source name for its events or writes, or<source>.deletedfor its deletes (resources and tables only).when: optional; the rule applies only when this is true.key: optional expressions for key columns. A key column that isn't listed takes the record's field of the same name, which must exist with the column's type.- Then either
set: { column: "expression" }, which creates or updates the row, ordelete: true, which deletes it.
Any set rule may be the one that creates a row, so every non-key column needs a value
from each set rule: set it there, give it a default, or make it nullable.
Rules run in the order they're listed, each seeing the one before. Within one set,
every expression sees the row as it was before the rule, so set: { a: "b", b: "a" }
swaps. A key expression reads only the record, since the key is what finds the row.
What a rule's expressions can read. Columns of the row are read by name. From the record:
| Record | Fields |
|---|---|
| event | the event struct's fields |
| resource write | the resource's fields, plus address |
| resource delete | address |
| table write | handle, key, value |
| table delete | handle, key |
For a Move enum (versioned layouts like V1/V2), a rule can read the fields every
variant has with the same type.
A rule can also read any other reduce or mirror table a row at a time, by key:
markets[market].fee_bps is that row's fee_bps, or null if there's no such row.
See reading another table.
Expressions are typed, exact integer arithmetic up to 256 bits, with no floating point.
The full language is in expressions.md. Quote expressions when they
contain YAML punctuation: "balance + amount".
Column types
| Type | Holds |
|---|---|
bool |
bool |
u8 … u256, i8 … i256 |
integers of exactly that width |
address |
address, and Object<T> (stored as its address) |
string |
0x1::string::String |
bytes |
vector<u8> |
json |
any struct, vector or other structured value |
An Option<T> field fits a nullable column of T's type. Integers never widen
implicitly: put a u64 into a u128 column with an expression.
Defaults are written as YAML values: 0, true, "text". Wide integers can be quoted
decimal strings ("340282366920938463463374607431768211455"). Addresses must be quoted
("0x1"), because YAML reads an unquoted 0x1 as the number 1. Bytes are "0x"
followed by hex.
webhooks
Where Nineveh sends state changes. Each entry is a named endpoint, so several changes can share one URL, one secret and one delivery cursor:
webhooks:
my_backend:
url: https://myapp.example/hooks/nineveh
on: [balances.changed, holders.inserted] # or .updated, .deleted
rows: true # optional; see below
| Key | Required | Meaning |
|---|---|---|
url |
yes | Where deliveries go. |
on |
yes | The changes it wants: <table>.changed, .inserted, .updated or .deleted. |
rows |
no | Whether a delivery carries the changed row, not only its key. Default true. |
URLs must use https; plain http is accepted only for localhost. Deliveries are
signed with the endpoint's own secret, so URLs never carry credentials.
rows: false sends only the key — "this row changed, come and look". That keeps
deliveries small, and it's self-correcting: however they're retried or reordered, a
fetch always returns current state however the deliveries were retried or reordered.
Changing a project's webhooks never rebuilds its tables: they don't shape what's built.
YAML notes
- Duplicate keys are errors, not silent overrides.
- Booleans are exactly
trueandfalse;yes,no,onandoffare rejected. - Floating-point numbers are rejected everywhere.