Skip to content
Get started

Using your ORM's transaction ​

send() joins whatever transaction your app is already in. It needs one thing from it: a client with a query(text, values) method that runs SQL on that transaction and returns { rows }. That's the node-postgres interface, so a pg client works as it is, and any other driver or ORM needs a wrapper of a few lines.

You usePass as client
pg (node-postgres)The transaction's client, as it is (Sending webhooks)
PrismafromPrisma(tx) from hookgres/prisma (guide)
KyselyfromKysely(trx) from hookgres/kysely (guide)
DrizzlefromDrizzle(tx), the recipe below (guide)
postgres.jsfromPostgresJs(tx), below
KnexfromKnex(trx), below
SequelizefromSequelize(sequelize, t), below
TypeORMfromTypeORM(queryRunner), below

The recipes are meant to be copied into your app, for example into db.ts. Hookgres doesn't ship them, so it doesn't depend on any ORM or follow its releases. Each recipe on this page runs in Hookgres's CI against a real Postgres: a commit must queue the webhook, a rollback must queue nothing, and a repeated idempotency key must be deduplicated.

Hookgres itself still needs a pg.Pool for its workers and for send() calls without a client, even when your app talks to the database through another driver. npm install hookgres pg, and give it the same DATABASE_URL.

Drizzle ​

Tested with Drizzle's node-postgres and postgres.js drivers. It should work with any Drizzle driver that has interactive transactions; neon-http has none, so use neon-serverless there.

ts
import { type SQL, sql } from "drizzle-orm";

/** Runs hookgres.send()'s SQL in a Drizzle transaction (node-postgres or postgres.js). */
export function fromDrizzle(tx: { execute(query: SQL): PromiseLike<unknown> }) {
  return {
    async query(text: string, values: unknown[] = []) {
      // Rebuild "... $1 ..." as Drizzle SQL with bound parameters.
      const chunks = text
        .split(/\$(\d+)/)
        .map((part, i) =>
          i % 2 ? sql`${values[Number(part) - 1]}` : sql.raw(part),
        );
      const result = await tx.execute(sql.join(chunks));
      // node-postgres returns { rows }; postgres.js returns the rows themselves.
      const rows = Array.isArray(result)
        ? result
        : (result as { rows: never[] }).rows;
      return { rows, rowCount: rows.length, command: "", oid: 0, fields: [] };
    },
  };
}
ts
await db.transaction(async (tx) => {
  await tx.update(invoices).set({ status: "paid" }).where(eq(invoices.id, id));
  await hookgres.send(
    { tenantId, eventType: "invoice.paid", payload: { id } },
    { client: fromDrizzle(tx) },
  );
});

More in the Drizzle guide.

postgres.js ​

ts
import type { TransactionSql } from "postgres";

/** Runs hookgres.send()'s SQL in a postgres.js transaction (`sql.begin`). */
export function fromPostgresJs(tx: TransactionSql) {
  return {
    async query(text: string, values: unknown[] = []) {
      // Send strings as text (oid 25). Otherwise postgres.js JSON-encodes the
      // payload, which Hookgres has already encoded, a second time for jsonb.
      const params = values.map((v) =>
        typeof v === "string" ? tx.typed(v, 25) : v,
      );
      const rows = [...(await tx.unsafe(text, params as never[]))] as never[];
      return { rows, rowCount: rows.length, command: "", oid: 0, fields: [] };
    },
  };
}
ts
await sql.begin(async (tx) => {
  await tx`UPDATE invoices SET status = 'paid' WHERE id = ${id}`;
  await hookgres.send(
    { tenantId, eventType: "invoice.paid", payload: { id } },
    { client: fromPostgresJs(tx) },
  );
});

Knex ​

Knex binds ? placeholders, so the recipe turns Hookgres's $1, $2 placeholders into ? and escapes any literal ?.

ts
import type { Knex } from "knex";

/** Runs hookgres.send()'s SQL in a Knex transaction (`knex.transaction`). */
export function fromKnex(trx: Knex.Transaction) {
  return {
    async query(text: string, values: unknown[] = []) {
      // Knex binds "?" placeholders: turn each $n into ? and order the values to match.
      const bindings: unknown[] = [];
      const knexText = text
        .replaceAll("?", "\\?")
        .replace(/\$(\d+)/g, (_, n) => {
          bindings.push(values[Number(n) - 1]);
          return "?";
        });
      const { rows } = await trx.raw(knexText, bindings as Knex.ValueDict[]);
      return {
        rows: rows as never[],
        rowCount: rows.length,
        command: "",
        oid: 0,
        fields: [],
      };
    },
  };
}
ts
await knex.transaction(async (trx) => {
  await trx("invoices").where({ id }).update({ status: "paid" });
  await hookgres.send(
    { tenantId, eventType: "invoice.paid", payload: { id } },
    { client: fromKnex(trx) },
  );
});

Sequelize ​

ts
import type { Sequelize, Transaction } from "sequelize";

/** Runs hookgres.send()'s SQL in a Sequelize transaction. */
export function fromSequelize(sequelize: Sequelize, transaction: Transaction) {
  return {
    async query(text: string, values: unknown[] = []) {
      const [results] = await sequelize.query(text, {
        bind: values,
        transaction,
        raw: true,
      });
      const rows = results as never[];
      return { rows, rowCount: rows.length, command: "", oid: 0, fields: [] };
    },
  };
}
ts
await sequelize.transaction(async (t) => {
  await Invoice.update({ status: "paid" }, { where: { id }, transaction: t });
  await hookgres.send(
    { tenantId, eventType: "invoice.paid", payload: { id } },
    { client: fromSequelize(sequelize, t) },
  );
});

TypeORM ​

ts
import type { QueryRunner } from "typeorm";

/** Runs hookgres.send()'s SQL on a TypeORM query runner inside a transaction. */
export function fromTypeORM(queryRunner: QueryRunner) {
  return {
    async query(text: string, values: unknown[] = []) {
      const { records } = await queryRunner.query(text, values, true);
      const rows = records as never[];
      return { rows, rowCount: rows.length, command: "", oid: 0, fields: [] };
    },
  };
}
ts
await dataSource.transaction(async (manager) => {
  await manager.update(Invoice, { id }, { status: "paid" });
  await hookgres.send(
    { tenantId, eventType: "invoice.paid", payload: { id } },
    { client: fromTypeORM(manager.queryRunner!) },
  );
});

Another driver or ORM ​

Write the same wrapper. It has to:

  1. Run the SQL on the transaction's own connection. A wrapper around the pool or the ORM's top-level client runs outside the transaction, so a rollback no longer removes the webhook.
  2. Send values as bound parameters for $1, $2, ... (rewrite them if your driver uses ?, as the Knex recipe does). Send string values as plain text: Hookgres passes the payload already JSON-encoded, and a driver that JSON-encodes jsonb parameters itself would store it as a string. The postgres.js recipe shows the fix.
  3. Return { rows } with the rows as Postgres sent them. Turn off anything that renames columns, such as camel-case plugins: send() reads Hookgres's own column names. Timestamps can come back as Date objects or strings; Hookgres parses both.

To test it, send inside a transaction and check that hookgres.messages has the payload as a JSON object, then roll one back and check that nothing was added.

If your stack has no transactions at all, such as the Supabase JavaScript client, call send() after the change is saved instead. You lose the guarantee that the webhook commits with your data, but everything else works.

Released under the MIT License.