easy-pingv0.7.0

Get in touch

Questions, bug reports, or anything about easy-ping. Either of these reaches me.

Emailteklumo.jembere@gmail.comTelegram@teklumt

For anything others would benefit from, a GitHub issue is better than a DM, because it's searchable.

GitHub

Adapters / Postgres · any driver

Edit this page

Postgres · any driver

Postgres without an ORM. One function — run a parameterised statement, return rows — is the entire contract.

Why this exists

The Drizzle adapter came first, but it only ever used Drizzle as a SQL builder — it imports exactly two things, sql and its type, and writes raw SQL with them. Nothing about this library needs an ORM.

So this adapter takes the query function directly. The whole contract is one signature:

type SqlQuery = (
  text: string,
  params: readonly unknown[],
) => Promise<readonly Record<string, unknown>[]>;

Run a parameterised statement, give back rows. Every Postgres driver already does this, which is why the same adapter covers all of them.

Setup

notify.ts
import { postgresAdapter } from "easy-ping/adapters/postgres";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export const notify = easyPing({
  database: postgresAdapter(
    async (text, params) => (await pool.query(text, params as unknown[])).rows,
  ),
  // ...
});

Create the tables with renderPostgresDdl(coreSchema) from easy-ping/schema, the same as any other non-Drizzle setup.

Other drivers

import postgres from "postgres";

const sql = postgres(process.env.DATABASE_URL);

postgresAdapter(
  async (text, params) => await sql.unsafe(text, params),
  { transaction: (fn) => sql.begin((tx) => fn((t, p) => tx.unsafe(t, p))) },
);

Prisma works too, via $queryRawUnsafe — though if you're already on Prisma you're paying for an ORM this adapter exists to avoid.

Transactions

The optional transaction option is what makes createNotifications atomic: a notification and its deliveries land together, or neither does.

import { pgTransaction, postgresAdapter } from "easy-ping/adapters/postgres";

postgresAdapter(query, { transaction: pgTransaction(pool) });

pgTransaction is the same sixteen lines everyone wrote by hand, in one place. The part worth centralising is the finally: drop the client.release() and every send leaks a connection until the pool is exhausted, which only shows up under load.

On another driver, write it yourself. The shape is the same:

transaction: async (fn) => {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const result = await fn(async (t, p) => (await client.query(t, p as unknown[])).rows);
    await client.query("COMMIT");
    return result;
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}
Optional

Omit it and the adapter still works — it just loses that one guarantee, so a crash between the two writes could leave a notification with no deliveries. That's the same tradeoff the MongoDB adapter makes when you don't pass the client.

Creating the tables

createPostgresTables renders the DDL from the installed version's schema and runs it, so the tables cannot drift from the package you have. Every statement is IF NOT EXISTS, so it is safe on every boot.

import { createPostgresTables } from "easy-ping/adapters/postgres";
import { pushSchema } from "easy-ping/plugins/push";

// Plugins own their own tables and export the declaration, so this needs no
// plugin instance — and therefore no provider invented just to read a schema.
await createPostgresTables(query, { plugins: [pushSchema] });
Bootstrap, not migration

This creates what is missing and never alters what exists. Once a database is live and the schema changes, use planPostgresMigration instead.

Postgres-specific, on purpose

This is not a generic SQL adapter, and calling it one would be a lie. The statements use ON CONFLICT, IS DISTINCT FROM, RETURNING and — the one that matters — FOR UPDATE SKIP LOCKED, which is what lets two concurrent sweeps step around each other instead of blocking.

The other SQL engines are their own adapters, sharing one dialect-parameterised core rather than copies of this file: MySQL spells the claim two ways depending on whether you pass a transaction, and SQLite leans on its single writer. All six backends in the repo run the same conformance suite; this one was verified against it before it shipped, including the row-lock case.

LISTEN/NOTIFY wake-ups

With more than one replica, a send() on one process should wake the worker and the bell's event stream on the others. postgresSignals does that over LISTEN/NOTIFY, with no extra table:

import postgres from "postgres";
import { postgresAdapter, postgresSignals } from "easy-ping/adapters/postgres";

const sql = postgres(process.env.DATABASE_URL!);

export const notify = easyPing({
  database: postgresAdapter((text, params) => sql.unsafe(text, params as never[])),
  signals: postgresSignals(sql),   // { channel?: "easy_ping", onError? }
  // ...
});

Every logical channel (inbox:<userId>, deliveries) travels as the payload on one Postgres channel, because Postgres channel names are identifiers and user ids are not. Each process tags its own publishes so it does not react to its own echo.

For node-postgres, LISTEN is per connection, so hand it a dedicated connected Client, not the pool:

import { Client } from "pg";
import { pgListenNotify, postgresSignals } from "easy-ping/adapters/postgres";

const listener = new Client({ connectionString: process.env.DATABASE_URL });
await listener.connect();
signals: postgresSignals(pgListenNotify(listener)),
PgBouncer

Transaction-mode pooling does not carry LISTEN. Point the listening connection at the database directly (Neon and Supabase expose a direct port next to the pooled one). If a signal is lost anyway, the stream's fingerprint probe and the cron sweep still catch up; nothing is dropped.