// Package dbdoor is the wire shape of the `carlos db` replica door, shared // by the console that serves it and the CLI that reads it (spec // 2026-09-02 §6), plus the CLI's read-only replica client and its restore. // // It is a LEAF package for the reason internal/dbverify is one: the server // (internal/console) and the client (the CLI's main package) must agree on // one shape, and neither can import the other. // // Files are addressed by COORDINATE (level plus TXID range), never by key. // That is not decoration: lock.json lives under the same prefix as the LTX // files and is not a member's to fetch, and a coordinate has no way to name // it. See spec §6 "The door". package dbdoor import ( "errors" "fmt" "net/url" "strconv" "strings" "time" ) // MaxLevel is the highest level a list may name. litestream's // CalcRestorePlan asks for SnapshotLevel (9) first, then 8 down to 0. The // activator writes only 0-3 and 9, so most of that range is legitimately // EMPTY, and empty is an empty list, never a 404, or the restore stops at // the first gap. const MaxLevel = 9 // LTXFile is one replica file as the door reports it. Size, MinTXID and // MaxTXID are MANDATORY: Restore rejects Size < ltx.HeaderSize and // ResumableReader uses Size to detect a premature EOF. type LTXFile struct { Handle string `json:"handle,omitempty"` SnapshotCRC64 *uint64 `json:"snapshot_crc64,omitempty"` Level int `json:"level"` MinTXID uint64 `json:"min_txid"` MaxTXID uint64 `json:"max_txid"` Size int64 `json:"size"` CreatedAt time.Time `json:"created_at"` } // ListResponse contains one ordered level, or a complete restore plan with // its root first. Clients retain a complete plan across level requests. type ListResponse struct { Protocol int `json:"protocol,omitempty"` Plan bool `json:"plan,omitempty"` Files []LTXFile `json:"files"` } // Base is the door's root for one instance: everything below it is a // coordinate. The account segment is passed through verbatim (sqid or // name), exactly as every other /api/cli/apps/ path does. func Base(acct, app, host string) string { return fmt.Sprintf("/api/cli/apps/%s/%s/db/%s", url.PathEscape(acct), url.PathEscape(app), url.PathEscape(host)) } // ListTail and GetTail are the coordinate halves of a path, relative to // Base. The CLI's client works in tails, because the account segment and its // ?exact=sqid switch belong to the CLI's own path builder. func ListTail(level int) string { return fmt.Sprintf("/ltx/%d", level) } func GetTail(level int, minTXID, maxTXID uint64) string { return fmt.Sprintf("/ltx/%d/%s", level, Span(minTXID, maxTXID)) } func ListPath(acct, app, host string, level int) string { return Base(acct, app, host) + ListTail(level) } func GetPath(acct, app, host string, level int, minTXID, maxTXID uint64) string { return Base(acct, app, host) + GetTail(level, minTXID, maxTXID) } // Span renders a TXID range as one path segment. func Span(minTXID, maxTXID uint64) string { return strconv.FormatUint(minTXID, 10) + "-" + strconv.FormatUint(maxTXID, 10) } var errSpan = errors.New("dbdoor: not a txid span") // ParseSpan is Span's inverse. TXIDs start at 1 and a span never runs // backwards; anything else is not a coordinate. func ParseSpan(s string) (minTXID, maxTXID uint64, err error) { a, b, ok := strings.Cut(s, "-") if !ok { return 0, 0, errSpan } minTXID, err1 := strconv.ParseUint(a, 10, 64) maxTXID, err2 := strconv.ParseUint(b, 10, 64) if err1 != nil || err2 != nil || minTXID == 0 || maxTXID < minTXID { return 0, 0, errSpan } return minTXID, maxTXID, nil } // RangeHeader mirrors litestream's own S3 client byte-for-byte // (s3/replica_client.go:646-651): open-ended when size is 0, inclusive end // otherwise. func RangeHeader(offset, size int64) string { if size == 0 { return fmt.Sprintf("bytes=%d-", offset) } return fmt.Sprintf("bytes=%d-%d", offset, offset+size-1) } var errRange = errors.New("dbdoor: unsupported range") // ParseRange is RangeHeader's inverse, and accepts only what RangeHeader // produces. An empty header is the whole file. func ParseRange(h string) (offset, size int64, err error) { if h == "" { return 0, 0, nil } spec, ok := strings.CutPrefix(h, "bytes=") if !ok { return 0, 0, errRange } a, b, ok := strings.Cut(spec, "-") if !ok || a == "" { return 0, 0, errRange } offset, err = strconv.ParseInt(a, 10, 64) if err != nil || offset < 0 { return 0, 0, errRange } if b == "" { return offset, 0, nil } end, err := strconv.ParseInt(b, 10, 64) if err != nil || end < offset { return 0, 0, errRange } return offset, end - offset + 1, nil }