Repository navigation
Conversation
B4.run persisted everything it owns — checkpoints, threads, permission grants — and nothing an application owns, so every stateful agent app rebuilt the same layer beside it, and rebuilt its bugs with it. The hashbrown invoicing example carries ~344 lines of it and only ~30 are about invoicing. `@b4run/sdk` now owns the contract and the in-process implementation: `Document`, `DocumentStore`, `ConflictError`, `createMemoryDocumentStore`. `create(value)` inserts under a generated key; `commit(key, version, value)` replaces a document only if it is still at that version; `commit(key, null, value)` inserts under a caller-supplied key and fails if the row exists, which is how a binding keyed by someone else's id is claimed exactly once across instances. One `ConflictError` class, in the package every app already depends on, so `instanceof` holds whichever backend produced it — two structurally identical classes would fail silently at the one moment the error is read. `@b4run/postgres-storage` adds `createPostgresDocumentStore`, and on the `/node` entry `documentStoreFromEnv` / `closeDocumentStoresFromEnv`: Postgres when `DATABASE_URL` is set, process memory when it is not, memoized per process and per store name over one shared pool, with a failed cold start deliberately not memoized so a database that comes back is picked up rather than replaced by a cached error. All document stores in a database share one `<prefix>_documents` table keyed `(store_name, doc_key)`; the store name is a bound parameter rather than an identifier, so an app may call a store `threads` without colliding with B4.run's own `<prefix>_threads`. `schema`/`tablePrefix` and the `B4_PG_SCHEMA` / `B4_PG_TABLE_PREFIX` bindings apply exactly as they do to the other three stores; `namingFromEnv` reads them, and takes its env explicitly because the main entry is edge-safe and `process` is undeclared on workerd. The in-process store is not a mock. It enforces compare-and-swap and deep-copies in and out, and `@b4run/testing` exports `runDocumentStoreConformance` — one suite, answered by both implementations: in-process on every run, and against real Postgres in the `B4_TEST_PGSTORAGE` lane. The table is created by a numbered, forward-only migration under the existing advisory lock (#723), not by a bare `CREATE TABLE IF NOT EXISTS`, which never alters a table that already exists and is therefore a first-boot side effect rather than a schema definition. Two rules are written where the next person will read them and pinned by `test/documents-ddl.test.ts`: a shipped migration is frozen, and no column default is load-bearing because every insert names every column. That is the exact failure the reference implementation hit when `id uuid DEFAULT gen_random_uuid()` reached new code — fresh databases got the default, older ones wrote NULL. Closes #734 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Parked as a draft until a consumer is ready. Triage 2026-10-03: the code is sound and passed full CI, and its migration follows the rules #745/#880 later adopted, but nothing in the repo consumes the store yet (the only named consumer is the external Hashbrown invoicing app) and two API questions in the description are unanswered (keep |
|
Closing as parked, per the 2026-10-03 triage rule (no consumer within a release or two). The work is preserved on branch 🤖 Generated with Claude Code |
Implements #734.
B4.run persists everything it owns — checkpoints, threads, permission grants — and nothing an application owns, so every stateful agent app rebuilds the same layer beside it. This adds that layer as a first-class store.
The API
@b4run/sdkDocument,DocumentStore,ConflictError,createMemoryDocumentStore@b4run/postgres-storagecreatePostgresDocumentStore,PostgresDocumentStore(Options),namingFromEnv,ResolvedNaming@b4run/postgres-storage/nodecreatePostgresDocumentStore(connection string),documentStoreFromEnv,closeDocumentStoresFromEnv@b4run/testingrunDocumentStoreConformanceThe contract and the in-process implementation live in
@b4run/sdk— the package every app already depends on, and onepostgres-storagealready has transitively through@b4run/permissions, so there is no new install weight. The important consequence is a singleConflictErrorclass: two structurally identical copies would be a silentinstanceoffailure at the one moment the error is ever read.One
<prefix>_documentstable per database, keyed(store_name, doc_key), rather than a table per named store. The store name is a bound parameter, never interpolated, so it may be any string and can never collide with a B4.run table — an app is free to call a storethreads.doc_keyistext, so a generated uuid and a caller-supplied thread id share one column.commit(key, null, value)isINSERT … ON CONFLICT DO NOTHING RETURNING: the row count, not a priorSELECT, decides who claimed the binding.commit(key, n, value)is a singleUPDATE … WHERE version = n RETURNING. No read-modify-write, no transaction, no lock.The DDL trap
Shipped real migrations rather than append-only
CREATE TABLE IF NOT EXISTS— and at no cost, because this repo'srunMigrationsalready does forward-only versioned migrations underpg_advisory_xact_lock(#723) for the other three stores. That is the one deviation from the issue's requirement 5, and it strictly dominates it:CREATE TABLE IF NOT EXISTSnever alters a table that already exists, which makes it a first-boot side effect rather than a schema definition.The reasoning is written on
DOCUMENTS_MIGRATIONSinpackages/postgres-storage/src/schema.ts, where the next person to change the shape will read it, and states both rules:{ version: 2, up: … }withALTER TABLE.packages/postgres-storage/test/documents-ddl.test.tspins migration 1's exact text, asserts noDEFAULTappears in any documents migration, and asserts everyINSERTthe store issues names all six columns — so editing history fails loudly with a message telling you to add version 2 instead. This is the exact failure the invoicing example hit:id uuid DEFAULT gen_random_uuid()in new code passed against a fresh database and wrote NULL into every database created before it.The memory store is not a mock
createMemoryDocumentStoreenforces CAS andstructuredClones on the way in and on the way out, and it answers the same conformance suite as Postgres —runDocumentStoreConformancein@b4run/testing, following the existingrunThreadsStoreConformancepattern. In-process on every run; against real Postgres in theB4_TEST_PGSTORAGElane. 17 cases covering CAS, claim-once inserts, concurrent races, deep-copy in both directions, andConflictError's payload.Namespacing
Reuses what exists rather than inventing a parallel mechanism: the same
assertIdentifier/IDENTIFIER_PATTERN(#682), the samerunMigrationsand its advisory lock (#723), and its own migration component so it versions independently of the other three stores.namingFromEnvreadsB4_PG_SCHEMA/B4_PG_TABLE_PREFIXwith the same$NAMEindirection the generatedstores.mjsuses, and carries the "change one, change the other" note already used betweenIDENTIFIER_PATTERNand@b4run/memory-pgvector. It takes itsenvexplicitly rather than defaulting toprocess.env— a default parameter put an unguardedprocessreference into the edge-safe main entry, whichtest/api-reference-compatibility.test.tscaught.Two things worth a reviewer's opinion
delete(key)is not in the issue's API. It is two lines in each backend and a store you cannot clean up is a leak, so it is included; say the word and it comes out.Document<T>is the name the issue proposes, and it shadows the DOMDocumentat an import site. Happy to rename it if that bothers anyone.First consumer
The hashbrown invoicing example is the intended first consumer and will delete roughly 340 lines when it adopts this —
persistence/{types,postgres,schema,memory,from-env}.ts, of which only about 30 lines are actually about invoicing.Test plan
Run from the worktree root, all green:
pnpm lint→Tasks: 29 successful, 29 totalpnpm build→Tasks: 26 successful, 26 totalpnpm typecheck→Tasks: 54 successful, 54 totalpnpm test→Test Files 578 total,Tests 6697 passed | 265 skippednode scripts/check-docs.mjs→Docs completeness check passed.pnpm check:release-inventory→pass 58, fail 0pnpm pack:check→Pack check passed.Postgres lane (Testcontainers
postgres:16, the repo's existing gated lane):B4_TEST_PGSTORAGE=1 pnpm --filter @b4run/postgres-storage test→Test Files 12 passed (12),Tests 133 passed (133)That covers the conformance suite against real Postgres plus document-store-specific cases: two named stores sharing one table without seeing each other, a second store instance reattaching to a collection, eight independent stores racing a commit and a claim (one winner, seven
ConflictErrors), eight concurrent cold starts all migrating, a jsonb value round-tripping unchanged, tables landing in the configured schema, anddocumentStoreFromEnvhonouringB4_PG_SCHEMAand recovering from a failed cold start.Scoped runs while developing:
pnpm --filter @b4run/testing test document-store-conformance→Tests 17 passed (17)(memory, no infrastructure)pnpm --filter @b4run/web test→Test Files 36 passed (36)Two notes on flakiness, both unrelated to this change and both passing in isolation on re-run:
@b4run/workspace test/source-bundle.test.ts("enforces 64 MiB total…") and@b4run/cli test/dev-command.test.ts("serializes a watched edit…") each failed once in a full-suite run on a loaded machine. Re-running each suite alone:96 passedand19 passed.Not released, not merged.
🤖 Generated with Claude Code