Remote / foldkit-remote

Advanced: the kernel

Everything above compiles to the lower-level exports below; there is not a second implementation. Reach for the kernel in tooling, SSR, tests, packages built on Remote, or applications that deliberately need one piece by hand.

Descriptors and binding

Remote.define({ entities, queries, mutations }) creates a domain without a place in an application Model. It exposes the Remote Model, initial, Message, reducer, RPC group, and a name-keyed registry consumed by RemoteServer.validate.

Remote.at(definition, modelRef) binds that domain to a ModelRef; Remote.make combines definition and binding in one step.

Entity.make(name, schema) is Remote's own entity descriptor, and what a foldkit-entity Entity is compiled to (Entity.from). Remote accepts either wherever it takes an entity. Declaring one directly skips foldkit-entity, and with it relations, derived members and query bodies, which is only worth it for a domain that Remote is all it will ever need. Selection.make(Entity, { ... }), Selection.connection(...), and Entity.patch(ref, values) are the kernel constructors behind the entity helpers. Remote.select(bound, selection)(id) is the lower-level form of Data.get.

Requirements and the planner

A Projection carries its requirements as Remote's own metadata, attached with RemoteRequirements.of / RemoteConnections.of and read with requirementsOf(projection) / connectionsOf(projection). Requirements are plain data: entity, id, fields, any relation pagination windows, and the nested target selections reached through relations. Connections describe query connections as { identity, window, select }.

Remote.plan(bound, model, projection, options?) deterministically diffs those requirements against the visible store and returns only missing/stale work. A relation field being fetched rides on the same request so the server can resolve the graph in one read; a relation already present in the store is followed into requirements for the concrete target.

Remote.planQueries performs the same job for absent/stale connections. RemotePolicy.toPlan(policy, now) compiles a high-level policy into planner options.

Remote.storeOf is the visible store after pending optimistic layers. Reads are memoized per visible store (and, for a list, its connection and overlays), so equal reads of one Model state assemble/decode once and return one value.

Across Model states, an entity's or row's decoded value stays the same object while its entity's fields are equal: a refetch or live patch that brought equal data hands the view the value it already rendered, so a keyed row's lazy view does not re-run. A write to any field of the entity, even one the row does not select, or a pending optimistic patch on it while other writes land, gives the row a new value. When the store changed, the RemoteData around a value and a page's items array are new.

Remote.prefetch(bound, model, projection, options?) runs the same plans imperatively and reduces their results into a new Model.

The reducer and its Messages

updateRemote(remote, message) is the pure reducer over Remote facts; Data.update applies it to a bound Remote state and Data.reduce applies it to the application Model. The common Messages that change nothing (a duplicate live event, a repeated ReadStarted, an empty Hydrated, a retention pass that collects nothing) return the Model they were given, so Foldkit neither renders nor recomputes Subscription dependencies.

Its Messages cover reads and refreshes, mutations, live events/gaps, query connections, retention, and hydration. Remote.messages is the case record for defineMessageUnion, and Remote.reduces(message) narrows an application union to those cases.

Remote.writeRead(store, requests, result, now) exposes the store write behind a read result for callers deliberately managing their own Remote store.

Subscription entries

Data.subscriptions is built from three kernel entries:

Remote.observe(bound, surface, params, toMessage?, options?)
  plan + entity reads + query reads

Remote.live(bound, surface, params, toMessage?, options?)
  live streams from the Model-owned cursor

Remote.retain(projections, toMessage?, { connections?, grace? })
  active roots -> RetentionChanged

The observe entry's dependencies are the current plan, the Model's refresh generation, and when a held value next ages out ({ requirements, queries, refresh, expires }), so Data.refresh restarts it. If entity requirements and queries are both empty, it performs no I/O. A refreshing read emits RefreshStarted first, then a result Message.

A Message that changes the Model makes Foldkit recompute Subscription dependencies. For example, after a query page lands, the next computation sees the page's entity ids and plans any selected fields those entities still lack. An interrupted identical read is joined by the client coalescer instead of needlessly restarted.

