The read contract: stream, get, and origins
One failure rule, four result kinds, honest origin attribution, and the lifecycle of a Store stream.
A Store has exactly two read operations (stream and get) and one failure rule:
stream emits failures as values and never throws them at your collector. get returns a value or
throws, and never emits partial states. Everything on this page is the contract of the Store
interface in store6-core. It holds for every store you build, from a five-line memory-only store
to one backed by persistence and optimistic writes.
public fun stream(key: K, freshness: Freshness = Freshness.CachedOrFetch): Flow<StoreResult<V>>
public suspend fun get(key: K, freshness: Freshness = Freshness.CachedOrFetch): VThe one-failure-channel rule
stream never throws retrieval failures. Fetcher and source-of-truth failures encountered
while retrieving a key are emitted as StoreResult.Error values rather than thrown to the
collector. Your collector sees the failure as data, decides how to render it, and (with one
exception covered below) keeps collecting a live flow.
get never emits. It returns the resolved value or throws StoreException. There is no
partial state on this path: get throws when fetching or source-of-truth access failed, when a
concurrent clear removed the key while its fetch was in flight, when Freshness.LocalOnly found
no local value, or when the server reported deletion.
The one exception on both doors is not a retrieval failure at all: any operation on a closed store
throws IllegalStateException with the message Store is closed. That is a usage error in your
code (you kept a reference past close()), not something a fetcher did. The structured error
vocabulary behind StoreResult.Error and StoreException has its own page:
Errors and failure handling.
The four result kinds
StoreResult is a sealed interface with exactly four kinds. There is no fifth case, so an
exhaustive when is safe to write and will stay safe:
Loading: the store has no servable resident value under the policy in effect. This is policy-relative, not merely "empty cache": a resident value thatFreshness.MustBeFreshwithholds producesLoadingeven though bytes are sitting in memory.Data(value, origin, age, isStale, refreshing): a value, with the source it came from, the elapsed time since it was committed, whether it was invalidated or exceeds the age bound of the freshness policy in effect, and whether a fetch was in flight for the key when the result was emitted.Revalidated(age): the not-modified signal of a conditional fetch. The server confirmed the current value is still fresh, metadata was refreshed, andageis the elapsed time since the last commit, measured at revalidation. No newDataframe is produced, because no new value exists.Error(error, servedStale): a retrieval failure, carrying a structuredStoreErrorand whether a stale value was served alongside it.
Collectors that only handle Loading/Data/Error silently miss the "nothing changed" signal.
After a conditional fetch returns not-modified, the store emits one Revalidated, not a redundant
Data. Handle all four. This is the exhaustive when from the quickstart program that compiles
and runs in CI:
users.stream(UserKey("1")).take(2).collect { result ->
when (result) {
is StoreResult.Loading -> println("Loading…")
is StoreResult.Data -> println("Data(name=${result.value.name}, origin=${result.origin})")
is StoreResult.Revalidated -> println("Revalidated(age=${result.age})")
is StoreResult.Error -> println("Error(${result.error})")
}
}Origins: attribution is honest
Every Data frame names the source it came from. Origin is an enum with four values:
| Origin | Meaning |
|---|---|
MEMORY | The value was served from in-memory resident state. |
SOT | The value was read from the store's persistent source of truth. |
FETCHER | The value was produced by the store's configured fetcher. |
OVERLAY | The value reflects an overlay applied above stored data. |
Attribution honesty is a tested contract, not a debugging aid: a network commit is attributed
FETCHER, an external durable change is attributed SOT, an optimistic write is attributed
OVERLAY, and each of those claims is pinned by a named conformance test on
Important defaults. If this page and a test ever disagreed, the
test would be right and this page would be a bug.
Two rules govern OVERLAY frames on the write path:
- Overlay frames are fresh by definition. They are stamped
age = Duration.ZEROandisStale = falseunconditionally, because an optimistic value genuinely is new. The user just wrote it. Onlyrefreshingis live on an overlay frame. A pending-write affordance therefore keys onorigin == Origin.OVERLAY, never onisStale. A spinner driven byisStalewill never fire for a pending write, and that is intended. The full consumer guidance lives in the stability policy, and the pending-write UI guide covers the consumer pattern in depth. getis never projected by a configured overlay. Overlays apply only tostream, so an optimistic write is invisible toget. It is a point read of committed truth. To observe your own optimistic write, observestream.
Conflation: lifecycle signals are never dropped
A slow collector never blocks a fast one, and never blocks the engine. When a collector falls behind, the store conflates, but conflation is per result kind:
- A newer
Datasupersedes an older queuedData. The slow collector skips intermediate values and observes the latest row. - A queued
Loading,Error, orRevalidatedis never displaced by another kind. Lifecycle signals survive backpressure. Only a newer signal of the same kind can supersede an older queued one, so the kind itself is never lost.
In UI code, you may not see every intermediate value, but you will never miss the fact that a load started, a fetch failed, or the server confirmed freshness, and every collector eventually observes the latest row. Each of these claims is pinned by a named conformance test on Important defaults.
Errors on the stream: servedStale
StoreResult.Error carries servedStale, and its meaning is exact: true when an invalidated
resident value was served and its refresh failed under Freshness.CachedOrFetch or
Freshness.StaleIfError. false when no resident was served or the policy withheld it.
The rendering rule follows directly. When servedStale is true, the user is still looking at
usable content. The stale value was emitted before the failure arrived. Show an error affordance
over that content. Do not blank the screen. When servedStale is false, there is nothing on
screen to preserve, and a full error state is honest. The StoreError vocabulary that tells you
what failed is covered in Errors and failure handling.
Failure trace: invalidated persisted data
This trace has four qualifying conditions: a first cold subscription after restart, a durably
invalidated persisted row, the default freshness validator, and Freshness.CachedOrFetch.
Wall-clock age alone does not trigger this fetch. A custom FreshnessValidator can plan
differently.
- Store hydrates the persisted row into resident state and requests an unconditional fetch with
etag=null. The durable record may still retain its stored ETag. - The initial public result is
Data(origin=Origin.SOT, isStale=true, refreshing=true). - A queued stale
Datareplay may occur before the error, without an interveningLoading. Bookkeeper.recordFailurecompletes before the public error is emitted.- The failed refresh produces
Error(StoreError.Fetch, servedStale=true), and the stream remains live.
After hydration populates memory, a later resident replay may carry Origin.MEMORY.
The one stream-terminating case
Exactly one failure completes a stream: a Freshness.MustBeFresh initial-cycle fetch or
revalidation failure emits one Error and completes the flow. MustBeFresh promises never to
serve residence, so when the fresh fetch fails there is nothing left for the stream to do. Every
other failure, under every other policy, leaves the flow live: the error arrives as a value and
collection continues.
Streams are unbounded
A stream has no natural end. The flow remains active until its collector is cancelled or the store is closed, and it continues to report later values:
- Refetches triggered by invalidation. After
invalidate(key), an active stream observes the refetched data. - The absent-value transition after a clear. After
clear(key), an active stream observesLoading(the value is gone) and then refetched data.
What each maintenance verb makes streams observe is the subject of Invalidate or clear.
Concurrent collectors and callers for one key share a single fetch. Fifty getters and fifty collectors of one key cost one fetch, and all of them observe its outcome. How sharing, eviction, and collector lifecycle interact is covered in Memory, eviction, and store lifecycle.
Closing the store
close() releases the resources the store owns and cancels its in-flight work. Collectors and
value requests waiting on in-flight work are cancelled. Every subsequent call to any operation
fails with IllegalStateException and the exact message Store is closed. Calling close() more
than once has no additional effect.
Read resolution at a glance
Every data result names the boundary that supplied the value. Origin and freshness are independent:
read origin to identify provenance, then read isStale and refreshing for lifecycle state.
- Origin.MEMORY
- Origin.SOT
- Origin.FETCHER
| Origin | Resolution boundary | Meaning |
|---|---|---|
Origin.MEMORY | Resident replay | The collector receives a value already resident in this engine. |
Origin.SOT | Source of truth | A source-of-truth read or write supplied the confirmed value. |
Origin.FETCHER | Fetcher | The configured fetcher produced or revalidated the authoritative value. |
Origin.OVERLAY | Stream projection | An overlay projected over confirmed residence or confirmed absence for streams. |
With Store 6's default freshness validator and Freshness.CachedOrFetch, the first cold stream after restart serves a durably invalidated persisted row as Data(origin=Origin.SOT, isStale=true, refreshing=true). If its refresh fails, the stream emits Error(StoreError.Fetch, servedStale=true) without an intervening Loading, and the stream stays live.
Bookkeeper.recordFailure completes before that fetch error is emitted. Hydrated resident metadata does not reuse the persisted ETag, so a fetch planned from that state sees etag=null. After the first hydrated emission, a later resident emission may use Origin.MEMORY.
Read the read contract for the complete stream and point-read semantics. Use the freshness policies to choose when a fetch participates.
Where next
- Freshness policies: how each read is planned, and what does and does not trigger a fetch.
- Errors and failure handling: the structured
StoreErrorvocabulary behindErrorandStoreException. - Invalidate or clear: what streams observe after each maintenance verb.
- Compose integration: collecting this contract in UI.
- Quickstart: the runnable program this page's snippet comes from.
Source recorded 2026-08-12 ·main@539614c0· pre-6.0.0-alpha01