better-push
Integrate your stack

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.

src/push.ts
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 usePickWhy
DrizzledrizzleAdapter(db)Your drizzle-kit generates the migration; better-push ships the table declarations.
PrismaprismaAdapter(prisma)One client, one connection pool. Add the model fragment if you also want to query bp_* yourself.
NeitherpostgresAdapter(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:

src/db/schema.ts
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";
PathWhat it is
BP_SCHEMA_SQLThe 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/schemaThe same schema as Drizzle tables, for drizzle-kit.
@better-push/core/adapters/prisma/schemaThe 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 prisma
created  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_unique is 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_idx and bp_job_due_idx are 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 CONFLICT against a partial index, row-value keyset pagination, percentile_cont. A second dialect would mean branches in 51 methods.
  • Edge runtimes. pg needs Node.

Adapter interfaces

Prop

Type

Prop

Type

On this page