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:
sharedis the slice replicas agree on. The rest of the Model is this device's.durablenames the Messages that change the slice. Each becomes an operation in the outbox and a row in the journal.SelectedTodois not listed, so it runs throughupdateand goes nowhere.authorizeis 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.withPrincipalfixes 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.