markaDocs
Architecture

Serializable Prisma mutations with P2034 retry

Every Marka domain mutation is a pure function's MutationBundle, committed in one Prisma transaction at Serializable isolation and retried on P2034 conflicts.

Marka has one way to run a domain mutation, and it is the same one everywhere: a pure function decides what should happen and returns a MutationBundle, then one serializable transaction writes that bundle — the entity, its audit log, its outbox events and its reliability events — atomically. No mutation writes an audit row by hand, and none writes an entity without one.

This page describes that pipeline because it explains several things the API does that would otherwise look arbitrary: why publishing is a separate endpoint rather than a status field, why a 400 names the key you got wrong instead of ignoring it, and why an operation that returns 200 has definitely been recorded.

Get an API key to try the API on your own account, then make your first call with Getting started.

The three steps

1. A pure function decides

Domain functions live in packages/domain and do no I/O at all. They take an entity and an actor, validate, and return a description of the change. They never see a database client, which is what makes the rules testable in isolation and impossible to bypass by writing a query.

Validation is a single assertion helper. assertDomain is a TypeScript assertion function, so a guard that passes also narrows the type for everything below it:

export class DomainError extends Error {
  constructor(
    public readonly code: DomainErrorCode,
    message: string,
  ) {
    super(message);
    this.name = "DomainError";
  }
}

export function assertDomain(
  condition: unknown,
  code: DomainErrorCode,
  message: string,
): asserts condition {
  if (!condition) {
    throw new DomainError(code, message);
  }
}

The code is what surfaces to you. When a domain guard fails on an API call, that code becomes the code field of the error envelope — which is why the docs there say code is not an enum: it carries API-level codes and domain codes like listing.authority_locked or subscription.tier_too_low, and new ones appear without a contract change. Branch on the HTTP status first.

2. The function returns a bundle

The return value is not the updated entity. It is the entity plus everything that must be recorded alongside it:

export interface MutationBundle<TResult> {
  result: TResult;
  auditLogs: AuditLog[];
  outboxEvents: OutboxEvent[];
  reliabilityEvents: ReliabilityEvent[];
}

Built by domainMutation, which takes the result, the actor, one or more audit descriptors and the events to emit:

export function domainMutation<TResult>(
  result: TResult,
  actor: ActorContext,
  audits: AuditDescriptor | AuditDescriptor[],
  outboxEvents: OutboxEvent[],
  reliabilityEvents: ReliabilityEvent[] = [],
): MutationBundle<TResult>

The important consequence: the audit trail is declared in the domain function, not at the call site. A new caller of activateListing cannot forget to log that a listing was activated, because the logging is part of what activation is. The actor is an { actorType, actorId } pair where actorType is one of system, seller, buyer, support, connector or ai — so an action taken by an AI tool is distinguishable in the audit log from the same action taken by the seller in the app.

3. One transaction writes all of it

runBundleTransaction takes the bundle and a callback that writes the entity rows, and commits everything in a single transaction at Serializable isolation:

export async function runBundleTransaction<T>(
  bundle: MutationBundle<T>,
  entityWrites: BundleEntityWrites,
  options: BundleTransactionOptions = {},
): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    try {
      await db.$transaction(
        async (tx) => {
          await entityWrites(tx);
          for (const log of bundle.auditLogs) {
            await tx.auditLog.create({ data: mapAuditLogToCreateInput(log) });
          }
          for (const event of bundle.outboxEvents) {
            await tx.outboxEvent.create({ data: mapOutboxEventToCreateInput(event) });
          }
          for (const event of bundle.reliabilityEvents) {
            await tx.reliabilityEvent.create({ data: mapReliabilityEventToCreateInput(event) });
          }
        },
        {
          isolationLevel: PrismaLib.TransactionIsolationLevel.Serializable,
          ...(options.timeoutMs === undefined ? {} : { timeout: options.timeoutMs }),
        },
      );
      return bundle.result;
    } catch (err) {
      if (isSerializationConflict(err) && attempt < MAX_TX_ATTEMPTS) {
        await sleep(backoffMs(attempt));
        continue;
      }
      throw err;
    }
  }
}

