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:

ts
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.

ts
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:

ts
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:

ts
coalesce: (last, next) =>
  last._tag === 'RenamedTodo' && next._tag === 'RenamedTodo' && last.id === next.id
    ? next
    : undefined

Replaying 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 it

EditableEntity.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.