Skip to main content
A Built-for-Bankroll app has to remember what it sold, and the record that matters most is “this payment signature is already spent” — a fact two concurrent requests must never both succeed at writing. @joinbankroll/sdk/store is a small durable-JSON interface with exactly the two guarantees that needs, and two backends behind it: local files while you develop, object storage once you deploy. It is entirely optional. If you already run a database, use it — a UNIQUE column on the signature is the same guard. This exists so an app can take its first payment safely without provisioning anything.
Notice what the interface does not have: a balance. A Bankroll app never holds anyone’s money — the host is the wallet. The app sells things and remembers what was sold.

Pick a backend

Three subpaths, so a backend is opt-in: ./store is pure interface, ./store/fs imports only Node builtins, and ./store/vercel is the only module that touches @vercel/blob — an optional peer (>= 2.3.0), installed only if you use it. Nothing written against StoreBackend knows which is live, which is what lets the same code deploy unchanged. The filesystem root carries the environment segment for a reason: without it a test run and the app you are developing share one directory, and the obvious fixture — clear the store before each case — deletes live data. That is worse than losing dev state, because removing a spent-signature record turns an already-spent payment back into a spendable one.

The two guarantees

createIfAbsent — record a payment exactly once

An atomic create that fails if the path is taken. Losing that race is an expected outcome, not an error, so it returns false rather than throwing — real failures (network, permissions) still propagate.
This is the replay guard from Payments, and it holds without a second document to coordinate it: the id is built from the transaction, so every retry of the same payment computes the same path.

writeJson with ifMatch — compare-and-swap

A conditional write that lands only if the stored etag still matches, and throws PreconditionFailed otherwise, so a concurrent writer can’t clobber a transition. updateJson is the read-modify-write loop over it:
change sees the current value and returns the next one, or throws to abort without writing. On a lost race it re-reads and decides again against the winner’s value, which is what makes “do this exactly once” hold. It gives up after 5 attempts by default ({ attempts }).

Reading and listing

list returns a page in ascending key order, and only the page — never the whole prefix, which is what keeps it cheap at any size. A caller that wants time order puts a sortable value at the front of the key rather than sorting after the fact. That is what sortableId(slot, signature) is for. A charge’s slot is a chain-assigned, monotonic number; the id inverts it so an object store’s lexicographic ordering comes back newest-first with no post-sort, and puts the signature after it so a document is only ever addressable by its full id:
Listing returns contents, but at a read per document — neither backend returns them in the listing itself. That is inherent to asking for a collection, not a gap: the alternative is an index document, a second thing to keep in step, which is exactly what this store is shaped to avoid.

Interface

Both backends satisfy the same conformance suite, so behavior that holds in development holds in production. Two details worth knowing:
  • Reads in the money path are never cached. The Blob backend passes useCache: false, because a CDN serving a document up to 60s stale would make read-modify-write unsafe.
  • A lost createIfAbsent whose response was lost reads as taken, which refuses a payment you already recorded rather than granting it twice. That is the safe direction to be wrong in.