Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-state-supabase-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/state-supabase": minor
---

Add a Supabase-backed Chat SDK state adapter that uses RPCs for durable locks, cache, subscriptions, and list state, and ship a copy-paste SQL migration for declarative schema workflows.
30 changes: 30 additions & 0 deletions packages/state-supabase/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# @chat-adapter/state-supabase

## 4.20.1

### Minor Changes

- Add a Supabase-backed state adapter that stores Chat SDK state in Postgres via Supabase RPCs and ships a copy-paste SQL migration for declarative schema workflows.

### Patch Changes

- Updated dependencies
- chat@4.20.1

## 4.20.0

### Patch Changes

- chat@4.20.0

## 4.19.0

### Patch Changes

- chat@4.19.0

## 4.18.0

### Patch Changes

- chat@4.18.0
92 changes: 92 additions & 0 deletions packages/state-supabase/PRD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
## Title
Add `@chat-adapter/state-supabase` for Supabase-native production state

## Issue body
### Summary

I would like a Supabase-native state adapter for Chat SDK:

```ts
import { createSupabaseState } from "@chat-adapter/state-supabase";
```

Even though Supabase uses Postgres under the hood, the existing `@chat-adapter/state-pg` adapter is not a good fit for apps that are already built around Supabase as the primary database and access layer.

### Why this is needed

In my app, Supabase is already the standard way all database access is handled. I want the Chat SDK state adapter to fit into that same model instead of introducing a second, parallel database access path.

My main requirements are:

- I already use Supabase throughout the app and want to stay within that stack.
- I want to reuse the existing Supabase client from my app instead of managing a separate direct Postgres connection URL.
- I want database access to stay centrally controlled through the same Supabase client/configuration patterns I already use.
- I want schema creation and evolution to be managed through my Supabase declarative schema/migrations, not auto-created at runtime.
- I want security, grants, roles, and schema exposure to be explicitly controlled in my Supabase setup.
- I want the adapter internals to reuse Supabase best practices and stable APIs, such as `supabase-js`, PostgREST, and RPCs where appropriate.
- I want the operational convenience of using only Supabase, with no extra direct DB connection setup and no extra cache/infra dependency.

### Why `@chat-adapter/state-pg` is not enough

`@chat-adapter/state-pg` is technically Postgres-based, but operationally it solves a different problem.

For a Supabase app, the gaps are:

- It expects a direct Postgres connection or `pg` client, not a Supabase client.
- It introduces another database access mechanism alongside the rest of the app.
- It pushes me toward managing separate raw Postgres credentials/URLs instead of reusing the centrally managed Supabase access path.
- It creates its own schema objects on `connect()`, which does not fit teams that manage DB objects declaratively through Supabase migrations.
- It does not align with Supabase-specific security and schema management workflows.
- It does not take advantage of Supabase-native patterns for RPC-based atomic operations and controlled API exposure.

So while Supabase is "just Postgres" underneath, the developer workflow, security model, and operational model are materially different.

### Desired developer experience

Something like this:

```ts
import { createClient } from "@/lib/supabase/server";
import { createSupabaseState } from "@chat-adapter/state-supabase";

const supabase = await createClient();

const bot = new Chat({
userName: "mybot",
adapters,
state: createSupabaseState({ client: supabase }),
});
```

### Desired packaging/setup model

- The package should ship a copy-paste SQL migration file that users can add to their own declarative schema folder.
- The migration should support using a dedicated schema, not require `public`.
- The adapter should work against that schema using Supabase APIs.
- Runtime schema creation should not be required.
- Security/grants should remain under user control.

### Implementation direction

A good fit would be:

- `createSupabaseState({ client, keyPrefix?, logger? })`
- use `supabase-js` internally
- use standard Supabase APIs where possible
- use RPCs for the operations that require atomicity or better performance
- keep behavior functionally equivalent to the existing production Postgres adapter from a Chat SDK perspective

### Acceptance criteria

- New package: `@chat-adapter/state-supabase`
- Accepts an existing `SupabaseClient`
- Does not require direct Postgres URLs
- Ships SQL schema/migration assets for declarative setup
- Uses a dedicated non-public schema (`chat_state`) for state tables and RPCs
- Supports production state features equivalent to the Postgres adapter:
- subscriptions
- distributed locking
- key-value cache with TTL
- list operations
- Uses Supabase-native access patterns rather than raw `pg`
- Works well for apps that already standardized on Supabase as their backend platform
193 changes: 193 additions & 0 deletions packages/state-supabase/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# @chat-adapter/state-supabase

