feat(cloud): launch pricing guardrails — egress caps, ignore defaults, storage tiering - #114
Merged
Merged
Conversation
…, storage tiering Pricing was re-cut to a single $19/user/mo Team tier plus contact-sales Enterprise, with a 3-seat free tier. This is the open-source half of that: the seams the managed hub needs, and the cost controls that make a retained-forever version history affordable. - QuotaProvider grows a read half: CheckRead(org, bytes) and RecordEgress(org, bytes). CheckRead is enforced on /s/* only — a public share link is the sole unauthenticated door to stored bytes, so it is the only egress a plan can cap. The sync proxy and viewer merely RecordEgress: refusing a device mid-cycle surfaces as ErrForbidden, which the syncer reads as "access is gone — pause and touch nothing", and sync must never break over a bill. UnlimitedQuota stays the OSS default, so a self-hoster hits none of this. - countingWriter bills what actually reached the client. FileInfo.Size and the journal's Size are claims made before the write; an abandoned download must not be charged as a whole file. - bdrive init warns when a folder would sync more than 1 GiB or 20k files, and says how to narrow it. syncer.Measure sizes that through the real filter and the one walkFolder predicate, so a repo whose bulk is already ignored stays quiet while the folder nobody meant to share does not. - starterIgnore gains video/archive/disk-image globs and Library/. Every version is kept forever, so a big binary committed once is paid for forever on every device that ever syncs. .git stays out of the list: it is already excluded at every depth by config.ReservedDirs, and a rule for it would imply a switch that does not exist. - deploy: a Nearline-at-30-days lifecycle rule, and the arithmetic for why it stops there. Coldline and Archive only pay off when an object is read less than about once a month, and a first sync pulls EVERY historical blob, not just the current tree — so blob read rate tracks device onboarding, not how often anyone opens an old version. - docs/launch-plan.md said Cloud was waitlist-only and framed Product Hunt as an OSS launch whose goal was "not signups or revenue". Cloud is live with public pricing and no waitlist; the PH launch is the managed service. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dHmCQSpn6WgkqFG62umK4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
QuotaProvidergainedCheckRead/RecordEgress, enforced on/s/*only so a sync can never be broken by a bill.bdrive initwarns before you sync a folder nobody meant to share, and the starter ignore list now keeps videos and archives out.docs/launch-plan.mdstill said Cloud was waitlist-only. It isn't.The read half of
QuotaProviderCheckWrite/CheckSeat/RecordUsagehad no read-side counterpart, whichmeant the managed side was structurally incapable of metering egress no
matter what a pricing page promised.
The asymmetry is deliberate:
/s/*public share links/store/*sync proxyA share link is the only unauthenticated door to stored bytes, so it is the
only egress a plan can cap and the only bandwidth number worth publishing.
Refusing a device mid-sync surfaces as
ErrForbidden, which the syncer readsas "access is gone — pause and touch nothing" — a far worse outcome than an
over-quota bill.
UnlimitedQuotastays the OSS default, so self-hosters hit none of it.countingWriterbills what actually reached the client:FileInfo.Sizeandthe journal's
Sizeare both claims made before the write, so a reader whoabandons a download halfway is not charged for the whole file.
Oversized-folder warning
bdrive initnow prints a warning past 1 GiB or 20k files and points atbdrive scope.syncer.Measuresizes it through the realFilterand theone
walkFolderpredicate, so a repo whose bulk is already ignored measuresas the few MB that really sync — the warning fires on the home directory
someone pointed at BearDrive, not on every ordinary checkout.
.gitis deliberately not instarterIgnore:config.ReservedDirsalready excludes it at every depth in two packages, and listing it would
imply a switch that does not exist.
Storage tiering — and why it stops at Nearline
The obvious move is to age blobs down to Archive (~6% of Standard). Verified
current us-central1 pricing, and the break-even is entirely about read rate:
r < 1.0/mor < 0.6/mor < 0.09/moOld blobs are not cold here. A device syncing a project for the first
time downloads a blob for every put op in every peer journal — the entire
history, not just the current tree. Measured on a 10-version file whose
working tree is 1 KB: a fresh device pulls 10 KB. So blob read rate tracks
how often anyone adds a device, and a team adding roughly one device a month
makes Coldline a wash and Archive a straight bill increase.
The real lever is making a first sync fetch only current-state blobs. That
would cut onboarding egress and make the colder tiers safe. Until then the
ladder is betting on a read pattern we do not have.
Architecture changes
Both detail diagrams are updated on this branch.
architecture/webapp-server.mdQuotaProvidergainedCheckRead(org, bytes)andRecordEgress(org, bytes);countingWriteris new;ShareDBnow depends onQuotaProvider, andServeroncountingWriter. Nothing was removed.flowchart TB Server["<b>Server</b>"] ShareDB["<div style='text-align:left'><b>ShareDB</b><br/>shares.go — /s/<token></div>"] QuotaProvider["<div style='text-align:left'><b>QuotaProvider</b> <i>(interface)</i><br/>+CheckWrite(org, bytes)<br/>+CheckSeat(org, members)<br/>+RecordUsage(org, bytes)<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +CheckRead(org, bytes)</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +RecordEgress(org, bytes)</span></div>"] UnlimitedQuota["<b>UnlimitedQuota</b>"] reservations["<div style='text-align:left'><b>reservations</b><br/>reserve.go</div>"] countingWriter["<div style='text-align:left'><b>countingWriter</b><br/>quota.go<br/>+Write(p) n<br/>+n int64</div>"] Asym["The read half is asymmetric on purpose.<br/>CheckRead runs on /s/* and nowhere else —<br/>a share link is the only unauthenticated<br/>door to stored bytes.<br/>Refusing a device mid-sync reads as<br/>ErrForbidden = 'access is gone', so<br/>/store/* and the viewer only RecordEgress.<br/>Sync must never break over a bill."] Bill["Bills what actually reached the client:<br/>a reader who abandons a download<br/>is not charged for the whole file."] Server --- QuotaProvider Server --- ShareDB UnlimitedQuota -. implements .-> QuotaProvider reservations -- "CheckWrite(size + outstanding), RecordUsage on landing" --> QuotaProvider ShareDB -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ CheckRead before the stream, RecordEgress after</span>" --> QuotaProvider Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ every bytes-out route that bills</span>" --> countingWriter QuotaProvider -.- Asym countingWriter -.- Bill classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class countingWriter added class Asym,Bill noteBox linkStyle 4 stroke:#22c55e,stroke-width:2px linkStyle 5 stroke:#22c55e,stroke-width:2pxarchitecture/cli-sync.mdMeasureis new — a third consumer of the singlewalkFolderpredicate,alongside
scanandExplain. Nothing was removed.flowchart TB Session["<b>Session</b>"] Explain["<div style='text-align:left'><b>Explain</b><br/>+Explain(folder, include, accepted) two lists</div>"] Measure["<div style='text-align:left'><b>Measure</b><br/>walk.go<br/>+Measure(folder, include) files, bytes</div>"] walkFolder["<div style='text-align:left'><b>walkFolder</b><br/>+walkFolder(folder, filter, fn)<br/>verdict: vSync vSkipFile vDescend vPruneDir vNested</div>"] Filter["<div style='text-align:left'><b>Filter</b><br/>ignore.go</div>"] Only["walk.go — the ONLY copy of the sync predicate.<br/>scan, Explain and Measure all go through it,<br/>so what --explain reports and what init warns<br/>about cannot drift from what leaves."] Why["Sizes what a FIRST sync would upload, for the<br/>oversized-folder warning bdrive init prints.<br/>Filter-aware on purpose: a 40 GB repo whose bulk<br/>is already ignored measures as the few MB that<br/>really sync. Advice only — never fatal."] Session -- "scan" --> walkFolder Explain -- "same predicate" --> walkFolder Explain -- "own fresh instance" --> Filter walkFolder -- "SkipUp / PruneDir / addNestedMount" --> Filter Measure -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ same predicate</span>" --> walkFolder Measure -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ own fresh instance</span>" --> Filter walkFolder -.- Only Measure -.- Why classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class Measure added class Only,Why noteBox linkStyle 4 stroke:#22c55e,stroke-width:2px linkStyle 5 stroke:#22c55e,stroke-width:2pxTesting
go build ./...,go vet ./...,go test ./...all pass.New:
internal/syncer/measure_test.goprovesMeasurecounts only whatreally syncs and shrinks with
bdrive scope. The existingQuotaProvidertest fakes now embed
UnlimitedQuota, so the next widening of that interfacedoes not need a no-op added to nine files.
Both mermaid blocks in each diagram were parse-checked before commit.
🤖 Generated with Claude Code
https://claude.ai/code/session_019dHmCQSpn6WgkqFG62umK4