Database adapters
One Postgres SQL core, three drivers - pg, Drizzle, and Prisma - and who owns the schema.
better-push needs Postgres. It does not need a particular way of talking to it:
pick pg, Drizzle, or Prisma, and every capability works identically because
all three run the same SQL.
import { postgresAdapter } from "@better-push/core/adapters/postgres";
// or
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
// or
import { prismaAdapter } from "@better-push/core/adapters/prisma";Which one
| You already use | Pick | Why |
|---|---|---|
| Drizzle | drizzleAdapter(db) | Your drizzle-kit generates the migration; better-push ships the table declarations. |
| Prisma | prismaAdapter(prisma) | One client, one connection pool. Add the model fragment if you also want to query bp_* yourself. |
| Neither | postgresAdapter(url) | No ORM in the path. This is also the smallest dependency footprint. |
There is no wrong answer here in the way there usually is: the driver decides how a statement is sent, not what it does.
The three drivers
pg
import { postgresAdapter } from "@better-push/core/adapters/postgres";
export const adapter = postgresAdapter(process.env.DATABASE_URL!);Pass a connection string and better-push builds a Pool; pass a Pool you
already have and it is borrowed and never ended - whoever opened a
connection closes it. pg is an optional peer dependency, imported lazily, so
an app on Drizzle or Prisma never installs it.
schema sets the search_path for a pool better-push creates, for a database
that keeps the bp_* tables outside public:
postgresAdapter(process.env.DATABASE_URL!, { schema: "notifications" });Drizzle
import { drizzle } from "drizzle-orm/node-postgres";
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
export const adapter = drizzleAdapter(drizzle(process.env.DATABASE_URL!));Any Drizzle Postgres client works - node-postgres, postgres.js, Neon,
whichever - because the driver normalizes the two result shapes those return.
Include the table declarations in your own schema so drizzle-kit generates
the migration:
export {
bpDevice,
bpNotification,
bpDelivery,
bpPreference,
bpJob,
bpDigestWindow,
bpMetricRollup,
bpRollupState,
bpWorker,
bpAudit,
bpMigration,
} from "@better-push/core/adapters/drizzle/schema";Prisma
import { PrismaClient } from "@prisma/client";
import { prismaAdapter } from "@better-push/core/adapters/prisma";
export const adapter = prismaAdapter(new PrismaClient());better-push takes no Prisma dependency: prismaAdapter accepts anything with
$queryRawUnsafe and $transaction, so a generated client of any shape works.
transactionTimeoutMs defaults to 15 seconds rather than Prisma's own 5.
better-push opens exactly two interactive transactions - writing a notification
with its deliveries, and replacing a range of rollup buckets - and the second
one is a real amount of work on a busy hour.
prismaAdapter(prisma, { transactionTimeoutMs: 30_000 });Who owns the schema
BP_SCHEMA_SQL is the source of truth. Everything else is a way of expressing
it, and a test in this repository builds each one for real and diffs the
catalog, so they cannot drift.
import { applySchema, BP_SCHEMA_SQL, BP_TABLES } from "@better-push/core/adapters/sql";| Path | What it is |
|---|---|
BP_SCHEMA_SQL | The complete schema as idempotent DDL. Every object is IF NOT EXISTS. |
applySchema(executor) | Runs it and records the version in bp_migration. Safe to run repeatedly and concurrently. |
@better-push/core/adapters/drizzle/schema | The same schema as Drizzle tables, for drizzle-kit. |
@better-push/core/adapters/prisma/schema | The same schema as Prisma models, plus the SQL Prisma cannot express. |
Prisma cannot express a partial index
better-push has three, and one of them is load-bearing.
The first-class path is the CLI, which writes both halves for you:
npx @better-push/cli generate --orm prismacreated prisma/better-push.prisma
created prisma/better-push-indexes.sql
1. Paste better-push.prisma into your schema (or keep it as a
multi-file schema member).
2. npx prisma migrate dev
3. psql "$DATABASE_URL" -f prisma/better-push-indexes.sql
(or: npx @better-push/cli migrate, which creates everything
including these three indexes)npx @better-push/cli migrate skips all of it and creates the complete schema in one
step, and npx @better-push/cli doctor reports the missing index as an error if
you take the Prisma path and forget the SQL.
The same two artifacts are exported for a script that would rather build them itself:
import {
PRISMA_SCHEMA_FRAGMENT,
PRISMA_UNSUPPORTED_SQL,
} from "@better-push/core/adapters/prisma/schema";Paste PRISMA_SCHEMA_FRAGMENT into your schema.prisma, run
prisma migrate dev, then run PRISMA_UNSUPPORTED_SQL once:
bp_digest_window_open_uniqueis what makes a digest append a single statement. It covers only windows that are open and unclaimed, so an event arriving while a window is being rendered opens a fresh window instead of being swallowed by a digest whose items have already been read. Without it, every append opens a new window and one digest becomes twenty.bp_digest_window_due_idxandbp_job_due_idxare the sweep's and the claim's exact predicates. Without them both still work, and scan instead.
Skipping the first is not a slow digest. It is a duplicated one.
A Prisma-created database is not finished
prisma migrate builds every table, column, constraint and ordinary index
correctly. It silently omits all three partial indexes, because the schema
language has no way to say them - and it will do so again on every reset.
npx @better-push/cli doctor is the second line of defence: it reports
bp_digest_window_open_unique as an error, and the other two as warnings.
Writing a fourth driver
All 51 DatabaseAdapter methods are implemented once, as Postgres SQL over
a two-method executor. A driver for a client better-push does not ship is
therefore about 120 lines:
import { sqlAdapter, type SqlExecutor } from "@better-push/core/adapters/sql";
function executorFor(client: MyClient): SqlExecutor {
return {
query: ({ text, params }) => client.query(text, params),
transaction: (fn) => client.begin((tx) => fn(executorFor(tx))),
};
}
export const adapter = sqlAdapter(executorFor(client));Two methods, and that is deliberate: every method added to this seam has to be implemented by every driver, including ones nobody here maintains.
There is no affected-row count, because Prisma's raw API does not expose
one. Every statement that needs a count returns RETURNING id and counts the
rows. A client that cannot answer with rows cannot back better-push.
transaction must run fn on a single connection. On a pool that means
checking a client out - a BEGIN on one connection and a COMMIT on another
is not a transaction, it is two statements that happen to look like one.
Not supported
- MySQL and SQLite. The SQL is deliberately Postgres dialect:
FOR UPDATE SKIP LOCKED,ON CONFLICTagainst a partial index, row-value keyset pagination,percentile_cont. A second dialect would mean branches in 51 methods. - Edge runtimes.
pgneeds Node.
Adapter interfaces
Prop
Type
Prop
Type