Remote / foldkit-remote-server

A backend held in memory

RemoteServer.memory serves rows you give it through the same handlers a real server uses, so a client sees what production would send: only the fields asked for, and nested relations resolved in one read. Use it for a first run, a test, a story or a demo, when there is no database yet.

ts
const backend = RemoteServer.memory({
  domain: Data, // or the definition from Remote.define
  rows: {
    User: [{ id: 'u1', name: 'Ada' }],
    Project: [{ id: 'p1', name: 'Apollo', status: 'active', owner: 'User:u1' }],
  },
  // Optional: each mutation writes through the store, and the next read sees it.
  mutations: store => [
    RemoteServer.mutation(RenameProject, ({ input }) => {
      store.write('Project', input.id, { name: input.name })
      return Effect.succeed({
        output: { id: input.id },
        entities: [Remote.patch(Project, input.id, { name: input.name })],
      })
    }),
  ],
})

backend.layer // provide it where the real RemoteClient would go

backend.server is the server definition the layer answers through. Serve it over a real transport to try a client against HTTP, or to test another server against it:

ts
const served = RemoteRpc.toLayer(RemoteServer.handlers(backend.server, undefined))
  • Rows are in their wire shape: a relation is its ref key ('User:u1'), and a to-many relation is a list of them.
  • A query declared with Query.define is answered by running its body over the rows with evaluate, the reference interpreter, and paged with offset cursors. A query with no body has nothing to run and is refused when the backend is made, unless you pass a source for it in queries.
  • Every field is readable unless authorize names the entity; see below. It pushes no live changes, and it is not a server to deploy.

To try authorization without a database, give authorize per entity name, the function RemoteServer.entity takes, and the principal the layer answers as. The principal's type is inferred from them, as RemoteServer.make infers it from its sources:

ts
const guarded = RemoteServer.memory({
  domain: Data,
  rows: { Project: [{ id: 'p1', name: 'Apollo', privateNotes: 'Internal' }] },
  principal: { isAdmin: false },
  authorize: {
    Project: (principal: Principal, fields) =>
      fields.filter(field => field !== 'privateNotes' || principal.isAdmin),
  },
})

// The layer reads as a non-admin; the server answers as anyone.
const asAdmin = RemoteServer.handlers(guarded.server, { isAdmin: true })

A withheld field is settled, as from any server: the client stops asking for it, and a selection that needs it fails as Unavailable. A name in authorize the domain does not declare is refused when the backend is made.

filter takes the same per-entity form. readable decides which rows a principal may read, once per read, given each row's stored values; its second argument looks another stored row up by id, for a rule that follows a relation:

ts
// Only an admin reads projects.
const adminsOnly = RemoteServer.memory({
  domain: Data,
  rows: { Project: [{ id: 'p1', name: 'Apollo', privateNotes: 'Internal' }] },
  principal: { isAdmin: false },
  readable: (principal: Principal) =>
    Effect.succeed(entity => principal.isAdmin || entity !== 'Project'),
})

A row it may not read is absent from a read by id (the client reads it as not found) and from every query, and a ref to it is dropped from a relation: a list leaves it out and a one ref reads null, as a database binding's visible hides it.

An entity no row backs, answered from who is asking (foldkit-guard's Me, say), is a Source of its own beside the rows, in entities. Leave it out of the rows' domain: a Source for an entity the domain declares is refused when the backend is made, as two Sources for one entity are.

ts
const Whoami = Entity.make('Whoami', Schema.Struct({ id: Schema.String, admin: Schema.Boolean }))
const WhoamiSource = RemoteServer.entity<Principal>(Whoami, {
  read: ({ ids, principal }) =>
    Effect.succeed(ids.map(id => ({ id, values: { admin: principal.isAdmin } }))),
})

// No principal: served through `serveFetch` or `handlers`, as each request's.
const { server } = RemoteServer.memory({
  domain: Data, // Project only; Whoami is the Source's
  rows: { Project: [{ id: 'p1', name: 'Apollo', privateNotes: 'Internal' }] },
  entities: [WhoamiSource],
})
const asVisitor = RemoteServer.handlers(server, { isAdmin: false })

principal is only who layer answers as. Without it (and a principal type that does not admit undefined), the backend has no layer, only its store and server, so nothing can read the rows as nobody.