carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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".
13package dbdoor
14
15import (
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.
29const 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.
34type 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.
46type 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.
55func 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.
63func ListTail(level int) string { return fmt.Sprintf("/ltx/%d", level) }
64
65func GetTail(level int, minTXID, maxTXID uint64) string {
66 return fmt.Sprintf("/ltx/%d/%s", level, Span(minTXID, maxTXID))
67}
68
69func ListPath(acct, app, host string, level int) string {
70 return Base(acct, app, host) + ListTail(level)
71}
72
73func 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.
78func Span(minTXID, maxTXID uint64) string {
79 return strconv.FormatUint(minTXID, 10) + "-" + strconv.FormatUint(maxTXID, 10)
80}
81
82var 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.
86func 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.
102func 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
109var 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.
113func 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