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

Journal storage

The default is in memory

If you leave journalStorage(...) unset, MutationStoreBuilder creates one InMemoryMutationJournalStorage. Its journal remains available only for the lifetime of that instance. Reusing the same object can model a reopen while the process is alive, but process death loses every queued record. Real restart hydration requires a persistent implementation reopened over the same durable store.

Each in-memory transaction takes one mutex and works on a private snapshot. A normal return validates and commits the whole snapshot. Any thrown Throwable discards it. This makes the default useful for optimistic UI and queued work within one process run, not for a durable offline queue. Use durable storage before depending on replay after process death. The drain and restart guide covers the hydration and replay contract.

The SQLDelight journal

SqlDelightMutationJournalStorage(driver, transacter) persists the journal in SQLite. The SqlDriver and Transacter must address the same connection and database authority. Store cannot verify that pairing.

The adapter supports synchronous drivers whose raw operations return QueryResult.Value. Async web drivers are not supported. Each adapter selects one of 64 hash-striped mutexes from the driver's hash, and every transaction uses that gate. Transactions for the same driver therefore serialize; unrelated drivers may share a stripe.

Construction installs an adapter-owned sidecar schema. Its store6_mutation_*, store6_key_alias, and store6_key_tombstone tables are separate from your generated schema, and SQLite user_version remains yours. The sidecar keeps its own store6_mutation_schema version row; the current version is 2. Construction can migrate version 1 to version 2 when durable mutation namespaces are quiescent. If they are not, construction fails with instructions to drain or park and retire the remaining work before retrying the upgrade.

Given an existing SqlDriver, its matching generated database or other Transacter, and the required mutation-store inputs, install the adapter through the builder door:

kotlin
@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class)

import org.mobilenativefoundation.store6.mutations.mutationStore
import org.mobilenativefoundation.store6.mutations.sqldelight.SqlDelightMutationJournalStorage

val journal =
    SqlDelightMutationJournalStorage(
        driver = driver,
        transacter = database,
    )

val store =
    mutationStore(
        registry = registry,
        server = server,
        keyResolver = keyResolver,
        valueCodecVersion = 1,
        valueCodec = valueCodec,
    ) {
        fetcher(networkFetcher)
        journalStorage(journal)
    }

The Store SQLDelight adapter is a separate artifact for read-side values and freshness metadata. Both adapters can use one database when every driver, transacter, and query belongs to that same database authority.

Implementing custom storage

The seam has one transaction door:

kotlin
@ExperimentalStoreApi
@SubclassOptInRequired(DelicateStoreApi::class)
public interface MutationJournalStorage {
    /** Runs [block] as one serializable, exception-atomic unit of work. */
    public suspend fun <R> transaction(block: (MutationJournalTransaction) -> R): R
}

Treat each callback as one serializable, exception-atomic unit. A normal return commits every operation. Any thrown Throwable, including cancellation, commits nothing and propagates unchanged. The outer function is suspending, but the callback is not. Codec, resolver, transport, and policy work cannot suspend while the transaction is held. The MutationJournalTransaction handle becomes invalid as soon as the callback returns.

Storage implementations must also preserve these representation rules:

  • Persist enum names, never ordinals.
  • Store every time as Unix epoch milliseconds.
  • Copy every byte array on entry and again on delivery.
  • Allocate storage-local IDs for intents and failure evidence through the seam's insertion methods.
  • Return records in the contract's deterministic orders: client sequence for intents and executions; client sequence then generation for attempts and acknowledgements; storage-local failure ID for failures; client sequence then effect index for effects; and deterministic source or generation identity for aliases and tombstones.

The nine journal records

At this revision, the storage package freezes nine logical records as the implementation contract. Preserve both their roles and their constructor invariants.

RecordRoleKey invariants
MutationClientRecordAllocation and retirement high-water state for one clientrecordVersion is positive; 0 <= serverConfirmedRetiredThroughSequence <= retiredThroughSequence <= lastAllocatedSequence.
MutationIntentRecordImmutable encoded mutation intentrecordVersion, clientSequence, and mutatorVersion are positive; argsBlob is copied on construction and every read.
MutationExecutionRecordCurrent durable execution state for one intentSequence is positive; generation and attempt are non-negative; lastAttemptAt exists exactly after an attempt; activeFailureId exists exactly in PARKED, and retiredAt exactly in RETIRED; prepared phases reference a positive generation.
MutationAttemptRecordImmutable semantic generation with a write-once conflict receiptSequence, generation, and codec version are positive; advertised retirement is non-negative; base and optimistic blob presence matches the corresponding presence enum; metadata and conflict-receipt fields are internally complete; blobs are copied.
MutationAckRecordWrite-once acknowledgement for one exact generationSequence, generation, and codec version are positive; authoritative blob presence matches its presence enum; canonical target fields are both absent or both present, and a target requires an authoritative present value; the blob is copied.
MutationFailureRecordAppend-only normalized failure evidenceSequence is positive and generation is non-negative; detail is at most 128 UTF-8 bytes and message at most 1,024 UTF-8 bytes.
MutationEffectRecordOne durable key or namespace invalidationSequence is positive and effect index is non-negative; canonicalId exists exactly for KEY; completedAt is null exactly while disposition is PENDING.
MutationKeyAliasRecordSame-namespace redirect edgeCreator sequence is positive; source and target namespaces match; source and target IDs differ; activatedAt exists exactly for ACTIVE.
MutationKeyTombstoneRecordOne absence generation for an effective identityCreator sequence is positive; successor identity fields are paired, and any successor sequence is positive; pending, active, and superseded states carry exactly their required activation and supersession fields.

Pruning

prune(clientId, serverConfirmedRetiredThroughSequence) may remove eligible rows only at or below the client's persisted server-confirmed retirement prefix. The supplied prefix may not exceed that persisted value. Ordinary pruning never removes alias redirects or active or pending tombstone generations.

A superseded tombstone becomes eligible only when its creator is within the supplied prefix and its superseding intent is also within the persisted server-confirmed prefix of the client that owns that intent. Keep these checks inside the same transaction as the deletions.

Certifying an implementation

Extend MutationJournalStorageContractKit in a consumer test source set. Implement createStorage() with a fresh journal and reopenStorage(previous) with a new adapter over the same durable store, then run every inherited test on every supported target. The kit also inherits semantic kill-point scenarios around retirement finalization and pruning, covering both sides of their commit boundaries. The mutations testing guide shows the contract-kit setup.


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