Mayura with Neon
Keep Mayura's state in Neon PostgreSQL over pooled, direct or WebSocket connections, and accept Neon Auth tokens on your agent server.
7 min read
Neon details checked against Neon's documentation on connection pooling, the serverless driver, Neon Auth and branching on October 12, 2026. Mayura's makers wrote this guide.
Neon is serverless PostgreSQL: compute that scales with load, a built-in connection pooler, a
driver that connects over WebSockets where TCP isn't available, and Neon Auth, a managed better-auth whose users live
in the same database. Mayura can use all of it. Its store keeps workflows, run records and API keys in a schema of your
Neon database, and @mayurajs/auth-neon turns Neon Auth tokens into identities on your agent server.
| Status in Mayura 1.1 | |
|---|---|
| Neon's pooled connection from Vercel Functions | Tested on a real Vercel deployment; see Deploy Mayura on Vercel |
Neon's WebSocket Pool through mayura/storage-postgres/driver |
Tested on a Vercel Edge preview deployment: request-bound agent runs and a timer workflow advanced by one-shot invocations |
| Mayura's PostgreSQL store on a Neon database | Passed the hosted-database live check (scripts/live/storage-postgres.mjs) on October 12, 2026: migrations, a record and its events, and API keys |
Neon Auth tokens (@mayurajs/auth-neon) |
Unit tests with real EdDSA signatures; the live check hasn't passed against a real Neon project yet |
Three ways to connect
The Connect dialog in the Neon console shows two connection strings for each branch. The pooled one has -pooler
after the endpoint id in its host name (ep-cool-darkness-123456-pooler...) and goes through PgBouncer; the other
connects to the compute directly. The serverless driver is the third way.
| Connection | Use it for |
|---|---|
Pooled (-pooler) |
Serverless functions and anything that runs many instances. PgBouncer accepts up to 10,000 client connections and shares a smaller set of real ones |
| Direct | mayura migrate, pg_dump and pg_restore, and long-running processes that want no pooler between them and the database |
@neondatabase/serverless Pool |
Runtimes without TCP sockets, such as Vercel Edge. It speaks PostgreSQL over a WebSocket |
Mayura behind PgBouncer
Neon runs PgBouncer in transaction mode: a connection goes back to the pool after each transaction. Neon lists what
that rules out: session advisory locks, SET and RESET, LISTEN/NOTIFY, and SQL-level PREPARE.
Mayura's PostgreSQL store needs none of them. Every operation is one transaction. Its statement and lock timeouts are
SET LOCAL, which lasts only for that transaction. The lock initialize() takes so that several instances can start at
once is pg_advisory_xact_lock, which is released when the transaction ends. And it sends parameterized queries,
never a named prepared statement. So the pooled string is safe for everything Mayura does at runtime.
Migrations and backups are the exception, by Neon's advice rather than Mayura's needs: Neon recommends the direct
connection for schema migrations and always for pg_dump.
1. Install
npm install mayura pg @mayurajs/auth-neonFor the edge setup in step 4, add @neondatabase/serverless. @mayurajs/auth-neon has no dependencies and no Neon
SDK: it checks tokens with WebCrypto and your project's published keys.
2. Storage and authentication
On Node.js, whether a long-running server or a function, the store takes the connection string:
// src/mayura.ts
import { principalId } from 'mayura/auth';
import { createAgentServer } from 'mayura/server';
import { createAggregateRunRecords } from 'mayura/storage-contracts';
import { createPostgresStore } from 'mayura/storage-postgres';
import { neonAuthAuthenticator } from '@mayurajs/auth-neon';
// The pooled string; one connection per instance on functions, a few on a long-running server.
const store = createPostgresStore({ connectionString: process.env['DATABASE_URL']!, schema: 'mayura', pool: { max: 1 } });
const ready = store.initialize();
export const authenticate = neonAuthAuthenticator({
authUrl: process.env['NEON_AUTH_BASE_URL']!, // https://ep-xxx.neonauth.<region>.aws.neon.tech/neondb/auth
identity: session => session.emailVerified ? {
principalId: principalId('neon', session.userId),
projectId: 'support',
agentIds: ['support.assistant'],
capabilities: session.role === 'admin' ? ['runs:submit', 'runs:read', 'runs:cancel'] : ['runs:submit', 'runs:read'],
} : null,
});
const api = createAgentServer({
publicOrigin: 'https://agents.example.com', mounted: true, agents, authenticate,
runRecords: createAggregateRunRecords(store),
runExecution: 'request',
});
/** Answer one request to Mayura's API. */
export async function handle(request: Request): Promise<Response> {
await ready;
return api.fetch(request);
}This is the module from Deploy Mayura on Vercel, with Neon Auth in place of a placeholder authenticate.
That guide adds the route files, the cron route that advances workflows, and what we measured with exactly this
database on a real deployment. On a long-running server, drop runExecution: 'request' and raise pool.max, or use
the application module from Deployment.
NEON_AUTH_BASE_URL is your project's Neon Auth URL, from the Auth page in the Neon console. It isn't a secret:
neonAuthAuthenticator uses it to find your project's public keys.
3. What Neon Auth tokens prove
Neon Auth signs a JWT for each signed-in user. neonAuthAuthenticator accepts one only if:
- it is signed with EdDSA (Ed25519) by a key from
<authUrl>/.well-known/jwks.json; - its issuer and audience are both your Neon Auth URL's origin;
- it carries
expand hasn't expired, with 5 seconds of leeway for clock differences; - it names a user (
sub), and that user isn't banned. A ban with abanExpiresin the past no longer counts.
Your identity mapping does the rest. The example only lets in users whose email is verified, and gives admins one
extra capability. Mayura never grants anything the mapping doesn't return, and a mapping that returns something the
server can't use answers 503, never lets the caller in.
The tokens last 15 minutes, so a user who signs out or is banned can keep calling your agents with a token they
already hold until it expires. If that matters, check the user in identity: Neon Auth keeps its data in the
neon_auth schema of the same database, which your server can query.
In the browser, Neon Auth signs users in with an HTTP-only cookie. For a separate backend such as your agent server, Neon's
client hands out the JWT itself with authClient.token(); call it again when the token expires. Send it as the bearer
token:
import { createClient } from 'mayura/client';
const agents = createClient({
baseUrl: 'https://agents.example.com/',
token: async () => {
const { data, error } = await authClient.token();
if (error) throw error;
return data.token;
},
});
const run = await agents.submit('support.assistant', { message: 'Where is my order?' }, { idempotencyKey: crypto.randomUUID() });4. At the edge, with Neon's serverless driver
mayura/storage-postgres opens TCP sockets through pg, and Vercel's Edge runtime has none. There,
mayura/storage-postgres/driver runs the same store on a pool you create, and Neon's WebSocket Pool is one. Two rules
come from the driver:
- Use
Pool, notneon(). The HTTP query function runs single queries and non-interactive transactions, but Mayura runs each operation as an interactive transaction on a client it checks out withconnect(). ThePoolgives real sessions over a WebSocket. - One pool per request. On edge runtimes a WebSocket can't outlive the request that opened it, so Neon says to create the pool, use it and close it inside one handler.
Mayura can't end the pool for you, because it never ends a pool it didn't create. And it may still be reading from the
database after fetch returns, while an event stream's body is being sent. So end the pool once the last byte is out:
// src/mayura-edge.ts
import { Pool } from '@neondatabase/serverless';
import { createAgentServer } from 'mayura/server';
import { createAggregateRunRecords } from 'mayura/storage-contracts';
import { createPostgresStore } from 'mayura/storage-postgres/driver';
type WaitUntil = (work: Promise<unknown>) => void;
/** Answer one request on an edge runtime, with a pool that lives as long as the response. */
export async function handleEdge(request: Request, waitUntil: WaitUntil): Promise<Response> {
const pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
try {
const store = createPostgresStore({ driver: pool, schema: 'mayura' });
await store.initialize();
const api = createAgentServer({
publicOrigin: 'https://agents.example.com', mounted: true, agents, authenticate,
runRecords: createAggregateRunRecords(store),
runExecution: 'request',
});
const response = await api.fetch(request);
if (!response.body) { waitUntil(pool.end()); return response; }
const { readable, writable } = new TransformStream();
waitUntil(response.body.pipeTo(writable).finally(() => pool.end()));
return new Response(readable, response);
} catch (error) {
waitUntil(pool.end());
throw error;
}
}On Vercel, pass waitUntil from @vercel/functions and serve the function at /v1/* on your public origin, where
mayura/client expects the API. The cron route that advances workflows follows the same pattern: open a pool, call
runOnce({ budgetMs }) and end the pool in a finally.
Every request opens a store and initializes it: one short transaction that checks Mayura's schema. That is the cost of a pool that can't outlive its request.
Time limits are tighter at the edge. A Vercel Edge function must begin its response within 25 seconds, and a
request-bound run answers only after the run has finished. Keep each agent's limits.maxDurationMs, plus the server's
requestTimeoutMs, under that. The same goes for the cron sweep: its budget plus your longest tool's timeoutMs must
fit in 25 seconds.
Unless you need the edge, prefer the Node.js runtime. Vercel itself recommends migrating from Edge to Node.js, and
Next.js 16.3 no longer supports runtime = 'edge' in route files. Edge runtimes
covers the other platforms, including Cloudflare Workers, where a pg Pool also works; see
Run Mayura on Cloudflare Workers.
5. Migrate over the direct connection
Functions never change the schema themselves. Run the migration from CI before each release, with the direct string:
npx mayura migrate --app dist/src/app.jsGive that one step the direct connection string as DATABASE_URL, and keep the pooled one for everything that serves
traffic. Version 1 is the baseline for Mayura 1.x: migrate creates Mayura's tables if they are missing and checks the
version. Back up the schema the same way, with pg_dump -Fc -n mayura over the direct connection; see
Back up and restore.
Watch your branches
A Neon branch is a copy of a database at a point in time, and with Neon Auth it copies your users and sessions too. That makes them good for previews and tests. It also copies Mayura's schema, workflows in flight included.
Mayura guarantees that a workflow step with side effects never runs twice, but that guarantee lives in one store. A branch is a second store. If a worker or a cron route on a preview deployment advances a branch copied from production, it finds production's due workflows and runs their next steps, against real payment providers, email services and APIs, a second time.
To keep previews safe:
- Don't advance workflows on previews that point at a copy of production. Vercel runs cron jobs on production deployments only, which helps, but a worker you start yourself has no such guard.
- Branch without the data where you can. Neon documents schema-only branches, which copy the tables and none of the rows.
- Give previews their own tool credentials, such as test-mode payment keys, so a step that does run can't reach a real customer.
Good to know
- Scale to zero has a short cold start. A compute suspends after 5 minutes without activity by default, and the
next connection waits a few hundred milliseconds while it restarts. That fits well within the store's
connectionTimeoutMs(5 seconds by default), but shows up in the first request's latency. - Watch the connection count on long-running servers: each process keeps up to its pool's
maxconnections. If you see connection errors under load, check that serving processes use the pooled string. - Storage errors never contain SQL or credentials. If the database is unreachable, calls fail with
STORAGE_UNAVAILABLE; check the run before retrying anything with effects. - Keep Mayura's tables in their own schema. Your tables, Neon Auth's
neon_authschema and Mayura'smayuraschema can share one database. A schema keeps data apart; it isn't an authorization boundary.
Next steps
- Deploy Mayura on Vercel: the full function setup on Neon, and what we measured.
- Neon Auth tokens: every option and error.
- Mayura with Supabase: the same pattern on Supabase's database and Auth.