Remote / foldkit-remote-server

Live data

There are two ways to produce live changes.

Write a live Source directly

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

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

  1. intersects the changed fields with what each subscriber selected;
  2. re-reads only that intersection through the Entity Source;
  3. applies that subscriber's authorization again;
  4. 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:

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

ts
// 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'],
})
  • shared names 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. A shared field 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's source and RemoteServer.memory take it too, and foldkit-guard's GuardServer.validate refuses 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.