foldkit-remote
v0.10.0
Published
Normalized application-facing server state for Foldkit: entities, selections, and the pure remote core.
Maintainers
Readme
foldkit-remote
Server data, cached inside the Foldkit Model. A screen says what it needs; the view gets what the cache knows.
const ProjectPage = App.surface('ProjectPage', {
params: { projectId: Schema.String },
model: ({ params }) => ({ project: Data.get(ProjectSummary, params.projectId) }),
})
RemoteData.render(project, {
loading: () => ProjectSkeleton(),
notFound: () => NoSuchProject(),
failed: error => ErrorView(error),
data: project => ProjectView(project),
})No fetch call, no cache object, no loading flag. While the page is active,
Remote fetches only the fields the Model lacks, stores each entity once, and
hands the view one of six states rather than an undefined. Every result is a
Message through one reducer, so a screen replays from a recorded Model.
Remote is for facts the server owns. An edit the client authors, which
must survive being offline, belongs to foldkit-sync.
If you know TanStack Query, TanStack DB or fate
The same job, done from the other side of the Model:
| TanStack Query | foldkit-remote |
| --- | --- |
| useQuery({ queryKey, queryFn }) in a component | Data.get(Selection, id) in a Surface; the active Surface fetches |
| a queryKey you design | the entity, its id and the fields selected; there is no key to design |
| one cache entry per key | one entity per id, shared by every selection of it |
| staleTime, refetchOnWindowFocus | RemotePolicy, Data.refresh from update |
| gcTime | retention: what no active Surface reaches is collected |
| onMutate + setQueryData | optimistic: [Remote.patch(...)], a layer the settlement removes |
| invalidateQueries | not needed for overlap: a result updates the one copy |
| isPending, isError, data | RemoteData: six states, one exhaustive fold |
| a QueryClient beside your state | Remote.Model, a field of your Model, changed only by Messages |
Two differences change how you build. There are no keys. A TanStack cache
is a map from keys you design to results you shape, so two components that
want overlapping data either share a key and over-fetch, or hold two copies
and invalidate by hand. Remote stores fields by entity and id, and a Surface
declares which fields it selects, so overlap is the normal case and a
mutation's result is seen everywhere at once. The cache is Model. A
QueryClient lives beside your state and is updated by effects; Remote.Model
is a field of your Model, changed by the same reducer as everything else, so a
screen is a function of the Model, replays from a recording, and is tested
without a network.
What Remote does not have: a component hook (it has Surfaces and Foldkit
Subscriptions), a devtools panel of its own (Data.inspect and Data.why are
the data one would show), and retries on failure (a failed read stays failed
until something asks again; see When a read fails).
And TanStack DB, and fate
TanStack DB is a local database: collections
populated eagerly (or on demand) from a REST API or a sync engine, live queries
that update incrementally, and optimistic transactions rolled back on failure.
Its unit is the collection; a screen queries what is already local. Remote's
unit is the field: a screen declares which fields of which entities it reads,
and only those are fetched, so there is no collection to load first and a page
carries what it shows. Remote has no live query engine: Data.filtered and
Remote.matching judge the rows a list already holds, and a new list is a
server query. Where TanStack DB syncs client-authored writes through a sync
engine, that job here belongs to foldkit-sync, and Remote stays
the disposable cache of what the server owns.
fate is the nearest cousin: components declare views of exactly the data they need, composed into one request per screen, with a normalized cache that masks what a component did not ask for, and actions with optimistic rollback. Remote's Selections are that strictness, and its store is that cache. The differences are where it lives and what runs it: fate is a React data client, driven by Suspense and Actions; Remote is a Submodel of a Foldkit Model, driven by which Surfaces are active, with no view framework in it.
Find what you need
Read the first run once, then jump to what you are doing:
| Task | Start here |
| --- | --- |
| See it work before there is a server | Step 5 |
| A read stays Initial | Why is it still Initial? |
| A list shows old rows | Why is it stale? |
| A saved change showed, then vanished | Why did my change disappear? |
| Data was gone when I came back | Why is it gone? |
| Show a failed read, and let the user retry | When a read fails |
| Load an ordered, paged list | Queries and pagination |
| Filter a list already on screen | Filtering a loaded list |
| Save, and show the result before the server answers | Mutations |
| Fetch outside an active screen | Policies and prefetch |
| Keep data across a reload | Hydration |
| Hand a server render's data to the browser | Server rendering |
| Answer the requests on the server | How the server packages fit |
Which state belongs here?
One owner per datum:
| State | Owner |
| --- | --- |
| Route, selected item, local form state, transient UI errors | the application Model and update |
| Server-derived facts that can be refetched | foldkit-remote |
| Client-authored state that must survive offline and converge | foldkit-sync |
| Local state represented in the URL or another store | the Model, observed by foldkit-mirror |
Remote Sync
server owns the fact client owns the edit
cache is disposable intent must survive offline/restart
refetch is recovery replay + reconciliation is recoveryA Surface may project all of these owners at once. Observation does not transfer ownership.
The mental model
Surface Projection → requirements → active Subscription → RemoteClient I/O
↑ |
└──────── read Model ← Data.reduce ← Remote Message ──┘A Projection reads the current cache and carries requirements such as
Project:p1.name and User:u7.name. An active Subscription compares those
requirements with the cache, then requests missing or stale fields. The
result returns as a Message; only reducing it changes what the next read sees.
Rendering the same Projection twice starts no work by itself.
A Remote Projection does not fetch. It declares what server-owned facts a consumer requires. I/O happens outside render, and its results reduce back into the Model.
Four roles carry the loop, and most application code needs no more:
| Piece | Responsibility |
| --- | --- |
| Data.get / Data.live / Data.query | create pure Projections that declare what a consumer needs |
| Data.subscriptions | turn the requirements of active Surfaces into reads, live subscriptions, and retention roots |
| Data.reduce | reduce returned Remote Messages into the application's Remote.Model |
| RemoteClient | perform the actual read/query/mutate/live I/O |
One rule keeps the cache coherent: Data.reduce is the only writer. Local
application state changes with modifyFields inside update, like anywhere
else; a server fact only enters the cache as a Remote Message. Never install
cache state with a ref set: requirements, staleness and tombstones stay
consistent only because every fact arrives through the reducer.
Install
pnpm add foldkit-remote foldkit-entityfoldkit and effect are peer dependencies; foldkit-surface comes with the
package. The server-side interpreter is foldkit-remote-server.
The first run: one entity, one Surface
Six steps take a project's name from a server to a screen. Each says what it does, and what it deliberately does not.
1. Declare an entity and what this screen needs of it
import { Schema } from 'effect'
import { Entity } from 'foldkit-entity'
const Project = Entity.define(
'Project',
Schema.Struct({
id: Schema.String,
name: Schema.String,
status: Schema.String,
}),
)
// A Selection is the exact server-owned shape this consumer needs.
const ProjectSummary = Entity.select(Project, {
id: true,
name: true,
})The entity comes from foldkit-entity, which declares a domain
without Remote in it, so the same declaration can serve the server's database
binding and your forms too. It is also what gives an entity
relations, derived members, and a
query body to point at. Nothing here touches a
network.
2. Give Remote a place in the Model, and route its Messages
import { defineMessageUnion } from 'foldkit/message'
import type * as Update from 'foldkit/update'
import { Remote, type RemoteClient } from 'foldkit-remote'
import { Surface } from 'foldkit-surface'
const Route = Schema.Union([
Schema.Struct({ _tag: Schema.Literal('home') }),
Schema.Struct({
_tag: Schema.Literal('project'),
projectId: Schema.String,
}),
])
const Model = Schema.Struct({
route: Route,
remote: Remote.Model,
})
type Model = typeof Model.Type
const Message = defineMessageUnion({
GotRemoteMessage: { message: Remote.Message },
})
type Message = typeof Message.Type
const App = Surface.application({
Model,
Message,
initial: {
route: { _tag: 'home' },
remote: Remote.initial,
},
update,
})
const Data = Remote.make({
model: App.model.remote,
entities: [Project],
})
// Remote under one variant of the union, the shape Foldkit gives a Submodel.
const foldData = Remote.fold(Data, message => Message.GotRemoteMessage({ message }))
function update(
model: Model,
message: Message,
): Update.Return<Model, Message, RemoteClient> {
return Message.match(message, {
// Remote owns the `remote` Submodel: everything it says goes through its reducer.
GotRemoteMessage: ({ message }) => foldData(model, message),
})
}Remote.Model is not a second store. It is a Submodel inside the
application Model, and Data is the bound API for this domain: it knows where
that Submodel lives and which entities this application registered. update
matches the union exhaustively; Remote's own Messages arrive inside
GotRemoteMessage and go straight to Remote's reducer. (There is a shorter
form for an update that only reduces; see
Routing Remote's Messages.)
3. Declare what the screen reads
const ProjectPage = App.surface('ProjectPage', {
params: { projectId: Schema.String },
model: ({ params }) => ({
// Important: this performs no I/O.
// It is a pure Projection over Model plus a requirement for these fields.
project: Data.get(ProjectSummary, params.projectId),
}),
})ProjectPage reads a RemoteData<{ id: string; name: string }> from the Model.
If those fields are absent, the Projection still does not fetch them: it says
what it needs, and something else decides whether to ask.
4. Let the active screen drive the fetching
import { Option } from 'effect'
import * as Subscription from 'foldkit/subscription'
const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
foldData.subscriptions({
project: Surface.at(ProjectPage, model =>
model.route._tag === 'project'
? Option.some({ projectId: model.route.projectId })
: Option.none(),
),
}),
)Surface.at makes activation a fact of the Model. When the route activates the
page, Remote sees its requirements, diffs them against the cache, and fetches
only what is missing. When the page is inactive, it creates no read work. This
is the one step that causes I/O, and it does so only while the screen is on.
A read that is not a Surface's, such as the record a detail pane shows, is made
active with Data.active: its function of the Model returns the read, or none
while there is nothing to read.
const detail = Data.active('ProjectDetail', model =>
Option.map(model.openProjectId, id => Data.get(Project.select({ name: true }), id)),
)
foldData.subscriptions({ detail })It belongs to the domain's application and sends no Messages. A domain bound
to a raw optic (ModelRef.fromOptic) names no application, so Data.active
throws for one rather than guess.
5. Provide a client
The Subscription needs a RemoteClient. Before there is a server, a backend
held in memory answers through the same handlers a real one uses:
import { RemoteServer } from 'foldkit-remote-server'
const clientLayer = RemoteServer.memory({
domain: Data,
rows: { Project: [{ id: 'p1', name: 'Apollo', status: 'active' }] },
}).layerWith a server, adapt your configured Effect RPC client instead; the server guide supplies the other side:
const clientLayer = Remote.clientLayer(rpcClient)Either is a Layer the runtime provides to subscriptions and Commands. The
transport itself is not owned by Remote. See
RemoteServer.memory
for what the in-memory one can and cannot do.
6. Watch it arrive
ProjectPage Projection
|
| requirements
v
Data.subscriptions
|
| plan missing fields
v
RemoteClient
|
| server result
v
Remote Message
|
v
Data.reduce
|
v
Remote.Model
|
v
ProjectPage now reads Ready(...)The first run starts on the home route, so no project read runs yet. Your
route update must activate { _tag: 'project', projectId: 'p1' }, and the
runtime must install subscriptions. The page then reads Initial until work
starts, Loading during the first request, and Ready once its result is
reduced. If it stays Initial, see Why is it still Initial?.
Routing Remote's Messages
The first run folds Remote's Messages under one variant of the application's
union, the shape Foldkit gives a Submodel. The fold's fetch, mutate, and
subscriptions yield that variant too, with the lift recorded so a Story
resolves a fetch or a mutation by Remote's own answer.
The shorter path, for an update that only reduces, spreads Remote's cases
into the union and narrows by tag; the rest of update is then a partial
match over tags the application never handles:
const Message = defineMessageUnion({ ...Remote.messages })
function update(model: Model, message: Message): Update.Return<Model, Message, RemoteClient> {
if (Remote.reduces(message)) return { model: Data.reduce(model, message) }
return { model }
}The rest of this guide uses the fold. With the spread, read Data.subscriptions
for foldData.subscriptions and Data.mutate for foldData.mutate.
One value with foldkit-bundle: Data.wiring
The integration steps above — fold or reduce, derive the Subscriptions,
provide the client — are one value when the application uses foldkit-bundle:
import { Bundle } from 'foldkit-bundle'
const Page = Bundle.parent({ Model, Message })
const wiring = Page.assemble(
Data.wiring({
project: Surface.at(ProjectPage, model =>
model.route._tag === 'project'
? Option.some({ projectId: model.route.projectId })
: Option.none(),
),
}),
)
const update = wiring.update(model => ({ model }))Data.wiring routes Remote's Messages into Data.reduce, brings the active
Surfaces' Subscriptions and the domain's contract, and requires RemoteClient
from the runtime's resources — the same client layer from above. One assembly
holds at most one Remote domain: every domain claims the same Message tags,
and the assembly refuses a second claimant at startup, naming both.
RemoteData: what does the Model know right now?
A Remote Projection never lies by pretending an absent value is present. It
returns a closed RemoteData union:
no active fetch
missing ------------------------------------------------> Initial
fetch starts
missing ------------------------------------------------> Loading
|
result
v
Ready
|
refetch starts
v
Refreshing
old value stays
|
result
v
Ready
server says entity is absent ---------------------------> NotFound
stored value fails Selection decoding -----------------> Failed
a read or a list's query fails ------------------------> Failed (with the old value, if any)The states are:
Initial— required data is absent and nothing is currently fetching it.Loading— required data is absent and a read, or a list's query, is in flight.Ready— every selected field is present and decodes.Refreshing— the current value remains visible while it is being refetched.Failed— stored server data does not decode against the Selection, the read or query behind it failed, or the server settled a selected field without a value (anUnavailableerror; see When a field is withheld). A value that was already shown is kept asprevious.NotFound— the entity is represented by a tombstone: a live event deleted it, or the server was asked for it by id and answered with nothing about it. That id is then known absent, whether it never existed, is gone, or is hidden whole from this principal. It is not planned again untilData.refreshforces it or a write brings it back. The target of a returned ref is not marked this way: a server need not expand a relation that rides on a request, and the planner asks for such a target by id next, which is when its absence is learned.
Initial is intentionally different from Loading. A Projection belonging to
no active Surface may remain Initial forever. Rendering a spinner for
Initial can therefore hide an activation/wiring mistake; Loading is the
state that actually means "wait for this request."
When a read sits at Initial and you expected data, ask the Model why with
Data.why; see Why is it still Initial?.
RemoteData.match is exhaustive, so adding or omitting a state is visible at
compile time.
Drawing one: RemoteData.render
Most views draw these six states three ways, under one policy: useful data
stays on screen. RemoteData.render is that fold.
RemoteData.render(project, {
loading: () => ProjectSkeleton(),
notFound: () => NoSuchProject(),
failed: error => ErrorView(error),
data: (value, freshness) =>
ProjectView({ project: value, dimmed: freshness._tag !== 'Fresh' }),
})Initial and Loading reach loading; Ready and Refreshing reach data;
a Failed that still carries the value it had reaches data too, so a read
that failed does not throw away what the reader was already looking at. Only a
Failed with nothing to show reaches failed.
The data branch is told which it got, as a Freshness — exported, so a view
helper that takes one can name it:
| Freshness | What it means |
| --- | --- |
| Fresh | This is the current answer. |
| Refreshing | A newer answer is on its way; this one is still good. |
| Stale | A read failed; this is what was last known good, with its error. |
It is one tag rather than a pair of booleans because a value cannot be both refreshing and stale, and a type that can say so invites a view to handle a state that never arrives.
notFound is its own branch and not optional. A row the server answered for
and does not have is neither loading nor a failure; drawing it as either is a
spinner that never ends or an error nobody can act on.
Reach for match instead when the six states really do draw differently — it
stays the exhaustive fold, and render does not replace it.
When it does not do what you expect
Each of these is a design of the package showing through, not a fault. The symptom names the section; the cure is one call.
Why is it still Initial?
Initial means required data is absent and nothing is fetching it, so a
spinner here hides a wiring mistake. Ask the Model:
Data.why(model, projection, { surfaces }) // the record you give Data.subscriptions
// { state: 'Initial', reason: 'NotFetching', surfaces: ['ProjectPage'],
// message: 'ProjectPage reads it and is active, yet nothing is fetching it. …' }NotObserved: no active Surface reads it. The Surface is not in the record you gaveData.subscriptions, or its params areundefinedfor this Model (the route is not there yet).NotFetching: an active Surface reads it and nothing started a request, which almost always means Remote's Subscriptions are not installed in the runtime.- Without
surfacesit can only sayUnknown. For every other state it says what the state means in words.
Data.plan(model, projection) shows whether Remote believes anything is
missing at all.
Why is it stale?
Under the default RemotePolicy.cacheFirst, a present field is never asked for
again. That is the point of a cache, and the reason a list shows what the
server said last time. To see change:
- On demand:
Data.refresh(model, projection)fromupdatemarks what the screen readsRefreshingand the active Subscription fetches it again, old value still on screen. A refresh button, a window regaining focus. - By age:
RemotePolicy.staleWhileRevalidate({ maxAge })refetches a value older thanmaxAgethe next time it is planned. - Always:
RemotePolicy.networkOnlyrequests every selected field, cached value visible meanwhile. - As it happens:
Data.liveinstead ofData.get, with a server that publishes changes; see Live data.
Connections have no age: a list re-queries when invalidated, refreshed, or
under networkOnly. See Reading, policies, and prefetch.
Why did my change disappear?
A mutation's optimistic patches are layers over the store while the request
is in flight. When the server answers, the layers come off and the cache shows
what the server said:
- It failed.
MutationFailedremoves the layers;Data.mutation(model, requestId)readsFailedwith the error. Nothing retries it: the mutation is not a durable queue, and if losing it is data loss the edit belongs tofoldkit-sync. - It succeeded, but the server's result did not include the field. The
layer came off and the store still holds the old value, until a read or the
result writes the new one. A mutation
Outputthat returns the changed entity settles this at once. - A preview shown with
Data.overlaystays untilData.lift; a settled mutation never takes it along.
See Mutations and optimistic state.
Why is it gone after navigating away?
Remote is a cache, and it forgets facts no active feature needs. Retention
roots come from the active Surfaces; after the grace period, RetentionChanged
collects everything they do not reach. A screen you left, and nothing else
reads, loses its rows, which is safe because coming back refetches them. To
keep something a screen does not read, name it in a retain entry or a
connections list; see Retention.
Why does a value the server has read Failed?
Failed with no previous and no request error means the stored value did not
decode against the Selection: the server's shape and the entity's Schema
disagree; the error is a DecodeError with the Schema's message. An
Unavailable error means the server answered without a field the Selection
names and will not answer with it; see
When a field is withheld. A failed request reads
the same way, with the request's error; see
When a read fails. A
RemoteProtocolError means the client and server disagree on
REMOTE_PROTOCOL_VERSION, and nothing is silently accepted.
Why did a live update not show?
Live events carry cursors. One behind the Model's is a duplicate and is
dropped; one ahead of the next is a gap, recorded in RemoteModel.gaps
and not applied, so the screen keeps what the server last said rather than
a history with a hole in it. The host resynchronizes and clears the gap; see
Live data.
Normalized entities and selections
Remote stores an entity once by identity, regardless of how many Surfaces read it. Consumers select different views of the same normalized fact.
Relations are references, not nested copies:
const User = Entity.make(
'User',
Schema.Struct({
id: Schema.String,
name: Schema.String,
}),
)
const Project = Entity.make(
'Project',
Schema.Struct({
id: Schema.String,
name: Schema.String,
owner: Entity.ref(User),
}),
)
const UserSummary = User.select({ id: true, name: true })
const ProjectSummary = Project.select({
id: true,
name: true,
owner: UserSummary,
})The store remains normalized:
Project:p1 name PRESENT owner PRESENT -> User:u7
User:u7 name PRESENTThe Projection assembles the nested consumer value by following the reference.
An unknown field or a nested Selection for the wrong entity is a type error.
Recursive relations remain finite because the entity schema stores references
(Entity.ref(User) / Entity.refTo('Node')), not recursively inlined schemas.
Entities declared with foldkit-entity
foldkit-entity declares a domain without Remote in it:
fields, relations as navigation edges, derived members. Remote.make registers
those Entities, and Data.get, Data.live, and a query's select take their
Selections. Remote compiles both into the descriptor and Selection above, so
the store, the planner, and the server see nothing new.
import { Schema } from 'effect'
import { Entity, Relation } from 'foldkit-entity'
import { Remote } from 'foldkit-remote'
const User = Entity.define('User', Schema.Struct({ id: Schema.String, name: Schema.String }))
const Project = Entity.define('Project', Schema.Struct({ id: Schema.String, name: Schema.String }))
const Work = Entity.relate({ User, Project }, { Project: { owner: Relation.one(User) } })
const ProjectCard = Entity.select(Work.Project, {
name: true,
owner: Entity.select(Work.User, { name: true }),
})
const Data = Remote.make({ model: App.model.remote, entities: [Work.User, Work.Project] })
const card = Data.get(ProjectCard, 'p1') // Projection<Model, RemoteData<{ name; owner: { name } }>>- A relation becomes a ref field:
onea ref, an optionalonea nullable ref,manyan array of refs. A derived member becomes a field the server supplies. - The Entity needs an
idfield; Remote keys the store by it. When that field is branded,Data.getandData.livetake that type for an Entity Selection: another Entity's id, or plain text, does not compile. - Register the Entities of one
Entity.relateresult and select from that same result;Object.values(Work)registers them all. Entity.from(entity)andSelection.from(selection)are the compile steps, for when you need the descriptor itself:patchandrefin a mutation handler.Entity.page(selection, window)in an Entity Selection is Remote'sSelection.connection: the window travels with the read, and the server answers with a page of refs. A page of a whole list is read under a name of its own,comments@first=10, so one view can show every comment while another shows the first ten of the same post: they are two fields to the store, fetched in one batch, merged and refreshed each on its own. A write to the list (a mutation's patch, a live change) marks its pages stale, so they are read again. A cursor is not part of the name: a page read from a cursor continues its page. The server refuses a request that pages one relation more than four ways, and a field's own name may not contain@.
Entity.make with Entity.ref keeps working, and both kinds can share one
domain. Both packages export Entity; a module that needs Entity.from beside
Entity.define aliases one of them.
Presence is tracked separately from the JavaScript value. These are distinct:
field missing
field present with undefined
field present with null
field stale
entity not foundRemote never infers presence from value === undefined.
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. 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, 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.
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.
Queries and pagination
Entities answer "which fields of this known thing?" A Query answers "which entities belong in this server-owned list?"
Declare the query and register it with the domain:
import { Query } from 'foldkit-remote'
const ProjectsByOwner = Query.make('ProjectsByOwner', {
Input: { ownerId: Schema.String },
Result: Project,
})
const Data = Remote.make({
model: App.model.remote,
entities: [User, Project],
queries: [ProjectsByOwner],
})Query.make names a query and says what it returns. Where it means
something the client should be able to state, declare it with a body instead:
import { Entity, Expr, Order, Query } from 'foldkit-entity'
const Task = Entity.define(
'Task',
Schema.Struct({ id: Schema.String, ownerId: Schema.String, updatedAt: Schema.Number }),
)
const TasksByOwner = Query.define(
'TasksByOwner',
{ ownerId: Schema.String },
({ input }) =>
Query.from(Task).pipe(
Query.where(Expr.eq(Task.fields.ownerId, input.ownerId)),
Query.orderBy(Order.desc(Task.fields.updatedAt), Order.asc(Task.fields.id)),
),
)A body is written over a foldkit-entity Entity, because that is
what has addressable fields: Task.fields.ownerId is a reference an Expr can
be built from, where this package's own Entity.make describes a field as the
schema of its value and has nothing to point at. An entity declared either way
still reads, selects and normalizes the same; only a query body needs the
richer declaration.
What comes back is an ordinary descriptor — the same name, Input, ref and
connection identity — carrying its body besides, and the result is a
connection over the Entity the body reads, so it is not named twice. A server
can compile that body rather than be told the same thing again in its own
dialect; one that would rather answer the query its own way still can, and a
descriptor from Query.make has no body at all.
The body is built once, when the query is declared. Inside it input.ownerId
is a placeholder for the value the query will be given, not the value — there is
nothing there yet to branch on. A condition that depends on what was passed is a
comparison over the placeholder, never a ?: around it.
Input must be fields or a plain Schema.Struct, because that is what the
placeholders are made from. A codec that exposes no keys is refused where the
query is declared rather than handing the body an empty object, which would
compare a column to nothing while every type agreed.
Then a query is still just a Projection:
const projects = Data.query(
ProjectsByOwner,
{ ownerId },
{ select: ProjectSummary, first: 25 },
)
projects.read(model)
// RemoteData<Page<ProjectSummary>>: at most 25 items
Data.more(model, projects)
// Option<Model>: "load more", the window grown to 50; none when all are shownThe connection stores entity references and explicit boundaries rather than one flat array. That matters when page 1 and page 3 are loaded but page 2 is not: Remote represents an honest gap instead of pretending the visible rows are adjacent.
A query Projection is Initial until the page and every selected field of its
visible items are present. The select travels with the query: the server
returns the selected fields of the page's items with the edges, and one
ConnectionMerged merges the page and writes them, so a fresh list usually
lands Ready in one response. What the server leaves out (withheld fields,
nested targets it does not expand) becomes ordinary entity requirements and
is fetched next, as before; a server that predates payloads sends edges only
and the list reads the old two-round-trip way.
A read shows at most its window. Remote keeps one connection per query and
input, whatever window asks for it, so a picker's first: 50 and a card's
first: 3 of the same query share the rows, and each read is cut to its own
window: first from the start, last from the end. hasNext / hasPrevious
are true when the read cut rows or the connection's boundary says there are
more; they are never guessed from a row count.
Two Projections of the same connection plan one query, for the wider window, and select the union of their fields. When a connection holds fewer rows than a window asks for, the read entry asks only for the rows it lacks, after the loaded end (or before the loaded start).
"Load more" grows the window. Data.more(model, projection) is the Model
with that read's window one page larger, called from update; the read entry
then fetches what the connection lacks. It is kept in Remote's store, under the
query, input and window first asked for, so a list holds no state of its own,
and it is forgotten when retention drops the connection. Another window of the
same query does not grow with it.
Filtering a loaded list without asking the server
A search box over a page you already have should not be a round trip.
Data.filtered narrows a loaded list by a query body:
const found = Data.filtered(model, projects, ActiveProjects, {})
found.items // decoded exactly as the list decodes them
found.complete // whether the answer was about the whole listIt filters a list; it does not run a query, and the distinction is the design. "Which rows match" would need to know this list holds every row the body could match — predicate containment, which Remote deliberately does not do. "Which rows of this list match" is decidable from what is already here.
complete is false unless three things hold: every edge could be judged (no row
missing a field the body reads), every match could be shown (no row missing a
field the Selection reads), and the list is terminal at both ends. So
empty-and-complete and empty-and-partial stay different answers — one means
"none", the other means "none that I can see yet".
Nothing is created: no connection, nothing new to retain, nothing new to fetch. The server stays authoritative for which rows exist.
Judging the rows you already hold
A connection's rows are edges the server delivered, and a query body describes
which rows — so until something evaluates the body on the client, nothing here
can say whether a row belongs to a query. Remote.matching says:
const judged = Remote.matching(Data.storeOf(model), ProjectsByOwner, { ownerId: 'u1' })
judged.matched // keys satisfying the body, in the order it asks for
judged.skipped // keys held, but missing a field the body readsIt is pure, and it runs the same interpreter the server checks itself
against — the reference implementation in
foldkit-entity — so a body means one thing in both places. The
conformance suite is run over a store as well as over rows and a table.
Three things about it are deliberate.
It answers "which of the rows I hold match", never "which rows match." Those are different questions, and the difference is not recoverable from a list of keys — so it is in the shape. A caller that knows it holds the whole population (a connection terminal at both ends) is the one that can turn this into a complete answer.
skipped is the honest half. A row missing a field the body reads cannot be
judged: it is neither a match nor a non-match. Dropping it silently would turn
"I could not tell" into "no", so it is named instead, and the caller decides
whether to fetch the field, ask the server, or say the answer is partial.
It takes the input decoded and encodes it itself. A store holds wire values, so a comparison has to happen in that space — and handing an interpreter a decoded value is a mistake no runtime error catches: the answer is simply empty, which reads exactly like a correct one. Doing the encoding here means a caller cannot get it wrong.
It is not authorization. On the server a compiled where is conjoined with
the binding's visible rule; there is no visible here. This is safe only
because the client holds only rows the server already released to it, and a
local filter is not an access decision.
Local execution is not a second authority. matching and filtered derive
conclusions from facts the server gave; they never promote those conclusions
into knowledge of the server's whole dataset. Knowing p1.ownerId = u7 says
that p1 matches ProjectsByOwner(u7); it does not say that
ProjectsByOwner(u7) is [p1], and nothing here will say so unless a
connection's boundaries, terminal at both ends, independently prove it. That
line is what keeps Remote a cache of what the server owns rather than a local
database with opinions of its own.
An input that changes as fast as someone types
A connection's identity is its query plus its input, which is exactly right for caching and exactly wrong for a search box. Bound naively, every keystroke is a different connection, a different request, and a different thing to retain:
// Don't: `postSearch` changes per keystroke, so the query's input does too.
h.OnInput(text => Message.Searched({ text }))Remote has no debounce, and should not: its job is to be a faithful function of
the Model, so if the Model says the search is Engi then Engi is what the
query asks. The debounce belongs between the input Message and the Model field
the query reads — which is ordinary application state, and
foldkit-primitives already has the piece:
import { debounce } from 'foldkit-primitives/time'
const SearchInput = debounce({ name: 'PostSearch', value: Schema.String })
// `latest` is what the box shows, so typing stays immediate.
// The settled OutMessage is what moves the field the query reads.
// A step of the page's Bundle.compose(...).pipe(...):
Bundle.withChild('search', SearchInput, {
args: { delayMs: 250 },
onOut: out => model => ({ model: modifyFields(model, { postSearch: () => out.value }) }),
})Two fields, deliberately: the one the box draws changes on every keystroke, and
the one the query reads changes a quarter second after the last one.
examples/entity does exactly this.
The same applies to any high-frequency query input — a slider, a map viewport, a
date scrubber. What makes a search box the usual case is that the query is often
written for it: Expr.contains over an empty string matches everything, so an
empty box and a filled one are one query rather than two.
A page that overruns its window is refused
The client asks for first: 25, and a page carrying more than that is a
protocol disagreement rather than a windfall: the client cannot tell which of
the edges the window meant, and the connection's boundaries stop describing what
it holds. Such a page becomes a QueryFailed with a named protocol error, and
none of its edges reach the store — a connection already loaded is left exactly
as it was, so a refresh that overruns cannot replace good rows with a rejected
page.
A window with neither first nor last bounds nothing: after/before says
where to start, not how much to take.
When a read fails
A failure is kept on the Model until something settles it, and the read says
so. Queries fail per connection, and entity reads per field. A read with
nothing to show reads Failed with the error. One that had a value (a failed
refresh, or a failed "load more") reads Failed with that value as
previous, which RemoteData.render draws as data with a Stale freshness,
so it stays on screen. A failure reaches every read that needs it: a list
fails when one of its rows' fields failed, and a project read fails when its
owner's did.
It is not retried on its own. A persistent error would otherwise be asked again every time some unrelated read restarted the entry. What failed is left out of the plan, and everything else is still fetched: the rows a failed list holds, the other fields of an entity. What asks again:
Data.refresh(model, projection)— the retry button. It works on something that never loaded, too.- The value arriving anyway: a page for a list, or a read, live patch or mutation result that writes the field.
- The server invalidating a list over a live stream, or deleting the entity.
- Retention dropping it: something nothing reads any more forgets its failure, so coming back to it later asks the server again.
Remote.inspect(model.remote).failures lists what failed, by connection
identity and by entity\0id\0field mark.
A broken live stream is not a failed read. Its ReadFailed carries the
stream, and records a gap in RemoteModel.gaps rather than failing any
field: nothing was being read, and the values on screen are still what the
server last said.
When a field is withheld
A server may answer a read without a field it was asked for, and mean it: the
Source's authorize withheld it from this principal, or the record it returned
had no such field. The read result says so, in settled: the fields it asked
for that this answer does not carry and a later one would not either. Why is
not on the wire. The client records each as unavailable, one more kind of
knowledge beside a value and a tombstone:
missing not established; the planner asks
present known
stale known, and being asked again
unavailable asked, and answered without: the planner does not ask again
tombstone the whole entity is known absentA Selection that names an unavailable field reads Failed with an
Unavailable error naming the field, not Loading (nothing is fetching it)
and not NotFound (the entity is there). What it is not is a seventh state:
the remedy is the one a failed read has. Select without the field, in a
Selection of its own if some principals may read it, or show the error. A
Selection that does not name it is unaffected: name reads Ready while
privateNotes is unavailable beside it.
The mark is forgotten by what forgets a failure: Data.refresh asks again,
and a read, live patch or mutation result that writes the field clears it.
It survives persistence and a server render's Remote.resume, so the browser
does not ask once more for what the server already declined.
An id the server answers with nothing about, neither values nor settled fields, is a tombstone as before. A server settles a withheld field without reading its Source for an id whose every field is withheld, so an absent id and a hidden one answer alike, and existence is not told.
Mutations and optimistic state
Register mutations on the domain:
import { Mutation } from 'foldkit-remote'
const RenameProject = Mutation.make('RenameProject', {
Input: { id: Schema.String, name: Schema.String },
Output: { id: Schema.String },
})A mutation starts from ordinary application update:
case 'ClickedRename': {
const { id, name } = message
const { model: started, command } = Data.mutate(
model,
RenameProject,
{ id, name },
{
optimistic: [Remote.patch(Project, id, { name })],
},
)
return { model: started, commands: [command] }
}Data.mutate does two things:
- it applies
MutationStartedto the Model, including any optimistic overlays; - it returns the Command that calls
RemoteClientand eventually emitsMutationSucceededorMutationFailed.
Remote.patch(entity, id, values) builds one entity's patch, and
Remote.ref(entity, id) names one row. Both take the entity you declared,
whether with foldkit-entity or Remote's Entity.make, and check values
against its fields in their wire shape: a relation is written as its ref key,
such as 'User:u1'.
Those settlement Messages go through Data.reduce like every other Remote fact.
The request id comes from the Remote Model's sequence, so update stays pure.
Settling is idempotent per requestId.
A mutation that deletes says so. The server's outcome carries
deleted: [{ entity, id }], and settling it tombstones those entities: they read
NotFound, and they leave every connection and relation they were in, so the
server names no list. Patches apply first, so an entity named both ways is
deleted; a retry of the same request deletes nothing again.
Data.mutation(model, requestId) reads what became of it from the Model:
Pending, Applied, Failed with the error the server or transport gave, or
Unknown for an id never started here (or settled so long ago it left the
bounded ledger). A retry reuses its id, and the latest outcome wins.
Optimistic entity patches are layers over the base store, not inverse patches. If multiple mutations overlap, the visible cache is recomputed as base plus the still-pending layers, so settling one does not require trying to undo its old value manually.
Connections can be optimistic too:
import { ConnectionChange } from 'foldkit-remote'
const { model: started, command } = Data.mutate(model, AddComment, input, {
optimistic: ({ tempId }) => [
Remote.patch(Comment, tempId, { id: tempId, body: input.body }),
ConnectionChange.prepend(
CommentsForPost.ref({ postId }),
Remote.ref(Comment, tempId),
),
],
})A successful server result can replace the request's temporary connection overlays with confirmed ones in place.
Showing a change nobody has made
A mutation's optimistic operations show over the store while it is in flight.
Data.overlay shows operations the same way with no request behind them, until
Data.lift: a preview, in every Selection and view, of something not yet sent.
const previewed = Data.overlay(model, 'post-preview', [Remote.patch(Project, id, { name: draft })])
const back = Data.lift(previewed, 'post-preview')- Both are called from
update, and neither performs I/O or touches what the server said: the store beneath is as it was. - Showing an id again replaces what it showed. Lifting what was never shown returns the same Model.
- An overlay's id is apart from every request's, so a mutation that settles does not take a preview with it.
- A preview of an entity the server has not seen, such as an unsaved post, has
only the fields the overlay holds. A Selection that reads more reads
Failed, with anOverlaiderror naming what is missing, since nothing will fetch it. Overlay every field the Selection reads, or preview through a smaller Selection.
Reading past what is only pending
Every projection reads the visible cache: the server-derived store under the layers still pending, which is why an optimistic change shows at once. A reader that must not believe a change until the server has agreed to it asks for the confirmed read instead:
Data.confirmed(Data.get(ProjectSummary, projectId))It is the same projection — the same requirements, planned the same way, so observing it fetches exactly what observing the original fetches — reading the server-derived store alone, with the pending layers and connection overlays left off. An optimistically inserted edge is not in its page; an optimistically patched field reads as the server last said.
A view almost always wants the projection itself. This is for the reader that reports on the world rather than drawing it, which in practice is an Agent capability that must not claim success before the server reflects it:
Agent.when({
projection: Data.confirmed(Data.get(ProjectSummary, projectId)),
predicate: (project, request) => project.name === request.name,
})There is deliberately no Data.visible: a projection already is the visible
read, and a second name for it would be a wrapper that only forwards.
Sync draws the same line over its replica, with the same words and a different mechanism: see what a reader sees while a change is in flight.
Remote mutation vs Sync operation
A Remote mutation requests the server now; its result updates a disposable
cache, and there is no durable outbox. If losing an unsent edit would be data
loss, it belongs to foldkit-sync, not here (see which state
belongs here). For the same reason, an optimistic
overlay is only a temporary view of server-owned data: do not persist one and
treat it as a queue of edits.
Live data
Data.live marks a Projection so active Data.subscriptions follow server
changes for it.
The Remote Model owns each stream's cursor. Live events carry monotonically ordered cursors:
- an old/repeated cursor is a duplicate and is ignored;
- the next cursor is applied;
- a cursor ahead of the expected value is a gap and is not applied.
A gap is recorded in RemoteModel.gaps so the host can resynchronize rather than
silently accepting missing history. An in-order event or GapCleared clears it.
Entity patches update normalized fields, deletes create tombstones, and connection insert/remove/invalidate events reconcile the same connection model used by query pages and optimistic overlays. A stream failure becomes a Remote failure Message rather than mutating anything out of band.
Retention and garbage collection
Remote is a cache, so it should be allowed to forget facts no active feature needs.
Data.subscriptions derives retention roots from the active Surfaces of every
call on the domain, so a call's retain entry never collects what another call
reads; connections given to any call are kept the same way. A root keeps:
- the entities and fields its Projection requires;
- referenced targets reached by nested selections;
- retained connections and the selected fields of their visible items;
- data touched by pending optimistic work.
After the configured grace period, RetentionChanged enters the Model and the
pure Remote reducer collects unreachable cache data. An inactive Surface that is
not otherwise retained may therefore lose its cache, which is safe because the
server remains authoritative and the planner can refetch it.
This is another important difference from Sync: Remote recovery may discard and refetch; Sync recovery must preserve unsent client intent.
Persistence and hydration
A snapshot is { entities, connections }. The entity cache is disposable and
always goes; a connection goes only if named, and keeps its edges alone — never
a cursor, which may name server state that is gone. Session machinery (live
cursors, optimistic layers, mutation bookkeeping, gaps, retention roots) is
never in one.
import { RemotePersistence } from 'foldkit-remote'
// A cache carried in the page: prefetch on the server, embed, then hydrate.
// `snapshotOf` names the connections that survive; none, here. For a page
// the browser resumes with foldkit-ssr, see Server rendering below.
const text = RemotePersistence.dehydrate(
RemotePersistence.snapshotOf(loaded.remote),
{ scope: userId },
)
const restored =
RemotePersistence.hydrate(text, { scope: userId }) ?? RemotePersistence.emptySnapshot
Data.reduce(model, {
_tag: 'Hydrated',
entities: restored.entities,
connections: restored.connections,
merge: 'preserve-existing',
})
// Or persist through Effect's KeyValueStore.
RemotePersistence.save(RemotePersistence.snapshotOf(model.remote), {
key: 'remote-cache',
scope: userId,
maxBytes: 512_000,
})
RemotePersistence.restore({
key: 'remote-cache',
scope: userId,
maxBytes: 512_000,
})Snapshots are deterministic, versioned, scoped, and optionally size-bounded. A
snapshot from another version/scope, an oversized snapshot, or malformed data is
discarded and the planner refetches. Hydrated is itself a Remote Message, so
hydration still changes the application through the reducer.
Server rendering: Remote.resume
A snapshot is built for a cache that survived a reload: it keeps the whole
entity store and deliberately no cursors. A page rendered a moment ago wants
the opposite. Remote.resume(Data) is a resume part for
foldkit-ssr (in development), which sends exactly what
the page's active Surfaces read, and resumes it in the browser:
const Page = SSR.plan(App, {
id: 'project',
state: Projection.pick(App.model.route),
surfaces: [ProjectPageAt],
parts: [Remote.resume(Data)],
})- Only what is read crosses. For each requirement, the fields it names, following its relations through the refs the store holds; for each connection, the fields its selection reads of each item. A field in the server's store that no Surface selects, such as a user's email, is not in the page.
- Connections cross whole, with their boundaries, so "load more" knows where the server stopped.
- Live cursors cross with the entities they follow, so a live subscription started in the browser asks from where the server's left off.
- Nothing else crosses. Loading marks, failures, the mutation ledger, optimistic layers, gaps and retention start at their initial values.
The browser's read entry plans against the restored store, so it asks for
none of it again. The capture is JSON through its own Schema, and a page whose
capture does not decode is refused whole by foldkit-ssr. Give a second domain
in one application its own id: Remote.resume(Catalog, { id: 'catalog' }).
How the server packages fit
foldkit-remote owns client cache semantics, not your source database or
network transport.
client
Surface Projection
|
v
foldkit-remote
requirements + normalized cache
|
v
RemoteClient / Effect RPC
---------------- server boundary ----------------
foldkit-remote-server
Sources + field authorization
|
+---------------------+
| |
v v
foldkit-remote-drizzle hand-written Source
(optional SQL compiler) API / service / databaseThe package roles are:
| Package | Responsibility |
| --- | --- |
| foldkit-remote | normalized cache, requirements, planning, queries, optimistic overlays, live cursors, retention |
| foldkit-remote-server | interpret client selections/queries against server Sources and enforce semantic-field authorization |
| foldkit-remote-drizzle | optionally compile those selections and queries into typed Drizzle access |
| Effect RPC / your layers | transport and deployment |
Remote.clientLayer(rpcClient) adapts RemoteRpc to RemoteClient and
coalesces compatible reads/queries. HTTP, WebSocket, worker, and in-process
execution are layer choices rather than Remote semantics.
See foldkit-remote-server for Source and authorization
rules, and examples/kitchen-sink for the real
server packages together.
Introspection
Remote exposes its decisions as data rather than requiring DevTools to reach into private state:
Data.inspect(model) // serializable cache/domain summary
Data.plan(model, projection) // the entity plan a read would execute
Data.meta(model, projection) // when what is shown was received, stale/loading
Data.explain(model, query) // what one query read is, and currently is
Remote.planQueries(...) // missing/stale query work
Remote.inspectEntity(...) // one normalized entityData.meta is for views, not debugging: updatedAt is the newest server
write among what the projection shows (undefined when nothing shown was
received), with stale and loading beside it, so "updated 5s ago" reads
the Model like any other render.
These are pure and useful in tests, tooling, and debugging. For a read that
stays Initial, Data.why and Data.plan are the tools; see
Why is it still Initial?.
Data.inspect(model).loading answers the other one — "what is Remote doing
right now?" — with the entity\0id\0field marks of the reads in flight,
beside mutations.pending for the writes. Both are read from the Model, not
from the fibers doing the work: a tool that shows them shows something a
recorded Model can be replayed to, and nothing that needs the runtime to be
asked. That is deliberate. Runtime activity is not a second source of truth
here, and a view that rendered from it would stop being reproducible from the
Model.
Explaining one query read
A query read is assembled from more pieces than it looks like: a definition, an
input, a connection identity that excludes the window, a window that does not,
and a Selection that is a slice asked of the server. Data.explain puts them in
one serializable value, beside what the read answers from this Model right now:
Data.explain(model, projects)
// {
// domain: 'remote',
// query: 'ProjectsByOwner',
// input: { ownerId: 'u1' },
// identity: 'ProjectsByOwner\u0000{"ownerId":"u1"}',
// window: { first: 25 },
// select: { entity: 'Project', fields: ['id', 'name'] },
// body: 'FROM Project\nWHERE Project.ownerId = $ownerId\nORDER BY Project.name D