[![npm version](https://img.shields.io/npm/v/@chat-adapter/state-supabase)](https://www.npmjs.com/package/@chat-adapter/state-supabase)
[![npm downloads](https://img.shields.io/npm/dm/@chat-adapter/state-supabase)](https://www.npmjs.com/package/@chat-adapter/state-supabase)

Production Supabase state adapter for [Chat SDK](https://chat-sdk.dev). It keeps Chat SDK state inside Postgres via Supabase RPCs, so you get durable subscriptions, distributed locks, cache TTLs, and list storage without adding Redis.

This package is intended for server-side usage. In most production deployments you should pass a service-role Supabase client.

## Installation

```bash
pnpm add @chat-adapter/state-supabase
```

## Quick start

1. Copy `sql/chat_state.sql` into your declarative schema folder.
2. Add `chat_state` to your Supabase API exposed schemas.
3. Create a server-side Supabase client and pass it to `createSupabaseState()`.

```typescript
import { createClient } from "@supabase/supabase-js";
import { Chat } from "chat";
import { createSupabaseState } from "@chat-adapter/state-supabase";

const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);

const bot = new Chat({
userName: "mybot",
adapters: { /* ... */ },
state: createSupabaseState({ client: supabase }),
});
```

## Usage examples

### Using an existing server-side helper

```typescript
import { createAdminClient } from "@/lib/supabase/admin";
import { createSupabaseState } from "@chat-adapter/state-supabase";

const state = createSupabaseState({
client: createAdminClient(),
});
```

### Custom key prefix

```typescript
const state = createSupabaseState({
client: supabase,
keyPrefix: "app-name-prod",
});
```

### Triggering cleanup from application code

```typescript
await supabase
.schema("chat_state")
.rpc("chat_state_cleanup_expired", { p_key_prefix: "app-name-prod" });
```

### Copying the migration into a declarative schema repo

```bash
cp node_modules/@chat-adapter/state-supabase/sql/chat_state.sql \
supabase/schemas/11_chat_state.sql
```

## Configuration

| Option | Required | Description |
|--------|----------|-------------|
| `client` | Yes | Existing `SupabaseClient` instance |
| `keyPrefix` | No | Prefix for all state rows (default: `"chat-sdk"`) |
| `logger` | No | Logger instance (defaults to `ConsoleLogger("info").child("supabase")`) |

## Migration file

The package ships a copy-paste migration at `sql/chat_state.sql`.

It creates:

```sql
chat_state.chat_state_subscriptions
chat_state.chat_state_locks
chat_state.chat_state_cache
chat_state.chat_state_lists
```

and the RPC functions the adapter calls internally.

The migration intentionally:

- uses `jsonb` for cache and list values
- fixes the common expired-key bug in `setIfNotExists()` by allowing expired rows to be replaced atomically
- clears or refreshes list TTLs consistently across all rows in a list
- grants RPC execution only to `service_role` by default

## Why RPCs instead of direct table APIs?

Supabase table APIs are enough for some simple operations, but a production-grade state adapter still needs server-side atomicity for:

- lock acquisition with "take over only if expired" semantics
- `setIfNotExists()` that can replace expired keys
- list append + trim + TTL update in a single transaction

Using RPCs for all state operations keeps the permission model narrower, avoids exposing raw table writes as the primary API, and keeps behavior consistent across operations.

## Features

| Feature | Supported |
|---------|-----------|
| Persistence | Yes |
| Multi-instance | Yes |
| Subscriptions | Yes |
| Distributed locking | Yes |
| Key-value caching | Yes (with TTL) |
| List storage | Yes |
| Automatic schema creation | No |
| RPC-only API | Yes |
| Key prefix namespacing | Yes |

## Locking considerations

This adapter preserves Chat SDK's existing lock semantics. Lock acquisition is atomic in Postgres, but the higher-level behavior is still bounded by how Chat SDK uses locks. If your handlers can run longer than the configured lock TTL, increase the TTL or use Chat SDK's interruption/conflict patterns as appropriate.

For extremely high-contention distributed locking, a dedicated Redis-based adapter may still be a better fit.

## Cleanup behavior

The adapter does opportunistic cleanup during normal reads and writes:

- expired locks can be replaced during `acquireLock()`
- expired cache rows are deleted during `get()`
- expired list rows are deleted during `appendToList()` and `getList()`

For high-throughput deployments, run periodic cleanup as well:

```sql
select chat_state.chat_state_cleanup_expired();
```

or, for a single namespace:

```sql
select chat_state.chat_state_cleanup_expired('app-name-prod');
```

## Integration tests (Testcontainers)

The package includes integration tests that run the migration against a real Postgres instance in Docker and assert on schema, RPCs, and return shapes. They live in `src/index.test.ts` (gated by `RUN_INTEGRATION=1`) and require **Docker**; they are skipped when you run `pnpm test`.

```bash
# Unit tests only (default; integration block skipped)
pnpm test

# Integration tests (requires Docker; sets RUN_INTEGRATION=1)
pnpm test:integration
```

Integration tests:

- Start a Postgres 16 container via [testcontainers](https://github.com/testcontainers/testcontainers-node)
- Create roles required by the migration (`service_role`, `anon`, `authenticated`)
- Apply `sql/chat_state.sql`
- Call each RPC with real SQL and assert on results (connect, subscribe, lock, cache, list, cleanup)
- Run the adapter against a pg-backed fake Supabase client to verify the full path

Use these to validate schema changes, grants, and jsonb behavior before releasing.

## Security notes

- Prefer a service-role client for server-side bots and background workers.
- Do not use this adapter from browser clients.
- The migration revokes direct table access and exposes RPC execution only to `service_role` by default.
- If you intentionally loosen those grants, do so with a clear threat model.

## License

MIT
62 changes: 62 additions & 0 deletions packages/state-supabase/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"name": "@chat-adapter/state-supabase",
"version": "4.20.1",
"description": "Supabase state adapter for chat (production)",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"sql"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run --coverage",
"test:watch": "vitest",
"test:integration": "RUN_INTEGRATION=1 vitest run",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@supabase/supabase-js": "^2.99.1",
"chat": "workspace:*"
},
"repository": {
"type": "git",
"url": "git+https://github.com/vercel/chat.git",
"directory": "packages/state-supabase"
},
"homepage": "https://github.com/vercel/chat#readme",
"bugs": {
"url": "https://github.com/vercel/chat/issues"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/node": "^25.3.2",
"@types/pg": "^8.18.0",
"@vitest/coverage-v8": "^4.0.18",
"pg": "^8.20.0",
"testcontainers": "^11.12.0",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^4.0.18"
},
"keywords": [
"chat",
"state",
"supabase",
"postgres",
"production"
],
"license": "MIT"
}
Loading
Loading