Documentation
¶
Overview ¶
Package hub implements the Cloudflare R2 zero-knowledge production backend for the DevStrap sync hub.
The logical DevStrap Hub (HUB-08) has three explicit implementation backends, each behind the one sync.Hub interface defined in internal/sync:
- file: the file-backed test backend (internal/sync.FileHub), retained ONLY for tests and the --hub-file spike.
- s3/r2: the direct Cloudflare R2 backend (R2Hub in this package), using S3 credentials and cursor-based polling with backoff. This is the production backend for the single-user fleet.
- http/sse: a future relay backend (deferred) that adds live push and multi-tenant routing. mTLS and SSE belong only to this later relay, not to the R2-direct path.
R2-direct uses S3 credentials and cursor polling (ListObjectsV2 with start-after) with backoff. It does not require a bespoke server — R2 is a managed object store. The HTTP/SSE relay is revisited only if a transport need (live push, multi-tenant routing) outgrows R2-direct.
FolderHub is the local-folder / cloud-drive-folder hub carrier (AD-1 final slice): a plain shared directory — a Dropbox/iCloud/Google Drive folder, an SMB/NFS mount, anything the OS presents as a filesystem path — carries the same zero-knowledge object set the R2/S3 and git-carrier backends store (enc.v2 event ciphertext, age blobs, sealed snapshots, signed manifests and acks). No bucket, no git remote, no credential plane beyond the drive the user already syncs.
Architecture: like the git carrier, the folder carrier composes the ALREADY-PROVEN R2Hub semantics over the plain-filesystem fsObjectStore, but rooted DIRECTLY in the shared folder — there is no fetch/commit/push loop, because the cloud drive (or network mount) is the replication transport. Each dssync.Hub method is just: acquire the cross-process lock → delegate to R2Hub → release.
Lock/observation placement: the cross-process lock file and the per-clone observation floor (observed.json) live in the LOCAL home cache (~/.devstrap/hub-folder/<hash>/), NEVER inside the shared folder. Replicating lock churn through a cloud drive would cause false contention and "conflicted copy" duplicates, and the observation floor is inherently per-device local state. Only the object payloads and their RFC3339Nano timestamp sidecars (.devstrap-meta/times/, the same freshness mechanism the git carrier uses) live in the shared folder, where they must replicate to converge.
CAS is best-effort across devices. The fsObjectStore conditional-put (PutObjectIfMatch / If-None-Match) is atomic only under the cross-process lock, and that lock lives in each device's LOCAL cache — so it serializes multiple processes of the SAME device but NOT two different devices writing through the same drive. A cloud drive gives no cross-writer linearization point (unlike the git carrier's atomic push-ref CAS or R2's conditional PUT), so a simultaneous retention/sweep-lock race between two devices can in principle produce two "winners"; the drive resolves it as a conflicted copy. This is the documented residual (spec/15), in the same advisory-cooperation class as the sweep lock's byzantine residuals — acceptable because the folder carrier targets the single-user, few-devices, rarely-simultaneous case, and every object is content-addressed or (device,seq)-unique so ordinary convergence never collides.
GitCarrierHub is the zero-infrastructure hub backend (AD-1 first slice): a private git repository — any host the user can already push to — carries the same zero-knowledge object set the R2/S3 backend stores (enc.v2 event ciphertext, age blobs, sealed snapshots, signed manifests/acks). No bucket, no credentials plane beyond the user's existing git auth.
Architecture: rather than re-implementing the 24-method Hub contract, the carrier composes the ALREADY-PROVEN R2Hub semantics over a plain-filesystem S3Client (fsObjectStore) rooted in a local clone of the carrier repo, and adds the git transport around it:
read = fetch + reset to the remote head, then delegate to R2Hub
write = fetch + reset, delegate to R2Hub (file mutations), one commit
for the whole batch, push; on a non-fast-forward rejection
refetch and re-apply with capped backoff (mutations are
idempotent: every key is content-addressed or (device,seq)-unique)
The atomic push-ref compare-and-swap is the linearization point that replaces S3 conditional PUT: If-None-Match/If-Match are evaluated against the freshly fetched head inside every attempt, so a lost push race re-evaluates them against the winner's state and surfaces the same ErrSweepLockHeld / ErrRetentionConflict outcomes as R2.
Object LastModified (gc grace windows, sweep-lock TTL) cannot ride git commit times — a dedup re-put changes no bytes and history rewrites reset commit times — so fsObjectStore keeps an RFC3339Nano timestamp sidecar per object under .devstrap-meta/times/. Sidecars live OUTSIDE the workspaces/ prefix, so no Hub listing ever sees them, and they travel with the tree through compaction squashes. The time is client-reported, which is acceptable for the advisory sweep lock's cooperating-clients contract (spec/15: a hostile writer with repo access defeats any dumb carrier).
hub compact is the only history-bounding operation: after deleting cold event objects it rewrites the branch to a single parentless commit of the surviving tree and pushes with --force-with-lease, so the carrier's git history stops growing monotonically (deleting files never shrinks a repo); the host garbage-collects the unreachable objects. Concurrent pushers recover through their own fetch-and-reapply loop. The caller (hub compact) already holds the advisory sweep lock.
R2 is S3-compatible with zero egress, strong consistency for object writes/listing, and conditional puts. Event-log payloads are envelope-encrypted (XChaCha20-Poly1305 under a per-epoch Workspace Content Key, P4-SEC-02/SEC-07) and Ed25519-signed before upload; blobs are age-encrypted. R2 stores only ciphertext plus a signed carrier map — it can decrypt nothing and holds no private key.
The event log is NOT one overwritten manifest object. Every event is an immutable, unique, lexicographically sortable object (HUB-06). Since P5-SYNC-01 the transport coordinate is the per-origin-device sequence number, so Push writes per-device, seq-ordered keys:
workspaces/<workspace_id>/eventlog/<device_id>/<seq-padded>_<event_id>.json
and Pull resumes each device's stream with a per-device Seq cursor (delimiter-discovered device prefixes + StartAfter within each). The retired HLC-keyed legacy layout
workspaces/<workspace_id>/events/<hlc-padded>/<device_id>/<seq>/<event_id>.json
is still READ (dual-read) so pre-migration hubs keep working — Pull parses (device, seq) out of legacy keys and applies the same per-device cursor; unparseable legacy keys fail open toward fetching so a parse bug can never silently lose events. Push never writes the legacy layout. A follow-up `hub migrate-events` can re-key legacy objects and delete the old prefix; the dual-read is O(1) on an empty prefix.
Blobs are content-addressed (HUB-06):
workspaces/<workspace_id>/blobs/<sha256>
The S3 operations are abstracted behind the S3Client interface so the keying scheme and Hub contract are unit-testable with an in-memory double (the conformance suite). A real implementation plugs in the AWS SDK v2 S3 client.
s3client_awssdk.go is the production S3Client adapter (P5-HUB-01). It wraps the aws-sdk-go-v2 S3 client pointed at a Cloudflare R2 (or any S3-compatible) endpoint and translates SDK errors into the four hub sentinels (ErrPreconditionFailed, ErrS3Throttle, ErrS3Transient, dssync.ErrBlobNotFound) so R2Hub.Retry is the single retry/classification layer.
It is constructed with s3.New(s3.Options{...}) — not config.LoadDefaultConfig — to keep the dependency/govulncheck surface to the S3 data path only (no SSO/IMDS/STS chain). The SDK's own retryer is disabled (aws.NopRetryer{}) so R2Hub.Retry is the ONLY retry layer and there is no double-retry/billing loop. Credentials are supplied inline via aws.CredentialsProviderFunc so the `credentials` module is never pulled in.
Index ¶
- Variables
- type FolderHub
- func (f *FolderHub) CompactEventsBelow(ctx context.Context, floors dssync.Cursor) (int, error)
- func (f *FolderHub) DeleteAck(ctx context.Context, deviceID string) error
- func (f *FolderHub) DeleteBlob(ctx context.Context, sha256Hex string) error
- func (f *FolderHub) DeleteDeviceStream(ctx context.Context, deviceID string) (int, error)
- func (f *FolderHub) DeleteSnapshotObject(ctx context.Context, sha256Hex string) error
- func (f *FolderHub) DeleteSweepLock(ctx context.Context) error
- func (f *FolderHub) GetBlob(ctx context.Context, sha256Hex string) (io.ReadCloser, error)
- func (f *FolderHub) GetRetention(ctx context.Context) ([]byte, string, error)
- func (f *FolderHub) GetSnapshotObject(ctx context.Context, sha256Hex string) ([]byte, error)
- func (f *FolderHub) GetSweepLock(ctx context.Context) ([]byte, time.Time, error)
- func (f *FolderHub) HasEvents(ctx context.Context) (bool, error)
- func (f *FolderHub) ListAcks(ctx context.Context) (map[string][]byte, error)
- func (f *FolderHub) ListBlobs(ctx context.Context) ([]dssync.BlobInfo, error)
- func (f *FolderHub) ListSnapshotObjects(ctx context.Context) ([]dssync.BlobInfo, error)
- func (f *FolderHub) MigrateLegacyEvents(ctx context.Context, dryRun bool) (int, int, error)
- func (f *FolderHub) Pull(ctx context.Context, after dssync.Cursor) ([]state.Event, error)
- func (f *FolderHub) Push(ctx context.Context, events []state.Event) error
- func (f *FolderHub) PutAck(ctx context.Context, deviceID string, raw []byte) error
- func (f *FolderHub) PutBlob(ctx context.Context, sha256Hex string, r io.Reader) error
- func (f *FolderHub) PutRetention(ctx context.Context, raw []byte, ifMatchETag string) error
- func (f *FolderHub) PutSnapshotObject(ctx context.Context, sha256Hex string, body []byte) error
- func (f *FolderHub) PutSweepLock(ctx context.Context, raw []byte) error
- func (f *FolderHub) StatBlob(ctx context.Context, sha256Hex string) (dssync.BlobInfo, error)
- type GitCarrierHub
- func (g *GitCarrierHub) CompactEventsBelow(ctx context.Context, floors dssync.Cursor) (int, error)
- func (g *GitCarrierHub) DeleteAck(ctx context.Context, deviceID string) error
- func (g *GitCarrierHub) DeleteBlob(ctx context.Context, sha256Hex string) error
- func (g *GitCarrierHub) DeleteDeviceStream(ctx context.Context, deviceID string) (int, error)
- func (g *GitCarrierHub) DeleteSnapshotObject(ctx context.Context, sha256Hex string) error
- func (g *GitCarrierHub) DeleteSweepLock(ctx context.Context) error
- func (g *GitCarrierHub) GetBlob(ctx context.Context, sha256Hex string) (io.ReadCloser, error)
- func (g *GitCarrierHub) GetRetention(ctx context.Context) ([]byte, string, error)
- func (g *GitCarrierHub) GetSnapshotObject(ctx context.Context, sha256Hex string) ([]byte, error)
- func (g *GitCarrierHub) GetSweepLock(ctx context.Context) ([]byte, time.Time, error)
- func (g *GitCarrierHub) HasEvents(ctx context.Context) (bool, error)
- func (g *GitCarrierHub) ListAcks(ctx context.Context) (map[string][]byte, error)
- func (g *GitCarrierHub) ListBlobs(ctx context.Context) ([]dssync.BlobInfo, error)
- func (g *GitCarrierHub) ListSnapshotObjects(ctx context.Context) ([]dssync.BlobInfo, error)
- func (g *GitCarrierHub) MigrateLegacyEvents(ctx context.Context, dryRun bool) (int, int, error)
- func (g *GitCarrierHub) Pull(ctx context.Context, after dssync.Cursor) ([]state.Event, error)
- func (g *GitCarrierHub) Push(ctx context.Context, events []state.Event) error
- func (g *GitCarrierHub) PutAck(ctx context.Context, deviceID string, raw []byte) error
- func (g *GitCarrierHub) PutBlob(ctx context.Context, sha256Hex string, r io.Reader) error
- func (g *GitCarrierHub) PutRetention(ctx context.Context, raw []byte, ifMatchETag string) error
- func (g *GitCarrierHub) PutSnapshotObject(ctx context.Context, sha256Hex string, body []byte) error
- func (g *GitCarrierHub) PutSweepLock(ctx context.Context, raw []byte) error
- func (g *GitCarrierHub) StatBlob(ctx context.Context, sha256Hex string) (dssync.BlobInfo, error)
- type R2Config
- type R2Hub
- func (h R2Hub) CompactEventsBelow(ctx context.Context, floors dssync.Cursor) (int, error)
- func (h R2Hub) DeleteAck(ctx context.Context, deviceID string) error
- func (h R2Hub) DeleteBlob(ctx context.Context, sha256Hex string) error
- func (h R2Hub) DeleteDeviceStream(ctx context.Context, deviceID string) (int, error)
- func (h R2Hub) DeleteSnapshotObject(ctx context.Context, sha256Hex string) error
- func (h R2Hub) DeleteSweepLock(ctx context.Context) error
- func (h R2Hub) GetBlob(ctx context.Context, sha256Hex string) (io.ReadCloser, error)
- func (h R2Hub) GetRetention(ctx context.Context) ([]byte, string, error)
- func (h R2Hub) GetSnapshotObject(ctx context.Context, sha256Hex string) ([]byte, error)
- func (h R2Hub) GetSweepLock(ctx context.Context) ([]byte, time.Time, error)
- func (h R2Hub) HasEvents(ctx context.Context) (bool, error)
- func (h R2Hub) ListAcks(ctx context.Context) (map[string][]byte, error)
- func (h R2Hub) ListBlobs(ctx context.Context) ([]dssync.BlobInfo, error)
- func (h R2Hub) ListSnapshotObjects(ctx context.Context) ([]dssync.BlobInfo, error)
- func (h R2Hub) MigrateLegacyEvents(ctx context.Context, dryRun bool) (migrated, kept int, err error)
- func (h R2Hub) Pull(ctx context.Context, after dssync.Cursor) ([]state.Event, error)
- func (h R2Hub) Push(ctx context.Context, events []state.Event) error
- func (h R2Hub) PutAck(ctx context.Context, deviceID string, raw []byte) error
- func (h R2Hub) PutBlob(ctx context.Context, sha256Hex string, r io.Reader) error
- func (h R2Hub) PutRetention(ctx context.Context, raw []byte, ifMatchETag string) error
- func (h R2Hub) PutSnapshotObject(ctx context.Context, sha256Hex string, body []byte) error
- func (h R2Hub) PutSweepLock(ctx context.Context, raw []byte) error
- func (h R2Hub) StatBlob(ctx context.Context, sha256Hex string) (dssync.BlobInfo, error)
- type R2Retry
- type S3Adapter
- func (a *S3Adapter) DeleteObject(ctx context.Context, key string) error
- func (a *S3Adapter) GetObject(ctx context.Context, key string) (data []byte, err error)
- func (a *S3Adapter) GetObjectWithETag(ctx context.Context, key string) (data []byte, etag string, err error)
- func (a *S3Adapter) ListCommonPrefixes(ctx context.Context, prefix, delimiter string) ([]string, error)
- func (a *S3Adapter) ListObjectsV2(ctx context.Context, prefix, startAfter string, maxKeys int) ([]dssync.BlobInfo, string, error)
- func (a *S3Adapter) ObjectExists(ctx context.Context, key string) (bool, error)
- func (a *S3Adapter) PutObject(ctx context.Context, key string, body []byte, ifNoneMatch bool) error
- func (a *S3Adapter) PutObjectIfMatch(ctx context.Context, key string, body []byte, etag string) error
- func (a *S3Adapter) StatObject(ctx context.Context, key string) (dssync.BlobInfo, error)
- type S3Client
Constants ¶
This section is empty.
Variables ¶
var ErrNotImplemented = errors.New("s3 operation not implemented")
ErrNotImplemented signals an S3Client method that has no production wiring yet.
var ErrPreconditionFailed = errors.New("object already exists (conditional put failed)")
ErrPreconditionFailed signals that a conditional PutObject (If-None-Match: *) was rejected because the object already exists (R2 error 10031 / HTTP 412). For content-addressed blobs and immutable event keys a 412 is definitionally an idempotent dedup hit, not an error (HUB-09): the same sha256 yields the same ciphertext, and a duplicate event key is the same event.
var ErrS3Auth = errors.New("s3 authentication/authorization failed")
ErrS3Auth signals a credential/authorization failure (401/403, SignatureDoesNotMatch, InvalidAccessKeyId, AccessDenied). Terminal — never retried — and carries remediation guidance so a bad or unresolved secret (e.g. an op:// ref pasted where a literal was expected before P6-HUB-02) surfaces as an actionable message instead of a raw SDK error.
var ErrS3Throttle = errors.New("s3 throttling (429/503 slow down)")
ErrS3Throttle signals an R2/S3 throttling response (429 TooManyRequests / 503 SlowDown) that is retryable after backoff (HUB-10).
var ErrS3Transient = errors.New("s3 transient (500/connection reset)")
ErrS3Transient signals an R2/S3 transient response (InternalError / connection reset) that is retryable after a short backoff (HUB-10).
Functions ¶
This section is empty.
Types ¶
type FolderHub ¶
type FolderHub struct {
// contains filtered or unexported fields
}
FolderHub implements dssync.Hub over a shared directory. Construct with NewFolderHub. Safe for concurrent use: every operation serializes on an in-process mutex plus a cross-process lock file (both keyed to this device's local cache, so they serialize same-device processes; cross-device ordering is left to the drive — see the package comment).
func NewFolderHub ¶
NewFolderHub prepares a folder-carrier hub rooted at dir (which must be an absolute path). cacheRoot is the local cache PARENT (one subdirectory per resolved folder, so two folders never share a lock or observation floor); the lock file and observed.json live under it, never inside the shared folder. The folder is created (0700) when missing and its symlinks are resolved once — cloud-drive roots are frequently symlinks — so the store, the lock, and the cache hash all key on the real path.
func (*FolderHub) CompactEventsBelow ¶
CompactEventsBelow deletes cold event objects. Unlike the git carrier there is no history to squash — file deletion IS the reclamation on a plain filesystem — so it is a single locked delegation to R2Hub. The caller holds the advisory sweep lock.
func (*FolderHub) DeleteBlob ¶
func (*FolderHub) DeleteDeviceStream ¶
func (*FolderHub) DeleteSnapshotObject ¶
func (*FolderHub) GetRetention ¶
func (*FolderHub) GetSnapshotObject ¶
func (*FolderHub) GetSweepLock ¶
func (*FolderHub) HasEvents ¶
HasEvents reports whether any event was ever recorded on this hub (the doctor --remote capability probe, mirroring GitCarrierHub.HasEvents).
func (*FolderHub) ListSnapshotObjects ¶
func (*FolderHub) MigrateLegacyEvents ¶
MigrateLegacyEvents delegates for symmetric reporting; the folder carrier, like the git carrier, never had the retired HLC-keyed layout, so it is a structural no-op. Both the dry run and the real run only read/rewrite object files, so a single lock covers either.
func (*FolderHub) PutRetention ¶
func (*FolderHub) PutSnapshotObject ¶
func (*FolderHub) PutSweepLock ¶
type GitCarrierHub ¶
type GitCarrierHub struct {
// contains filtered or unexported fields
}
GitCarrierHub implements dssync.Hub over a private git repository. Construct with NewGitCarrierHub. Safe for concurrent use; all operations serialize on an in-process mutex plus a cross-process lock file, because they share one working clone.
func NewGitCarrierHub ¶
func NewGitCarrierHub(remote, branch, workspaceID, cacheRoot string) (*GitCarrierHub, error)
NewGitCarrierHub prepares a git-carrier hub for remote/branch. cacheRoot is the local clone cache directory (one subdirectory per remote+branch, so two hubs never share a checkout); it is created on demand. The remote must pass git.ValidateRemote; the branch must be a safe git branch name.
func (*GitCarrierHub) CompactEventsBelow ¶
CompactEventsBelow deletes cold event objects like R2, then — because file deletion never shrinks a git repository — rewrites the branch to a single parentless commit of the surviving tree and pushes it with --force-with-lease against the head this pass fetched. The caller holds the advisory sweep lock; a concurrent pusher that loses the lease race simply refetches the squashed head and re-applies (its mutations are idempotent). A lost lease HERE refetches and re-runs the deletion against the new head.
func (*GitCarrierHub) DeleteAck ¶
func (g *GitCarrierHub) DeleteAck(ctx context.Context, deviceID string) error
func (*GitCarrierHub) DeleteBlob ¶
func (g *GitCarrierHub) DeleteBlob(ctx context.Context, sha256Hex string) error
func (*GitCarrierHub) DeleteDeviceStream ¶
func (*GitCarrierHub) DeleteSnapshotObject ¶
func (g *GitCarrierHub) DeleteSnapshotObject(ctx context.Context, sha256Hex string) error
func (*GitCarrierHub) DeleteSweepLock ¶
func (g *GitCarrierHub) DeleteSweepLock(ctx context.Context) error
func (*GitCarrierHub) GetBlob ¶
func (g *GitCarrierHub) GetBlob(ctx context.Context, sha256Hex string) (io.ReadCloser, error)
func (*GitCarrierHub) GetRetention ¶
func (*GitCarrierHub) GetSnapshotObject ¶
func (*GitCarrierHub) GetSweepLock ¶
func (*GitCarrierHub) HasEvents ¶
func (g *GitCarrierHub) HasEvents(ctx context.Context) (bool, error)
HasEvents reports whether any event was ever recorded on this hub (the doctor --remote capability probe, mirroring FileHub.HasEvents).
func (*GitCarrierHub) ListSnapshotObjects ¶
func (*GitCarrierHub) MigrateLegacyEvents ¶
MigrateLegacyEvents is a structural no-op: the git carrier never had the retired HLC-keyed layout. Delegated for symmetric reporting. A dry run goes through the READ path — the write loop would seed the carrier marker (and, on an empty carrier, create the branch), violating the CLI's report-without-writing dry-run contract.
func (*GitCarrierHub) PutRetention ¶
func (*GitCarrierHub) PutSnapshotObject ¶
func (*GitCarrierHub) PutSweepLock ¶
func (g *GitCarrierHub) PutSweepLock(ctx context.Context, raw []byte) error
type R2Config ¶
type R2Config struct {
Endpoint string // R2 S3 API endpoint (https://<account>.r2.cloudflarestorage.com)
Bucket string
WorkspaceID string
// CredentialMode is "self-hosted" (bucket-scoped key) or "hosted"
// (temporary prefix-scoped credentials brokered by the control plane).
CredentialMode string
// PrefixScope restricts operations to workspaces/<workspace_id>/. Required
// in hosted mode so a scoped key cannot access other tenants.
PrefixScope string
}
R2Config configures the Cloudflare R2 backend credentials and endpoint (HUB-07). Two credential modes are supported:
- Self-hosted (single owner): a bucket-scoped R2 API token may be used directly. The key is kept on the trusted device and never reaches runners.
- Hosted/SaaS: the parent R2 key stays only in trusted control-plane code. Devices and runner Machines receive short-lived temporary credentials or presigned URLs scoped to workspaces/<workspace_id>/... with the minimum needed operations. Runner Machines never receive the parent key.
PrefixScope, when set, restricts all operations to a workspace prefix so a scoped credential cannot touch another tenant's objects.
type R2Hub ¶
type R2Hub struct {
S3 S3Client
WorkspaceID string
// RetentionSeqs is the hub's per-device retention horizon (P5-HUB-03,
// re-based on the P5-SYNC-01 Seq cursor): for each origin device, the
// minimum Seq still retained on the hub. A Pull whose cursor would leave a
// gap below a device's floor (after[dev]+1 < RetentionSeqs[dev]) returns
// dssync.ErrSnapshotRequired so the caller performs a full-state snapshot
// exchange instead of silently receiving a partial (post-compaction) event
// set and diverging. Empty means "no compaction yet" (R2 retains
// everything). The future compaction marker must be recorded per device
// (e.g. eventlog/<device>/.retention).
RetentionSeqs map[string]int64
// Retry configures R2Hub-level retry, backoff, and error classification for
// S3 operations (HUB-10). A zero value uses a default policy: throttling
// (429/503 SlowDown) and transient (500/connection-reset) errors are retried
// with capped exponential backoff plus full jitter; terminal errors (auth,
// precondition, not-found, malformed) fail fast. A real aws-sdk-go-v2
// client wires its own standard retryer; this seam works with any S3Client
// (including the in-memory conformance double) and is exercised via fault
// injection before the SDK is wired.
Retry R2Retry
}
R2Hub is the Cloudflare R2 zero-knowledge Hub backend (HUB-02). It implements dssync.Hub. All content is client-side encrypted before it reaches S3Client.
func (R2Hub) CompactEventsBelow ¶
CompactEventsBelow deletes event objects strictly below each device's floor (Seq < floors[dev]) in both layouts. The caller must have durably published the superseding snapshot + manifest first (confirm-before-delete). In the seq-keyed layout the per-device prefix list is bounded with StartAfter-free enumeration from the stream head, stopping at the floor key; in the legacy layout parsed (device, seq) keys below the floor are deleted and unparseable keys are KEPT (fail safe — mirroring the dual-read's fail-open posture, a parse bug must never delete an event it cannot account for).
func (R2Hub) DeleteBlob ¶
DeleteBlob removes a content-addressed blob from the hub (SEC-01/HUB-12). A missing blob is not an error (idempotent delete). On revoke, after a blob is rewrapped to the reduced recipient set and its references repointed, the old ciphertext is deleted so the revoked device can no longer fetch it.
func (R2Hub) DeleteDeviceStream ¶
DeleteDeviceStream deletes every object under one origin device's event-log prefix (idempotent), reclaiming a revoked device's stream after compaction has folded its state into the snapshot. It returns the object count deleted.
func (R2Hub) DeleteSnapshotObject ¶
DeleteSnapshotObject removes a superseded snapshot object (idempotent).
func (R2Hub) DeleteSweepLock ¶
DeleteSweepLock removes the sweep-lock object (idempotent).
func (R2Hub) GetRetention ¶
GetRetention returns the raw retention-manifest bytes plus the object's ETag for CAS (P4-SYNC-02/P6-HUB-04). Absent manifest is ErrRetentionNotFound.
func (R2Hub) GetSnapshotObject ¶
GetSnapshotObject returns a sealed snapshot object's bytes. A missing object wraps dssync.ErrBlobNotFound.
func (R2Hub) GetSweepLock ¶
GetSweepLock reads the advisory sweep-lock object plus the object's LastModified (from a single-key list) for TTL judgment (P4-HUB-12). A missing lock is dssync.ErrSweepLockNotFound.
func (R2Hub) HasEvents ¶
HasEvents reports whether this workspace has any events at all on the hub (P4-SEC-07 doctor mismatch check): one retried MaxKeys=1 ListObjectsV2 call per layout (seq-keyed, then legacy). It answers "is this prefix populated" cheaply, without paging the whole event log.
func (R2Hub) ListBlobs ¶
ListBlobs returns metadata for every blob in this workspace's blob prefix (P5-HUB-02), the enumeration primitive for mark-and-sweep hub GC.
func (R2Hub) ListSnapshotObjects ¶
ListSnapshotObjects returns metadata for every snapshot object in this workspace's snapshots prefix (compaction prunes superseded ones by age).
func (R2Hub) MigrateLegacyEvents ¶
func (h R2Hub) MigrateLegacyEvents(ctx context.Context, dryRun bool) (migrated, kept int, err error)
MigrateLegacyEvents re-keys the retired HLC-keyed legacy layout
workspaces/<ws>/events/<hlc pad20>/<device>/<seq>/<id>.json
into the per-device seq layout and deletes the migrated legacy objects (P4-HUB-12). It is idempotent and resumable — the dual-read keeps unmigrated objects live, so a partial run leaves a correct superset — and FAILS OPEN: a key that does not parse, a body that does not decode as a state.Event, or a body whose (DeviceID, Seq) disagree with the key coordinates is reported and KEPT (never deleted), mirroring Pull's fail-open dual-read. Each object is verified by read-back on the new key before the legacy object is deleted, so a mid-migration crash or a backend that returns wrong bytes never loses an event. Re-running against a fully migrated hub reports (0, 0, nil). A dryRun classifies every object (would-migrate vs would-keep) but writes and deletes NOTHING.
func (R2Hub) PutAck ¶
PutAck writes one device's signed sync-ack marker (P4-SYNC-06). Single writer per key (each device writes only its own ack), so an unconditional PUT is last-writer-wins by design.
func (R2Hub) PutRetention ¶
PutRetention writes the retention manifest with compare-and-swap semantics: ifMatchETag "" is create-only (If-None-Match: *); otherwise If-Match. A lost race in either mode is dssync.ErrRetentionConflict — the caller must re-read, re-derive floors, and retry or refuse.
A conditional PUT retried after an ambiguous failure (the first attempt committed but the response was lost to a transient network/5xx error) would 412 against its OWN success — the object's ETag already changed. So a 412 is disambiguated by a read-back: if the current manifest bytes equal what this call was writing, the write already happened and this is success, not a lost race (post-#65 Codex review, P2).
func (R2Hub) PutSnapshotObject ¶
PutSnapshotObject stores a sealed snapshot object. Content-addressed: a 412 on the conditional put is definitionally a dedup hit (same sha256 = same bytes), so concurrent compactors producing identical snapshots cannot clobber or fail each other.
func (R2Hub) PutSweepLock ¶
PutSweepLock writes the sweep-lock object create-only (If-None-Match:*): an existing lock surfaces as dssync.ErrSweepLockHeld (P4-HUB-12).
type R2Retry ¶
type R2Retry struct {
MaxAttempts int // total attempts including the first; 0 => default (3)
BaseDelay time.Duration // base delay for transient errors; 0 => 50ms
ThrottleDelay time.Duration // base delay for throttling errors; 0 => 1s
Cap time.Duration // max backoff delay; 0 => 20s
// Jitter returns a non-negative int64 in [0, n). Defaults to math/rand.Int63n.
// Tests inject a deterministic source (e.g. always 0) for fast retries.
Jitter func(n int64) int64
}
R2Retry configures R2Hub-level retry behavior (HUB-10): throttling and transient S3 errors are retried with capped exponential backoff plus full jitter; terminal errors fail fast. A real aws-sdk-go-v2 client wires its own standard retryer (retry.NewStandard + a token-bucket RateLimiter so retries cannot create a runaway billing loop); this seam is the R2Hub-level policy that works with any S3Client and is tested via fault injection.
type S3Adapter ¶
type S3Adapter struct {
// contains filtered or unexported fields
}
S3Adapter is the production hub.S3Client backed by aws-sdk-go-v2 (P5-HUB-01). It points at one bucket; workspace key-prefix scoping is handled by R2Hub's keying (workspaces/<workspace_id>/...), so the adapter itself is prefix-free.
func NewS3Client ¶
NewS3Client builds a production S3Client against an R2/S3-compatible endpoint (P5-HUB-01). endpoint is the S3 API URL (e.g. https://<account>.r2.cloudflarestorage.com); region defaults to "auto" (R2); bucket is the target bucket. accessKeyID/secretAccessKey are the bucket-scoped static credentials (self-hosted mode). The SDK retryer is disabled so R2Hub.Retry is the single retry layer.
func (*S3Adapter) DeleteObject ¶
DeleteObject removes the object at key. A missing object is not an error (idempotent delete) so blob/event GC (HUB-12) and revoke cleanup (SEC-01) can call it unconditionally for superseded ciphertext.
func (*S3Adapter) GetObject ¶
GetObject returns the object bytes at key, or an error wrapping dssync.ErrBlobNotFound when the object is absent (HUB-02).
func (*S3Adapter) GetObjectWithETag ¶
func (a *S3Adapter) GetObjectWithETag(ctx context.Context, key string) (data []byte, etag string, err error)
GetObjectWithETag returns the object bytes at key plus the object's ETag, for compare-and-swap read-modify-write of the retention manifest (P4-SYNC-02/P6-HUB-04).
func (*S3Adapter) ListCommonPrefixes ¶
func (a *S3Adapter) ListCommonPrefixes(ctx context.Context, prefix, delimiter string) ([]string, error)
ListCommonPrefixes returns the distinct sub-prefixes directly under prefix, grouped at delimiter (P5-SYNC-01 device-stream discovery). Pagination uses the S3 continuation token, NOT the start-after-last-prefix trick: resuming after a common prefix would re-list that prefix's own keys (they sort after the bare prefix) and return it again — duplicate device streams past 1000 devices (post-#59 opus review, Minor).
func (*S3Adapter) ListObjectsV2 ¶
func (a *S3Adapter) ListObjectsV2(ctx context.Context, prefix, startAfter string, maxKeys int) ([]dssync.BlobInfo, string, error)
ListObjectsV2 returns objects under prefix, lexicographically after startAfter, up to maxKeys. When truncated, it returns the last key of the page as nextStartAfter (the memS3 start-after contract — NOT the S3 continuation token) so R2Hub.Pull/ListBlobs page with the same semantics as the in-memory conformance double (HUB-06).
func (*S3Adapter) ObjectExists ¶
ObjectExists reports whether an object exists at key via HEAD (HUB-02). A missing object is (false, nil), not an error.
func (*S3Adapter) PutObject ¶
PutObject stores body at key. When ifNoneMatch is true the put is conditional on the object not already existing (If-None-Match: *), making event append and content-addressed blob put idempotent (HUB-06/HUB-09). A collision surfaces as ErrPreconditionFailed, which R2Hub classifies as a dedup no-op.
func (*S3Adapter) PutObjectIfMatch ¶
func (a *S3Adapter) PutObjectIfMatch(ctx context.Context, key string, body []byte, etag string) error
PutObjectIfMatch stores body at key conditionally on the current object still carrying etag (If-Match — an S3 extension R2 supports on PUT). A lost CAS race surfaces as ErrPreconditionFailed via mapS3Error.
func (*S3Adapter) StatObject ¶
StatObject returns one object's metadata (Key + LastModified) via HEAD, for hub GC's pre-delete revalidation (P4-HUB-12). A missing object wraps dssync.ErrBlobNotFound (via mapS3Error, which maps *types.NotFound / 404).
type S3Client ¶
type S3Client interface {
// PutObject stores data at key. When ifNoneMatch is true the put is
// conditional on the object not already existing (If-None-Match: *),
// making event append idempotent (HUB-06).
PutObject(ctx context.Context, key string, body []byte, ifNoneMatch bool) error
// GetObject returns the object at key, or an error wrapping
// dssync.ErrBlobNotFound when absent.
GetObject(ctx context.Context, key string) ([]byte, error)
// ObjectExists reports whether an object exists at key.
ObjectExists(ctx context.Context, key string) (bool, error)
// DeleteObject removes the object at key. A missing object is not an error
// (idempotent delete) so blob/event GC (HUB-12) and revoke cleanup (SEC-01)
// can call it unconditionally for superseded ciphertext.
DeleteObject(ctx context.Context, key string) error
// ListObjectsV2 returns objects under prefix, lexicographically after
// startAfter, up to maxKeys. BlobInfo is reused as a generic key+time pair;
// for event objects, Key is the full trimmed key as before. When truncated,
// it returns the next key to continue from.
ListObjectsV2(ctx context.Context, prefix, startAfter string, maxKeys int) (objs []dssync.BlobInfo, nextStartAfter string, err error)
// ListCommonPrefixes returns the distinct sub-prefixes directly under
// prefix, grouped at the given delimiter (ListObjectsV2 CommonPrefixes).
// P5-SYNC-01 uses it to discover origin-device streams under the eventlog/
// prefix without listing every object.
ListCommonPrefixes(ctx context.Context, prefix, delimiter string) ([]string, error)
// GetObjectWithETag returns the object at key plus its ETag, for
// compare-and-swap read-modify-write of head objects (the retention
// manifest, P4-SYNC-02/P6-HUB-04). A missing object wraps
// dssync.ErrBlobNotFound.
GetObjectWithETag(ctx context.Context, key string) (data []byte, etag string, err error)
// PutObjectIfMatch stores data at key conditionally on the current object
// still carrying etag (If-Match). A lost race surfaces as
// ErrPreconditionFailed. R2 supports If-Match on PUT as an S3 extension.
PutObjectIfMatch(ctx context.Context, key string, body []byte, etag string) error
// StatObject returns one object's metadata (Key + LastModified) via HEAD,
// for hub GC's pre-delete revalidation (P4-HUB-12). A missing object wraps
// dssync.ErrBlobNotFound.
StatObject(ctx context.Context, key string) (dssync.BlobInfo, error)
}
S3Client is the minimal S3-compatible operation set the R2 backend needs (HUB-02). It is abstracted so the keying scheme and Hub contract are testable with an in-memory double. A production implementation wraps the AWS SDK v2 S3 client pointed at an R2 endpoint.