Files
vercel__chat/packages/state-pg/AGENTS.md
Ben Sabic 79227ae991 docs: refresh adapter pages with hand-authored MDX (#474)
## 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>
2026-05-12 16:01:19 +10:00

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-detects POSTGRES_URL (a.k.a. DATABASE_URL).
  • PostgresStateAdapter class — implements the Chat SDK StateAdapter interface. Public methods cover subscriptions, locks, cache, lists, queues, plus disconnect().
  • 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, null otherwise.

releaseLock(lockId, token):

  • DELETE FROM chat_state_locks WHERE key_prefix = $1 AND lock_id = $2 AND token = $3 RETURNING 1.
  • Returns true if 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 CONFLICT upsert.
  • 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 acquireLock call.
  • Expired cache entries are deleted on the next get call 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) → AdapterRateLimitError with a short retry-after; the adapter retries internally up to 3 times before surfacing.
  • Other errors propagate as plain Error instances since they typically indicate programmer error.

Testing approach

  • The contract tests in index.test.ts mirror the state-memory and state-redis suites.
  • The Postgres client is replaced with a vi.fn()-backed stub that records SQL + parameters and returns canned pg.Result objects.
  • Real-Postgres tests live in packages/integration-tests/src/state-pg.test.ts and run against a local Postgres instance only when POSTGRES_URL is exported.
  • Schema migrations are tested in index.test.ts by running connect() against a fresh stub and asserting on the issued CREATE TABLE statements.

Coding conventions

  • Use named exports throughout. No default exports.
  • All SQL lives at the top of index.ts as named template-literal constants. Inline SQL strings inside method bodies are an anti-pattern.
  • TTLs are stored as timestamptz columns — never as interval values — so reads can compare against now() 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