@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
./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.
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
- 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
createIfAbsentwhose 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.