Build

Publish durable work atomically

Queue jobs and publish durable events with transaction alignment and idempotency metadata.

Without an executor, enqueueJob and publishDurableEvent each create their own transaction. That is fully durable, but it is not atomic with a separate domain write.

Pass the executor from ModuleService.transaction when the domain row and durable work represent one business action:

import { enqueueJob, publishDurableEvent } from "@damatjs/framework";

await users.transaction(async (executor) => {
  const user = await users.users.create({
    data: { email: "a@b.co" },
    returning: ["id", "email"],
  });

  await enqueueJob(
    "send-welcome-email",
    { userId: user.id },
    {
      executor,
      deduplication: { key: `welcome:${user.id}` },
    },
  );

  await publishDurableEvent(
    "user.created",
    { userId: user.id, email: user.email },
    {
      executor,
      correlationId: user.id,
      idempotencyKey: `user.created:${user.id}`,
    },
  );
});

The domain insert, job run, event record, and acceleration-outbox entries commit together. A rollback leaves none of them behind and emits no Redis wake-up.

Job deduplication and durable-event idempotency solve replay, not handler-side exactly-once effects. Use context.withIdempotency for database effects and pass the same stable key to external providers when supported.

Each key is bound to a canonical fingerprint of its complete durable intent. The same key and intent replay the retained result; a changed payload, tenant, policy, scheduling mode, or other intent field throws IdempotencyConflictError. Rows created before fingerprints were recorded also raise this typed conflict because their original intent cannot be verified.