The shape of it
Here is a complete reducers file. Read it before the explanation; most of it explains itself.
export const balances = table({
key: { user: address },
columns: { balance: u128.default(0), deposits: u64.default(0) },
})
on(deposits, (d) => {
const b = balances.row(d.user)
b.balance += u128(d.amount)
b.deposits += 1
})
on(withdrawals, (w) => {
if (w.amount == 0) return
balances.row(w.user).balance -= u128(w.amount)
})
A table is declared once. Handlers say what each kind of record does to it. When a deposit arrives, the row for that user gains the amount and its deposit count goes up.
The file is called something like myproject.nineveh.ts, and your nineveh.yaml
names it:
name: myproject
network: testnet
reducers: ./myproject.nineveh.ts
sources:
deposits: { event: 0xabc::vault::DepositEvent }
withdrawals: { event: 0xabc::vault::WithdrawEvent }
The config holds what to follow. The reducers hold what to do about it.
No JavaScript runs — not here, not on your data. The file is parsed and compiled. It looks like TypeScript so your editor can help you, and because the shape is one every developer already knows.
Declaring a table
export const balances = table({
key: { user: address },
columns: { balance: u128.default(0), memo: string.nullable() },
})
Key columns identify a row. One deposit from 0xabc and another from 0xdef are
two rows; two deposits from 0xabc are one row, folded together. A key can be more
than one column:
key: { market: u64, day: u64 },
Every other column needs a value whenever a row is created. A handler might write
a row that doesn't exist yet, and the columns it doesn't mention still need something.
So each one must either be set by every handler that writes the table, have a
.default(…), or be .nullable(). If you forget, Nineveh tells you which column and
where.
The types are Move's, plus json for anything structured:
bool · u8 u16 u32 u64 u128 u256 · i8 i16 i32 i64 i128 i256 ·
address · string · bytes · json
Pick a column wide enough for the total, not for one record. Adding up u64 amounts
overflows a u64 column eventually; use u128. Overflow halts your project rather
than wrapping silently, so this is a decision you make once rather than a bug you find
later.
Handlers
on(deposits, (d) => { … })
deposits is a source name from your config. The parameter — d here, call it what
you like — is the record that arrived. You read its fields with a dot.
What fields it has depends on what kind of source it is:
| Source | The record holds |
|---|---|
event: |
the event's own fields |
resource: |
the resource's fields, plus address |
table: |
handle, key and value |
Resources and table items can be deleted; events can't. Handle a delete with
.deleted:
on(vaults.deleted, (v) => {
balances.row(v.address).delete()
})
Every expression can also read tx.version and tx.timestamp — the transaction's
number and its block time in microseconds.
The six statements
That is the entire language.
| Statement | What it does |
|---|---|
const x = <expr> |
names a value, so you don't repeat it |
const r = <table>.row(<key>, …) |
names the row this rule writes |
r.<column> = <expr> · += · -= |
sets a column of that row |
r.delete() |
deletes that row |
if (<expr>) { … } else { … } |
applies the writes inside only when it holds |
return |
stops the handler; later writes don't apply |
Writing a row creates it if it isn't there. There is no separate "insert" and "update" — that distinction doesn't exist here, and not having it removes a whole category of mistake.
b.balance += x reads the row's current value and adds to it. That is the ordinary
case: a reducer's job is usually to accumulate.
Values
Expressions are the same in reducers as in config, written the way JavaScript writes
them: && || !, == != < <= > >=, + - * / %, 1_000_000, 'text', and .field
for a struct's fields. Expressions is the full reference; three
things are worth knowing now.
Integers are exact and never convert silently. Adding a u64 to a u128 is an
error until you say which you meant:
b.balance += u128(d.amount) // balance is u128, amount is u64
That is deliberate. Silent widening is how money bugs happen.
Arithmetic that can't be represented stops the project. Subtracting below zero in an unsigned column, dividing by zero, a conversion that doesn't fit — each halts at that transaction with an error naming the rule, rather than storing a wrong number. Fix the reducer and replay.
Nothing is bare. A record's field is d.amount. A row's column is b.balance.
There is no naked amount that might mean either — every name says where it came from.
Reading another table
A reducer can read any other table by key. Here markets is a table the project
already has, and sales is the one being written:
export const sales = table({
key: { id: u64 },
columns: { amount: u64, fee: u64.default(0) },
})
on(sold, (s) => {
const fee_bps = markets.get(s.market)?.fee_bps ?? 0
const sale = sales.row(s.id)
sale.amount = s.amount
sale.fee = s.amount * fee_bps / 10000
})
markets.get(s.market) is a row that may not be there, which is what ?. means in
JavaScript and means here too. ?? 0 supplies a value when it isn't. If you leave out
the ??, the column you're writing has to be .nullable() — one or the other.
You can read any reduce or mirror table, including the one you're writing, where it
means the row as it was before this rule. You cannot read a log table: a log is
history, not state.
Rules apply in the order you wrote them, across every table. That matters only when one handler writes a table a later statement reads back — and then it does exactly what reading top-to-bottom suggests.
Patterns worth stealing
Running total per key. The commonest table there is.
export const volume = table({
key: { market: u64 },
columns: { total: u128.default(0), trades: u64.default(0) },
})
on(trades, (t) => {
const v = volume.row(t.market)
v.total += u128(t.size)
v.trades += 1
})
Latest value per key. No accumulation — each record overwrites.
export const quotes = table({
key: { market: u64 },
columns: { price: u64.default(0), updated: u64.default(0) },
})
on(prices, (p) => {
const q = quotes.row(p.market)
q.price = p.price
q.updated = tx.version
})
One row per day, for a chart. The day isn't in the record, so compute it from the transaction's own clock and make it part of the key.
export const daily = table({
key: { market: u64, day: u64 },
columns: { total: u128.default(0) },
})
on(trades, (t) => {
daily.row(t.market, tx.timestamp / 86_400_000_000).total += u128(t.size)
})
Something that appears and disappears. Two handlers, one table.
export const positions = table({
key: { id: u64 },
columns: { owner: address, size: u64.default(0) },
})
on(opened, (o) => {
const p = positions.row(o.id)
p.owner = o.owner
p.size = o.size
})
on(closed, (c) => {
positions.row(c.id).delete()
})
Counting only some records. A condition around the write.
export const whales = table({
key: { taker: address },
columns: { trades: u64.default(0) },
})
on(trades, (t) => {
if (t.size < 100) return
whales.row(t.taker).trades += 1
})
How it becomes rules
You don't have to know this, but it explains every error message you'll get, so it's worth two minutes.
Your handlers are compiled into rules. One rule is: when a record from this source arrives, and this condition holds, write this row's columns. The compiler builds them with one rule:
Every write is grouped by the row it targets and the condition it sits under. Each group becomes one rule.
So:
- A row named once and written three times is one rule setting three columns.
- The same row written in both arms of an
ifis two rules with opposite conditions. - An early
returncontributes its negation to everything after it.
Two consequences you will meet:
A column can only be set once per row per condition. If you write b.count twice
under the same condition, which one wins is a coin flip, so it's an error instead.
Combine them into one expression.
A row is either written or deleted, not both. Deciding between them is what if
and else are for.
What isn't here
The language is small on purpose. Everything it can express is deterministic and replayable, which is what makes editing a reducer cheap instead of a re-read of the chain.
There are no loops, no functions of your own, no let, no imports, and no calls beyond
the built-ins. Date.now() isn't blocked by a list of forbidden names — it fails
because Date isn't anything. The only names in scope are your sources, your tables,
the handler's parameter, the consts you wrote, tx, and:
u8(…)–u256(…) · i8(…)–i256(…) · min · max · abs · address
There is no create or update — writing a row creates it. There is no increment —
+= already is one.
When you get it wrong
Every problem is reported at the line it's on, in the file it's in:
error: `balances` has no column `depsits`
--> myproject.nineveh.ts:10:5
|
10 | b.depsits += 1
| ^^^^^^^
= help: did you mean `deposits`?
A few you're likely to meet:
nothing here is called 'x' — a name that isn't a source, a table, the parameter or
a const. Usually a typo, and it suggests the closest match.
'b' isn't the row this rule writes — you read a column of a row you named but
aren't writing. A rule reads its own row directly; for any other, use
table.get(key)?.column.
'balance' is set twice for the same row — see §8. Combine the two writes.
this row is both written and deleted — put the two outcomes in if and else.
A type error against the whole expression rather than one token. Expressions are checked once the contract's real types are known, and at that point the location is the expression, not the character. The message names the column and both types.
Editor support
nineveh init writes a nineveh.d.ts beside your config, holding your sources' fields
and your tables' columns. With it, an editor completes d. and b., and tsc catches
a misspelled field before you save.
It is a convenience, not the checker. It simplifies integer widths to bigint and says
nothing about exactness or overflow. nineveh validate is what decides whether a
project is correct, and it checks against the real Move types.