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 than maxAge.
  • 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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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.