Only one caller passes options. Account deletion rewrites every message, review and order a long-lived seller ever touched, so it raises Prisma's interactive-transaction timeout; every other mutation runs inside Prisma's default.

The loop around the transaction is the retry: when Postgres aborts it over a write conflict or a deadlock, the whole transaction runs again. The next section, Retrying Prisma P2034 serialization conflicts, says when, how many times and why that is safe.

Ordering is fixed: entity writes first, then audit logs, then outbox events, then reliability events. All inside the one transaction.

What this means for an API caller

A 2xx from a write endpoint means the entity change, its audit row and its events all committed. There is no window in which the listing changed but the audit did not, and no background job that might fail to record what happened. If any part could not be written, the whole call failed and you got a non-2xx.

Retrying Prisma P2034 serialization conflicts

When a Prisma transaction at Serializable isolation fails with P2034, retry the whole transaction, as the error's own message asks. runBundleTransaction does that for every domain mutation, and retries nothing else. The retry and the fixed ids below are what make Serializable isolation practical rather than merely correct.

  • Only P2034 is retried. Under Prisma 7 that one code signals both a Serializable write conflict and a deadlock, and both are worth another attempt. P2028, an interactive-transaction timeout, is deliberately not retried: a retry would just spend the timeout again.
  • 3 attempts, so at most 2 retries. If the third attempt also fails with P2034, the error is thrown to the caller and nothing of the transaction is committed. Any other error is thrown at once, on the attempt that raised it.
  • Exponential backoff with jitter. The wait before the first retry is 25 ms and before the second 50 ms, each plus a random 0 to 24 ms, so two conflicting writers do not march back into each other in lockstep.

The three pieces, as they are in packages/db/src/bundle-writer.ts:

const MAX_TX_ATTEMPTS = 3;

function isSerializationConflict(err: unknown): boolean {
  return err instanceof PrismaLib.PrismaClientKnownRequestError && err.code === "P2034";
}

function backoffMs(attempt: number): number {
  return 25 * 2 ** (attempt - 1) + Math.floor(Math.random() * 25);
}

Why running the transaction again is safe

A retry calls entityWrites again from the top, so it has to be safe to re-invoke. It is, because IDs are minted outside the transaction. createId runs in the pure domain function, before any of this:

export function createId(prefix: string): string {
  return `${prefix}_${crypto.randomUUID()}`;
}

So every attempt inserts rows with identical ids. A retry cannot leave behind a half-written first attempt with different keys: the failed attempt is rolled back, and the next one writes the same audit rows and events under the same keys. Entity writes are keyed the same way, on values the domain function fixed before the transaction began. A caller that read a value inside the transaction and wrote back an increment would break that, and no current caller does.

A worked example: publishing a listing

activateListing is the domain function behind POST /api/v1/listings/{id}/publish. It is a good example because it is nearly all guards — which is the point of it:

