Skip to content

Application document store: versioned JSONB with compare-and-swap, memory and Postgres #734

Open

Description

@blove

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 stale

Requirements the invoicing case establishes:

  1. Compare-and-swap, not last-write-wins. Two browser tabs approving the same proposal is the case this exists for. commit rejects on a stale version; the caller retries.
  2. 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.
  3. Memory implementation with identical semantics, including CAS and deep-copy in and out. This is what lets the example run with no DATABASE_URL and lets its tests run with no infrastructure. It is not a mock — it answers the same contract test.
  4. Namespacing already solved for B4's stores. B4_PG_SCHEMA and B4_PG_TABLE_PREFIX should cover app tables too, so preview and production separate the same way.
  5. No migration tooling for the v1 shape. A document store is one table shape; CREATE TABLE IF NOT EXISTS covers 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions