tasksync

package
v1.35.0 Latest Latest
Warning

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

Go to latest
Published: Aug 23, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package tasksync is taskr's cross-device sync engine: the pure merge fold (Merge), the wire protocol (Request/Response, PostSync), the HTTP endpoint (Server), the real-time change push (Hub, Listener), and conflict detection (DroppedLocalEdits). It is deliberately storage- and UI-free: the ONLY thing it asks of the application is the one-method Store interface (fold a task set into storage atomically). SQL, file paths, config files and Bubble Tea all live on the application side of that doorway — keep it that way, so the engine stays independently testable and reusable.

Index

Constants

View Source
const (
	// ProtocolVersion is the wire version this build speaks. Bump it in the
	// same commit that changes the meaning of a field in Request or Response
	// — adding a field that older peers can safely ignore does not need one.
	ProtocolVersion = 1

	// MinProtocolVersion is the oldest client wire this build still accepts.
	// Raise it only to drop support deliberately; every raise strands the
	// clients that still speak the older version.
	MinProtocolVersion = 1
)

Wire protocol versioning.

Sync is the one place where two taskr binaries of different vintages talk to each other — that is the entire reason it exists — so the wire needs a version on it. Without one, a client sending a field the server does not know, or expecting a field the server does not send, fails by silent misinterpretation: the merge runs on whatever decoded, and the user finds out when data is wrong rather than when the request is made.

The rule is: every request carries the version it was written against, and a server that cannot speak it says so in a message naming which side is behind. That turns an invisible data bug into a legible upgrade prompt.

View Source
const ProtocolHeader = "Taskr-Sync-Protocol"

ProtocolHeader carries the server's supported range on unauthenticated health responses, so an operator can check what a server speaks without holding the sync token.

View Source
const VersionHeader = "Taskr-Version"

VersionHeader carries the taskr build the server is running, on *every* response including errors.

The wire version above only moves when a field changes meaning, so two builds years apart still negotiate cleanly — which is the point, and also why it cannot answer "why did this stop working". The failure it misses is two taskr builds of different vintages against one store: the newer one migrates the schema, the older server process keeps running and answers every sync with a 500 whose body ("no such table: task_learnings") means nothing to the person reading it. A header rather than a body field because that failure produces no decodable body at all — an http.Error — and that is exactly the moment the client needs to be able to name which side is old.

Variables

This section is empty.

Functions

func CanonicalJSON

func CanonicalJSON(t todo.Todo) []byte

CanonicalJSON is a single task's order-insensitive fingerprint source: a value copy with its unordered slices sorted, marshalled. The app's changed-row detection compares these to decide what a merge must write.

func ClockSkewWarning

func ClockSkewWarning(serverTime, now time.Time) string

ClockSkewWarning returns a human warning when this device's clock and the server's differ by more than maxClientClockSkew. The LWW merge orders edits by device wall clocks: a clock behind silently loses every conflict it touches, a clock ahead wrongly wins (until the server's clamp catches the worst of it) — and no error ever surfaces either way. A zero serverTime (a server from before the field existed) skips the check. The measurement includes the network round trip, which is noise at a five-minute threshold.

func DroppedLocalEdits

func DroppedLocalEdits(local, merged []todo.Todo, since time.Time) []todo.Todo

DroppedLocalEdits returns the local versions of tasks whose scalar fields were overwritten by the merge — a local edit that lost last-writer-wins. Only tasks the client had live are considered, and only ones actually modified here since the last successful sync (`since`). Without that baseline, every remote edit arriving via a pull read as a "conflict" — the local copy differs from the merged result, but it's merely stale, nothing was lost. A zero `since` (no sync recorded yet) logs everything: when unsure, over-log — it's a recovery net.

func InsecureURLWarning

func InsecureURLWarning(rawURL string) string

InsecureURLWarning returns a human warning when rawURL sends the bearer token in cleartext somewhere it could actually be sniffed: plain http to a host that is not loopback, RFC1918/link-local private, Tailscale CGNAT (100.64/10), or a *.ts.net name. https and private transports return "". Empty/unparseable URLs return "" too — reachability errors surface later, on the sync itself; this is only about the token's exposure.

func Merge

func Merge(server, client []todo.Todo) []todo.Todo

Merge folds two task sets into one authoritative set. It is symmetric in its arguments. Tombstones are retained; a task whose parent is missing or deleted is re-homed to top level; output is sorted by ID for a stable wire order.

func StoreDigest

func StoreDigest(ts []todo.Todo) [32]byte

StoreDigest is an order-independent fingerprint of a task set: identical content hashes identically regardless of slice ordering. It must have no false positives — a stable order is imposed on the task slice and every child/tag slice before hashing.

func VersionGapWarning

func VersionGapWarning(serverVersion, clientVersion string) string

VersionGapWarning returns a human warning when a *successful* sync came back from a server running a different taskr build. Succeeding is what makes it worth saying: a schema migration that only adds a column lets an older server keep answering 200 while dropping the new field on every round trip, so the mismatch never surfaces as an error — it surfaces as data quietly not arriving. Equal versions, or either side unknown, return "".

Types

type Hub

type Hub struct {
	// contains filtered or unexported fields
}

Hub fans a single "the store changed" signal out to all subscribers.

func NewHub

func NewHub() *Hub

func (*Hub) Broadcast

func (h *Hub) Broadcast()

Broadcast nudges every subscriber without blocking on any of them. A client whose buffer is already full has a nudge pending, so skipping it loses nothing: one sync pulls every change accumulated since.

func (*Hub) Subscribe

func (h *Hub) Subscribe() chan struct{}

Subscribe registers a client and returns its nudge channel, or nil when the hub is at capacity (the caller rejects the connection).

func (*Hub) SubscriberCount

func (h *Hub) SubscriberCount() int

func (*Hub) Unsubscribe

func (h *Hub) Unsubscribe(ch chan struct{})

type Listener

type Listener struct {
	C chan struct{}
	// contains filtered or unexported fields
}

Listener owns the SSE listener goroutine. C carries coalesced change nudges (1-buffered — the consumer needs "something changed", not a count); Close ends the goroutine.

func StartListener

func StartListener(url, token string) *Listener

StartListener launches the SSE listener against the server at url with the shared bearer token. The goroutine reconnects on its own with backoff, so the caller just reads C.

func (*Listener) Close

func (ls *Listener) Close()

func (*Listener) Done

func (ls *Listener) Done() <-chan struct{}

Done is closed once Close has been called — for teardown ordering and tests; the listener goroutine may still be mid-return when it fires.

type Request

type Request struct {
	Tasks []todo.Todo `json:"tasks"`
	// Protocol is the wire version the client was built against. Absent
	// (zero) means a client from before versioning existed, which speaks
	// v1 — see legacyProtocolVersion.
	Protocol int `json:"v,omitempty"`
}

Request and Response are the /v1/sync wire format: the client pushes its full task set (tombstones included) and receives the merged authoritative set in one round trip.

type Response

type Response struct {
	Tasks []todo.Todo `json:"tasks"`
	// Protocol is the wire version the server speaks, so a newer client can
	// tell an older server apart from one that simply sent nothing. Zero
	// from a server that predates the field.
	Protocol int `json:"v,omitempty"`
	// ServerTime lets the client detect a skewed local clock (see
	// ClockSkewWarning): the LWW merge runs on wall-clock timestamps, so a
	// device with a bad clock silently loses or wrongly wins conflicts, and
	// nothing else in the protocol would ever tell the user. Zero when the
	// server predates the field; clients skip the check then.
	ServerTime time.Time `json:"server_time,omitempty"`
	// ServerVersion is the taskr build that answered, filled in by PostSync
	// from VersionHeader. Not on the wire (`json:"-"`) on purpose: it has to
	// come from the header to be readable on the error responses that carry
	// no body at all, and one source beats two that can disagree.
	ServerVersion string `json:"-"`
}

func PostSync

func PostSync(serverURL, token, clientVersion string, tasks []todo.Todo, timeout time.Duration) (Response, error)

PostSync pushes tasks to the server at serverURL and returns its response: the merged authoritative set plus the server's clock reading.

clientVersion is this build's taskr version, used only to describe a version gap in an error — pass "" from anywhere that has no version stamp and the comparison is skipped rather than guessed at.

type Server

type Server struct {
	Token string
	Store Store
	// Version is the taskr build this server is running, stamped onto every
	// response via VersionHeader. Empty means unknown (a test server, or a
	// build without the ldflags stamp) and the header is then omitted rather
	// than sent blank — a client must be able to tell "the server did not say"
	// from "the server said something".
	Version string
	// Hub, when non-nil, is nudged after every merge that changed the store so
	// connected clients pull immediately.
	Hub *Hub
	// OnClientSync, when non-nil, is told the time of every authenticated sync
	// (the app records it for `sync --status`). Called under the sync lock —
	// keep it fast; throttling is the callback's business.
	OnClientSync func(time.Time)
	// contains filtered or unexported fields
}

Server is the sync endpoint: POST /v1/sync does push+pull in one round trip, GET /v1/events streams change nudges. It is single-owner by design — one shared bearer token, not multi-tenant. Transport (Tailscale IP, localhost behind a proxy, LAN) is the caller's deployment choice.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler builds the route set. Shared by every entry point (headless serve, in-process TUI server, tests) so they never drift.

func (*Server) Sync

func (s *Server) Sync(clientTasks []todo.Todo) ([]todo.Todo, error)

Sync merges the client's tasks into the authoritative set and returns it. Serialized by mu so concurrent HTTP syncs can't interleave; atomicity against writers in OTHER processes is the Store's job (MergeIn).

type Store

type Store interface {
	// MergeIn folds incoming into the store atomically and returns the merged
	// authoritative set (tombstones included). changed=false means the store
	// already contained the result and nothing was written — callers use it to
	// skip change broadcasts so idle syncs can't feed back.
	MergeIn(incoming []todo.Todo) (merged []todo.Todo, changed bool, err error)
}

Store is the doorway the sync engine needs into task storage — the only thing this package asks of the application. Keeping it to one method keeps the package free of SQL, file paths and app config.

Jump to

Keyboard shortcuts

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