@loess/committer
A committer reads a circle's waiting transactions in storage's order, checks each one, runs the circle's program on it, and commits the result as the circle's next head. A server, an agent, and a device run the same code with their own layers, and the package imports no device code, so a server that only commits needs nothing else. It also holds what an owner does to a circle record: creating a circle, and committing a new record.
import { commitCircle } from '@loess/committer';
// A server that commits one circle for as long as the scope is open.
yield * Effect.forkScoped(commitCircle(circle, server));Architecture shows where a committer sits, and protocol holds the records it reads and writes.
What it promises
A committer settles each waiting transaction once, in storage's order. It skips none: every transaction after the head's inboxCounter is accepted or rejected in the next commit, and a transaction whose writer, device, and deviceCounter a commit already settled is dropped, so a transaction sent twice is committed once, and a writer can't settle another writer's transaction by reusing its device and counter. It commits a transaction only if the program accepts it, and commits its changes as the writer wrote them.
Interface
type MemberKeys = { member: MemberId; sign: Signer; box: HeldSealer };
function commitCircle(
circle: CircleId,
member: MemberKeys,
options?: { sqlite?: WebAssembly.Module; now?: () => number },
): Effect<never, CommitFailure, Storage | Relay | Programs>;
function openCommitter(
circle: CircleId,
member: MemberKeys,
options?: { sqlite?: WebAssembly.Module; now?: () => number },
): Effect<
{ commitOnce: Effect<void, CommitFailure>; headNumber: () => number },
NotListed | CommitFailure,
Storage | Programs | Scope
>;A member acts in a circle with its MemberKeys. For an identity, sign is the identity's write key and box its box key, which every device of the identity holds. For a key member, such as a server, they are its own keys. member opens the circle's key box, and signs each head beside the commit key.
openCommitter reads the head, checks the circle records back to the first, opens the member's key box, and loads the circle's database and program, which it keeps for the life of the scope. It fails with NotListed if the circle record doesn't list the member as a committer. Each commitOnce:
- reads the head and the inbox in one storage read, and opens the circle again if another commit stored a newer head;
- takes each transaction after the head's
inboxCounter, checks it, runs the program, and writes it to the database or rejects it; - seals the changed pages, the page map leaves they fall in, and a new root with the hashes of the first pages, and stores them with the commit and the next head;
- on
LostRace, opens the circle again from the newer head and repeats from step 2, so it returns once nothing is waiting.
If a transaction is missing from storage, or refers to a missing blob, step 2 stops before it, and commitOnce commits the transactions before it and reads the inbox again every 250 ms. A device writes a post while its photos upload, so the post can reach the inbox first, and storage swaps the inbox while it stores the transaction, so the inbox entry can be stored one put before its transaction. The committer rejects a transaction with blob once it has waited 60 seconds for a blob, and with seal once it has waited 5 seconds for itself, so a transaction whose put failed holds up the circle for 5 seconds at most.
If a commitOnce fails, such as when storage can't be reached, the database may hold transactions that no stored commit holds, so the next commitOnce opens the circle again from the stored head first. The same committer goes on, and a caller doesn't open a new one.
commitCircle runs commitOnce once, then after each head notice from the relay, and each transaction notice with an inboxCounter. A round reads the inbox again instead of trusting the notices, so a lost notice can't stall the loop. A server that a request wakes, such as the host Worker's committer service, runs commitOnce once for each request.
sqlite is a compiled sqlite3.wasm for a runtime that can't compile WebAssembly from bytes, such as a Cloudflare Worker. now is the clock a commit records and a program reads, Date.now by default.
Rejections
A rejected transaction stays on record in the commit with its reason:
| Reason | When |
|---|---|
seal | the transaction doesn't open with the circle's keys |
signature | the write key of its key version or the writer didn't sign it, or the writer's identity has a newer write key |
role | the writer holds no role that writes |
join request | a link's key signed a transaction that isn't a join request, or a member's transaction changes circle/join, which only a join request sets |
blob | it refers to a blob storage still doesn't hold after 60 seconds |
conflict | a change's replaces doesn't match the current value; the rejection carries that value |
program failed | the program crashed or passed a limit |
| any other | the program rejected it with that reason |
A writer holds a role if the member list lists it, or if a committed join request admitted it from a link the record still lists. From a link's key, a committer accepts only a join request, and commits it with a key box for the joiner, so from that head the joiner is a member with the link's role.
Circles and records
| Export | What it does |
|---|---|
createCircle | makes head 1: a first record that its own key signs, listing the owner as an owner and a committer, a commit that accepts nothing, no pages, a key box for everyone, and an owner box |
commitRecord | an owner's commit of a new circle record, outside the inbox, with keys set to same, data, or owners |
identitySecretsOf | the secrets and the nonce of an identity's first record, derived from its recovery phrase's seed |
openCircle | reads a circle's head, checks its records, and opens the member's key box, owner box, and member list. It fails with CircleUnreadable when it can't |
readIdentityKeys | reads the heads of a list of identities, in two storage reads for any number of them and a third when a record changed, and gives each one's write and box keys only if its head checks against its records back to the first, so storage can't swap in keys of its own |
storedPages | the PageSource of one head, read from storage and opened, with the page map's root and leaves |
checkTransaction | the checks a committer makes before it runs the program |
withPages | runs an effect again after fetching the pages it found missing |
commitRecord's keys says what changes. same keeps the key version, and only a member or committer the record adds gets a key box. data makes the next key version, as after removing a member. owners also makes new commit and own keys, as after removing an owner or a committer. A new version seals a key box for every member and committer that stays, and an owner box for every owner. A member that is also a committer has one key box id, so it gets one box that holds the commit key too.
The owner signs the record, and committer, a committer that the new record lists, signs the head. It is the owner when you leave it out. A passkey prompts for each signature, so when a passkey adds a device, passing the device as committer saves a prompt.
Keys in detail
A key box holds the circle key and the box key for everyone; the write key for a writer, an owner, or a committer; and the commit key for an owner or a committer. It is sealed to the box key of its member or committer. Each box also holds every earlier version's circle key, box key, and public write key, so a new member or a new device opens older pages and older boxes from one box, and a committer checks a transaction that waited through a key change.
The own key is in an owner box of its own, and only an owner can make one. An owner box's id comes from the owner's box key and the circle's box key at that record's key version, so an owner works out the id only after it opens its key box, and reads the owner box one round trip later.
An identity's first record lists only the phrase, and identitySecretsOf derives every byte of it from the phrase, the seal's nonce included, so the phrase alone gives the id. Its head 1 holds no pages. Sign-up then commits a second record with owners, which adds the passkey and the device with new commit and own keys.
To remove a lost phone, the design has the owner's device make the identity's next keys first, which the phone never gets. It then commits a record with new commit and own keys for each circle the identity owns, with the boxes sealed to the identity's next box key, because the phone still opens a box sealed to the current one. Last, it commits the identity's record without the phone. This and a committer that makes a new key version when a member's identity removes a device are not built yet.
Tests
pnpm vitest run --project committer runs the commit loop in process over memory storage and a memory relay: it settles the waiting transactions in storage's order, rejects what the program rejects and a blob still missing after the wait, commits the transactions before one whose blob is uploading, and stores the next head. The host Worker's committer service runs it in miniflare.