Adapters / MongoDB
MongoDB
The same library, the same conformance suite, no tables — just indexes.
Setup
import { MongoClient } from "mongodb";
import { createMongoIndexes, mongoAdapter } from "easy-ping/adapters/mongodb";
const client = new MongoClient(process.env.MONGO_URL!);
await client.connect();
const db = client.db("app");
await createMongoIndexes(db); // once at startup
export const notify = easyPing({
database: mongoAdapter(db, { client }), // pass the client, not just the db
// ...
});Everything above the database line is identical to the Postgres path — same config shape, same
send(), same routes, same client. The adapter boundary is the only thing that changes.
{ client } is what makes createNotifications atomic — a notification and its deliveries land
in one transaction, or neither does. Omit it and the adapter still works; it just stops
guaranteeing that a crash between the two writes can't leave a notification with no deliveries.
Why it needs a replica set
MongoDB only offers transactions on a replica set — a standalone mongod rejects a session
outright. A single-node replica set is enough for local development:
mongo:
image: mongo:7
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
ports: ["27017:27017"]
healthcheck:
test: mongosh --quiet --eval "try { rs.status().ok } catch (e) { rs.initiate().ok }"
interval: 2s
retries: 30Indexes instead of migrations
There are no tables on MongoDB, so there's nothing to migrate in the SQL sense —
createMongoIndexes(db) is both the setup step and the upgrade step, because createIndex is
idempotent: rerunning it after a version bump adds whatever's new and leaves existing indexes
alone. See Upgrading for how this compares to the Postgres path.
One index is worth knowing about specifically: the dedupe index is a partial unique index
(partialFilterExpression: { dedupeKey: { $type: "string" } }), because a plain unique index
treats every missing dedupeKey as the same null and would allow exactly one undeduped
notification per user — the opposite of what Postgres does with NULL. The partial filter is
what makes the two databases agree on dedupe semantics.
Change-stream wake-ups
The replica set you already run for transactions also gives you change streams, which is what
mongoSignals uses to wake other replicas' workers and event streams
when this one commits a send:
import { mongoAdapter, mongoSignals } from "easy-ping/adapters/mongodb";
export const notify = easyPing({
database: mongoAdapter(db, { client }),
signals: mongoSignals(db), // { collection?: "easy_ping_signals", size?: 1 MiB, onError? }
// ...
});
Signals are inserts into a small capped collection that the process creates on first use and watches for inserts. Old signals fall off the end; nobody reads history. Each process tags its own inserts so it does not react to its own echo.