What chobo reads and writes, as JSON
chobo reads one kind of JSON, a scenario, and writes four: the result of a scenario, the
diagnostics and the report of chobo check --format json, what chobo api says about a book,
and what a client sends a target (chobo run --show). The keys are English whatever --lang
says; only the prose in them (a diagnostic's title, its hint) follows the language. Every output
on this page is what chobo prints for the examples, cut where … stands.
A scenario
chobo scenarios <book> prints the scenarios it makes as one list (or writes them one a file
with --out <dir>, from 001.json), and chobo run <book> --scenario <file> takes one of them
or the whole list.
$ chobo scenarios examples/inventory/inventory.book
[
{
"name": "bound: reserve.hold takes stock(sku) to 1, one above `at least 0`",
"book": "inventory",
"steps": [
{
"op": "do",
"kind": "receive",
"args": {
"delivery": "delivery-1",
"sku": "sku-2",
"qty": 2
}
},
{
"op": "hold",
"kind": "reserve",
"args": {
"order": "order-3",
"sku": "sku-2",
"qty": 1
}
}
]
},
…
- A step is an operation (
do,hold,post,void) on a transfer (kind), with its arguments by name.doandholdtake every parameter;postandvoidtake the key alone.posttakesamountstoo, every amount parameter or none (none posts all of it). - An amount is a JSON integer, in the smallest step of its unit (cents for
USD scale 2). {"op": "pass", "duration": "31 minutes"}moves the clock on: seconds, minutes, hours or days.{"op": "together", "callers": [[…], […]]}holds the operations of each caller, in order; atogetherholds eight operations at most, and nopassortogether.- The name of a scenario chobo makes starts with what it tries:
bound:,key:,hold:,pass:,moves:,together:orsame_account:. The string values are the parameter's name and a number, numbered in the order they first appear, so that no two values meet by chance. - A scenario written by hand beside a book, as
<book>.more.json, runs on every target too (examples/refunds/refunds.more.json).
The result of a scenario
$ chobo run examples/refunds/refunds.book --scenario examples/refunds/refunds.more.json --format json
{
"outcomes": [
{
"steps": [
{
"op": "do",
"kind": "sale",
"result": "done"
},
{
"op": "together",
"callers": [
[
{
"op": "hold",
"kind": "refund",
"result": "done"
}
],
[
{
"op": "hold",
"kind": "refund",
"result": "refused",
"reason": "refund_exceeds_sale"
}
]
]
},
…
"accounts": [
{
"account": "refundable",
"args": [
"A-100"
],
"posted": 3000,
"held_in": 0,
"held_out": 3000
},
…
"holds": [
{
"kind": "refund",
"key": [
"R-1"
],
"state": "held"
},
…
stepshas one entry a step, in the order of the scenario; atogetherhas its callers' answers in the order they are written, not the order they were dealt with.accountshas every account adoor aholdof the scenario names (outside accounts too, and those of refused calls), in the order the book declares the kinds, then of the arguments.holdshas every hold made, in the order of the transfer kinds, then of the keys; itsstateisheld,posted,voidedorexpired.- With
together, the result is{"outcomes": [...]}: every distinct way it can come out. A database's answer must be one of them.
Diagnostics and the report
$ chobo check tests/fixtures/split.book --format json
{
"v": 2,
"files": [
{
"file": "tests/fixtures/split.book",
"ok": true,
"diagnostics": [
{
"code": "W103",
"severity": "warning",
"file": "tests/fixtures/split.book",
"line": 13,
"col": 3,
"message": "move 1 takes from shop_balance(shop) before move 2 puts into it: when shop_balance(shop) is short at that point, the call is refused with insufficient_balance, even when the two moves together would leave enough",
"notes": [],
"excerpt": " move fee from shop_balance(shop) to fees",
"operations": [
{
"op": "do",
"kind": "sale",
"args": {
"order": "order-1",
"shop": "shop-2",
"price": 1,
"fee": 1
},
"result": "refused",
"reason": "insufficient_balance"
}
],
"hint": "write the move that puts into shop_balance(shop) first",
"fix": null
}
],
"report": [
{
"kind": "sale",
"op": "do",
"refusals": [
{
"name": "insufficient_balance",
"because": "bound",
"spends_key": true,
"example": [
…
"account": "shop_balance",
"as_written": "shop_balance(shop)",
"bound": "at least 0.00",
"move": 1
},
…
- A diagnostic has the keys every language of ritsu writes — its
code,severity(errororwarning),file,lineandcol, themessageand thenotesin the language asked for, andfix(null: chobo says how to fix it in a sentence, thehint) — and chobo's own: the source line it is about (excerpt), theoperationsthat get there, as scenario steps with what each answered, and thehintin the language asked for.vis 2 since the diagnostics took these keys (1 hadcolumnandtitle, and avin each). okis false when the file has an error; the exit code is then 1.reportis there when the book has no error: for each transfer and operation, the reasons it can be refused with (refusals), each with anexamplethat gets there.becauseisbound,key(key_conflict,already_refused),account(same_account) orhold; a bound's refusal names the account kind, the account as the move writes it, the bound and the move.spends_keysays whether the key is used up. Adoor aholdhas akeytoo: its parameters, and examples of a call that differs only outside the key (key_conflict) and of a hold made again with the key of one that ended (done_before).
The API of a book
chobo api is for another tool that calls the book, dandori among them: how to call it, what
each operation can be refused with, the life of a hold as a state machine, how IDs are made, and
what each target names its functions and types.
$ chobo api examples/refunds/refunds.book
{
"v": 1,
"book": "refunds",
"version": 1,
…
"units": [
{
"name": "USD",
"scale": 2,
"ledger": 1838891465
}
],
"accounts": [
{
"name": "refundable",
"params": [
"order"
],
"unit": "USD",
"outside": false,
"lower": {
"value": 0,
"refused_as": "refund_exceeds_sale"
},
"upper": null,
"description": "what is left to refund on the order",
"code": 25116
},
…
"machines": [
{
"name": "refund",
"over": "refund",
"carry": {
"input": "state",
"output": "next_state",
"enum": "state"
},
"held": [],
"never": [],
"once": [],
"states": [
"held",
"posted",
"voided",
"expired"
],
"initial": "held",
"final": [
"posted",
"voided",
"expired"
],
"events": [
"post",
"void",
"expire"
],
"external": [
"expire"
],
"certificate": {
…
"targets": {
"postgres": {
"schema": "refunds",
…
"mysql": {
"tables": {
"accounts": "refunds_accounts",
"calls": "refunds_calls",
"entries": "refunds_entries"
},
"checks": [
"refunds_within_lower",
"refunds_within_upper"
],
"begin": "begin /*T! pessimistic */",
"expire": "expire"
},
"typescript": {
"file": "refunds.ts",
"factories": {
"postgres": "postgres",
"tigerbeetle": "tigerbeetle",
"mysql": "mysql"
},
…
units,accountsandtransfersare the book; a unit that says whether it is with tax or without has itstax(incl_taxorexcl_tax), and one that does not has no such key; a transfer has itsoperations, each with itsrefusalsas the report has them, itscode, and itsdefinition, the hash of what it moves, which a target keeps beside each key.machineshas one state machine a transfer that holds: the states, the events, which of them happen outside the caller's hands (expire, unless the hold never expires), and acertificatewith the table of every event in every state, in the shape rulec'scertificategives a rule's state machine. dandori reads rulec's machines in this shape; it does not read chobo's yet.idssays how an ID is made, for a tool that works one out itself;targetssays what each target names: the PostgreSQL schema, functions and parameters; the MySQL family's tables, the names of their checks and thebegina call starts its transaction with; the members and types of the TypeScript, Python, Go and Rust clients, with the function that makes the book value for each database (factories), and for Rust the module, the method of each operation (doisr#do), the variants of the enum of the reasons (reasons) and the crates the client needs at the versions its tests run (crates); and the chain TigerBeetle is sent for each operation.
What a client sends
chobo run --show postgres, --show tigerbeetle or --show mysql prints, for each operation of
a scenario, what the clients chobo build writes send that target: the SQL and its arguments for
PostgreSQL; for TigerBeetle, the hold read before a post or a void, the accounts made, the opening
transfers, and the chain, field by field; for the MySQL family, the accounts a book value makes
sure of and the statements, with the values in place. The tests hold the clients of all four
languages to it, on every scenario. targets.md says what the clients do, and shows
what --show mysql prints.
$ chobo run examples/refunds/refunds.book --scenario examples/refunds/refunds.more.json --show postgres
examples/refunds/refunds.book (more: two refunds of 20.00 at the same time on a sale of 30.00, then one of 10.00): 3 steps, as sent to PostgreSQL (tenant "")
1 sale.do(order: A-100, amount: 3000)
select * from "refunds"."sale_do"('', 'A-100', 3000)
2 together, caller 1: refund.hold(request: R-1, order: A-100, amount: 2000)
select * from "refunds"."refund_hold"('', 'R-1', 'A-100', 2000)
2 together, caller 2: refund.hold(request: R-2, order: A-100, amount: 2000)
select * from "refunds"."refund_hold"('', 'R-2', 'A-100', 2000)
3 refund.hold(request: R-3, order: A-100, amount: 1000)
select * from "refunds"."refund_hold"('', 'R-3', 'A-100', 1000)
$ chobo run examples/refunds/refunds.book --scenario examples/refunds/refunds.more.json --show tigerbeetle
examples/refunds/refunds.book (more: two refunds of 20.00 at the same time on a sale of 30.00, then one of 10.00): 3 steps, as sent to TigerBeetle (tenant "")
1 sale.do(order: A-100, amount: 3000)
accounts:
account sales 069c57ca75a83bb1b62d3735a168e8ae
account refundable(A-100) 5e57413e175e6a7135612aa241f9cdb7 debits_must_not_exceed_credits
chain:
0 main sales → refundable(A-100) 3000 be4165a032199c2adfdc384159a28ab2
2 together, caller 1: refund.hold(request: R-1, order: A-100, amount: 2000)
accounts:
account refundable(A-100) 5e57413e175e6a7135612aa241f9cdb7 debits_must_not_exceed_credits
account refunded 32282c2ff4dcedae062c27e267ffe99c
chain:
0 main refundable(A-100) → refunded 2000 8bce0c8ed52efa84945d40f7587de959 pending timeout 604800
…
The IDs are made from the keys (the tenant is "" here), so a retry sends the same ones; with
--format json, the same is written field by field, as the tests compare it with what each
client sent. For the MySQL family, an operation has its statements as a client sends them one at
a time, in a transaction of its own:
$ chobo run examples/refunds/refunds.book --scenario examples/refunds/refunds.more.json --show mysql --format json
{
"scenario": "more: two refunds of 20.00 at the same time on a sale of 30.00, then one of 10.00",
"target": "mysql",
"tenant": "",
"operations": [
{
"step": 1,
"op": "do",
"kind": "sale",
"accounts": [
"069c57ca75a83bb1b62d3735a168e8ae",
"5e57413e175e6a7135612aa241f9cdb7"
],
"statements": [
"begin /*T! pessimistic */",
…
"commit"
],
"head": 2,
"tails": [
…
accountshas the IDs of the accounts adoor aholdnames, which a book value makes sure of, outside the call's transaction, the first time it names each; a post or a void has none.statementsis what the client sends, in order, for the state the steps before it left.- The first
headstatements are sent whatever the call finds, andtailshas every way the rest can go: for adoor ahold, done, the key used before, or refused by each bound it checks; for a post or a void, rolled back, or the writes that end the hold. An operation of atogethercan find another state than the one before thetogether, so the tests hold what it sent after the head to one of the tails.