Transactional Outbox
Atomic event emission with content mutations — polling, backoff, webhook fan-out.
On this page
SveltyCMS uses the transactional outbox pattern so domain events (webhooks, automations, internal pub/sub) are not lost when a write succeeds but side-effect delivery fails (or vice versa).
Flow
- Write path (collection create/update/delete) uses
persistWithOutbox:- The entity write is a single atomic statement (
INSERT/UPDATE/DELETE— no BEGIN/COMMIT wrapper needed). - The outbox emit is scheduled as a
queueMicrotaskoff the response path — never awaited by the caller. (The old “one DB transaction for entity write + emit” wrapper was removed: single-statement writes are natively atomic, and coalesced buffering avoids serializing content writes 1:1 with outbox rows.)
- The entity write is a single atomic statement (
- Buffered (non-transactional) emits —
emit()outside a transaction pushes into an in-memory buffer and bulk-flushes via oneinsertManyafterOUTBOX_BUFFER_FLUSH_MS(25ms) or at 64 events. The event_id(UUID) is stamped at emit time — it is part of the emit contract and equal to the stored id. - Event types:
entry:create|entry:update|entry:delete. afterMutationinvalidates cache and publishesentryUpdated; on the main create/update/delete path it passesskipOutbox: true(the outbox event was already emitted bypersistWithOutbox). Callers that bypasspersistWithOutboxfall back to theafterMutationemit.- Background
outboxService.startPolling()(hooks cold-start) runsprocessBatch()— which first flushes the emit buffer so milliseconds-old buffered events are included in the tick. - Delivery: internal
pubSub+ optionalwebhookService.triggerfor mapped event types. - Success →
status: delivered; failure → exponential backoff retry; after 5 attempts →failed.
emit(..., { transaction }) remains supported for callers that manage their own transaction (e.g. the testing API outbox-tx-rollback action) — the event is written on the same connection and rolls back with the data change.
getPendingCount() reports the true backlog: persisted status: pending rows plus events still sitting in the emit buffer.
Storage
| Adapter | Table / collection |
|---|---|
| SQLite / PostgreSQL / MariaDB | svelty_outbox (migrations + Drizzle schema) |
| MongoDB | svelty_outbox model (OutboxEvent) |
API
import { outboxService } from "@src/services/outbox";
await outboxService.emit(
"entry:create",
"entry",
entryId,
{ collection, data },
tenantId,
{ transaction }, // optional — same DB transaction as the write
);
await outboxService.processBatch(50);
await outboxService.cleanup(olderThanISO);
Backoff
outboxBackoffMs(attempts) → 1s, 2s, 4s, … capped at 5 minutes, based on updatedAt after each failure. Pending events inside the window are skipped until ready.
Kill switches
| Env | Effect |
|---|---|
DISABLE_OUTBOX=true |
Skip emit + polling |
DISABLE_OUTBOX is the only service-level kill switch — ambient benchmark env flags (BENCHMARK=true) no longer disable outbox emits; they only prevent hooks.server.ts from starting the background poller during benchmark cold-start.
Tests
- Unit:
tests/unit/services/outbox-service.test.ts - Integration:
tests/integration/api/outbox-plugin-storage.test.ts - Testing API:
outbox-emit,outbox-process-batch,outbox-tx-rollback, …
Implementation
- Service:
src/services/outbox/outbox-service.ts - Boot:
hooks.server.ts→outboxService.startPolling(5000) - Emit:
collections-namespace.tspersistWithOutbox(+afterMutationfallback)