The live entry's dependencies are what the Surface reads live, the refresh floor, and the stream's cursor. The stream stays open as events advance the cursor; the cursor is only where a stream restarted for another reason resumes.

Retention uses the Projection requirements/connections as roots and eventually emits RetentionChanged; the reducer's pure gc keeps everything reachable from those roots plus pending work and collects the rest.

The entries Data.subscriptions returns carry these dependencies in their types: ReadDependencies for <key>.read, LiveDependencies for <key>.live, and RetentionRoots for retain.

Connections by hand

A connection stores entity references in segments with explicit boundaries, so an unloaded middle page remains a gap rather than an implied adjacency. Query pages, live inserts/removals, and optimistic overlays are reconciled as evidence about that same structure.

A QueryRef has a canonical identity based on its descriptor and encoded input, excluding the page window. Thus first(25) and after(cursor).first(25) merge into the same logical connection.

ts
// Inside Effect.gen, with RemoteClient provided.
const ref = Query.first(25)(ProjectsByOwner.ref({ ownerId }))
const page = yield* Remote.query(ref)

updateRemote(remote, Remote.queryMessage(ref, page))
Data.visibleItems(remote, ref)

items, hasNext, hasPrevious, and isGapped describe the known region. ConnectionInvalidated keeps visible rows but marks the connection stale until a refresh/query result settles it. A ConnectionMerged page with refreshes: true replaces a stale connection's pages rather than merging into them.

Where a row falls: placeIn

A query whose body declares a placeable order (Query.placement in foldkit-entity: numbers, booleans, and text under a portable collation) can have a changed row placed in its loaded pages by the client, by the same order the server compiles. Such a connection also reads every field its body filters and orders by, so the rows it holds can be compared with one that changed.

ts
// Schematic: `ProjectsByRank` is a Query.define ordered by a number, and
// `connection` one of `remote.connections`. test/placement.test.ts runs it.
placeIn(remote.entities, connection, ProjectsByRank.body!, {}, 'Project:p7')
// Before { segment, edge } | End { segment } | Outside | Unknown { reason }
  • Before and End are positions among loaded edges: before an edge, or at the end of a segment that ends the list (Terminal).
  • Outside is a row that sorts past a Cursor boundary or into a gap between segments: it belongs to rows the client has not read, so it is not shown.
  • Unknown is anything not judged exactly: a body that is not placeable, a row or an edge lacking a field the order reads, keys that do not compare.

It is pure and changes nothing. A mutation's answer is placed through it, as above.

Mutations by hand

Remote.mutateInto(bound, model, mutation, input, requestId, { optimistic, now }) is the one-step imperative start/run/settle form for SSR and tests.

Remote.mutate(mutation, input, requestId) is the transport call itself. It decodes the typed Output and returns normalized entity patches and confirmed connection changes for a caller to reduce through updateRemote.

MutationSucceeded reconciles at most once per requestId; an unknown or already-applied settlement is a no-op. It carries now, the clock reading of the answer, which dates the entities it writes as a read's now does, so a freshness policy ages them from then. Data.mutate and Remote.mutateInto stamp it from their now option, Date.now by default.

The transport seam

RemoteClient is an Effect service with read, query, mutate, and live. The wire schemas (ReadBatch, QueryRequest, MutationRequest, LiveRequirement, results/errors) and the RemoteRpc group are exported.

Remote.clientLayer(rpcClient, { window? }) adapts an Effect RPC client, including live-change mapping. It takes the client RpcClient.make(RemoteRpc) builds, whose calls can also fail with RpcClientError; each such failure becomes the call's Remote error rather than a defect. Compatible reads and queries coalesce: requirements issued together become one ReadBatch, an identical in-flight requirement/query is joined, and a failed request releases the coalesced entry. A relation request with its own pagination window stays separate because one response answers one window exactly.

Remote.coalesced(layer, options) adds the same behavior to a hand-written RemoteClient layer; window widens batching beyond work issued concurrently.