carlos / platform Public

carlos db pull/query --at: read a database as of a past time (#262)

--at takes an RFC 3339 time with a zone, refuses a time in the future, and
reaches dbdoor.RestoreAsOf. The as-of time is rounded down to its second
before planning: S3 lists LastModified at second resolution, rounded down,
so without it a write made just after --at in the same second got into the
copy. A time before the kept history names the first second that works.
Strings approved in copy review 2026-10-08, unedited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Human

Prompt

Yes, go ahead.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

Adds --at to carlos db pull and query, with the second-resolution fix in dbdoor.
Agent notes
The e2e through the real console door uses the fake S3's second-resolution LastModified; a mutated row-count assertion confirmed it tells 20 rows from 30.
claude agent, operated by paul@keymail.dev af256c320d5f53eb293cf48d0c3cc5cb58036f93 parent 1f8d9b1
5 files changed, +235 −30
  • db.go +64 −6
  • db_at_test.go +68 −0
  • db_e2e_test.go +35 −1
  • internal/dbdoor/restore.go +13 −6
  • internal/dbdoor/restore_asof_test.go +55 −17

CI cancelled โ€” superseded by a newer push — run details

diff --git a/db.go b/db.go
index 8efc220a4..12c4bb267 100644
--- a/db.go
+++ b/db.go
@@ -45,6 +45,11 @@ const dbUsage = `usage: carlos db <verb> --app <a> [--instance <host>] [flags]
query --sql <text> restore a copy and run one read-only query against it;
rows go to stdout, tab-separated, with a header row
+Add --at <time> to either verb to read the database as it was at that
+time, for example --at 2026-10-07T14:00:00Z. The copy holds every change
+replicated before then, so it can be a little older than the time you give.
+How far back you can go depends on how much history the platform keeps.
+
Both read the platform's replica of the database, never the live one, and
nothing you do to the copy reaches the instance. --instance names which of
the app's instances to read; it may be left out when the app has one. The
@@ -76,6 +81,9 @@ type dbTarget struct {
acct apiAcct
app string
host string
+ // at is --at: the copy holds what was replicated before it. Zero is
+ // the newest copy.
+ at time.Time
}
// dbFlags registers the flags both verbs share and returns the resolver.
@@ -86,7 +94,14 @@ func dbFlags(fs *flag.FlagSet, verb string) func() dbTarget {
instance := fs.String("instance", "", "the instance whose database to read (may be left out when the app has one)")
acctRaw, acctIDRaw, _ := accountFlags(fs)
consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)")
+ atRaw := fs.String("at", "", "read the database as it was at this time (RFC 3339, for example 2026-10-07T14:00:00Z)")
return func() dbTarget {
+ // Before any network: a time that can't be read is the person's
+ // to fix, and costs nothing to refuse.
+ at, err := parseDBAt(*atRaw, time.Now())
+ if err != nil {
+ log.Fatalf("carlos db %s: %v", verb, err)
+ }
sess := requireDBAPISession(*consoleFlag)
printUsing(os.Stderr, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
@@ -95,8 +110,28 @@ func dbFlags(fs *flag.FlagSet, verb string) func() dbTarget {
if err != nil {
log.Fatalf("carlos db %s: %v", verb, err)
}
- return dbTarget{sess: sess, acct: acct, app: name, host: host}
+ return dbTarget{sess: sess, acct: acct, app: name, host: host, at: at}
+ }
+}
+
+// parseDBAt reads --at. Empty is the zero time, the newest copy. A time
+// must carry its zone: a bare clock time would be read in whichever zone
+// this shell is in, and the copy would silently be hours off. A time after
+// now is refused rather than quietly answered with the newest copy, so
+// nobody holds a copy labelled with a moment that hasn't happened.
+func parseDBAt(raw string, now time.Time) (time.Time, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return time.Time{}, nil
+ }
+ at, err := time.Parse(time.RFC3339, raw)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("--at %q is not a time. Write it in RFC 3339 with a time zone, for example 2026-10-07T14:00:00Z for UTC", raw)
}
+ if at.After(now) {
+ return time.Time{}, fmt.Errorf("--at %s is in the future. Leave --at out to read the newest copy", raw)
+ }
+ return at, nil
}
// requireDBAPISession resolves signed-in mode, refusing direct bucket mode
@@ -146,7 +181,7 @@ func resolveDBInstance(ctx context.Context, sess *apiSession, acct apiAcct, app,
// dbRestore restores the target's replica into a private directory under
// the system temp dir. The caller removes Restored.Dir.
func dbRestore(ctx context.Context, t dbTarget) (*dbdoor.Restored, error) {
- r, err := dbdoor.RestoreTo(ctx, t.sess.dbClient(t.acct, t.app, t.host), "")
+ r, err := dbdoor.RestoreAsOf(ctx, t.sess.dbClient(t.acct, t.app, t.host), "", t.at)
if err != nil {
return nil, dbRestoreError(t.host, err)
}
@@ -158,7 +193,12 @@ func dbRestore(ctx context.Context, t dbTarget) (*dbdoor.Restored, error) {
// named here so none of them reads as a transient "no replica found".
func dbRestoreError(host string, err error) error {
var door *dbdoor.DoorError
+ var before *dbdoor.BeforeHistoryError
switch {
+ case errors.As(err, &before):
+ return fmt.Errorf("%s's replica does not go back that far. The oldest point it keeps is %s", host, dbTime(before.Oldest))
+ case errors.Is(err, dbdoor.ErrNoTimes):
+ return fmt.Errorf("%s's replica can't be read as of a time yet. Leave --at out to read the newest copy", host)
case errors.As(err, &door) && door.Status == 404:
return fmt.Errorf("no instance %s that you can read", host)
case errors.As(err, &door):
@@ -181,7 +221,26 @@ func dbAsOf(r *dbdoor.Restored) string {
if r.At.IsZero() {
return fmt.Sprintf("transaction %d", r.TXID)
}
- return fmt.Sprintf("transaction %d, written %s", r.TXID, r.At.UTC().Format("2006-01-02 15:04:05 UTC"))
+ return fmt.Sprintf("transaction %d, written %s", r.TXID, dbTime(r.At))
+}
+
+func dbTime(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05 UTC") }
+
+// dbPullHolds is pull's line saying what the copy holds; with --at it also
+// names the requested time, since the copy can be older than it.
+func dbPullHolds(r *dbdoor.Restored, at time.Time) string {
+ if at.IsZero() {
+ return fmt.Sprintf("It holds every change up to %s.\n", dbAsOf(r))
+ }
+ return fmt.Sprintf("It holds every change up to %s, the last one replicated before %s.\n", dbAsOf(r), dbTime(at))
+}
+
+// dbQueryStatus is query's stderr line before the rows.
+func dbQueryStatus(host string, r *dbdoor.Restored, at time.Time) string {
+ if at.IsZero() {
+ return fmt.Sprintf("Querying a copy of %s's database, up to %s. Nothing you run here reaches %s.\n", host, dbAsOf(r), host)
+ }
+ return fmt.Sprintf("Querying a copy of %s's database as it was before %s, up to %s. Nothing you run here reaches %s.\n", host, dbTime(at), dbAsOf(r), host)
}
func runDBPull(args []string) {
@@ -219,7 +278,7 @@ func dbPull(ctx context.Context, t dbTarget, out string) error {
return err
}
fmt.Fprintf(stdout, "Saved a copy of %s's database to %s.\n", t.host, out)
- fmt.Fprintf(stdout, "It holds every change up to %s.\n", dbAsOf(r))
+ fmt.Fprint(stdout, dbPullHolds(r, t.at))
fmt.Fprintf(stdout, "It is a copy. Nothing you do to it reaches %s.\n", t.host)
return nil
}
@@ -312,8 +371,7 @@ func dbQuery(ctx context.Context, t dbTarget, sqlText string) error {
return err
}
defer os.RemoveAll(r.Dir)
- fmt.Fprintf(os.Stderr, "Querying a copy of %s's database, up to %s. Nothing you run here reaches %s.\n",
- t.host, dbAsOf(r), t.host)
+ fmt.Fprint(os.Stderr, dbQueryStatus(t.host, r, t.at))
return runQueryContext(ctx, stdout, r.Path, sqlText)
}
diff --git a/db_at_test.go b/db_at_test.go
new file mode 100644
index 000000000..ecb0acd59
--- /dev/null
+++ b/db_at_test.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/dbdoor"
+)
+
+func TestParseDBAt(t *testing.T) {
+ now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
+ for _, ok := range []string{"2026-10-07T14:00:00Z", "2026-10-07T15:00:00+01:00", "2026-10-07T14:00:00.5Z"} {
+ got, err := parseDBAt(ok, now)
+ if err != nil {
+ t.Errorf("parseDBAt(%q) = %v, want a time", ok, err)
+ } else if !got.Before(now) {
+ t.Errorf("parseDBAt(%q) = %v, want before %v", ok, got, now)
+ }
+ }
+ if got, _ := parseDBAt("", now); !got.IsZero() {
+ t.Errorf("parseDBAt(\"\") = %v, want zero (the newest copy)", got)
+ }
+ bad := map[string]string{
+ "2026-10-07 14:00": `--at "2026-10-07 14:00" is not a time. Write it in RFC 3339 with a time zone, for example 2026-10-07T14:00:00Z for UTC`,
+ "2026-10-07T14:00:00": `--at "2026-10-07T14:00:00" is not a time. Write it in RFC 3339 with a time zone, for example 2026-10-07T14:00:00Z for UTC`,
+ "yesterday": `--at "yesterday" is not a time. Write it in RFC 3339 with a time zone, for example 2026-10-07T14:00:00Z for UTC`,
+ "2026-10-09T00:00:00Z": `--at 2026-10-09T00:00:00Z is in the future. Leave --at out to read the newest copy`,
+ }
+ for in, want := range bad {
+ if _, err := parseDBAt(in, now); err == nil || err.Error() != want {
+ t.Errorf("parseDBAt(%q) = %v, want %q", in, err, want)
+ }
+ }
+}
+
+func TestDBRestoreErrorsForAnAsOfRead(t *testing.T) {
+ oldest := time.Date(2026, 10, 1, 9, 12, 44, 0, time.UTC)
+ cases := map[error]string{
+ &dbdoor.BeforeHistoryError{Oldest: oldest}: "a.go.tito.io's replica does not go back that far. The oldest point it keeps is 2026-10-01 09:12:44 UTC",
+ dbdoor.ErrNoTimes: "a.go.tito.io's replica can't be read as of a time yet. Leave --at out to read the newest copy",
+ }
+ for err, want := range cases {
+ if got := dbRestoreError("a.go.tito.io", err).Error(); got != want {
+ t.Errorf("%v -> %q, want %q", err, got, want)
+ }
+ }
+}
+
+func TestDBAsOfLines(t *testing.T) {
+ r := &dbdoor.Restored{TXID: 42, At: time.Date(2026, 10, 7, 13, 58, 2, 0, time.UTC)}
+ at := time.Date(2026, 10, 7, 15, 0, 0, 0, time.FixedZone("", 3600))
+ if got, want := dbPullHolds(r, at), "It holds every change up to transaction 42, written 2026-10-07 13:58:02 UTC, the last one replicated before 2026-10-07 14:00:00 UTC.\n"; got != want {
+ t.Errorf("pull with --at:\n got %q\nwant %q", got, want)
+ }
+ if got, want := dbPullHolds(r, time.Time{}), "It holds every change up to transaction 42, written 2026-10-07 13:58:02 UTC.\n"; got != want {
+ t.Errorf("pull without --at:\n got %q\nwant %q", got, want)
+ }
+ if got, want := dbQueryStatus("a.go.tito.io", r, at), "Querying a copy of a.go.tito.io's database as it was before 2026-10-07 14:00:00 UTC, up to transaction 42, written 2026-10-07 13:58:02 UTC. Nothing you run here reaches a.go.tito.io.\n"; got != want {
+ t.Errorf("query with --at:\n got %q\nwant %q", got, want)
+ }
+ if got, want := dbQueryStatus("a.go.tito.io", r, time.Time{}), "Querying a copy of a.go.tito.io's database, up to transaction 42, written 2026-10-07 13:58:02 UTC. Nothing you run here reaches a.go.tito.io.\n"; got != want {
+ t.Errorf("query without --at:\n got %q\nwant %q", got, want)
+ }
+ if !strings.Contains(dbUsage, "Add --at <time> to either verb") {
+ t.Error("dbUsage does not explain --at")
+ }
+}
diff --git a/db_e2e_test.go b/db_e2e_test.go
index 29fad228b..2c8d08b41 100644
--- a/db_e2e_test.go
+++ b/db_e2e_test.go
@@ -9,8 +9,10 @@ package main
import (
"bytes"
"context"
+ "database/sql"
"encoding/json"
"encoding/xml"
+ "fmt"
"io/fs"
"net/http"
"net/http/httptest"
@@ -123,7 +125,26 @@ func TestE2EDBThroughConsole(t *testing.T) {
tmp := t.TempDir()
const host = "acme.go.tito.io"
replicaRoot := filepath.Join(tmp, "data")
- dbdoortest.Replicate(t, filepath.Join(replicaRoot, "instances", host), 3)
+ // Two batches, then a third in a later whole second, so --at can
+ // name a time between them in the fake S3's second-resolution listing.
+ replicaDir := filepath.Join(replicaRoot, "instances", host)
+ src := dbdoortest.Replicate(t, replicaDir, 2)
+ time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second + 10*time.Millisecond)))
+ between := time.Now()
+ time.Sleep(time.Until(between.Truncate(time.Second).Add(time.Second + 10*time.Millisecond)))
+ {
+ db, err := sql.Open("sqlite", src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < dbdoortest.Rows; i++ {
+ if _, err := db.Exec(`INSERT INTO t (body) VALUES (?)`, fmt.Sprintf("row-2-%d", i)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ db.Close()
+ dbdoortest.Sync(t, src, replicaDir)
+ }
// lock.json sits under the same prefix in production. Nothing may fetch it.
if err := os.WriteFile(filepath.Join(replicaRoot, "instances", host, "lock.json"), []byte(`{"owner":"box"}`), 0o600); err != nil {
t.Fatal(err)
@@ -220,6 +241,19 @@ func TestE2EDBThroughConsole(t *testing.T) {
t.Fatalf("pulled copy = %q %v", rows.String(), err)
}
+ // pull --at: the copy as of a time between the second and third
+ // batches holds the first two, and says which time it was asked for.
+ outAt := filepath.Join(t.TempDir(), "pulled-at.db")
+ buf.Reset()
+ runDB([]string{"pull", "--console", ts.URL, "--app", "titogo", "--account", "ops", "--instance", host, "--out", outAt, "--at", between.UTC().Format(time.RFC3339Nano)})
+ if want := "the last one replicated before " + dbTime(between); !strings.Contains(buf.String(), want) {
+ t.Errorf("pull --at said %q, want it to mention %q", buf.String(), want)
+ }
+ rows.Reset()
+ if err := runQuery(&rows, outAt, "SELECT count(*) AS n FROM t"); err != nil || rows.String() != "n\n"+strconv.Itoa(2*dbdoortest.Rows)+"\n" {
+ t.Fatalf("copy as of %v = %q %v, want %d rows", between, rows.String(), err, 2*dbdoortest.Rows)
+ }
+
// query: rows on stdout, nothing else.
buf.Reset()
runDB([]string{"query", "--console", ts.URL, "--app", "titogo", "--account", "ops", "--instance", host, "--sql", "SELECT body FROM t ORDER BY id LIMIT 2"})
diff --git a/internal/dbdoor/restore.go b/internal/dbdoor/restore.go
index 8eee6edb2..9f5690d5e 100644
--- a/internal/dbdoor/restore.go
+++ b/internal/dbdoor/restore.go
@@ -37,8 +37,8 @@ var (
)
// BeforeHistoryError is ErrBeforeHistory with the oldest point the replica
-// keeps: the earliest time a file it could start a restore from was
-// written. Oldest is zero only when no listing named one.
+// keeps: the earliest time an as-of restore can ask for, the first whole
+// second after the oldest file a restore could start from was listed.
type BeforeHistoryError struct {
Oldest time.Time
}
@@ -93,7 +93,8 @@ func RestoreTo(ctx context.Context, c *Client, parent string) (*Restored, error)
// The plan is litestream's own (Timestamp set, TXID not). It keeps a file
// only if its listed time is before at, and the door lists S3's
// LastModified, the upload time, which is never earlier than the writes a
-// file holds. So the copy can be older than at, never newer. Restored.At
+// file holds. at is rounded down to its second first (restoreOnce says
+// why). So the copy can be older than at, never newer. Restored.At
// says what it actually holds.
func RestoreAsOf(ctx context.Context, c *Client, parent string, at time.Time) (*Restored, error) {
dir, err := os.MkdirTemp(parent, "carlos-db-")
@@ -140,8 +141,10 @@ func RestoreAsOf(ctx context.Context, c *Client, parent string, at time.Time) (*
return nil, ErrNoTimes
case errors.Is(err, litestream.ErrNoSnapshots) || errors.Is(err, litestream.ErrTxNotAvailable):
if oldest := rec.oldestStart(); !at.IsZero() && !oldest.IsZero() {
- if !oldest.Before(at) {
- return nil, &BeforeHistoryError{Oldest: oldest}
+ if !oldest.Before(at.Truncate(time.Second)) {
+ // The first whole second after the oldest file: the
+ // earliest --at that, rounded down, still includes it.
+ return nil, &BeforeHistoryError{Oldest: oldest.Truncate(time.Second).Add(time.Second)}
}
return nil, err
}
@@ -174,7 +177,11 @@ func RestoreAsOf(ctx context.Context, c *Client, parent string, at time.Time) (*
func restoreOnce(ctx context.Context, client litestream.ReplicaClient, out string, at time.Time) error {
opt := litestream.NewRestoreOptions()
opt.OutputPath = out
- opt.Timestamp = at
+ // S3 lists LastModified to the whole second, rounded down: a file
+ // uploaded at 14:00:00.7 lists as 14:00:00, before a T of 14:00:00.5,
+ // though it holds writes after T. Rounding T down to its second keeps
+ // it out: a listed time before a whole second means an upload before it.
+ opt.Timestamp = at.Truncate(time.Second)
// Follow stays false (spec ยง12 condition ii); IntegrityCheck is left at
// litestream's default and CheckDB runs after (condition iii).
return litestream.NewReplicaWithClient(nil, client).Restore(ctx, opt)
diff --git a/internal/dbdoor/restore_asof_test.go b/internal/dbdoor/restore_asof_test.go
index 22bbd2f37..77a4ecebd 100644
--- a/internal/dbdoor/restore_asof_test.go
+++ b/internal/dbdoor/restore_asof_test.go
@@ -32,12 +32,13 @@ func addBatch(t *testing.T, src, dir string, batch int) {
dbdoortest.Sync(t, src, dir)
}
-// pause makes sure the next file's time is strictly after the last one's,
-// whatever the filesystem's timestamp resolution.
+// pause returns a time in a whole second of its own: after every file
+// written so far, and before the next one. RestoreAsOf rounds its time down
+// to the second, as S3's listing does.
func pause() time.Time {
- time.Sleep(20 * time.Millisecond)
+ time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second + 10*time.Millisecond)))
at := time.Now()
- time.Sleep(20 * time.Millisecond)
+ time.Sleep(time.Until(at.Truncate(time.Second).Add(time.Second + 10*time.Millisecond)))
return at
}
@@ -99,6 +100,13 @@ func TestRestoreAsOfBeforeTheHistory(t *testing.T) {
t.Fatalf("RestoreAsOf before the history = %#v, want the oldest kept point, after %v", err, at)
}
assertEmptyDir(t, parent)
+
+ // The point it names is one a person can ask for.
+ got, err := RestoreAsOf(context.Background(), NewClient(newTestDoor(t, dir).do), t.TempDir(), bh.Oldest)
+ if err != nil {
+ t.Fatalf("RestoreAsOf at the named oldest point %v = %v, want a copy", bh.Oldest, err)
+ }
+ os.RemoveAll(got.Dir)
}
// An empty replica is still "nothing replicated", with or without a time.
@@ -117,8 +125,25 @@ func TestRestoreAsOfEmptyReplica(t *testing.T) {
func TestRestoreAsOfRefusesAListingWithoutTimes(t *testing.T) {
dir := t.TempDir()
dbdoortest.Replicate(t, dir, 2)
- door := newTestDoor(t, dir)
- untimed := func(ctx context.Context, tail string, h http.Header) (*http.Response, error) {
+ untimed := relist(newTestDoor(t, dir), func(f *LTXFile) { f.CreatedAt = time.Time{} })
+ parent := t.TempDir()
+ _, err := RestoreAsOf(context.Background(), NewClient(untimed), parent, time.Now())
+ if !errors.Is(err, ErrNoTimes) {
+ t.Fatalf("RestoreAsOf over an untimed listing = %v, want ErrNoTimes", err)
+ }
+ assertEmptyDir(t, parent)
+
+ // Without a time, the same listing restores as before.
+ got, err := RestoreTo(context.Background(), NewClient(untimed), t.TempDir())
+ if err != nil {
+ t.Fatalf("RestoreTo over an untimed listing = %v, want the newest copy", err)
+ }
+ os.RemoveAll(got.Dir)
+}
+
+// relist serves door's listings with each file passed through edit.
+func relist(door *testDoor, edit func(*LTXFile)) Doer {
+ return func(ctx context.Context, tail string, h http.Header) (*http.Response, error) {
res, err := door.do(ctx, tail, h)
if err != nil || res.StatusCode != http.StatusOK || h.Get("Range") != "" {
return res, err
@@ -128,23 +153,36 @@ func TestRestoreAsOfRefusesAListingWithoutTimes(t *testing.T) {
return nil, err
}
for i := range list.Files {
- list.Files[i].CreatedAt = time.Time{}
+ edit(&list.Files[i])
}
rec := httptest.NewRecorder()
json.NewEncoder(rec).Encode(list)
return rec.Result(), nil
}
- parent := t.TempDir()
- _, err := RestoreAsOf(context.Background(), NewClient(untimed), parent, time.Now())
- if !errors.Is(err, ErrNoTimes) {
- t.Fatalf("RestoreAsOf over an untimed listing = %v, want ErrNoTimes", err)
- }
- assertEmptyDir(t, parent)
+}
- // Without a time, the same listing restores as before.
- got, err := RestoreTo(context.Background(), NewClient(untimed), t.TempDir())
+// S3 lists LastModified to the whole second, rounded down, so a file
+// uploaded just after T in the same second lists as before T. The copy must
+// still leave it out.
+func TestRestoreAsOfWithSecondResolutionTimes(t *testing.T) {
+ dir := t.TempDir()
+ src := dbdoortest.Replicate(t, dir, 2)
+ // Start just after a whole second, so batch three lands in the same
+ // second as T but after it.
+ time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second + 100*time.Millisecond)))
+ at := time.Now()
+ time.Sleep(50 * time.Millisecond)
+ addBatch(t, src, dir, 2)
+ if done := time.Now(); done.Truncate(time.Second) != at.Truncate(time.Second) {
+ t.Skipf("batch three took until %v, past T's second; the case needs it inside", done)
+ }
+ c := NewClient(relist(newTestDoor(t, dir), func(f *LTXFile) { f.CreatedAt = f.CreatedAt.Truncate(time.Second) }))
+ got, err := RestoreAsOf(context.Background(), c, t.TempDir(), at)
if err != nil {
- t.Fatalf("RestoreTo over an untimed listing = %v, want the newest copy", err)
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(got.Dir)
+ if rows := countRows(t, got.Path); rows != 2*dbdoortest.Rows {
+ t.Fatalf("copy as of %v holds %d rows, want %d: a write after T is in it", at, rows, 2*dbdoortest.Rows)
}
- os.RemoveAll(got.Dir)
}