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:

ts
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, with input and refusal typed by it; without the mutation, every failed write. A write is listed until it is retried, dismissed, or Data.forget changes the principal. Nothing drops one on a timer, and the list is persisted only for a durable write (below): a reload forgets the rest.
  • refusal is Some only when the server refused the write as the mutation declares. A network failure or a server error is None, with the error saying what happened.
  • Data.retry sends 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 is None for 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:

FailureOrdinary writeDurable write
Nothing answered (_tag: 'Unreachable': offline, a gateway's error page)Failed; retry by handstays Waiting and is sent again with a backoff, and when the browser is back online
The server refused itFailed, listed by failedWritesthe same, and kept in the store until retried or dismissed
The page reloaded before the answerlostsent 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 in outboxStatus as Unreadable, and the writes after it are sent. Data.dismiss removes 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:

RefusalWhenWhat 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 noshow it; asking again is refused again
Conflictthe row moved past the revision the write expects (an update or a delete)read it again; let the author decide
Unmetthe row does not hold the write's when (only for a write with one)show why; the state changed
Invalidthe row it would leave breaks an Entity invariant; refused where it is made when the client holds what the invariant readsshow message beside each field path names
Takenan 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 insteadchoose 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.