| new file mode 100644 |
| index 000000000..67aba1ced |
| --- /dev/null |
| +++ b/internal/dbdoor/coords.go |
| @@ -0,0 +1,133 @@ |
| +// 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 { |
| + 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 is ordered by MinTXID ASCENDING: restoreLevelCursor assumes |
| +// filename order, which S3 gives because the names are zero-padded hex. |
| +type ListResponse struct { |
| + 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 |
| +} |