diff --git a/internal/database/metadata_cache_test.go b/internal/database/metadata_cache_test.go index 46a38a71..26e0a639 100644 --- a/internal/database/metadata_cache_test.go +++ b/internal/database/metadata_cache_test.go @@ -362,3 +362,42 @@ func TestMetadataCacheRoundTripsContentEncoding(t *testing.T) { t.Fatalf("content encoding round-trip failed: %#v", got) } } + +// On Postgres, fetched_at is a TIMESTAMP column without a zone. East of UTC +// it must still read back as the moment it was written, or metadata_ttl +// expires hours late. last_modified is an HTTP date in UTC and must keep it. +func TestMetadataCacheTimesOffUTC(t *testing.T) { + tokyo, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + t.Skipf("time zone data unavailable: %v", err) + } + prev := time.Local + time.Local = tokyo + t.Cleanup(func() { time.Local = prev }) + + runWithBothDatabases(t, func(t *testing.T, db *DB) { + fetched := time.Now().Add(-90 * time.Minute) + modified := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + entry := &MetadataCacheEntry{ + Ecosystem: testEcosystemNPM, + Name: "tz", + StoragePath: "_metadata/npm/tz/metadata", + LastModified: sql.NullTime{Time: modified, Valid: true}, + FetchedAt: sql.NullTime{Time: fetched, Valid: true}, + } + if err := db.UpsertMetadataCache(entry); err != nil { + t.Fatalf("UpsertMetadataCache() error = %v", err) + } + + got, err := db.GetMetadataCache(testEcosystemNPM, "tz") + if err != nil || got == nil { + t.Fatalf("GetMetadataCache() = %v, %v", got, err) + } + if diff := got.FetchedAt.Time.Sub(fetched).Abs(); diff > time.Second { + t.Errorf("fetched_at read back as %v, want %v", got.FetchedAt.Time, fetched) + } + if !got.LastModified.Time.Equal(modified) { + t.Errorf("last_modified read back as %v, want %v", got.LastModified.Time, modified) + } + }) +} diff --git a/internal/database/queries.go b/internal/database/queries.go index 95030224..5897d959 100644 --- a/internal/database/queries.go +++ b/internal/database/queries.go @@ -1122,6 +1122,13 @@ func (db *DB) GetMetadataCache(ecosystem, name string) (*MetadataCacheEntry, err func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error { now := time.Now() + // fetched_at is a TIMESTAMP without zone on Postgres, and lib/pq reads it + // back labelled UTC, so it has to be stored in UTC. last_modified already + // is, since it comes from an HTTP date. + fetchedAt := entry.FetchedAt + if fetchedAt.Valid { + fetchedAt.Time = fetchedAt.Time.UTC() + } var query string if db.dialect == DialectPostgres { @@ -1162,7 +1169,7 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error { _, err := db.Exec(query, entry.Ecosystem, entry.Name, entry.StoragePath, entry.ETag, entry.Link, - entry.ContentType, entry.ContentEncoding, entry.ContentDigest, entry.Size, entry.LastModified, entry.FetchedAt, now, now, + entry.ContentType, entry.ContentEncoding, entry.ContentDigest, entry.Size, entry.LastModified, fetchedAt, now, now, ) if err != nil { return fmt.Errorf("upserting metadata cache: %w", err)