Remote / foldkit-remote

Normalized entities and selections

Remote stores an entity once by identity, regardless of how many Surfaces read it. Consumers select different views of the same normalized fact.

Relations are references, not nested copies:

ts
const User = Entity.make(
  'User',
  Schema.Struct({
    id: Schema.String,
    name: Schema.String,
  }),
)

const Project = Entity.make(
  'Project',
  Schema.Struct({
    id: Schema.String,
    name: Schema.String,
    owner: Entity.ref(User),
  }),
)

const UserSummary = User.select({ id: true, name: true })
const ProjectSummary = Project.select({
  id: true,
  name: true,
  owner: UserSummary,
})

The store remains normalized:

Project:p1   name PRESENT   owner PRESENT -> User:u7
User:u7      name PRESENT

The Projection assembles the nested consumer value by following the reference. An unknown field or a nested Selection for the wrong entity is a type error. Recursive relations remain finite because the entity schema stores references (Entity.ref(User) / Entity.refTo('Node')), not recursively inlined schemas.

Entities declared with foldkit-entity

foldkit-entity declares a domain without Remote in it: fields, relations as navigation edges, derived members. Remote.make registers those Entities, and Data.get, Data.live, and a query's select take their Selections. Remote compiles both into the descriptor and Selection above, so the store, the planner, and the server see nothing new.

ts
import { Schema } from 'effect'
import { Entity, Relation } from 'foldkit-entity'
import { Remote } from 'foldkit-remote'

const User = Entity.define('User', Schema.Struct({ id: Schema.String, name: Schema.String }))
const Project = Entity.define('Project', Schema.Struct({ id: Schema.String, name: Schema.String }))
const Work = Entity.relate({ User, Project }, { Project: { owner: Relation.one(User) } })

const ProjectCard = Entity.select(Work.Project, {
  name: true,
  owner: Entity.select(Work.User, { name: true }),
})

const Data = Remote.make({ model: App.model.remote, entities: [Work.User, Work.Project] })
const card = Data.get(ProjectCard, 'p1') // Projection<Model, RemoteData<{ name; owner: { name } }>>
  • A relation becomes a ref field: one a ref, an optional one a nullable ref, many an array of refs. A derived member becomes a field the server supplies.
  • The Entity needs an id field; Remote keys the store by it. When that field is branded, Data.get and Data.live take that type for an Entity Selection: another Entity's id, or plain text, does not compile.
  • Register the Entities of one Entity.relate result and select from that same result; Object.values(Work) registers them all.
  • Entity.from(entity) and Selection.from(selection) are the compile steps, for when you need the descriptor itself: patch and ref in a mutation handler.
  • Entity.page(selection, window) in an Entity Selection is Remote's Selection.connection: the window travels with the read, and the server answers with a page of refs. A page of a whole list is read under a name of its own, comments@first=10, so one view can show every comment while another shows the first ten of the same post: they are two fields to the store, fetched in one batch, merged and refreshed each on its own. A write to the list (a mutation's patch, a live change) marks its pages stale, so they are read again. A cursor is not part of the name: a page read from a cursor continues its page. The server refuses a request that pages one relation more than four ways, and a field's own name may not contain @.

Entity.make with Entity.ref keeps working, and both kinds can share one domain. Both packages export Entity; a module that needs Entity.from beside Entity.define aliases one of them.

Presence is tracked separately from the JavaScript value. These are distinct:

field missing
field present with undefined
field present with null
field stale
entity not found

Remote never infers presence from value === undefined.