Sync / foldkit-sync
Growing the application
Compose a document from features
Each feature declares the fields it shares and the Messages that change them;
compose merges them into one document:
const AppSync = Sync.forApplication(App)
const Todos = AppSync.fragment({
shared: Projection.pick(App.model.todos),
durable: MessageSet.make(App, [Message.CreatedTodo, Message.RenamedTodo]),
})
const Members = AppSync.fragment({
shared: Projection.pick(App.model.members),
durable: MessageSet.make(App, [Message.Invited]),
})
const Board = AppSync.make({
documentId: DocumentId.make('board'),
...AppSync.compose(Todos, Members),
})compose throws on a field declared twice with different codecs, a Message
declared durable twice, or a fragment from another application. The result is
still one document: one outbox, one cursor, one server order.
Authorize on the server
Policy belongs on the contract, beside the Messages it governs, and runs on the server inside the journal's commit. The client never runs it.
const Authorized = Sync.forApplication(App).withPrincipal<{ readonly role: 'admin' | 'guest' }>()
const Board = Authorized.make({
documentId: DocumentId.make('board'),
...Authorized.compose(Todos, Members),
authorize: {
RenamedTodo: ({ principal, message, shared }) =>
principal.role === 'admin' && shared.todos.some(todo => todo.id === message.id),
},
})A rule returns true, false, or { allowed: false, reason }; the reason
is sent to the replica whose edit it was, as its rejection's reason.
message is exactly that variant, shared is the authoritative snapshot, and
principal is whatever the server's transport established for the connection;
it is never read from the operation. A variant without a rule is allowed. A key
that is not a durable variant is a type error. The rules ride in
journalContract(), so the server enforces them by spreading the contract.
An edit the policy refuses still shows at once on the client, then comes back as a rejection, as above.
Stamp what only the commit decides
A fact carries its own nondeterminism, but a client cannot know where its operation lands in the server's order. A stamp writes it in, on the server, as the journal commits:
const Message = defineMessageUnion({
// `at` is absent on what a client sends: the server's sequence once committed.
PriceEdited: { id: Schema.String, cents: Schema.Number, at: Schema.optionalKey(Schema.Number) },
})
const Prices = Sync.forApplication(App).make({
documentId: DocumentId.make('prices'),
shared: Projection.pick(App.model.prices),
durable: MessageSet.make(App, [Message.PriceEdited]),
stamp: {
PriceEdited: ({ id, cents }, { sequence }) => Message.PriceEdited({ id, cents, at: sequence }),
},
})The rule gets that variant and { sequence, actorId, replicaId } (the
replica that sent it, which tells one person's tabs apart), and returns the same
variant; a key that is not durable is a type error, and a rule that returns
another variant has its operation rejected. It rides in journalContract() as
Durable's stamp, so the journal stores, reduces and sends the stamped
Message. A replica shows what it sent until the exchange, then replays the
stamped Message in its place, the same on every replica. A retry is still
recognized by what was sent.
Use it for what a reader compares with the server's order: a read model that
records the last sequence it applied can tell which edits it holds by comparing
the two. A client that sends the stamped field itself is claiming a commit it
did not get; refuse it in the journal's validate.
Coalesce a burst of typing
Typing makes one durable Message per keystroke. coalesce(last, next) is
offered each submit together with the last operation in the outbox; return a
merged Message to replace it, or undefined to keep them apart:
coalesce: (last, next) =>
last._tag === 'RenamedTodo' && next._tag === 'RenamedTodo' && last.id === next.id
? next
: undefinedReplaying the merged Message must equal replaying last then next. An
operation is only merged into while no exchange has carried it, so there is no
timer to tune: keystrokes typed while a request is out become one operation, and
a lone keystroke goes out as fast as before.
The URL, mirrors, and agents
mount takes the application's own subscriptions and the resources Layer
they need, and url: { init, onUrlChange, onUrlRequest? } to route navigation
through update: init(model, url) reduces the first URL before the first
render and onUrlChange(url) names the Message for every navigation. A
foldkit-mirror URL mirror plugs into exactly that seam.
Mounted is also the host an agent binds to: model, dispatch, subscribe,
and observe, which reports every application Message the runtime applies.
See examples/todo-app for both.
Carry local state across a reinstall
When an exchange or a failed persist replaces the shared slice outside
update, onReinstall(next, previous, { reset }) returns the transition
instead. Use it to keep a selection that points into the slice, or to return a
Command that patches a DOM the change has to reach. Omitted, the Model is
simply next. It is not called for a status that changed nothing the Model
shows. reset is true when the server's history is a new one (a new
status.epoch: the server was reset), so whatever the application derived
from the old one, such as reads keyed by commit sequence, is to be read again.
afterUpdate(model) runs after every update of the mounted application, a
durable Message's and a reinstall's included, and never in replay. A durable
Message is replayed through update on the server and in the replica, where
it may change only the shared slice; local state derived from the slice (the
Remote overlays below) is kept in step here instead.
Edit rows a server owns
When rows are too many to replicate but edits to them must survive offline,
let the journal own the edits and the table be its read model, each row
carrying the highest sequence it has applied (revision). foldkit-sync/entity
keeps such edits one per cell, and foldkit-sync/remote shows them over the
rows Remote reads:
cell shown = Remote's row, with each edit over it the row has not reached:
one pending, one committed after the row's revision, or one the
journal absorbed while the cached row is still below itEditableEntity.make(Product, { members: ['description', 'cents'] }) gives
the schemas (Change on the wire, Edit in the slice, the Message fields
edited and absorbed) and pure functions for update, the stamp
(stamped) and onReinstall: merge, absorb, replaced (a cell of this
tab's that another's later commit took), cellsOf (what a refusal undid) and
changeAt (what a row holds in a member, as the change that sets it: an
undo's value). It owns no state and does no I/O.
RemoteEdits.make(Data, ProductEdits) is the bridge to a Remote domain over
the same Entity. Its reconcile(model, edits, replica) brings Remote's
overlays (Data.overlay) in line with the slice, so every read of a row draws
its edits, a second view or a filter as well as the grid. It holds an edit the
journal absorbed while the cached row is below it, and lifts it once a read
reaches it; it returns those newly held (ask for their rows again) and this
replica's edits a read reached with another value (replaced: the table
applies in commit order, so a later edit took the cell). An edit to a field a
list orders or filters by moves its row there at once, as Remote places any
pending edit. To see that when the
row arrives live after its own edit landed, it keeps this replica's landed
edit, showing the row's own value, until the slice drops it. Call it from
afterUpdate. shown(model) lists what the overlays show, for a cell's
mark, and clear lifts them all, for a reset. Remote stores none of it: after
a reload, reconcile rebuilds the overlays from the slice the replica
restored. On the server, editsJournal from foldkit-sync/journal keeps
the table: its settle (for journalExchange) applies each committed change
through your apply as a recovery intent, from the journal's floor and keyed
by its epoch, and its absorb (on a clock) records what the table holds and
compacts behind it. It refuses to settle a table whose tableRevision is past
the journal's cursor (TableAheadOfJournalError: the table outlived a reset of
the journal), and an edit to a row the table lacks is the journal's validate
to refuse. Editing server data through a
journal explains the pattern, its failure
cases and its limits; examples/registry is the
whole of it.