Skip to content

Atomicity of multi-events commits isn't preserved globally #974

@IGassmann

Description

@IGassmann

Problem

Multi-event commits are atomic locally on a single client session, but this atomicity isn't preserved globally. Individual events from an atomic commit can be synced separately, causing other clients to see intermediate states that may violate domain invariants and cause invalid states.

By "global" we mean that the atomic unit of a commit is preserved across the whole system:

  • Across clients: Other clients syncing with the sync backend receive either all events from a commit or none
  • In persistence: The store persists commits as an indivisible unit
  • Subscribing to store.events: When iterating over synced events, commit boundaries are observable

Example 1: Issue Tracker

Consider an issue tracker where every issue in "In Progress" status must have an assignee. When reassigning an issue to a different user:

issueStore.commit(
  issueEvents.issueAssigneeRemoved({ id, previousAssigneeId }),
  issueEvents.issueStatusChanged({ id, oldStatus, newStatus })
)

If these events are synced separately, another client could see an invalid intermediate state where the issue is "In Progress" but has no assignee—violating the domain invariant.

Example 2: Email Client Labels

Consider an email app (e.g. Gmail) where an email thread should only and always have one system label (INBOX, SENT, ARCHIVE, TRASH) applied at a time. Archiving a thread requires removing the current label and applying the new one atomically:

// Both clients start with the same synced state: LabelApplied("INBOX")

// Client A: User moves thread to trash
threadStore.commit(
  threadEvents.labelRemoved({ threadId, label: "INBOX" }),
  threadEvents.labelApplied({ threadId, label: "TRASH" })
)

// Client B: User archives thread (concurrent, before syncing)
threadStore.commit(
  threadEvents.labelRemoved({ threadId, label: "INBOX" }),
  threadEvents.labelApplied({ threadId, label: "ARCHIVE" })
)

The problem occurs during sync:

  1. Client A pushes its events, but only its LabelRemoved("INBOX") reaches the server before Client B pulls
  2. Client B pulls Client A's LabelRemoved("INBOX") from the server and materializes it
  3. The thread now has no system label—an invalid state that should never exist

User-observable issues:

  • The thread "disappears" from all folder views (Inbox, Trash, Archive) since folder view queries filter by label
  • The UI may show broken/undefined state for the thread's location
  • If the user refreshes or the remaining events arrive, the thread suddenly "reappears" in Trash—confusing UX
  • Any business logic that assumes exactly one system label may behave unexpectedly

This happens because Client A's atomic commit (LabelRemoved("INBOX") + LabelApplied("TRASH")) was split during sync, exposing an invalid intermediate state to Client B.

Why Not Use Single-Event Designs?

We could argue that for such scenarios, the user should instead emit a single combined event:

issueStore.commit(
  issueEvents.issueUnassigned({
    id: issue.id,
    previousAssigneeId: issue.assigneeId,
    oldStatus: "In Progress",
    newStatus: "Backlog"
  })
)

However, this single event approach breaks down when the same facts can occur independently or in different combinations.

For instance, a user can change status without changing the assignee (e.g., "In Progress" → "Review"). Now the system needs both IssueUnassigned and IssueStatusChanged. A downstream notification service that sends an email for status changes must now subscribe to both event types. It's easy for a developer working on such service to see IssueStatusChanged in an event catalog and mistakenly assume that's the only event the service needs to listen to.

More broadly, event granularity is a domain decision. Real domains often involve nuanced and multi-step workflows. Bundling them into single coarse events forces artificial groupings that don't match how stakeholders talk about the business.

By allowing users to commit multiple events atomically, we allow users to model events in a way that properly reflect their domain needs while also protecting invariants.

Current Behavior

Local Atomicity ✅

The commit method wraps multi-event commits in a single SQLite transaction:

if (events.length > 1) {
  return this[StoreInternalsSymbol].sqliteDbWrapper.txn(runMaterializeEvents)
}

Global Atomicity ❌

However, the sync backend interface explicitly notes this limitation:

// TODO support transactions (i.e. group of mutation events which need to be applied together)
push: (
  /**
   * Constraints for batch:
   * - Number of events: 1-100
   * - sequence numbers must be in ascending order
   * */
  batch: ReadonlyArray<LiveStoreEvent.Global.Encoded>,
) => Effect.Effect<void, IsOfflineError | InvalidPushError>

Events are pushed to the backend in batches via backendPushBatchSize, but these batches don't preserve the boundaries of atomic commits—events from a single commit can end up in different sync batches.

Batching vs. Commit Atomicity

These are orthogonal concepts:

Concept Purpose Boundaries determined by
Event batching Transport/performance optimization Size limits, timing, throughput
Commit atomicity Semantic correctness Application logic (what user commits together)

Transport batching is irrelevant to atomicity. Events from a single commit could be sent across multiple batches without breaking atomicity, as long as:

  1. Commit boundaries are tracked — Events carry metadata (e.g., a commit ID) indicating which commit they belong to
  2. Visibility is atomic — The receiving side (server or pulling client) buffers events and only makes complete commits visible

The current implementation lacks both: events have no commit ID, and partial commits can be exposed to other clients. The core constraint is: a commit must never be partially visible—regardless of how many batches it takes to transport.

Expected Behavior

Multi-event commits should be treated as atomic units globally, not just locally. When syncing, either all events from a commit should be visible to other clients, or none should be.

Related

  • #503 comment discusses why batch processing of atomically committed events matters
  • #945 RFC exploring commands and rebase invariant violations

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions