Repository navigation
Description
Problem
B4.run persists everything it owns — checkpoints, threads, permissions — and nothing an application owns. So every stateful agent app rebuilds the same layer beside it.
The hashbrown invoicing example (https://invoicing.hashbrown.dev) carries ~344 lines of it, and only ~30 are about invoicing:
persistence/types.ts 67 Document<T>, ConflictError, repository interfaces ← generic but for 2 type aliases
persistence/postgres.ts 106 JSONB + version CAS over pg/drizzle ← generic
persistence/schema.ts 93 table declarations + CREATE TABLE IF NOT EXISTS ← generic
persistence/memory.ts 60 the same contract for local dev and tests ← generic
persistence/from-env.ts 18 DATABASE_URL ? postgres : memory, memoised ← generic
Nothing there is invoicing-shaped. It is "a versioned document, written with compare-and-swap, stored in Postgres when there is a database and in memory when there is not" — the same decision B4 already makes for its own stores, including the B4_PG_SCHEMA / B4_PG_TABLE_PREFIX namespacing added in #682 and the create-race fix in #723. An app that rebuilds this gets none of that for free, and rebuilds the bugs too: the invoicing version hit both a schema-creation race and a CREATE TABLE IF NOT EXISTS migration trap of its own.
Proposal
An application document store alongside the existing ones, with the same driver selection and namespacing B4 already applies:
import { documentStore } from "@b4run/sdk"; // or @b4run/postgres-storage
const sessions = await documentStore<Session>({ name: "sessions" });
const id = await sessions.create(value); // server-generated key
const doc = await sessions.load(id); // { version, value } | undefined
await sessions.commit(id, doc.version, next); // throws ConflictError when staleRequirements the invoicing case establishes:
- Compare-and-swap, not last-write-wins. Two browser tabs approving the same proposal is the case this exists for.
commitrejects on a stale version; the caller retries. - Caller-supplied keys as well as generated ones. One store is keyed by a server-generated session id, the other by B4's own thread id.
commit(key, null, value)inserts and rejects if the row already exists — that is how a thread binding is claimed exactly once under concurrency. - Memory implementation with identical semantics, including CAS and deep-copy in and out. This is what lets the example run with no
DATABASE_URLand lets its tests run with no infrastructure. It is not a mock — it answers the same contract test. - Namespacing already solved for B4's stores.
B4_PG_SCHEMAandB4_PG_TABLE_PREFIXshould cover app tables too, so preview and production separate the same way. - No migration tooling for the v1 shape. A document store is one table shape;
CREATE TABLE IF NOT EXISTScovers it, provided the DDL never needs to change (see the trap below).
A trap worth encoding
CREATE TABLE IF NOT EXISTS never alters a table that already exists. When the invoicing example moved to Drizzle, declaring id uuid DEFAULT gen_random_uuid() in the new code passed against a fresh database and broke against every database created before it — the column has no default there, and the insert wrote NULL. A framework-owned store should either ship real migrations or state the invariant that its DDL is append-only and its inserts state every column.
Why upstream
The invoicing example is the second implementation of this in the B4 codebase's orbit — @b4run/postgres-storage is the first. Every difference between them is an accident. Moving it upstream deletes ~340 lines from the example, and the next stateful app starts with a store instead of a blank file.
Filed from dog-fooding the invoicing example: source in examples/invoicing/server/src/persistence, Drizzle rewrite in liveloveapp/hashbrown#555.
Activity