sync

package
v0.9.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

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

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

View Source
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).

View Source
const (
	// PullLimitDefault and PullLimitMax are the contract's (api/openapi.yaml, SyncPullRequest).
	PullLimitDefault = 500
	PullLimitMax     = 2000
)
View Source
const DefaultSkew = 5 * time.Minute

DefaultSkew is §4.1's default.

View Source
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

type ConflictDetail struct {
	Field              string
	Mine               any
	Theirs             any
	PreservedCommentID shared.ID
}

ConflictDetail is both versions of a free-text field that lost (offline-sync.md §5).

type Containers

type Containers interface {
	Find(ctx context.Context, id shared.ID) (work.Container, error)
}

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

type Cursors interface {
	Encode(position Position) string
	Decode(cursor string) (Position, error)
}

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"
)

func (Depth) Valid

func (d Depth) Valid() bool

Valid reports whether the depth is one the contract names.

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

type FieldChange struct {
	Value any
	HLC   string
}

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

func (Position) Walking

func (p Position) Walking() bool

Walking reports whether the position is inside an initial synchronisation.

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

type PushResponse struct {
	Results    []Result
	Cursor     Position
	ServerTime time.Time
}

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

type ResultError struct {
	Code        string
	MessageCode string
}

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

type Scope struct {
	ContainerID shared.ID
	Depth       Depth
}

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL