Skip to main content

Runtime Transactions

Use runtime transactions when a service must keep several writes consistent.

Public helpers

The root package now exposes:
  • transaction(connectionName, work, options?)
  • lockedTransaction(connectionName, lockKey, work, options?)
  • model.withTransaction(work, options?)

SQL transaction example

import { transaction } from "@alpha.consultings/eloquent-orm.js";

await transaction("pg", async (tx) => {
  await tx.execute(
    "UPDATE appointments SET status = $1 WHERE id = $2",
    ["confirmed", 42],
  );

  await tx.execute(
    "INSERT INTO audit_logs (actor_id, event_code) VALUES ($1, $2)",
    [7, "appointment.confirmed"],
  );
});

Transaction-bound model helpers

Use Model.useTransaction(tx) when you want ORM reads and writes to stay inside the active transaction.
import { transaction } from "@alpha.consultings/eloquent-orm.js";

await transaction("pg", async (tx) => {
  const appointment = await Appointment.useTransaction(tx)
    .where("practitioner_id", 18)
    .where("appointment_date", "2026-04-11")
    .forUpdate()
    .skipLocked()
    .first();

  if (!appointment) {
    await Appointment.useTransaction(tx).create({
      practitioner_id: 18,
      appointment_date: "2026-04-11",
      appointment_time: "09:00",
    });
  }
});
Lock-helper rules:
  • forUpdate() and forShare() are available only on transaction-bound SQL finders
  • skipLocked() requires forUpdate() or forShare() first
  • PostgreSQL: FOR UPDATE, FOR SHARE, SKIP LOCKED
  • MySQL: FOR UPDATE, FOR SHARE, SKIP LOCKED
  • SQLite: row-lock helpers are rejected
  • MongoDB: row-lock helpers are rejected
  • raw tx.execute(...) remains available for advanced SQL paths
Nested-helper rules:
  • a model already bound to tx may call .useTransaction(tx) again as a no-op
  • rebinding that model to a different transaction on the same connection fails fast
  • hydrated finder results keep the active transaction/session for later save() / delete() calls

SQL locking example

Use lockedTransaction(...) when one critical section must serialize concurrent writers.
import { lockedTransaction } from "@alpha.consultings/eloquent-orm.js";

await lockedTransaction("pg", "appointments:slot:2026-04-11:09:00", async (tx) => {
  const existing = await tx.queryOne(
    "SELECT id FROM appointments WHERE practitioner_id = $1 AND appointment_date = $2 AND appointment_time = $3",
    [18, "2026-04-11", "09:00"],
  );

  if (existing) {
    throw new Error("slot already booked");
  }

  await tx.execute(
    "INSERT INTO appointments (practitioner_id, appointment_date, appointment_time) VALUES ($1, $2, $3)",
    [18, "2026-04-11", "09:00"],
  );
});
Current native lock support:
  • PostgreSQL: advisory transaction lock
  • MySQL: named lock + SQL transaction
  • SQLite: no lockedTransaction(...) helper
  • MongoDB: no lockedTransaction(...) helper

Mongo transaction example

import { transaction } from "@alpha.consultings/eloquent-orm.js";

await transaction(
  "mongo",
  async (tx) => {
    await tx.collection("wallets").updateOne(
      { user_id: "u1" },
      { $inc: { balance_minor: -500 } },
      { session: tx.session },
    );

    await tx.collection("ledger_entries").insertOne(
      {
        user_id: "u1",
        amount_minor: 500,
        direction: "debit",
      },
      { session: tx.session },
    );
  },
  {
    mongo: { maxCommitTimeMS: 5000 },
  },
);
Mongo note:
  • the helper exposes db, session, and collection(name)
  • each write must pass { session: tx.session }
  • multi-document transactions still require a transaction-capable Mongo deployment

Model-owned convenience helper

const user = new User();

await user.withTransaction(async (tx) => {
  if (tx.driver === "mongo") {
    throw new Error("expected SQL context");
  }

  await tx.execute("DELETE FROM sessions WHERE user_id = $1", [42]);
});
withTransaction(...) is a convenience wrapper over the model’s configured connectionName.

Practical rule

  • keep transaction orchestration in services
  • use transaction(...) for grouped writes
  • use lockedTransaction(...) only for real race-sensitive flows
  • do not put transaction control in controllers