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.
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
P2034is 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.readEach 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
| Concern | File |
|---|---|
domainMutation, createId, buildOutboxEvent, derivePriceSnapshot | packages/domain/src/helpers.ts |
DomainError, assertDomain | packages/domain/src/errors.ts |
MutationBundle, ActorContext, AuditDescriptor | packages/domain/src/types.ts |
runBundleTransaction, MAX_TX_ATTEMPTS, backoffMs | packages/db/src/bundle-writer.ts |
DOMAIN_EVENT_NAMES | packages/shared/src/events.ts |
activateListing and the listing guards | packages/domain/src/listings.ts |
Architecture
How Marka is built — the canonical listing model, the mutation pipeline every state change flows through, the two order rails, search, and the AI seam.
Listings and channels
One canonical listing, projected onto external marketplaces as ChannelListings — how authority, drift detection, sell-through and cross-channel decrements actually work.