Remote / foldkit-remote

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

ts
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

ts
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

ts
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

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

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

ts
import { RemoteServer } from 'foldkit-remote-server'

const clientLayer = RemoteServer.memory({
  domain: Data,
  rows: { Project: [{ id: 'p1', name: 'Apollo', status: 'active' }] },
}).layer

With a server, hand Remote the Effect RPC client for RemoteRpc; the server guide supplies the other side:

ts
import { Effect, Layer } from 'effect'
import { FetchHttpClient } from 'effect/http'
import { RpcClient, RpcSerialization } from 'effect/rpc'
import { RemoteRpc } from 'foldkit-remote'

const clientLayer = Layer.unwrap(
  Effect.map(RpcClient.make(RemoteRpc), rpcClient => Remote.clientLayer(rpcClient)),
).pipe(
  Layer.provide(RpcClient.layerProtocolHttp({ url: '/rpc' })),
  Layer.provide([RpcSerialization.layerJson, FetchHttpClient.layer]),
)

Or, with no Effect RPC on the server, plain JSON over HTTP: each call a POST of { operation, payload }, answered { result } or { error }. RemoteServer.answer is the other end. Remote.json(send) is the same client over any other way of sending the request, a worker or a server running in the page:

ts
const overHttp = Remote.clientLayer(
  Remote.http('/remote', { headers: () => ({ 'x-session': 'the session token' }) }),
)

headers is read for each request, so a token that changes reaches the next call. A 401 fails every call kind, a read, a query, a mutation or a live stream, with _tag: 'Unauthenticated' (and reason when the server gave one): the server could not tell who is asking. RemoteData.denied(error) reads a failure as that or a 403 (Forbidden), typed, and as none for any other, so a view tells "sign in again" from "could not load" without comparing tags. Remote treats it as any failure: a read or query is Failed, and a live stream restarts with its backoff, refused again each time, until the application signs in again and calls Remote.forget. A 403 whose body does not say forbidden itself, as Remote's server's does, comes from middleware in front of it refusing a session (a bare { error } included): it fails a mutation as Forbidden and any other call with the status. A 401 is read the same whatever its body says. Neither carries live data. For live data over the same POSTs, Remote.httpWithLive keeps reads, queries, and mutations as http sends them and streams live requirements as a text/event-stream of changes; serveFetch is the other end. Abandoning the stream aborts the request, which interrupts the subscription server-side.

For a server in a worker or in the page (a sandbox), foldkit-remote/port speaks Effect's own RPC worker protocol over a MessagePort, so live data, typed errors and interruption cross it too. servePort is the other end:

ts
import { port } from 'foldkit-remote/port'

// A new conversation each time the protocol connects: one port here, the other to the server.
const overPort = port(() => {
  const channel = new MessageChannel()
  worker.port.postMessage({ open: 'remote' }, [channel.port2])
  return channel.port1
})

It is a Layer<RemoteClient> like the others; a port that cannot be set up is a defect, not a failed read.

The stock client is accepted as it is. When its transport fails, the call fails with its own Remote error (RemoteReadError, RemoteQueryError, RemoteMutationError, RemoteLiveError), which the UI shows and retries like any other failure.

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