db --at: an explicit year-1 time is before the history, not the newest copy (#262)
Astra's review (P2): --at 0001-01-01T00:00:00Z parsed to Go's zero time, which RestoreAsOf and litestream read as 'no cutoff', so the newest copy came back for a time before everything. parseDBAt and RestoreAsOf now move any time before the Unix epoch (or that rounds down to zero) to the epoch, which gets the before-history answer. 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
Clamps pre-epoch --at times to the epoch in both layers.
4 files changed,
+44
−0
db.go+6 −0db_at_test.go+7 −0internal/dbdoor/restore.go+11 −0internal/dbdoor/restore_asof_test.go+20 −0
CI passed — run details
diff --git a/db.go b/db.go| index 12c4bb267..596afd821 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -131,6 +131,12 @@ func parseDBAt(raw string, now time.Time) (time.Time, error) { |
| if at.After(now) { |
| return time.Time{}, fmt.Errorf("--at %s is in the future. Leave --at out to read the newest copy", raw) |
| } |
| + // 0001-01-01T00:00:00Z parses to Go's zero time, which means "no |
| + // --at". It is before any history, so it is asked as the earliest |
| + // time instead, and gets the before-history answer. |
| + if at.Before(dbdoor.EarliestAsOf) { |
| + at = dbdoor.EarliestAsOf |
| + } |
| return at, nil |
| } |
diff --git a/db_at_test.go b/db_at_test.go| index ecb0acd59..78e64ee43 100644 |
| --- a/db_at_test.go |
| +++ b/db_at_test.go |
| @@ -18,6 +18,13 @@ func TestParseDBAt(t *testing.T) { |
| t.Errorf("parseDBAt(%q) = %v, want before %v", ok, got, now) |
| } |
| } |
| + // An explicit time that is Go's zero time must not read as "no --at" |
| + // (found in Astra's review). |
| + for _, early := range []string{"0001-01-01T00:00:00Z", "0001-01-01T01:00:00+01:00"} { |
| + if got, err := parseDBAt(early, now); err != nil || got.IsZero() { |
| + t.Errorf("parseDBAt(%q) = %v, %v, want a non-zero time before any history", early, got, err) |
| + } |
| + } |
| if got, _ := parseDBAt("", now); !got.IsZero() { |
| t.Errorf("parseDBAt(\"\") = %v, want zero (the newest copy)", got) |
| } |
diff --git a/internal/dbdoor/restore.go b/internal/dbdoor/restore.go| index 9f5690d5e..2c78ffb37 100644 |
| --- a/internal/dbdoor/restore.go |
| +++ b/internal/dbdoor/restore.go |
| @@ -36,6 +36,11 @@ var ( |
| ErrNoTimes = errors.New("dbdoor: the replica's listing carries no times, so it cannot be read as of a time") |
| ) |
| +// EarliestAsOf is the earliest time RestoreAsOf asks litestream for. Any |
| +// earlier request is moved to it: it is before every replica's history, |
| +// and unlike Go's zero time it cannot mean "the newest copy". |
| +var EarliestAsOf = time.Unix(0, 0).UTC() |
| + |
| // BeforeHistoryError is ErrBeforeHistory with the oldest point the replica |
| // 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. |
| @@ -97,6 +102,12 @@ func RestoreTo(ctx context.Context, c *Client, parent string) (*Restored, error) |
| // 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) { |
| + // A requested time that rounds down to Go's zero time would read as |
| + // "the newest copy" to litestream. Anything that early is before every |
| + // replica's history, so it is asked as the Unix epoch, which still is. |
| + if !at.IsZero() && at.Truncate(time.Second).Before(EarliestAsOf) { |
| + at = EarliestAsOf |
| + } |
| dir, err := os.MkdirTemp(parent, "carlos-db-") |
| if err != nil { |
| return nil, err |
diff --git a/internal/dbdoor/restore_asof_test.go b/internal/dbdoor/restore_asof_test.go| index 77a4ecebd..3143a4d1f 100644 |
| --- a/internal/dbdoor/restore_asof_test.go |
| +++ b/internal/dbdoor/restore_asof_test.go |
| @@ -186,3 +186,23 @@ func TestRestoreAsOfWithSecondResolutionTimes(t *testing.T) { |
| t.Fatalf("copy as of %v holds %d rows, want %d: a write after T is in it", at, rows, 2*dbdoortest.Rows) |
| } |
| } |
| + |
| +// Go's zero time means "the newest copy" to RestoreAsOf and to litestream. |
| +// A requested time at or near it (year 1, or a fraction of a second into |
| +// it that rounds down to zero) must still be a time before the history, |
| +// never the newest copy. Found in Astra's review. The zero instant itself, |
| +// in any zone, is "the newest copy" by contract; parseDBAt keeps a typed |
| +// one from reaching here. |
| +func TestRestoreAsOfNeverTreatsAnEarlyTimeAsTheNewest(t *testing.T) { |
| + dir := t.TempDir() |
| + dbdoortest.Replicate(t, dir, 2) |
| + for _, at := range []time.Time{ |
| + time.Time{}.Add(500 * time.Millisecond), |
| + time.Time{}.Add(time.Hour), |
| + } { |
| + _, err := RestoreAsOf(context.Background(), NewClient(newTestDoor(t, dir).do), t.TempDir(), at) |
| + if !errors.Is(err, ErrBeforeHistory) { |
| + t.Errorf("RestoreAsOf(%v) = %v, want ErrBeforeHistory", at, err) |
| + } |
| + } |
| +} |