Skip to content
In development. Nothing in Store 6 is published yet.

Mutations: the journalled write path

Two facts before anything else

First, this write path is experimental. The factory, protocol, storage records, and inspection shapes can change in any release.

A MutationStore is a Store

mutationStore(...) returns a MutationStore<K, V>, which implements Store<K, V> by delegation. Its stream, get, invalidation, clearing, and close behavior therefore follows the core read contract.

The mutation facade adds:

  • mutate(key, ref, args), which appends a typed intent and returns its opaque mutation id.
  • drain(key) and drain(), which run foreground passes over pending work.
  • pending(key), pendingWrites(), and deadLetters() for durable inspection.
  • poisoned and events, two advisory flows for observing failures and lifecycle activity.

The facade withholds the raw engine write handle. Calling runtime() on a MutationStore returns null, so consumer writes cannot bypass the journal through a second write path.

The factory takes five required inputs. Optional core Store configuration stays inside the builder, which exposes no overlay door because the mutation engine installs the Store's sole overlay.

kotlin
@OptIn(ExperimentalStoreApi::class)   // required: the whole module is experimental
val users = mutationStore(
    registry = registry,
    server = server,
    // Restart-safe key recovery is compile-time required. For keys reconstructible from the
    // identity pair, the resolver is one line:
    keyResolver = MutationKeyResolver { identity -> UserKey(identity.canonicalId) },
    valueCodecVersion = 1,
    valueCodec = userJsonCodec,
) {
    fetcher { key -> api.load(key) }
}

users.mutate(key, renameRef, Rename("new name"))   // journalled — the only write path
users.drain(key)                                   // push pending intents and adopt each ack

mutate appends but does not push. drain(key) makes one scheduler-agnostic foreground pass for the effective key. It pushes the pending FIFO prefix once and has no retry or backoff policy of its own.

The model, end to end

  1. Register typed intents. Build a MutatorRegistry once. Every durable operation has a name, argument codec, projection, and invalidation set. A call-site closure never becomes a durable intent. See Authoring mutators.
  2. Append to the journal. mutate records the intent and returns its opaque id. The default InMemoryMutationJournalStorage does not provide restart durability. Install the SQLDelight-backed storage, or another conforming durable implementation, when queued writes must survive process death. See Journal storage.
  3. Observe the optimistic projection. stream applies pending intents through the Store's sole overlay. When projection changes the value, the frame has origin == Origin.OVERLAY, age = Duration.ZERO, and isStale = false. get remains deliberately unprojected, so observe stream when the UI must see its own write. See Pending-write UI.
  4. Push one foreground pass. drain(key) sends pending work to the app-owned MutationServer. Each attempt generation carries an idempotency key that stays stable across transport retries. See Implementing a MutationServer and Draining, offline, and restart.
  5. Record, adopt, apply effects, then retire. Store atomically records the complete acknowledgement receipt and ACKED phase before local adoption. It then adopts the authoritative presence, completes the intent's invalidation effects, and retires the journal row. Recovery from ACKED or a later phase may repeat those local steps, but never the push. A later retirement checkpoint lets the server confirm how much history the journal may prune. A stream opened after the drain completes sees the confirmed value. Convergence for a collector that was already active across acknowledgement is not yet a promised behavior.

Conflict handling is optional. Without a registered merge, server-wins is the non-removable terminal. With one, the policy can retry a new generation or accept server-wins. See Conflict resolution.

Where each piece is documented

The mutations family has ten subpages, in adoption order:

PageScope
Mutations quickstartConfigure the five required factory inputs, enqueue the first intent, and drain it.
Authoring mutatorsRegister the generic mutator plus typed update, create, delete, and upsert operations; define presence, purity, and codec-version rules.
Pending-write UIRender Origin.OVERLAY state and respect the distinction between stream and get.
Implementing a MutationServerImplement push and retirement transport, idempotency, conflicts, and present or absent acknowledgements.
Conflict resolutionSelect preconditions, retry merged generations, and understand the server-wins terminal.
Aliases and canonical rekeyingFollow a provisional identity through its durable alias edge to the server's canonical identity.
Draining, offline, and restartChoose keyed or global drains and reconstruct durable identities after restart.
Journal storageChoose the in-memory default, install SQLDelight storage, or implement the storage seam.
Inspection and observabilityRead pending work and dead letters, and separate durable truth from advisory flows.
Testing mutationsCertify storage behavior and exercise projector purity and crash boundaries.

Coming from Store 5 MutableStore

Store 6 collapses the Store 5 write assembly into one factory and one journalled path:

Store 5 responsibilityStore 6 mutation path
Per-request write lambdas on MutableStoreNamed, typed intents registered once in a MutatorRegistry
Updater.post-driven transportAn app-owned MutationServer, invoked by drain
Hand-assembled failed-sync bookkeeping or an outboxThe mutation journal plus pendingWrites() and deadLetters() inspection

Detailed migration steps are in Migrating from Store 5 and the Store 5 component map. The mutation path remains experimental even when it replaces a stable Store 5 write assembly.


Source recorded 2026-08-12 ·main@c67a94ed· pre-6.0.0-alpha01