Artifact 7f4a29456f816e0b5d721d152f289a63a1952a1083123a6f8071409b9eadb790:
- File
docs/architecture/hub-package.md
— part of check-in
[afa1002be5]
at
2026-05-04 20:19:55
on branch trunk
— docs(architecture): add hub-package.md ADR (#128)
Documents the top-level hub/ package shipped in PR #109 + revised in PR #122 (deadlock fix).
Covers public API surface, lifecycle ordering (HTTP listener bound during NewHub for immediate HTTPAddr availability, then NATS server ReadyForConnections), the SQLite tuning decision (busy_timeout yes, MaxOpenConns(1) explicitly no — see #120), the in-root vs. separate- module choice (with #118 as the migration path), and integration with the leaf agent (sim/hub_leaf_test.go::TestHubLeafE2E locks the contract).
Cross-referenced from agent-deployment.md and listed at the top of CLAUDE.md alongside the other ADRs.
Closes #114 (user: [email protected], size: 5513) [annotate] [blame] [check-ins using]
Hub Package
Purpose
In-process host for an EdgeSync hub: a fossil hub repo, an embedded NATS server with JetStream, auto-port allocation, and an HTTP server exposing the fossil timeline UI / xfer endpoint. Lets consumers (notably the bones project) host a hub without rolling their own libfossil + nats-server bootstrap.
Before this package, every adopter that needed a hub fossil — a central shared repo that leaves push to and that serves the timeline — had to write the same ~900-line wrapper around libfossil.Create/Open, natsserver.NewServer, JetStream config, port allocation, PID files, and shutdown supervision.
Public API
All types are libfossil-free and nats-server-free. Consumers import only github.com/danmestas/EdgeSync/hub.
type Config struct {
RepoPath string // hub.fossil path; created if absent
BootstrapUser string // default "hub"
NATSStoreDir string // default <repo dir>/nats-store
FossilHTTPPort int // 0 = auto-pick
NATSClientPort int // 0 = auto-pick
NATSLeafPort int // 0 = auto-pick
}
type Hub struct{ /* opaque */ }
func NewHub(ctx context.Context, cfg Config) (*Hub, error)
func (h *Hub) ServeHTTP(ctx context.Context) error
func (h *Hub) Stop() error
func (h *Hub) NATSURL() string // "nats://127.0.0.1:NNNN"
func (h *Hub) LeafUpstream() string // "nats-leaf://127.0.0.1:LLLL"
func (h *Hub) HTTPAddr() string // "127.0.0.1:HHHH"
type User struct{ Login, Caps string }
func (h *Hub) AddUser(User) error
func (h *Hub) GetUser(login string) (User, error)
func (h *Hub) HasUser(login string) bool
func (h *Hub) ListUsers() ([]User, error)
func (h *Hub) RemoveUser(login string) error
type RevID string
type FileToCommit struct{ Name string; Content []byte }
type CommitOpts struct{ Files []FileToCommit; Message, Author string }
func (h *Hub) Commit(ctx context.Context, opts CommitOpts) (RevID, error)
func (h *Hub) Read(ctx context.Context, path string) ([]byte, error)
func (h *Hub) ReadAt(ctx context.Context, rev RevID, path string) ([]byte, error)
Lifecycle
NewHub does the bootstrap-or-open: opens an existing RepoPath if present, calls libfossil.Create if absent. It then binds the HTTP listener (so HTTPAddr() is immediately usable for downstream consumers wiring leaves to the hub) and starts the embedded NATS server, waiting for ReadyForConnections. After NewHub returns successfully, all three address accessors return live, reachable URLs.
ServeHTTP is a blocking goroutine — typical pattern is go h.ServeHTTP(ctx). It runs the fossil HTTP server (h.repo.XferHandler()) against the listener bound by NewHub until ctx is cancelled or Stop is called. Stop is idempotent.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
h, err := hub.NewHub(ctx, hub.Config{RepoPath: "/data/hub.fossil"})
if err != nil { return err }
defer h.Stop()
go h.ServeHTTP(ctx)
// Hub now serving at h.HTTPAddr(), NATS at h.NATSURL(), leaf upstream h.LeafUpstream()
SQLite Tuning
busy_timeout = 30000 is applied at hub bootstrap so concurrent operations retry on SQLITE_BUSY rather than failing fast.
The hub deliberately does not cap MaxOpenConns. An earlier iteration set SetMaxOpenConns(1) (matching what bones did before this package existed), but libfossil's clone path has internal goroutines that all need a DB connection — capping the pool deadlocked every concurrent clone (issue #120). busy_timeout alone is the right primitive for concurrent-write safety.
Layout: in-root vs. separate module
The hub/ directory lives inside the EdgeSync root module rather than as a separate go.mod sibling of leaf//bridge/. This was a deliberate choice to ship the package without a chicken-and-egg release: the package can evolve in lockstep with the leaf agent, and root tags pick it up.
If a downstream consumer needs to depend on hub independently of EdgeSync's full transitive deps, the package can be promoted to its own module without changing its public API. That migration is tracked in issue #118; triggers worth waiting for include consumers wanting to embed hub but skip the rest of EdgeSync's deps, or hub picking up dependencies the rest of EdgeSync doesn't want.
Out of Scope
- Multi-hub federation / cross-host hub replication. Out of band.
- Hub-side authentication beyond fossil user management. Caller passes raw
Capsstrings; everything else is fossil's user table. - Embedded
fossilbinary support. In-process libfossil only. - Public OTel / metrics surface. If
leaf/telemetryis wired in, hub hooks in; otherwise no-op. No new telemetry interface added at this stage.
Integration with the Leaf Agent
A leaf agent boots with NATSUpstream: hub.NATSURL() (or pulls the leaf-node URL via hub.LeafUpstream() for the leaf-node path). Sync goes over the HTTP-xfer transport — leaves construct an HTTP transport against hub.HTTPAddr() and call Agent.SyncTo(...). End-to-end coverage of this path is locked by sim/hub_leaf_test.go::TestHubLeafE2E (see testing-strategy.md).
Cross-references
- agent-deployment.md — leaf agent lifecycle and the NATS mesh roles that consume
hub.LeafUpstream(). - sync-protocol.md — the xfer wire format the hub serves at
/. - core-library.md — libfossil's opaque-handle API the hub wraps internally.