Remote / foldkit-remote
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.
Which lists a mutation changed is worked out from its answer. Each patch says which row it wrote; the fields whose value differs from what the client held are what changed (an answer returning a whole row changes only what is new). Every loaded list over that Entity is judged by its query's body against the store the answer left:
- a list whose body reads none of the changed fields stands;
- a row it holds that still matches, or a row it does not hold that still does not, leaves it standing;
- a held row that stops matching leaves it, with nothing fetched: whether a row belongs is the body's, judged exactly;
- a row that starts matching, a row new to the client, a held row whose
ordering field changed, or a row whose order moves it in from past what a
list has loaded, is placed where the body's order puts it among the rows
loaded (
placeIn), when the order is placeable (Query.placement); a row that sorts past what is loaded is left out of it (and out of what showed it), for the next page. A row that was itself where a page met rows not loaded (the last before a cursor, the first after one) cannot be told to have moved past it, its old values being gone, so it invalidates the list instead; - anything else invalidates it, and its read entry fetches it again: a row the client cannot judge (it lacks a field the body reads), an order it cannot place (text with no declared collation, the server's own), or a row an optimistic overlay shows; a read of it already in flight is restarted, since it could answer with the rows before the write;
- a list over a
Query.makequery, which declares no body, is invalidated by any change to its Entity; - a row a list does not hold that changes a member the server's own read rule
filters on (an owner, a team: declared on the Entity as
ReadFilter, whichfoldkit-guardattaches) invalidates it: whether the row now belongs is the server's to say, since the client runs no such rule. A row it holds is judged by the body as usual. A new row counts, so under such a rule a create refetches the lists over its Entity; - a list whose body declares no order leaves its order to the server, by
fields the client cannot see, so any change to a row it holds invalidates
it. Declare the order in the body (
Query.orderBy) to keep a list that a change provably did not move.
A placement can be checked against the server. Remote.make({ ...,
verifyPlacement: true }) places as usual, and also fetches each list it placed
what the server said in (an answer, as above, or a read or a live patch, as
below); when the server's first page arrives it is compared with the order
placed, and a disagreement is kept in model.remote.placementMismatches (and
Data.inspect): an order the page does not share, or a row placing took out
that the page still lists. A pending edit is not checked: the server does not have it
yet. Turn it on in development and tests: a collation the client
and the server disagree on then fails an assertion instead of misplacing a row
for a reader.
So a rename updates every view of the row and fetches nothing, a status change moves a row out of the lists filtered by status and into the others in their order, and a price change moves the row within a list sorted by price, all without a request.
A pending edit, and the server's word on a row, are placed the same way.
While a request's optimistic patch or a Data.overlay is pending, a list
that holds the row shows it where the edit puts it, or leaves it out once it
stops matching; the server's pages are untouched, and lifting the edit puts
the row back. A read or a live EntityPatched that brings a new value for a
row a list holds moves it in the list's pages, as an answer does. Neither
brings a row into a list, since what the list selects of it may not be
loaded, and neither fetches: a list it cannot place stands as it is.
An inserted row is drawn in order. A ConnectionChange.prepend or
append (a pending create's, a confirmed one's) and a live
ConnectionInsert say a row is in a list; they do not say where. In a list
the client can place, a read draws the row where the body's order puts it,
or leaves it out when it sorts past what is loaded; one it cannot place (a
field the order reads is missing, the order is the server's) stays at the end
its change names. Data.visibleItems(remote, connection) gives a list's edges
as its reads draw them, for a persistence snapshot that stores a list as
shown.
Data.refresh stays for what an answer cannot say, such as a server-side
effect on rows it did not return.
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.
A mutation can say what the server may refuse it with, as data rather than a message to parse:
const SetPrice = Mutation.make('SetPrice', {
Input: { id: Schema.String, price: Schema.Number },
Output: {},
Refusal: Schema.Union([Refusal.field('price', Schema.String), Refusal.conflict]),
})
Data.refusal(model, requestId, SetPrice) // Option<{ _tag: 'Field', key: 'price', reason: string } | { _tag: 'Conflict' }>A mutation can also be a declared write: Mutation.write(name, write) over a
foldkit-entity Write.update, Write.insert or Write.delete. Its input is
the write's, it answers nothing of its own, and Data.mutate shows an update's
or an insert's row until the server answers, so it needs no optimistic list;
a delete shows nothing until its answer removes the row everywhere. keys names the input keys
an author changed: only those are shown, and the request carries them for the
server to write; the input is still sent and validated whole.
const EditProject = Mutation.write(
'EditProject',
Write.update(Entity.input(Project, EditProjectInput), { id: 'id' }),
)
Data.mutate(model, EditProject, { id: 'p1', name: 'Apollo II', status: 'active' }, { keys: ['name'] })Refusal.field(key, reason) names an input key and why; Refusal.conflict
says the row moved on. The server sends the refusal encoded by this schema and
Data.refusal decodes it by the same one; it is none while the mutation is
pending or applied, or when it failed for another reason.
A server may also refuse who is asking, whatever the mutation
(RemoteServer.forbid). Then Data.mutation(model, requestId) is Failed
with an error of _tag: 'Forbidden', and reason when the server gave one,
for the person who asked. Asking again as the same person is refused again,
so show it rather than retry.
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. Data.overlays(model)lists the ids shown now, in the order they are drawn. A caller that overlays from state of its own reconciles against it:foldkit-sync/remoteshows a replica's pending cell edits this way, so the durable edit stays Sync's and Remote only draws it.
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.