export function activateListing(
  listing: Listing,
  actor: ActorContext,
  options: ActivateListingOptions,
): MutationBundle<{ listing: Listing }> {
  assertMarkaAuthority(listing);
  assertDomain(
    availableQuantity(listing) > 0,
    "out_of_stock",
    listing.quantityTotal > 0
      ? "All units are held by open orders."
      : "Active listings require stock.",
  );
  validateFulfillmentFlags(listing);
  assertDomain(Boolean(listing.categoryPath), "missing_category", "Active listings require a category.");
  assertPlausibleCategoryPath(listing.categoryPath);
  assertDomain(Boolean(listing.title.trim()), "missing_title", "Active listings require a title.");
  assertDomain(
    Boolean(listing.description.trim()),
    "missing_description",
    "Active listings require a description.",
  );
  assertDomain(listing.price > 0, "invalid_price", "Active listings require a valid price.");
  assertDomain(
    listing.pickupEnabled || listing.shippingEnabled,
    "missing_fulfillment",
    "Active listings require pickup, shipping, or both.",
  );
  assertDomain(
    listing.directEnabled || listing.checkoutEnabled,
    "missing_rail",
    "Active listings require Direct, Checkout, or both.",
  );
  assertDomain(
    !(listing.shippingEnabled && !(listing.shippingFlatAmount && listing.shippingFlatAmount > 0)),
    "shipping_requires_price",
    "Shipping-enabled listings need a flat shipping price. Add one, or turn shipping off.",
  );

  if (listing.checkoutEnabled) {
    assertSellerCanAcceptCheckout(options.sellerPaymentAccount, listing.currency);
  }

  const updatedListing: Listing = { ...listing, status: "active", updatedAt: nowIso() };

  return domainMutation(
    { listing: updatedListing },
    actor,
    {
      action: "listing.activate",
      entityType: "Listing",
      entityId: listing.id,
      summary: "Activated listing.",
      metadata: { previousStatus: listing.status },
    },
    [
      buildOutboxEvent("listing.updated", "Listing", listing.id, { listing: updatedListing }),
      buildOutboxEvent("listing.published", "Listing", listing.id, { listingId: updatedListing.id }),
    ],
  );
}

Read the guard list and you have the publish contract exactly: a category, a non-empty title and description, a price above zero, at least one fulfilment option, at least one order rail, a shipping price if shipping is on, and — only if card checkout is enabled — a seller payment account capable of that currency. Every one of those failures reaches you as a 400 naming its code.

This is why PATCH cannot publish

PATCH /v1/listings/{id} has a strict body schema, so {"status": "active"} is a 400, not a silently-dropped field. That strictness is what protects the guards above: a status write that slipped through would produce an active listing that never ran a single one of them — no payment account check, no fulfilment option, possibly no price. The lifecycle is an endpoint precisely so it cannot be a field.

The event vocabulary

Outbox events carry a topic from a closed list of 29 domain event names:

listing.created          listing.updated          listing.published
listing.paused           listing.sold
order.created            order.accepted           order.cancelled
order.abandoned
payment.captured         payment.refunded         payment.chargeback_opened
payment.chargeback_updated
pickup.session_created   pickup.completed         pickup.rejected
pickup.no_show_resolved
shipment.created         shipment.delivered
review.created
channel.import_requested channel.sync_requested   channel.sync_failed
channel.drift_detected
reliability.event_created
authority.promoted
thread.created           message.sent             message.read

Each event is built with a deterministic idempotency key of the form `${topic}:${aggregateId}:${occurredAt}`, which is a unique column on the table — so the same logical event written twice collides rather than duplicating.

An outbox row is a record, not a delivery

Writing an OutboxEvent row does not, by itself, cause a worker to run. The row is the durable record that the event happened. Delivery to the background workers is a separate, explicit publish to the job engine. This matters if you are reasoning about timing: the transaction committing means the event is recorded, not that anything has reacted to it yet.

Reliability events

The third list in the bundle is a separate ledger of things that reflect on a participant rather than on an entity — a seller cancelling after accepting, a no-show at a pickup, a shipment never handed to the carrier, an item rejected at the meetup as not-as-described. They are written in the same transaction as the mutation that caused them, for the same reason the audit log is: a behavioural record that can be lost when a listener fails is not a record.

These are not exposed on the public API. They are described here because they explain why some domain functions do more than the endpoint appears to ask for.

Where to look

ConcernFile
domainMutation, createId, buildOutboxEvent, derivePriceSnapshotpackages/domain/src/helpers.ts
DomainError, assertDomainpackages/domain/src/errors.ts
MutationBundle, ActorContext, AuditDescriptorpackages/domain/src/types.ts
runBundleTransaction, MAX_TX_ATTEMPTS, backoffMspackages/db/src/bundle-writer.ts
DOMAIN_EVENT_NAMESpackages/shared/src/events.ts
activateListing and the listing guardspackages/domain/src/listings.ts

On this page