Remote / foldkit-remote-server
Live data
There are two ways to produce live changes.
Write a live Source directly
const ProjectLive = RemoteServer.live(Project, {
subscribe: ({ requirements, after, principal }) =>
projectEvents({
requirements,
after,
principal,
}),
})after is the client's resume cursor. Events at or before it are duplicates.
The stream carries Remote LiveChanges: entity patches/deletes and connection
insert/remove/invalidate events.
If an Entity has no live Source, it contributes no stream. That is not an error: the client planner can fall back to refetching it.
Let a liveHub re-read changed fields
A hub fans what changed out to live subscribers. Give it to the handlers, and every mutation's answer is published to it: each patch as a change of the fields it carries, each deletion as a deletion. A mutation Source names nothing twice.
const entitySources = [ProjectSource]
const hub = yield* RemoteServer.liveHub(entitySources)
const handlers = RemoteServer.handlers(Server, principal, {
live: hub,
})
// Only for a write that is not a mutation: a database trigger consumer, a job.
yield* hub.changed(Project.ref(projectId), ['status', 'updatedAt'])
yield* hub.deleted(Project.ref(projectId))A mutation's publish runs after its Source returned, so after a write that commits as it goes. A failed publish is logged and the mutation still answers: its write has happened, and a failed answer would be retried.
The hub remembers each subscriber's requirements and principal. changed:
- intersects the changed fields with what each subscriber selected;
- re-reads only that intersection through the Entity Source;
- applies that subscriber's authorization again;
- emits the resulting patch with a continuing cursor, or a deletion where the re-read no longer finds the row: deleted, or moved out of that subscriber's sight.
deleted asks each subscriber again too, for every field it selects: a row
deleted for one writer may only be hidden from them, so a subscriber that
still reads it is sent what it now sees, and only one it is gone for a
deletion. One subscriber's failed read does not keep the others from theirs;
the call fails once they are sent.
Subscribers that selected none of the changed fields do no work, and
subscribers with one principal share one re-read. One principal means the same
value, unless the hub is given a key: then two requests whose principals
have one key, each with a principal object of its own, share it, read as the
first of them. A key must therefore name everything the Sources decide by:
const hub = yield* RemoteServer.liveHub(entitySources, {
key: user => (user.isAdmin ? 'admin' : 'member'),
})Fields every principal reads alike
A re-read per principal is how the hub stays exact when who is asking decides what a row shows. Where nothing does, a Source can say so, and the hub sends a written value as it is:
// Everyone reads a project's name alike; only an admin reads its private notes.
const SharedProjectSource = RemoteServer.entity<Principal>(Project, {
authorize: ProjectSource.authorize,
read: ProjectSource.read,
shared: ['name'],
})sharednames fields every principal reads alike, of every row: no rule about who is asking hides the row or the field, or changes its value. The server trusts it, so declare it only where that holds; a field the Source does not serve is refused when the Source is made.hub.changed(ref, fields, values)takes the row's stored values. Asharedfield among them goes to each subscriber that selected it with no read; the rest are re-read as above. A field a subscriber pages with a window is always re-read.- The handlers pass a declared write's answer (
RemoteServer.write), which is the row as stored. A handler's answer is its own claim, so its fields are re-read. foldkit-remote-drizzle'ssourceandRemoteServer.memorytake it too, andfoldkit-guard'sGuardServer.validaterefuses a shared field a Read rule could tell readers apart by.
A hub is an in-memory fan-out, so whatever calls changed must be able to wake
the subscriber's fiber. In a worker it cannot: one request cannot wake another's
stream, so a hub fed from a second request delivers nothing and tears the first
stream down (see AGENTS.md). On Workers, poll the shared store from inside the
subscriber's own request instead — examples/cloudflare has the shape. A hub is
right for a long-lived server process that owns both sides.
A hand-written live Source and a hub number their events independently. Use one or the other for a given subscription path rather than mixing cursor domains.