Postgres
Any Postgres database. You create A2's tables; A2 never changes your schema.
import { createServer } from 'experimental-a2/server'
import { postgres } from 'experimental-a2/store-postgres'
import { orders } from '@/contracts'
export const ordersServer = createServer({
contract: orders,
store: postgres({ connectionString: process.env.DATABASE_URL }),
consumers: [
/* ... */
],
})npm install pg
Works with Neon, Supabase, RDS, or your own server, including transaction-mode poolers.
Create the tables
A2 doesn't create or change tables in your database. This is the SQL it needs:
schema.sql,
also in the package at node_modules/experimental-a2/sql/postgres/schema.sql.
Apply it before your first deploy, however you run SQL against your database
or as one of your migrations, as the role your app connects with. A breakpoint
comment ends each statement, for tools that send one statement per query.
a2_batch runs the statements it's handed, so only its owner may call it,
whatever your default privileges grant new functions. An app that connects as
another role needs grant execute on function a2_batch(text[], jsonb) beside
its grants on the tables, and keeps it when you apply the file again.
A2 checks nothing up front. It relies on the table and column names, the
column types, the primary keys, the unique index on a2_events (session_id, event_id), and the a2_batch function; the other indexes keep its queries fast. A query that
finds a table, column, or the function missing throws STORE_NOT_CONFIGURED,
naming the file. Once it's applied, the next request works, with no redeploy.
a2_batch runs several statements in one round trip. Each append, claim,
completion, and consume poll is one call of it, so they hold a session's lock
only while the database runs the call, not across round trips from your app.
That's what keeps one busy session fast on a database a few milliseconds
away. It needs read committed, Postgres's default isolation level, and
throws STORE_NOT_CONFIGURED under a stricter default.
With Drizzle
drizzle-schema.ts,
next to schema.sql, shows the same tables as Drizzle definitions. It's an
example, not an API: one way to fit A2's tables into a Drizzle project is a
copy next to your own schema, so your migrations create them with yours.
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'postgresql',
// your tables, and a copy of drizzle-schema.ts:
schema: ['./db/schema.ts', './db/a2.ts'],
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_URL ?? '' },
})drizzle-kit generate then writes A2's tables into a migration you can read,
and drizzle-kit migrate applies it. Adapt the copy to your Drizzle version
and layout, as long as the names and types stay A2's.
Drizzle can't declare a function, so add a2_batch as a custom migration:
run drizzle-kit generate --custom, and paste the do block that creates
a2_batch from schema.sql into the file it makes. It creates the function
only if it's missing, so the migration is safe on any database.
A generated migration only creates tables. On a database that already has
A2's tables, from an earlier release or a schema.sql applied by hand, put
schema.sql in place of the SQL drizzle-kit generated for them: it creates
what's missing and upgrades the rest, keeping their events, and your schema
still describes them for the next generate. To keep a table of your own
from A2's logs, see SQL projections.
Upgrade
A release that changes A2's tables says so in its changelog, and its
upgrade page says when to apply its schema.sql. With
Drizzle, bring your copy of drizzle-schema.ts in line with the new one.
Live updates (polling)
By default, live streams poll every 25ms while a session is busy, backing off
to every two seconds when it's quiet, so an update from another client can take
up to two seconds, plus query latency, to reach a quiet stream. A client's own
push() refreshes its stream immediately, even when the write and the stream
run in different invocations.
Live updates (notifications)
For instant updates, pass listenConnectionString, a direct connection to the
same database (not through a pooler), and apply
notifications.sql
once, the same way as schema.sql (with Drizzle, as a custom migration:
drizzle-kit generate --custom). A missing trigger fails no query, so this is
the one thing A2 looks for: a listening store's first subscription throws
STORE_NOT_CONFIGURED until the triggers exist.
Its triggers notify listeners of every write, including writes from stores without the option. Notifications arrive after commit, a reconnect catches up from the cursor, and subscriptions also reconcile every ten seconds. Every serverless instance opens its own direct connection, so leave connection headroom.
Use postgres.js
connectionString creates a pg pool. To use
postgres.js instead, wrap it as
client:
import postgresJs from 'postgres'
import { postgres } from 'experimental-a2/store-postgres'
const url = process.env.DATABASE_URL
if (!url) throw new Error('set DATABASE_URL')
const sql = postgresJs(url, {
// A2 sends JSON as text; this keeps postgres.js from encoding it again.
types: {
json: {
to: 114,
from: [114, 3802],
serialize: (x: unknown) => (typeof x === 'string' ? x : JSON.stringify(x)),
parse: (x: string) => JSON.parse(x),
},
},
})
export const store = postgres({
client: {
query: async (text, values = []) => ({
rows: [...(await sql.unsafe(text, values))],
}),
connect: async () => {
const connection = await sql.reserve()
return {
query: async (text, values = []) => ({
rows: [...(await connection.unsafe(text, values))],
}),
release: () => connection.release(),
}
},
end: () => sql.end(),
},
})listenConnectionString still opens its listener with pg, so live
notifications need pg installed too.
Options
| Option | |
|---|---|
connectionString | creates a pg pool, shared by stores with the same settings |
client | your own pool instead: a pg Pool, PGlite, or a pooler wrapper. Its lifecycle is yours |
listenConnectionString | a direct connection for notifications |
clock, ids | test seams for timestamps and generated event ids |