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