carlos / platform Public

dbdoor: the coordinate wire shape the door and client share

Level plus TXID span, never a key, so lock.json under the same prefix has
no address. Range headers mirror litestream's S3 client exactly, and the
door's parsers accept only what the client's renderers produce.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 98db0e097c413713e7508a591dd4408ae35856f9 parent 27883b0
2 files changed, +210 −0
  • internal/dbdoor/coords.go +133 −0
  • internal/dbdoor/coords_test.go +77 −0
diff --git a/internal/dbdoor/coords.go b/internal/dbdoor/coords.go
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
+}
diff --git a/internal/dbdoor/coords_test.go b/internal/dbdoor/coords_test.go
new file mode 100644
index 000000000..0acb17a50
--- /dev/null
+++ b/internal/dbdoor/coords_test.go
@@ -0,0 +1,77 @@
+package dbdoor
+
+import (
+ "strings"
+ "testing"
+)
+
+// Range semantics must match litestream's S3 client exactly
+// (s3/replica_client.go:646-651), or a resumed read returns the wrong bytes.
+func TestRangeHeaderMatchesTheS3Client(t *testing.T) {
+ cases := []struct {
+ name string
+ offset, size int64
+ want string
+ }{
+ {"whole file", 0, 0, "bytes=0-"},
+ {"open-ended from an offset", 512, 0, "bytes=512-"},
+ {"sized read", 0, 100, "bytes=0-99"},
+ {"sized read at an offset", 512, 100, "bytes=512-611"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := RangeHeader(tc.offset, tc.size); got != tc.want {
+ t.Errorf("RangeHeader(%d, %d) = %q, want %q", tc.offset, tc.size, got, tc.want)
+ }
+ })
+ }
+}
+
+// ParseRange is RangeHeader's inverse, used by the door. Anything it does not
+// produce is refused rather than guessed at.
+func TestParseRangeInvertsRangeHeader(t *testing.T) {
+ for _, c := range []struct{ offset, size int64 }{{0, 0}, {512, 0}, {0, 100}, {512, 100}} {
+ off, size, err := ParseRange(RangeHeader(c.offset, c.size))
+ if err != nil || off != c.offset || size != c.size {
+ t.Errorf("ParseRange(RangeHeader(%d, %d)) = %d, %d, %v", c.offset, c.size, off, size, err)
+ }
+ }
+ if off, size, err := ParseRange(""); err != nil || off != 0 || size != 0 {
+ t.Errorf("ParseRange(\"\") = %d, %d, %v, want the whole file", off, size, err)
+ }
+ for _, bad := range []string{"bytes=-5", "bytes=5-2", "bytes=0-1,3-4", "items=0-", "bytes=x-", "bytes=0-y"} {
+ if _, _, err := ParseRange(bad); err == nil {
+ t.Errorf("ParseRange(%q) accepted", bad)
+ }
+ }
+}
+
+// Coordinates, never keys: the caller names a level and a TXID range, and
+// the server decides what object that is. lock.json has no coordinate.
+func TestGetPathIsCoordinateAddressed(t *testing.T) {
+ got := GetPath("bab", "titogo", "a.bab.oncarlos.com", 3, 16, 32)
+ for _, want := range []string{"bab", "titogo", "a.bab.oncarlos.com", "/3/", "16", "32"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("GetPath = %q, missing %q", got, want)
+ }
+ }
+ if strings.Contains(got, ".ltx") || strings.Contains(got, "lock") {
+ t.Fatalf("GetPath = %q leaks the key layout", got)
+ }
+ if !strings.HasSuffix(got, GetTail(3, 16, 32)) || !strings.HasSuffix(ListPath("bab", "titogo", "h.example.com", 3), ListTail(3)) {
+ t.Fatalf("full paths and tails disagree")
+ }
+}
+
+// The span segment round-trips, and nothing that is not a span parses.
+func TestParseSpanRoundTripsAndRefusesTheRest(t *testing.T) {
+ min, max, err := ParseSpan(Span(16, 32))
+ if err != nil || min != 16 || max != 32 {
+ t.Fatalf("ParseSpan(Span(16, 32)) = %d, %d, %v", min, max, err)
+ }
+ for _, bad := range []string{"", "16", "16-", "-32", "32-16", "0-1", "a-b", "lock.json", "16-32-48", "+1-2"} {
+ if _, _, err := ParseSpan(bad); err == nil {
+ t.Errorf("ParseSpan(%q) accepted", bad)
+ }
+ }
+}