Documentation
¶
Overview ¶
Package sync is the application half of the change stream: what a client is allowed to be told about, and where in the log it stands (ADR-0021).
It is not a use case and is deliberately absent from the catalogue in domain-model.md §5, for the reason ReconcileMedia and RunRetention are absent: the catalogue is the list of things a person, an agent or a rule can *ask for*, and a stream is a connection being served rather than an action being performed. It has no MCP tool and no automation action, because "open a stream" is not something a rule could usefully do.
Index ¶
- Constants
- type Authorizer
- type Batch
- type Catalogue
- type ConflictDetail
- type Containers
- type Cursors
- type Depth
- type DeviceWriter
- type DisplacedFiler
- type FieldChange
- type ForgetSyncDevice
- type ListSyncDevices
- type Mutation
- type Page
- type Position
- type PullChanges
- type PullRequest
- type PushChanges
- type PushRequest
- type PushResponse
- type Record
- type Result
- type ResultError
- type Scope
- type SessionRevoker
- type SetElements
- type Sets
- type SnapshotRequest
- type StepRecorder
- type StreamChanges
Constants ¶
const ( ListSyncDevicesName = "ListSyncDevices" ForgetSyncDeviceName = "ForgetSyncDevice" )
The two catalogue use cases of the sync context. A device is the person's, like a session: listing and forgetting are things a person asks for about their own account, which is what makes them catalogue entries where the pull and the push are not.
const ( DeviceForgottenAction audit.Action = "sync.device_forgotten" DeviceReadAction audit.Action = "sync.device_read" )
The audit codes. Forgetting a device ends a way into the workspace's data, the class of event a review looks for; the read declares one all the same, because a refused read is recorded against the action that was refused (audit.md §4).
const ( // PullLimitDefault and PullLimitMax are the contract's (api/openapi.yaml, SyncPullRequest). PullLimitDefault = 500 PullLimitMax = 2000 )
const DefaultSkew = 5 * time.Minute
DefaultSkew is §4.1's default.
const PushLimit = 500
PushLimit is the most mutations one push may carry (api/openapi.yaml, SyncPushRequest).
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Authorizer ¶
type Authorizer interface {
Permits(ctx context.Context, actor appshared.ActorContext, request access.Request) (bool, error)
}
Authorizer answers whether the actor may read a container, without recording anything.
Permits rather than Authorize, and the difference is audit.md §4's. The trail records what somebody tried to do and was refused; a stream deciding that ninety of a hundred records are not for this client is nobody trying anything, and an entry per withheld record would bury the refusals that matter under a feed nobody even read.
type Batch ¶
type Batch struct {
// Records are the entries to send, oldest first. Empty is an ordinary answer - the log moved
// and none of it concerned this caller.
Records []Record
// Cursor is where the walk stands *after* this batch, whether or not anything survived the
// filter. Advancing past records the caller may not see is the point: a cursor that stalled on
// a container somebody lost access to would re-read it forever.
Cursor Position
// More says the log had at least a full batch left. The caller comes straight back rather than
// waiting to be woken.
More bool
}
Batch is one round of the stream: the records the caller may see, and where the walk now stands.
type Catalogue ¶
type Catalogue interface {
Invoke(ctx context.Context, name string, actor appshared.ActorContext, in usecase.Input) (usecase.Output, error)
}
Catalogue is the slice of the use case registry a push writes through: run one use case by name. The only way this package writes anything.
type ConflictDetail ¶
ConflictDetail is both versions of a free-text field that lost (offline-sync.md §5).
type Containers ¶
Containers is the slice of the container repository this package reads: one lookup, to resolve the permission path of a change. Narrow rather than the whole port, for the reason every slice in this codebase is - a stream that held the container repository could create one.
type Cursors ¶
Cursors turns a position in the change log into an opaque string and back.
An interface here and a keyed HMAC in infrastructure/security, because the installation secret has no business above the adapters (security.md §8) - and the application never looks inside the string, which is what "opaque" means from this side.
type Depth ¶
type Depth string
Depth says how far below a scope's container a device wants to hold, counted in containers (offline-sync.md §3.1). The values are the contract's.
const ( // DepthSelf is the container and what it holds directly: its entries, labels and buckets. A // change is filed under the container of the object it concerns, so an entry's change is in // its collection's SELF and not in its hub's. DepthSelf Depth = "SELF" // DepthChildren adds the container's child containers and what they hold. DepthChildren Depth = "CHILDREN" // DepthSubtree is every level below. The hierarchy has two levels of containers today, so // CHILDREN and SUBTREE coincide; they are two values because the contract promised both, and // a third level would part them without a client noticing. DepthSubtree Depth = "SUBTREE" )
type DeviceWriter ¶
type DeviceWriter struct {
Devices repository.Devices
Sessions SessionRevoker
Cursors Cursors
Audit audit.Sink
UnitOfWork persistence.UnitOfWork
Clock clock.Clock
}
DeviceWriter holds what both use cases share.
type DisplacedFiler ¶
type DisplacedFiler interface {
FileDisplaced(ctx context.Context, actor appshared.ActorContext, itemID shared.ID, body string,
params map[string]string) (work.Comment, error)
}
DisplacedFiler files the version of a free-text field that lost a merge as a system comment (work.AddComment.FileDisplaced).
type FieldChange ¶
FieldChange is one field's new value and the reading it was written under.
type ForgetSyncDevice ¶
type ForgetSyncDevice struct{ Writer DeviceWriter }
ForgetSyncDevice ends what one of the caller's devices holds (offline-sync.md §6): the session it last synchronised under is revoked, and every contact from the identifier is refused until the sweep removes the row.
func (ForgetSyncDevice) Descriptor ¶
func (h ForgetSyncDevice) Descriptor() usecase.Descriptor
Descriptor is the catalogue entry.
func (ForgetSyncDevice) Execute ¶
func (h ForgetSyncDevice) Execute( ctx context.Context, actor appshared.ActorContext, deviceID shared.ID, ) error
Execute forgets. Somebody else's device is not found rather than forbidden - whether a device identifier exists is nobody's business but its holder's - and forgetting twice is somebody making sure, not an error.
type ListSyncDevices ¶
type ListSyncDevices struct{ Writer DeviceWriter }
ListSyncDevices answers the caller's own devices - and never anybody else's, whatever the role (offline-sync.md §6, the session listing's rule).
func (ListSyncDevices) Descriptor ¶
func (h ListSyncDevices) Descriptor() usecase.Descriptor
Descriptor is the catalogue entry.
func (ListSyncDevices) Execute ¶
func (h ListSyncDevices) Execute( ctx context.Context, actor appshared.ActorContext, ) ([]domain.Device, error)
Execute reads them, most recently seen first.
type Mutation ¶
type Mutation struct {
OpID shared.ID
Kind domain.MutationKind
ItemID shared.ID
BaseVersion *int
// Fields are per-field changes, each under its own clock (ITEM_PATCH).
Fields map[string]FieldChange
// Set and Element name a set change (SET_ADD, SET_REMOVE); HLC is its tag, and a creation's
// or a deletion's reading.
Set string
Element shared.ID
HLC string
Payload map[string]any
}
Mutation is one thing a device did offline, in the contract's shape.
type Page ¶
type Page struct {
Records []Record
Cursor Position
More bool
ServerTime time.Time
Window time.Duration
}
Page is one answer: the records, where the walk now stands, and the two facts a client needs beside them - the server's clock, to bound its own, and the window, to know when a cursor it holds has expired.
type Position ¶
type Position struct {
Seq int64
IssuedAt time.Time
// Epoch is the workspace's synchronisation epoch the position was minted under
// (backup-restore.md §12 B-5). A restore into the workspace advances the epoch, and a
// cursor from an older one is refused as too old: the rows a restore wrote are in no change
// log entry, so a delta past them would leave the device believing itself current.
Epoch int64
// Kind and After are where an initial synchronisation stands: the kind being walked
// and the key of the last row handed out. Both empty is a delta position, the only kind the
// stream resumes from.
Kind string
After string
}
Position is where a client stands in the log, and when it was told so. The moment is what decides whether the cursor is still usable; the sequence is what orders the walk (ADR-0021).
type PullChanges ¶
type PullChanges struct {
Stream StreamChanges
// Devices registers the device on its first contact and records every one after.
// Nil registers nothing, which is a test's convenience and not an installation's.
Devices repository.Devices
// Snapshot reads the current state for an initial synchronisation. Nil means this
// installation does not serve one, and a device with no cursor is told so.
Snapshot repository.Snapshot
// Structure resolves each container's structure in force onto its record in the initial
// synchronisation, as every read of a container answers it (domain-model.md §2).
Structure workservice.StructureResolver
}
PullChanges serves `POST /sync:pull`, the paged form of the stream (ADR-0021).
Not a catalogue use case, for the stream's reason: the catalogue lists what a person, an agent or a rule can ask for, and a pull is a connection being served in pages rather than an action - an agent has no offline queue, and a rule that pulled would be reading its own effects. What a push applies, by contrast, is always a catalogue use case.
The reading, the cursor and the per-record permission are the stream's, shared rather than copied - which is what makes the stream an accelerator over this rather than a second source of truth: the same records, the same order, the same cursor.
func (PullChanges) Encode ¶
func (p PullChanges) Encode(position Position) string
Encode is how the presentation layer spells a position on the wire - the stream's codec, so that a cursor a pull handed out resumes a stream and the other way round.
func (PullChanges) Pull ¶
func (p PullChanges) Pull( ctx context.Context, actor appshared.ActorContext, request PullRequest, ) (Page, error)
Pull answers one page.
func (PullChanges) WalkAll ¶
func (p PullChanges) WalkAll( ctx context.Context, actor appshared.ActorContext, request SnapshotRequest, emit func(Record) error, ) (Position, error)
WalkAll serves the initial synchronisation as one walk rather than a page sequence (SY-C): every record the page sequence would answer, in the same order, handed to `emit` as it is read, and the delta cursor the sequence would end on answered last.
The same reads, the same permission per record, the same scope filter and the same cursor - the snapshot is the sequence with the pages joined, which is what makes a test able to hold the two against each other. The device registers by turning up exactly as it does on a pull. An `emit` that fails ends the walk with its error and the cursor is not answered: what was written before the failure is a device's problem to start over from, never something it may resume.
type PullRequest ¶
type PullRequest struct {
DeviceID shared.ID
// Platform and DisplayName are what the device says about itself, both optional.
Platform string
DisplayName string
// Cursor is empty for an initial synchronisation.
Cursor string
Scopes []Scope
// Limit is the page size. Zero means the contract's default; more than the maximum is refused
// rather than clamped, because a client that asked for more than the contract allows has a
// defect worth hearing about.
Limit int
}
PullRequest is one page's worth of question: which device asks, where it stands, what it wants to hold, and how much it can take at once.
type PushChanges ¶
type PushChanges struct {
Stream StreamChanges
Devices repository.Devices
Ops repository.OpLog
Tombstones repository.Tombstones
// Clocks is the server's clock per field, which ITEM_PATCH decides against.
Clocks repository.FieldClocks
Catalogue Catalogue
// IDs mints the push's own identity: every event a mutation of this push raises names
// it, which is what the webhook fan-out collapses the deliveries of one push on
// (offline-sync.md §8).
IDs clock.IDGenerator
// Activity writes the two steps a merge owes the history - a change with meaning that lost,
// and a merge that displaced free text. Nil writes neither, a test's convenience.
Activity StepRecorder
// Displaced files the version of a free-text field that lost as a comment (§5). Nil files
// nothing, and the conflict still carries both values.
Displaced DisplacedFiler
// Sets is where each set's tags are read for the OR-set merge.
Sets Sets
// Skew is how far a device's clock may stand from the server's before its readings are
// replaced by server readings (offline-sync.md §4.1). Zero means the contract's five minutes.
Skew time.Duration
}
PushChanges serves `POST /sync:push`.
func (PushChanges) Encode ¶
func (p PushChanges) Encode(position Position) string
Encode is how the presentation layer spells the cursor after a push.
func (PushChanges) Push ¶
func (p PushChanges) Push( ctx context.Context, actor appshared.ActorContext, request PushRequest, ) (PushResponse, error)
Push applies the queue.
type PushRequest ¶
type PushRequest struct {
DeviceID shared.ID
Platform string
DisplayName string
Mutations []Mutation
}
PushRequest is a device's queue.
type PushResponse ¶
PushResponse is the answer to a push: one result per mutation in the order they were sent, and where the log stands afterwards, saving a pull.
type Record ¶
type Record struct {
repository.Recorded
// Cursor is the position after this record. What the stream sends as the event's `id`, and what
// a client sends back as `Last-Event-ID` - so a client that received half a batch resumes in
// the middle of it rather than at its start.
Cursor Position
}
Record is one change as a client receives it: the log entry, and the cursor that follows it.
type Result ¶
type Result struct {
OpID shared.ID
Result domain.ResultKind
EntityID shared.ID
// ServerState is the object as the server holds it after the mutation, on APPLIED and
// MERGED, so that a client adopts the server's answer (offline-sync.md §9, requirement 5).
ServerState map[string]any
Conflict *ConflictDetail
Error *ResultError
}
Result is what became of one mutation.
type ResultError ¶
ResultError is why a mutation was rejected: the use case's own code, which the client localises the way it localises any problem document.
type Scope ¶
Scope is one container a device wants to hold, to a depth. No scope at all means everything the caller may read; several are a union.
type SessionRevoker ¶
type SessionRevoker interface {
Revoke(ctx context.Context, sessionID, accountID shared.ID, at time.Time) (bool, error)
}
SessionRevoker is the slice of the session repository forgetting a device needs: end the one session the device last synchronised under. Narrow rather than the whole port, because what the sync context may do to a sign-in is exactly this and nothing else.
type SetElements ¶
type SetElements interface {
Elements(ctx context.Context, itemID shared.ID) ([]work.SetElement, error)
}
SetElements is the slice of a set's repository the merge reads: every tag of one set on one entry. The three set repositories share the shape (work.ItemLabels, work.ItemMembers, media.ItemAttachments).
type Sets ¶
type Sets struct {
Labels SetElements
Members SetElements
Attachments SetElements
}
Sets is the table of sets a device may change. `watchers` is in the contract's enum and no use case writes a watcher; it stays out of this table until one does, and the frame refuses it by name.
type SnapshotRequest ¶
type SnapshotRequest struct {
DeviceID shared.ID
Platform string
DisplayName string
Scopes []Scope
}
SnapshotRequest is what `:pull` takes for an initial synchronisation without a cursor or a page size (SY-C): the device, what it says about itself, and what it wants to hold.
type StepRecorder ¶
type StepRecorder interface {
RecordStep(ctx context.Context, actor appshared.ActorContext, tenantID, itemID, collectionID shared.ID,
verb activity.Verb, changeSet map[string]any, at time.Time) error
}
StepRecorder is the slice of the history the push writes through: one step, named by the entry it is about (work.ActivityJournal.RecordStep).
type StreamChanges ¶
type StreamChanges struct {
Changes repository.Changes
Containers Containers
Authorizer Authorizer
UnitOfWork persistence.UnitOfWork
Cursors Cursors
// Epochs is the workspace's synchronisation epoch: every position is minted under the
// current one, and a cursor from an older one is refused.
Epochs repository.Epochs
Clock clock.Clock
// Window is how far back a cursor may reach: the maximum offline window, which is also the
// minimum tombstone period (offline-sync.md §7). Beyond it the log no longer holds everything
// that happened, so a delta would be silently wrong.
Window time.Duration
// Batch is how many entries one round reads. A bound rather than a tuning knob: a round is a
// read inside one transaction, and an unbounded one would hold it open across a backlog.
Batch int
}
StreamChanges reads the change log on behalf of one connection.
The authorisation is applied per record and at the moment the record is read, never by trusting what a subscription stated (ADR-0005, offline-sync.md §3.3). Permission lost while a connection is open therefore stops the records for that container without the client having to do anything, and without this package having to be told.
func (StreamChanges) Encode ¶
func (s StreamChanges) Encode(position Position) string
Encode is how the presentation layer turns a position into the event's `id`, without knowing what a cursor is made of.
func (StreamChanges) Next ¶
func (s StreamChanges) Next( ctx context.Context, actor appshared.ActorContext, from Position, ) (Batch, error)
Next reads one round and returns what this actor may see.
func (StreamChanges) Resume ¶
func (s StreamChanges) Resume( ctx context.Context, actor appshared.ActorContext, cursor string, ) (Position, error)
Resume decides where a connection starts.
An empty cursor means "from now": a client with no position is starting fresh, and the stream is an accelerator rather than a history - what it missed before it connected is a pull's business.