Remote / Server-derived state
Requirements and observation
A Surface's projection carries its remote requirements — entity, id, fields,
a pagination window per relation, and through relations, the slice required of
each relation's target — as plain data under Remote's key in Projection.metadata
(requirementsOf(projection)). Reading is pure; it performs no I/O. A
nested selection (owner: UserSummary) reads through the ref in the store and
assembles the target's fields; the store itself stays normalized.
The planner diffs requirements against the store and returns only the missing or
stale fields. A relation whose field is being fetched rides on the request, so
the server resolves the graph in one read; a relation the store already holds is
followed into concrete requirements for its targets. It is deterministic and
takes now as input (PlanFreshness)
rather than reading the clock, so the same store and requirements produce the same
plan.
Fetching is a Foldkit Subscription derived from the active Surfaces, and activation is a fact of the Model:
Data.subscriptions({
page: Surface.at(ProjectPage, model => ({ projectId: model.route.projectId })),
})Each active Surface gives a read entry, which plans and fetches only the missing
fields through RemoteClient and emits a RemoteMessage (a fully-known Surface
emits nothing). Its dependencies are plain data: the planned fields, the queries
to run, and the Model's refresh generation, so Foldkit restarts it when the plan
changes or a refresh is requested. Each Surface also gives a live entry for what it reads through Data.live; one
retain entry keeps what the active Surfaces reach. SSR, route/hover prefetch,
and tests reuse the same plan through Data.prefetch.
A query is a Projection too. Data.query(ProjectsByOwner, { ownerId }, {
select: ProjectSummary, first: 25 }) reads the connection as a Page of the
selected items, and carries the connection next to its requirements. The read
entry plans it like a field: a connection the Model does not hold (or holds
stale) is a query to run, and once its page is known, the page's items are
requirements like any other, read under select. A read shows at most its
window, whatever else loaded the connection. Data.more(model, projection) grows
that window by a page, and the read entry fetches the rows the connection
lacks.
What a field the store already holds means is a RemotePolicy on observe and
prefetch: cacheFirst (default) fetches only what is missing,
staleWhileRevalidate({ maxAge }) refetches an entry older than the window, and
networkOnly fetches every selected field. A policy compiles to planner options;
it is not a second cache. A refreshing policy emits RefreshStarted with the
read's ReadStarted, which marks the refetched fields stale.
Time reaches Remote only as a Message. The read entry knows, from the Model and
now, when the earliest value it holds ages out under maxAge; it sleeps until
then under the Effect clock and emits RefreshStarted for what is due, and the
plan that follows fetches it. A Projection never reads the clock, so a Model
reads the same twice and a recorded one replays.
To revalidate on demand — a refresh button, a regained focus — update returns
Data.refresh(model, projection) (or a Surface without params). It performs no
I/O: it marks the selected fields the store holds stale, so they read
Refreshing over their old value, invalidates the loaded connections, and bumps
the refresh generation. The read entries already observing that projection plan
stale data again under every policy, so the data is fetched once, by the entry,
instead of by a Command racing it. The projection must therefore be observed; for
one nothing observes, use Data.prefetch with networkOnly. A read in flight
from before the refresh restarts rather than landing after it, and a refreshed
connection's first page replaces its loaded pages, so removed or reordered items
follow the server and later pages are paged again. Refreshing what is already
refreshing returns the same Model. The package README's
Refreshing from update
has the example.
Reads through Remote.clientLayer coalesce: requirements issued together are one
batch, a requirement already in flight is joined, and every waiter gets the
whole result. Retention is a Message too: Remote.retain lists the observed
projections as roots and emits RetentionChanged after a grace period, and the
reducer's pure gc keeps what the roots reach through the store's refs plus any
pending optimistic change.
In the view, a remote field is a RemoteData. Remote.select produces
Ready once its selected fields are present, Refreshing while one is stale
(an observer is refetching it), Failed if the server data does not decode or
the server settled a selected field without a value, and NotFound for a
tombstone. A value the store lacks reads as Loading while a
read is fetching it and Initial when none is: the read entry emits
ReadStarted before it fetches, and ReadReceived or ReadFailed clears the
mark. That distinction is the one worth rendering differently, because nothing
fetches a projection no active Surface observes; it reads Initial forever, and
a spinner there hides the wiring mistake rather than showing progress. There is
no hidden suspense; the states are explicit.