Remote / foldkit-remote
Reading, policies, and prefetch
Data.get(selection, id) returns a pure Projection. Data.live(selection, id)
returns the same kind of Projection but marks its requirements as live so
Data.subscriptions also follows server changes.
What Remote should do when the Model already contains the selected fields is a
RemotePolicy:
RemotePolicy.cacheFirst(default) — fetch missing, stale, or re-windowed data.RemotePolicy.staleWhileRevalidate({ maxAge })— keep a present value visible and refetch when it is older thanmaxAge.RemotePolicy.networkOnly— request every selected field; cached values stay visible while the request is in flight.
A refreshing policy emits RefreshStarted before the read, so the Projection
becomes Refreshing without discarding the old value. Planning accepts now
as an input, so tests can control time. It defaults to Date.now, read at each
use, so fake timers (vi.useFakeTimers) move it; Effect's TestClock does not,
because a plan runs outside Effect, so a test that drives TestClock passes
now. A mutation's answer is dated the same way, by Data.mutate's now
option.
A policy belongs to one subscriptions call, and covers every read in it. Reads
under different policies take a call each:
const projectPage = Surface.at(ProjectPage, model =>
model.route._tag === 'project' ? Option.some({ projectId: model.route.projectId }) : Option.none(),
)
const owner = Data.active('Owner', () => Option.some(Data.get(UserSummary, 'u1')))
const pageEntries = Data.subscriptions({ page: projectPage })
// Retention is the domain's: one `retain` roots every read, so the second
// call's is left out rather than spread over the first's.
const { retain: _domainRetain, ...ownerEntries } = Data.subscriptions(
{ owner },
{ policy: RemotePolicy.staleWhileRevalidate({ maxAge: 5_000 }) },
)
const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() => ({
...pageEntries,
...ownerEntries,
}))Retention stays the domain's: a call's retain entry roots every read any call
of the domain names, so neither call collects what the other reads. Keeping one
retain entry is all it takes; it waits for its own call's grace.
Time reaches Remote only as a Message. The plan is a function of the Remote
model, what is asked, and now, and it runs again only when one of those
changes; a page that sits still past its
maxAge would otherwise never be looked at again. So a read entry under
staleWhileRevalidate knows when the earliest value it holds ages out, sleeps
until then under the Effect clock, and emits RefreshStarted for what is due.
That marks the fields stale in the Model, and the plan that follows fetches
them, the same path a refresh button takes. Nothing reads the clock while a
Projection is read, so a Model reads the same twice, and a recorded one
replays.
For SSR, route prefetch, hover prefetch, and tests, run the same plan explicitly:
import { Effect } from 'effect'
const loaded = await Effect.runPromise(
Data.prefetch(model, ProjectPage.projection({ projectId }), {
policy: RemotePolicy.staleWhileRevalidate({ maxAge: 30_000 }),
}).pipe(Effect.provide(clientLayer)),
)Data.prefetch performs I/O explicitly and returns the Model with the resulting
Remote Messages reduced into it. It never changes the semantics of the
Projection itself.
A render that fetches nothing (SSR, a prerender) needs everything a page's
active Surfaces read, and one read can decide what another asks for: a page's
Blocks are known once its document is. Data.satisfy prefetches every active
Surface, cache-first, pass after pass until a pass plans nothing:
const ready = await Effect.runPromise(
Data.satisfy(model, actives).pipe(Effect.provide(clientLayer)),
)actives is the record Data.wiring takes. A Surface.each family is one
entry of it: its parent's requirements join its instances', so the parent is
fetched before what it reveals. The passes are bounded (8 by
default, { passes } to change it); a Surface still reading after the last
fails the Effect with RemoteUnsatisfied, which names it — a still-reading
family instance as Child[key] — rather than
rendering it loading. A read the server leaves unanswered settles as missing,
so it ends the loop; what runs out the passes is a chain whose every read
reveals one more.
Preloading a destination from update
prefetch and satisfy return a Model, which suits a render that has none
yet. A running application has one, and it moves on while a read is out, so
writing the returned Model back would undo what happened meanwhile.
Data.preload is satisfy as a Command: it reads what actives would read
in the Model you give it (the live one with the route set to where the reader
may go) and yields one Preloaded, carrying the Messages that wrote it,
which update reduces into the Model as it is by then:
const project = Surface.at(ProjectPage, model =>
model.route._tag === 'project' ? Option.some({ projectId: model.route.projectId }) : Option.none(),
)
// The reader is over the link to p2: read its page before they arrive.
const preload = Data.preload({ ...model, route: { _tag: 'project', projectId: 'p2' } }, { project })
// update returns { model, commands: [preload] }; under Remote.fold, foldData.preload(...)It never fails: a read that fails, or a client that dies, ends it with what was read before, and the destination reads the rest when the reader arrives. A Surface of another application is refused when the Command is made. What it reads is kept like any other read until what is retained next changes, so preload close to the navigation you expect (a hover, a focus), not far ahead of it.
Refreshing from update
To revalidate what a screen already declares — a refresh button, a focus
regained — hand its Projection (or a Surface without params) to Data.refresh:
case 'ClickedRefresh': {
if (model.route._tag !== 'project') return { model }
const page = ProjectPage.projection({ projectId: model.route.projectId })
return { model: Data.refresh(model, page) }
}Data.refresh performs no I/O and restates no request. It returns the Model
with every selected field the store holds reading Refreshing, every entity it
knew to be absent forgotten, so NotFound reads Loading and is asked for
again, and every loaded connection invalidated; the Data.subscriptions read entries then refetch it,
since stale data is planned again under every policy, so the page is requested
once. The Projection must be observed, as it is while it is on screen; for data
nothing observes, use Data.prefetch with RemotePolicy.networkOnly.
- A refreshed connection is asked for again as one page the size of the widest window reading it (including what "load more" grew), which replaces what it held, so items the server removed or reordered follow it.
- A read already in flight restarts instead of landing after the refresh.
- Refreshing what is already refreshing, or nothing, returns the same Model.
Forgetting everything: a change of principal
Everything Remote knows, it knows for a principal: a value the server gave
this session, a tombstone for an id it answered nothing about, a field it
settled as unavailable, the rows of a list. Log in, log out, switch
organization, and none of it is trustworthy. Data.forget is the one
boundary:
case 'SignedOut': {
return { model: Data.forget({ ...model, session: Option.none() }) }
}It performs no I/O and returns the Model with every server-derived fact gone: values, tombstones, unavailable fields, connections, live cursors, failures, and the reads in flight. Every active Surface's read and live entries restart, so the screen asks again as whoever the client now is; a read or stream begun before is interrupted rather than landing after. A mutation in flight is treated as applied, so its answer writes nothing into the new store, and the mutation sequence is kept, so request ids stay unique.
Who the client is belongs to the RemoteClient layer or the server's session,
not to Remote. forget only says that what was known no longer is.