Sync / Replicated state

1. Declare what is shared

Start from the application you have. This one keeps a todo list and a selection, and only the list is shared:

ts
import { Option, Schema } from 'effect'
import { defineMessageUnion } from 'foldkit/message'
import { modifyFields } from 'foldkit/struct'
import type * as Update from 'foldkit/update'
import { MessageSet, Projection, Surface } from 'foldkit-surface'
import { DocumentId, Sync } from 'foldkit-sync'

const Model = Schema.Struct({
  todos: Schema.Array(Schema.Struct({ id: Schema.String, title: Schema.String })),
  selectedTodoId: Schema.Option(Schema.String),
  lastError: Schema.Option(Schema.String),
})
type Model = typeof Model.Type

const Message = defineMessageUnion({
  CreatedTodo: { id: Schema.String, title: Schema.String },
  RenamedTodo: { id: Schema.String, title: Schema.String },
  SelectedTodo: { id: Schema.String },
})
type Message = typeof Message.Type

const initial: Model = { todos: [], selectedTodoId: Option.none(), lastError: Option.none() }

type Return = Update.Return<Model, Message>

const update = (model: Model, message: Message): Return =>
  Message.match<Return>(message, {
    CreatedTodo: ({ id, title }) => ({
      model: modifyFields(model, { todos: () => [...model.todos, { id, title }] }),
    }),
    RenamedTodo: ({ id, title }) => ({
      model: modifyFields(model, {
        todos: () => model.todos.map(todo => (todo.id === id ? { ...todo, title } : todo)),
      }),
    }),
    SelectedTodo: ({ id }) => ({
      model: modifyFields(model, { selectedTodoId: () => Option.some(id) }),
    }),
  })

const App = Surface.application({ Model, Message, initial, update })

type Principal = { readonly actorId: string; readonly canWrite: boolean }

const TodoSync = Sync.forApplication(App)
  .withPrincipal<Principal>()
  .make({
    documentId: DocumentId.make('todos'),
    shared: Projection.pick(App.model.todos),
    durable: MessageSet.make(App, [Message.CreatedTodo, Message.RenamedTodo]),
    authorize: {
      RenamedTodo: ({ message, shared }) => shared.todos.some(todo => todo.id === message.id),
    },
  })

Three decisions are in that declaration, and they are the whole design:

  • shared is the slice replicas agree on. The rest of the Model is this device's.
  • durable names the Messages that change the slice. Each becomes an operation in the outbox and a row in the journal. SelectedTodo is not listed, so it runs through update and goes nowhere.
  • authorize is policy, beside the Messages it governs. It runs on the server only, inside the commit, against the authoritative snapshot and the principal the server's transport established. withPrincipal fixes that principal's type so a rule cannot read a field the transport does not set.

A durable Message has one obligation: its update must be a deterministic, state-only transition of the slice, because every replica and the server replay it. make derives replay from update and refuses, at the first submit, a Message that returns a Command or writes a local field. So ids and timestamps are minted by a local intent Message's Command and carried inside the durable fact; the Sync README shows the pattern.