Sync / foldkit-durable

Validation and authorization

Both run inside the append transaction, while the write lock is held, so they stay local to the snapshot, cannot require services, and should be fast.

ts
import { Effect } from 'effect'
import { InvalidOperationError, type JournalOptions } from 'foldkit-durable'

const hooks: Pick<JournalOptions<Operation, Snapshot, Principal>, 'validate' | 'authorize'> = {
  // Structural checks: throw, or fail with InvalidOperationError.
  validate: ({ operation }) =>
    operation.title.length === 0
      ? Effect.fail(new InvalidOperationError({ message: 'title is empty' }))
      : Effect.void,
  // Policy: true, false, a refusal with its reason, or an Effect of either.
  authorize: ({ principal, operation }) =>
    principal.actorId === operation.opId.split(':')[0] || {
      allowed: false,
      reason: 'an operation must be committed by the tab that created it',
    },
}

A refusal is an OperationRejectedError; its reason is also repeated in message, so a server can tell the client why an optimistic edit was undone. With Sync, the rules declared per Message on the contract arrive here through journalContract().

The principal is whatever your transport established for the connection. It is never decoded from the operation, which is why a client cannot claim to be someone else.

Replica binding. When operations carry the replica that made them (replicaId: operation => …, which journalContract() supplies), the first commit from a replica binds it to that actor for the document, and any other actor's operation from it is refused before validate runs. The first actor to use an id claims it, even across reset, so replica ids should be unguessable or assigned per actor. Opening an existing journal with replicaId recovers the bindings from retained operations; if some were compacted before schema 6, supply legacyReplicaId to read the replica from their ids, or opening is refused.