## Summary
Refreshes the adapter docs end-to-end so every adapter — official,
vendor-official, and community — now ships hand-authored MDX, lives
under a clean URL structure, and renders on a polished
sidebar/right-rail layout dedicated to `/adapters` (the shared `/docs`
chrome is untouched).
```mermaid
flowchart LR
subgraph Before
direction TB
OB[official] --> CB[community<br/>incl. 5 vendor pages]
end
subgraph After
direction TB
OA[official] --> VA[vendor-official<br/>5 pages] --> CA[community]
end
Before -.-> After
```
### Content & routing
- **New `/adapters/vendor-official/<slug>` route** for vendor-maintained
adapters (Beeper Matrix, Photon iMessage, Liveblocks, Resend, Zernio).
Sidebar gets a third labelled group ("Vendor-Official Adapters") between
Official and Community, with a top divider matching the existing
Community treatment.
- **All 13 vendor-official + community adapters migrated** from runtime
README fetching to hand-authored MDX with rich `features:` matrices and
full body content (install, quick start, configuration, auth,
gateway/streaming, troubleshooting). README fetch stays as a fallback
for any future community adapter that hasn't been migrated yet, gated by
a new `mdxBody: true` frontmatter flag.
- **Messenger filter pages removed** (`/adapters/for/<messenger>` + the
"Browse by messenger" chip row on `/adapters`). Existing URLs
308-redirect to `/adapters`.
- **Permanent redirects** from
`/adapters/community/{matrix,imessage,resend,zernio,liveblocks}` to
their new `/adapters/vendor-official/...` paths.
- **Fixed** `/docs/adapters` and `/docs/state` so the bare pages are
accessible again — the previous catch-all redirect (`:slug*`) was
swallowing them. Switched to `:slug+` so subpath URLs still 308 while
the bare pages render.
### Visual polish
- **Adapter-only sidebar variant** (`AdaptersDocsLayout` +
`AdaptersSidebar`) with uppercase eyebrow separators, tighter rows, and
a thin themed scrollbar utility class. The shared `/docs` sidebar is
untouched.
- **Restyled `AdapterHero`**: drops the badges row + packageName, sits
the title inline with the logo, larger 17 px tagline, horizontal divider
beneath the block.
- **Restyled `PackageInstall`** as a tabbed dark single-line snippet
with a `$` prompt prefix and a copy button — replaces the previous
multi-line `CodeBlock` layout.
- **New "Deploy your chat app on Vercel" upsell card** (`<Upsell />`)
replaces the old `EditSource / ScrollTop / Feedback / CopyPage` footer
cluster on every adapter detail page.
- **Listing & messenger pages**: align the H1 to a tighter `text-4xl
sm:text-[44px]`, and the section headers to `text-base font-medium
tracking-tight` with a one-line muted lede.
### Tooling & tests
- Added `mdxBody: true` opt-in to the adapter frontmatter schema
(`source.config.ts`), and updated both detail-page handlers
(`community/[slug]` and the new `vendor-official/[slug]`) to render the
MDX body when present, falling back to README fetch otherwise.
- Refactored both detail-page handlers to flatten the body-render
branches into a `renderBody()` helper, removing the nested ternaries
that were tripping `lint/style/noNestedTernary`.
- New test file
[`packages/integration-tests/src/docs-adapters.test.ts`](https://github.com/vercel/chat/blob/docs/refresh-adapters/packages/integration-tests/src/docs-adapters.test.ts)
— **220 new assertions** covering:
- Adapter MDX frontmatter completeness, slug ↔ filename consistency, and
`type ∈ {platform, state}`.
- Vendor-official invariants: exactly the expected slugs,
`vendorOfficial: true`, `community: true`, `author`, `mdxBody: true`,
`<FeatureSupport />` rendered.
- Community invariants: `community: true` (never vendor-official),
`mdxBody: true`, `<FeatureSupport />`.
- Official invariants: never flagged, `packageName` always under
`@chat-adapter/*`.
- `adapters.json` ↔ MDX sync on `packageName` / `type` / `community` /
`vendorOfficial`.
- Extended `VALID_DOC_PACKAGES` so `docs-content.test.ts` accepts the
new vendor-official + community packages, plus `@chat-adapter/web`,
`@chat-adapter/web/react`, and `@chat-adapter/messenger`.
### Per-package AGENTS.md
- Added `AGENTS.md` to every official adapter and state adapter (14
packages), each tailored to that adapter's surface — overview, directory
layout, build/test commands, public exports, thread ID format, webhook
flow, authentication, format conversion, cards/streaming, platform
quirks, testing approach, coding conventions, and release rules.
- Added a one-line `CLAUDE.md` (`@AGENTS.md`) beside each so Claude Code
picks up the same instructions through its built-in resolver — same
convention as the root.
### Web adapter copy
- Cleaned up the Web adapter tagline (removed inline backticks) and
dropped the now-redundant "v1 scope" section from the body.
---------
Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
8.9 KiB
AGENTS.md — @chat-adapter/state-pg
Guidance for coding agents working inside the PostgreSQL state adapter package. The top-level repository AGENTS.md covers monorepo-wide build, lint, and release rules — read it first. This file documents the adapter-specific surface, conventions, and pitfalls.
Overview
@chat-adapter/state-pg persists Chat SDK state in PostgreSQL using
the pg client (with optional
support for the postgres library). Pick this adapter when:
- Postgres is already your primary datastore and you don't want to add Redis to the stack.
- You need rich query access (analytics, audit, joins) over subscriptions / cache / lists.
- You're deploying to platforms with managed Postgres (Vercel Postgres, Supabase, Neon, RDS, Cloud SQL) and prefer not to add a separate cache.
Trade-offs vs Redis: lock acquisition is slower (single-row
upsert with timestamp comparison instead of SET NX PX) and TTL
cleanup must run as a periodic job rather than via Redis's built-in
expiration.
Directory layout
packages/state-pg/
├── src/
│ ├── index.ts # PostgresStateAdapter + createPostgresState
│ └── index.test.ts # full StateAdapter contract suite
├── package.json
├── tsconfig.json
├── tsup.config.ts
├── vitest.config.ts
└── README.md
Build, test, typecheck
pnpm build
pnpm dev
pnpm test
pnpm test:watch
pnpm typecheck
pnpm clean
# from repo root
pnpm --filter @chat-adapter/state-pg build
pnpm --filter @chat-adapter/state-pg test
The contract tests use a vi.fn()-backed Postgres stub. Real-Postgres
tests live in packages/integration-tests/src/state-pg.test.ts and
run only when POSTGRES_URL is exported.
Public surface
Main exports from src/index.ts:
createPostgresState(config?)— primary factory. Auto-detectsPOSTGRES_URL(a.k.a.DATABASE_URL).PostgresStateAdapterclass — implements the Chat SDKStateAdapterinterface. Public methods cover subscriptions, locks, cache, lists, queues, plusdisconnect().PostgresStateAdapterConfig— configuration type.
Configuration
import pg from "pg";
import { createPostgresState } from "@chat-adapter/state-pg";
createPostgresState({
url: process.env.POSTGRES_URL, // or DATABASE_URL
client: existingPgPool, // bring your own pg.Pool
keyPrefix: "chat-sdk",
logger: customLogger,
schemaName: "public", // optional schema override
});
Either url, client, or one of the auto-detected env vars must be
present at runtime. The adapter creates a pg.Pool with sensible
defaults when only url is provided.
Schema
The adapter creates these tables automatically on connect():
chat_state_subscriptions (
key_prefix text not null,
thread_id text not null,
primary key (key_prefix, thread_id)
)
chat_state_locks (
key_prefix text not null,
lock_id text not null,
token text not null,
expires_at timestamptz not null,
primary key (key_prefix, lock_id)
)
chat_state_cache (
key_prefix text not null,
cache_key text not null,
value text not null,
expires_at timestamptz,
primary key (key_prefix, cache_key)
)
chat_state_lists (
key_prefix text not null,
list_key text not null,
value text not null,
expires_at timestamptz,
inserted_at timestamptz not null default now(),
id bigserial primary key
)
chat_state_queues (
key_prefix text not null,
queue_key text not null,
value text not null,
dedupe_key text,
expires_at timestamptz not null,
inserted_at timestamptz not null default now(),
id bigserial primary key,
unique (key_prefix, queue_key, dedupe_key)
)
Set schemaName to deploy the tables to a non-default schema; all
queries reference {schemaName}.{tableName}. Migrations are managed
inside connect() — there is no separate migration runner.
Locking semantics
acquireLock(lockId, ttlMs):
- Generates a unique token (UUID v4).
- Issues
INSERT ... ON CONFLICT (key_prefix, lock_id) DO UPDATE SET token = EXCLUDED.token, expires_at = EXCLUDED.expires_at WHERE chat_state_locks.expires_at <= now(). - Returns
{ token }when the row was inserted or updated,nullotherwise.
releaseLock(lockId, token):
DELETE FROM chat_state_locks WHERE key_prefix = $1 AND lock_id = $2 AND token = $3 RETURNING 1.- Returns
trueif the lock was held by the caller and released.
forceReleaseLock(lockId) is unconditional DELETE.
InnoDB-style row locking on the (key_prefix, lock_id) primary key
gives serialised acquisitions; the expires_at <= now() clause lets
expired locks be replaced atomically.
Capabilities
- Persistence — yes.
- Multi-instance — yes.
- Subscriptions — yes.
- Distributed locking — yes, atomic via
ON CONFLICTupsert. - Key-value cache — yes, with TTL.
- Lists — yes, with per-entry TTL.
- Queues — yes, with per-entry TTL + dedupe key.
- Automatic reconnect — yes (delegated to
pg.Pool). - Cluster — n/a; use a multi-replica Postgres or a connection pooler (PgBouncer).
- Sentinel — n/a.
- Key prefix namespacing — yes.
Expired row cleanup
Postgres does not expire rows automatically. The adapter performs opportunistic cleanup:
- Expired locks are overwritten on the next
acquireLockcall. - Expired cache entries are deleted on the next
getcall for the same key. - Expired queue and list entries are filtered out of reads and deleted on the next write to the same key.
For high-throughput deployments, run a periodic job:
DELETE FROM chat_state_locks WHERE expires_at <= now();
DELETE FROM chat_state_cache WHERE expires_at <= now();
DELETE FROM chat_state_lists WHERE expires_at <= now();
DELETE FROM chat_state_queues WHERE expires_at <= now();
A scheduled worker once per minute is plenty.
Connection management
The adapter uses one Postgres pool per PostgresStateAdapter
instance. If you bring your own client (pg.Pool or compatible),
the adapter assumes it is already connected and does not call
end() on disconnect().
Per-call queries are issued via pool.query — there is no implicit
transaction. Composite operations that need atomicity wrap their SQL
in BEGIN ... COMMIT blocks via pool.connect() checkout.
Error handling
28P01(invalid_password) →AuthenticationError.42501(insufficient_privilege) →AuthenticationError(with a message that points to the missing GRANT).08006,08003,08001(connection failures) →NetworkError.40001(serialization_failure) →AdapterRateLimitErrorwith a short retry-after; the adapter retries internally up to 3 times before surfacing.- Other errors propagate as plain
Errorinstances since they typically indicate programmer error.
Testing approach
- The contract tests in
index.test.tsmirror thestate-memoryandstate-redissuites. - The Postgres client is replaced with a
vi.fn()-backed stub that records SQL + parameters and returns cannedpg.Resultobjects. - Real-Postgres tests live in
packages/integration-tests/src/state-pg.test.tsand run against a local Postgres instance only whenPOSTGRES_URLis exported. - Schema migrations are tested in
index.test.tsby runningconnect()against a fresh stub and asserting on the issuedCREATE TABLEstatements.
Coding conventions
- Use named exports throughout. No default exports.
- All SQL lives at the top of
index.tsas named template-literal constants. Inline SQL strings inside method bodies are an anti-pattern. - TTLs are stored as
timestamptzcolumns — never asintervalvalues — so reads can compare againstnow()without per-row math. - Errors thrown to chat-sdk callers come from
@chat-adapter/shared(AuthenticationError,AdapterRateLimitError,NetworkError). - Top-level regex literals only.
- Use parameterized queries everywhere — never string-concatenate user input into SQL.
Releases
Behavioural changes need a changeset (pnpm changeset, choose
@chat-adapter/state-pg plus chat if a public StateAdapter type
changed). Schema-changing PRs additionally need a note in
apps/docs/content/adapters/official/postgres.mdx so existing
deployments understand what migrations the next connect() will
run.
Where to look next
- User-facing docs:
apps/docs/content/adapters/official/postgres.mdx - README:
packages/state-pg/README.md - Core StateAdapter contract:
packages/chat/src/state.ts - Sibling state adapters: