> ## Documentation Index
> Fetch the complete documentation index at: https://docs.joinbankroll.com/llms.txt
> Use this file to discover all available pages before exploring further.

# The store

> @joinbankroll/sdk/store — durable JSON with an atomic create and compare-and-swap, so a payment can't be redeemed twice. No database to provision.

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.

<Note>
  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.
</Note>

## Pick a backend

```ts theme={null}
import { fsBackend } from "@joinbankroll/sdk/store/fs";
import { vercelBlobBackend } from "@joinbankroll/sdk/store/vercel";

export const store = process.env.BLOB_READ_WRITE_TOKEN
  ? vercelBlobBackend()
  : fsBackend();
```

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.

| Backend               | Where it stores                                    | Setup                                                       |
| --------------------- | -------------------------------------------------- | ----------------------------------------------------------- |
| `fsBackend(root?)`    | `bankroll/<NODE_ENV>/` under the working directory | None.                                                       |
| `vercelBlobBackend()` | A private Vercel Blob store                        | Connect a Blob store to the project; the token is injected. |

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.

```ts theme={null}
import { confirmCharge, HSUSD_MINT } from "@joinbankroll/sdk/server";
import { sortableId } from "@joinbankroll/sdk/store";

const payment = await confirmCharge(signature);
if (payment.payee !== PAYMENT_ADDRESS) throw new Error("not paid to your address");
if (payment.mint !== HSUSD_MINT) throw new Error("paid in another asset");
if (payment.amountCents !== order.amountCents) throw new Error("wrong amount");

const id = sortableId(payment.slot, signature);
const first = await store.createIfAbsent(`purchases/${id}.json`, {
  wallet: session.user.wallet,
  amountCents: payment.amountCents,
  item: order.item,
});
if (!first) return alreadyGranted(id); // replay — do not grant twice
```

This is the replay guard from [Payments](/build/payments#confirm-on-your-server),
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:

```ts theme={null}
import { updateJson, DocumentNotFound, TooContended } from "@joinbankroll/sdk/store";

const next = await updateJson(store, `matches/${matchId}.json`, (match) => {
  if (match.status !== "open") throw new Error("already settled");
  return { ...match, status: "settled", winner };
});
```

`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 }`).

| Thrown               | Meaning                                              |
| -------------------- | ---------------------------------------------------- |
| `DocumentNotFound`   | Nothing at that path — a caller mistake.             |
| `TooContended`       | The swap never settled within `attempts` — load.     |
| `PreconditionFailed` | From `writeJson` directly: someone else wrote first. |

## Reading and listing

```ts theme={null}
const stored = await store.readJson<Purchase>(`purchases/${id}.json`);
// { value, etag } — or null. Never served stale.

const page = await store.list<Purchase>("purchases/", { limit: 20 });
// { items, cursor? } — cursor absent once the prefix is exhausted
```

`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:

```ts theme={null}
sortableId(298_000_123, "5Zx…"); // '9007198956740868-5Zx…'
sortableId(298_000_456, "9Ab…"); // '9007198956740535-9Ab…' — later slot, sorts first
```

<Note>
  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.
</Note>

## Interface

```ts theme={null}
interface StoreBackend {
  readJson<T>(pathname: string): Promise<{ value: T; etag: string } | null>;
  writeJson(pathname: string, value: unknown, ifMatch?: string): Promise<void>;
  createIfAbsent(pathname: string, value: unknown): Promise<boolean>;
  list<T>(
    prefix: string,
    options?: { limit?: number; cursor?: string },
  ): Promise<{ items: T[]; cursor?: string }>;
}
```

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.
