Skip to content

feat(postgres-storage): namespace generated stores per environment - #699

Merged
blove merged 4 commits into
mainfrom
blove/sweet-khorana-2fe64b
Sep 17, 2026
Merged

blove merged 4 commits into
mainfrom
blove/sweet-khorana-2fe64b

Conversation

@blove

@blove blove commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Closes #682.

Problem

b4 build for the hono and vercel targets emits stores.mjs, which built the checkpointer, threads and permissions stores with the package defaults: schema public, prefix b4. One Neon database attached to both the preview and production environments of a Vercel project therefore shared public.b4_* between them. The stores already accepted schema and tablePrefix; the generated wiring exposed neither.

Change

  • Generated stores.mjs reads two new per-request bindings through the same lookup as DATABASE_URL:

    Binding Store option Default
    B4_PG_SCHEMA schema public
    B4_PG_TABLE_PREFIX tablePrefix b4

    Each value is a lowercase identifier or a $NAME reference resolved the same way, so B4_PG_SCHEMA=$VERCEL_ENV is one setting that puts preview deployments in preview and production in production. B4_ENV or any other variable works the same way.

  • Unset keeps the defaults, so existing deployments continue to use public.b4_*.

  • Bad values fail loudly. A non-identifier, a reference to an unset variable, or a reference whose value is not an identifier throws an error naming the binding, before the pool is built. It does not fall back to public: a typo silently writing production data into the shared schema is the bug this prevents.

  • The isolate's migrated boolean becomes a Set keyed by schema.prefix, since the bindings are per request.

  • @b4run/postgres-storage: assertIdentifier tightens from /^[a-z_][a-z0-9_]*$/i to the case-sensitive pattern, exported as IDENTIFIER_PATTERN. Unquoted DDL folds mixed case to lowercase, so MySchema created myschema while its advisory-lock key differed from the lowercase spelling of the same tables.

Migrations already run CREATE SCHEMA IF NOT EXISTS, so a fresh schema needs no extra step.

Design notes

  • $NAME indirection was chosen over reading VERCEL_ENV automatically: Vercel sets VERCEL_ENV on every deployment, so an automatic default would move existing production deployments out of public on their next deploy.
  • Validation is duplicated in the emitted file on purpose: the store's own check would name the option (schema), the emitted check names the binding the operator set.

Tests

  • postgres-storage/test/schema.test.ts: lowercase-only pattern, IDENTIFIER_PATTERN shape.
  • postgres-storage/test/threads.test.ts (gated real Postgres, B4_TEST_PGSTORAGE=1): two stores with one prefix in two schemas hold independent data and create their tables under their own schema. Ran locally in the Testcontainers lane: 88/88.
  • cli/test/hono-target.test.ts: the emitted stores.mjs is executed with stubbed drivers for the default, literal, $VERCEL_ENV reference, runtime-env fallback, per-namespace migration pass, and four failure cases (with zero pools opened).
  • Docs: deployment/edge.mdx gains a "Namespace preview and production" section; persistence.mdx, the API reference and the package README updated; check-docs.mjs pinned phrases updated to the new wording. node scripts/check-docs.mjs passes.

Local verification: postgres-storage unit + gated lane, hono-target 55/55, vercel-target 95/95, lint and typecheck for both packages, docs check. The web api-reference-inventory analysis reports zero failures but exceeds its 60 s spawn limit on my laptop (78 s), which is unrelated to this change.

No release: changeset only.

🤖 Generated with Claude Code

The hono and vercel targets' generated stores.mjs always built the
checkpointer, threads and permissions stores in public.b4_*, so a Neon
database shared by a Vercel project's preview and production environments
shared its tables too. The stores already accepted schema and tablePrefix;
only the generated wiring lacked a way to set them.

stores.mjs now reads B4_PG_SCHEMA and B4_PG_TABLE_PREFIX per request through
the same binding lookup as DATABASE_URL. Each takes a lowercase identifier or
a $NAME reference resolved the same way, so B4_PG_SCHEMA=$VERCEL_ENV is one
setting that yields the preview and production schemas. Unset keeps the
package defaults. A bad value or dangling reference throws by binding name
before the pool exists rather than falling back to public. The isolate's
migrated flag is keyed by schema.prefix.

assertIdentifier tightens to /^[a-z_][a-z0-9_]*$/ and the package exports
IDENTIFIER_PATTERN: unquoted DDL folds mixed case to lowercase, so a
mixed-case value never named the tables it appeared to and took a different
advisory-lock key from the lowercase spelling.

Closes #682

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
b4-run Ready Ready Preview Sep 17, 2026 4:22pm UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

The persistence, edge deployment and postgres-storage API reference pages
changed in this branch, so the generated manifest's source digests no longer
matched and `generate-seo-lastmod --check` failed the web lane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects found reviewing the branch.

The isolate-level migration memo was keyed by schema and prefix alone. The
generated entry binds env per request precisely so a later request can reach a
different database, so request 2 against a virgin database was told it had
already been migrated because request 1 migrated a different one under the same
public.b4 — and every query then failed with undefined_table for the life of
the isolate. The key now includes an FNV-1a hash of the connection string,
hashed rather than stored so a password does not outlive the request.

A binding is not necessarily a string. A host can bind the name to a namespace
handle from one config typo, and calling startsWith on that threw a TypeError
naming no binding at all, defeating the named errors this path exists to give.
The hint is target-specific so the Vercel bundle carries no Workers vocabulary.

`$` alone named nothing and reported "references , which is not set".

The changeset now states the mixed-case rejection as a behavior change rather
than mentioning it in passing. It stays a patch: on this 0.x fixed group a
minor would bump every package to 1.0.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#698 restructured the generated stores.mjs around selectPostgresDriver and
moved the emitted-stores harness into test/helpers/emitted-stores.ts, both in
the code this branch rewrites.

Resolved by taking main's structure and re-applying this branch's work on top:
the naming bindings resolve before the driver is selected (still before any
pool is opened), NAMING_STORAGE_STUB joins the shared helper beside the other
store stubs, and the new tests use the helper's (appRoot, storesDir, envs)
signature. The inline cliStub gained describeConnectionTarget and
formatErrorChain, which the emitted file now imports.

The edge docs line about per-isolate migration tracking now says per database,
schema and prefix, matching the memo key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@blove
blove enabled auto-merge (squash) September 17, 2026 16:21
@blove
blove merged commit 03be72b into main Sep 17, 2026
29 of 30 checks passed
@blove
blove deleted the blove/sweet-khorana-2fe64b branch September 17, 2026 16:44
blove added a commit that referenced this pull request Sep 17, 2026
…cing

Two more vercel-target changes landed while this branch was open, and
both contradicted the guide:

- #697 closed #681 by adding `build.vercel.reconcileVercelJson`. The
  guide said flatly that there is no opt-out. It now documents the flag,
  what `b4 build` stops doing when it is off, the four `B4_E1003` near
  misses that are rejected rather than read as configured, and that
  Fluid must then be enabled in the project settings because nothing
  checks it from source.
- #699 added `B4_PG_SCHEMA` and `B4_PG_TABLE_PREFIX`. The guide said a
  generated app requires an app-dedicated database. It now carries the
  `B4_PG_SCHEMA=$VERCEL_ENV` recipe, which is the Vercel-shaped reason
  the feature exists, and links to the edge page's table for the rest.

cli.mdx conflicted and takes main's `vercel` bullet, which already
covers the opt-out, with a link to this page appended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blove added a commit that referenced this pull request Sep 17, 2026
Two follow-ups from #699.

memory-pgvector kept its own copy of assertIdentifier, still case-insensitive,
while postgres-storage tightened to lowercase. Both interpolate the value into
DDL unquoted and Postgres folds an unquoted identifier to lowercase, so
`MySchema` created `myschema` and never named the tables the store then
queried. The two packages had come to disagree about what a valid identifier
is. The copies stay separate, because memory-pgvector depends on @b4run/memory
rather than on the agent-state stores and a dependency edge purely to share
four lines would drag that package's LangGraph peers in with it; each now
carries a comment pointing at the other.

The api-reference inventory test allowed 60s for a child analysis that takes
~80s on a laptop, so it failed with ETIMEDOUT while reporting zero analysis
failures — red, and saying nothing about the docs. Raised to 180s, sized for a
loaded machine rather than close to the observed time.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active and 1 inactive deployments
Preview — a35c161b Deployed Sep 17, 2026 by vercel[bot]
vercel-preview — a35c161b Deployed Sep 17, 2026 by blove via vercel-native #1953
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

postgres-storage: per-environment schema / table prefix for generated stores

1 participant