Learn / Errors and failures
Writes
A mutation's optimistic change is a layer over the cache. When the write fails, the layer comes off and the change disappears; Remote keeps the failure, with the mutation and the input it was started with, until the application answers it. A mutation can declare what the server may refuse it with, as data. Continuing the first example, with a mutation registered:
import { Mutation, Refusal } from 'foldkit-remote'
const Rename = Mutation.make('Rename', {
Input: { id: Schema.String, name: Schema.String },
Output: {},
Refusal: Schema.Union([Refusal.field('name', Schema.String), Refusal.conflict]),
})
const Data = Remote.make({ model: App.model.remote, entities: [Project], mutations: [Rename] })
// The view: one notice per rename that did not land.
const notices = (model: Model): ReadonlyArray<string> =>
Data.failedWrites(model, Rename).map(write =>
Option.match(write.refusal, {
onNone: () => `"${write.input.name}" was not saved: ${write.error.message}`,
onSome: Match.valueTags({
Field: ({ reason }) => `"${write.input.name}": ${reason}`,
Conflict: () => 'Someone changed this project meanwhile.',
}),
}),
)
// update: a notice's two buttons.
const retried = (model: Model, requestId: string): Update.Return<Model, Message, RemoteClient> =>
Option.match(Data.retry(model, requestId), {
onNone: () => ({ model }),
onSome: started => ({ model: started.model, commands: [started.command] }),
})
const dismissed = (model: Model, requestId: string): Model => Data.dismiss(model, requestId)Data.failedWrites(model, Rename)lists the failed writes of one mutation, oldest first, withinputandrefusaltyped by it; without the mutation, every failed write. A write is listed until it is retried, dismissed, orData.forgetchanges the principal. Nothing drops one on a timer, and the list is persisted only for a durable write (below): a reload forgets the rest.refusalisSomeonly when the server refused the write as the mutation declares. A network failure or a server error isNone, with theerrorsaying what happened.Data.retrysends the write again as itself (same input, request id and operation id, so a server that already applied it answers from its record) and shows its optimistic change again. It isNonefor a write that is not failed, so a second press does nothing.
To act on a failure in update (move focus, put text back), share it:
Data.wiring(actives, { shared: ['MutationFailed'] }) hands MutationFailed
to the application's own update after Remote has reduced it, so the Model it
receives already lists the write. A wiring that does not share it reports a
WriteUnheard diagnostic for each failed write: the change went, and nothing
in the application was told.
A write that must outlive the page
Data.mutate(model, M, input, { durable: true }) queues the write in the
outbox (Data.outbox) instead of sending it: stored first, sent in order by
one tab, and kept across a reload. What changes for its failures:
| Failure | Ordinary write | Durable write |
|---|---|---|
Nothing answered (_tag: 'Unreachable': offline, a gateway's error page) | Failed; retry by hand | stays Waiting and is sent again with a backoff, and when the browser is back online |
| The server refused it | Failed, listed by failedWrites | the same, and kept in the store until retried or dismissed |
| The page reloaded before the answer | lost | sent again with the same operation id and now |
Data.outboxStatus(model) says what waits ({ waiting, failed, items }), for
a "3 changes waiting" line. A write the server applied but whose answer never
came is answered again from the writer's record, by its operation id: a
durable Mutation needs a ledgered writer (RemoteServer.write, or
remote-drizzle's writer with a ledger).
Two diagnostics are the outbox's:
OutboxUnreadable: a stored write this version cannot read (its Mutation is gone, or its input no longer decodes). It is kept, never sent, shown inoutboxStatusasUnreadable, and the writes after it are sent.Data.dismissremoves it.OutboxUnsaved: the store could not be written (full, or refused). The writes are still sent, but a reload before their answers loses them.
A durable write is still Remote's: one write, sent once, the server's
answer final. Edits that must merge with other devices' edits while offline
belong to foldkit-sync; see Remote mutation vs Sync operation.
What a declared write refuses
A Mutation.write over an Entity's Write needs no declared Refusal: its
type says what it may be refused with, and the server decides in this order,
so a refusal discloses nothing to someone who may not write:
| Refusal | When | What to do |
|---|---|---|
Forbidden (a RemoteError, not a refusal) | the row is hidden from the caller, or a guard rule does not hold; refused where it is made when the held may says no | show it; asking again is refused again |
Conflict | the row moved past the revision the write expects (an update or a delete) | read it again; let the author decide |
Unmet | the row does not hold the write's when (only for a write with one) | show why; the state changed |
Invalid | the row it would leave breaks an Entity invariant; refused where it is made when the client holds what the invariant reads | show message beside each field path names |
Taken | an insert's id is already a row's (only for an insert), whatever the row holds and whether the caller may see it; a retry of the create that made it is answered from the writer's ledger instead | choose another id; asking again is refused again |
An Invalid over a member hidden from the caller names no rule and no path.
Refusal.isInvalidBy(refusal, invariant) asks which invariant refused it, by
its Entity and its id, since an id is unique only within an Entity. A
refusal made where the write is made is the one the server would send, so the
application handles both alike (see Refused where it is made).
A CHECK constraint from checkConstraints or entityChecks is a backstop for writes past the
server: one tripped by a handler's statement fails it as Database write
failed, and the driver's error goes only to the StatementFailed
diagnostic. See Declared writes.