| 1 | // Package dbdoor is the wire shape of the `carlos db` replica door, shared |
| 2 | // by the console that serves it and the CLI that reads it (spec |
| 3 | // 2026-09-02 §6), plus the CLI's read-only replica client and its restore. |
| 4 | // |
| 5 | // It is a LEAF package for the reason internal/dbverify is one: the server |
| 6 | // (internal/console) and the client (the CLI's main package) must agree on |
| 7 | // one shape, and neither can import the other. |
| 8 | // |
| 9 | // Files are addressed by COORDINATE (level plus TXID range), never by key. |
| 10 | // That is not decoration: lock.json lives under the same prefix as the LTX |
| 11 | // files and is not a member's to fetch, and a coordinate has no way to name |
| 12 | // it. See spec §6 "The door". |
| 13 | package dbdoor |
| 14 | |
| 15 | import ( |
| 16 | "errors" |
| 17 | "fmt" |
| 18 | "net/url" |
| 19 | "strconv" |
| 20 | "strings" |
| 21 | "time" |
| 22 | ) |
| 23 | |
| 24 | // MaxLevel is the highest level a list may name. litestream's |
| 25 | // CalcRestorePlan asks for SnapshotLevel (9) first, then 8 down to 0. The |
| 26 | // activator writes only 0-3 and 9, so most of that range is legitimately |
| 27 | // EMPTY, and empty is an empty list, never a 404, or the restore stops at |
| 28 | // the first gap. |
| 29 | const MaxLevel = 9 |
| 30 | |
| 31 | // LTXFile is one replica file as the door reports it. Size, MinTXID and |
| 32 | // MaxTXID are MANDATORY: Restore rejects Size < ltx.HeaderSize and |
| 33 | // ResumableReader uses Size to detect a premature EOF. |
| 34 | type LTXFile struct { |
| 35 | Handle string `json:"handle,omitempty"` |
| 36 | SnapshotCRC64 *uint64 `json:"snapshot_crc64,omitempty"` |
| 37 | Level int `json:"level"` |
| 38 | MinTXID uint64 `json:"min_txid"` |
| 39 | MaxTXID uint64 `json:"max_txid"` |
| 40 | Size int64 `json:"size"` |
| 41 | CreatedAt time.Time `json:"created_at"` |
| 42 | } |
| 43 | |
| 44 | // ListResponse contains one ordered level, or a complete restore plan with |
| 45 | // its root first. Clients retain a complete plan across level requests. |
| 46 | type ListResponse struct { |
| 47 | Protocol int `json:"protocol,omitempty"` |
| 48 | Plan bool `json:"plan,omitempty"` |
| 49 | Files []LTXFile `json:"files"` |
| 50 | } |
| 51 | |
| 52 | // Base is the door's root for one instance: everything below it is a |
| 53 | // coordinate. The account segment is passed through verbatim (sqid or |
| 54 | // name), exactly as every other /api/cli/apps/ path does. |
| 55 | func Base(acct, app, host string) string { |
| 56 | return fmt.Sprintf("/api/cli/apps/%s/%s/db/%s", |
| 57 | url.PathEscape(acct), url.PathEscape(app), url.PathEscape(host)) |
| 58 | } |
| 59 | |
| 60 | // ListTail and GetTail are the coordinate halves of a path, relative to |
| 61 | // Base. The CLI's client works in tails, because the account segment and its |
| 62 | // ?exact=sqid switch belong to the CLI's own path builder. |
| 63 | func ListTail(level int) string { return fmt.Sprintf("/ltx/%d", level) } |
| 64 | |
| 65 | func GetTail(level int, minTXID, maxTXID uint64) string { |
| 66 | return fmt.Sprintf("/ltx/%d/%s", level, Span(minTXID, maxTXID)) |
| 67 | } |
| 68 | |
| 69 | func ListPath(acct, app, host string, level int) string { |
| 70 | return Base(acct, app, host) + ListTail(level) |
| 71 | } |
| 72 | |
| 73 | func GetPath(acct, app, host string, level int, minTXID, maxTXID uint64) string { |
| 74 | return Base(acct, app, host) + GetTail(level, minTXID, maxTXID) |
| 75 | } |
| 76 | |
| 77 | // Span renders a TXID range as one path segment. |
| 78 | func Span(minTXID, maxTXID uint64) string { |
| 79 | return strconv.FormatUint(minTXID, 10) + "-" + strconv.FormatUint(maxTXID, 10) |
| 80 | } |
| 81 | |
| 82 | var errSpan = errors.New("dbdoor: not a txid span") |
| 83 | |
| 84 | // ParseSpan is Span's inverse. TXIDs start at 1 and a span never runs |
| 85 | // backwards; anything else is not a coordinate. |
| 86 | func ParseSpan(s string) (minTXID, maxTXID uint64, err error) { |
| 87 | a, b, ok := strings.Cut(s, "-") |
| 88 | if !ok { |
| 89 | return 0, 0, errSpan |
| 90 | } |
| 91 | minTXID, err1 := strconv.ParseUint(a, 10, 64) |
| 92 | maxTXID, err2 := strconv.ParseUint(b, 10, 64) |
| 93 | if err1 != nil || err2 != nil || minTXID == 0 || maxTXID < minTXID { |
| 94 | return 0, 0, errSpan |
| 95 | } |
| 96 | return minTXID, maxTXID, nil |
| 97 | } |
| 98 | |
| 99 | // RangeHeader mirrors litestream's own S3 client byte-for-byte |
| 100 | // (s3/replica_client.go:646-651): open-ended when size is 0, inclusive end |
| 101 | // otherwise. |
| 102 | func RangeHeader(offset, size int64) string { |
| 103 | if size == 0 { |
| 104 | return fmt.Sprintf("bytes=%d-", offset) |
| 105 | } |
| 106 | return fmt.Sprintf("bytes=%d-%d", offset, offset+size-1) |
| 107 | } |
| 108 | |
| 109 | var errRange = errors.New("dbdoor: unsupported range") |
| 110 | |
| 111 | // ParseRange is RangeHeader's inverse, and accepts only what RangeHeader |
| 112 | // produces. An empty header is the whole file. |
| 113 | func ParseRange(h string) (offset, size int64, err error) { |
| 114 | if h == "" { |
| 115 | return 0, 0, nil |
| 116 | } |
| 117 | spec, ok := strings.CutPrefix(h, "bytes=") |
| 118 | if !ok { |
| 119 | return 0, 0, errRange |
| 120 | } |
| 121 | a, b, ok := strings.Cut(spec, "-") |
| 122 | if !ok || a == "" { |
| 123 | return 0, 0, errRange |
| 124 | } |
| 125 | offset, err = strconv.ParseInt(a, 10, 64) |
| 126 | if err != nil || offset < 0 { |
| 127 | return 0, 0, errRange |
| 128 | } |
| 129 | if b == "" { |
| 130 | return offset, 0, nil |
| 131 | } |
| 132 | end, err := strconv.ParseInt(b, 10, 64) |
| 133 | if err != nil || end < offset { |
| 134 | return 0, 0, errRange |
| 135 | } |
| 136 | return offset, end - offset + 1, nil |
| 137 | } |
| 138 | |