From 65be341fc575daaf7f1093b698b40193bf75ab93 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 15 Aug 2026 13:16:44 +0300 Subject: [PATCH] db: read a database's timestamps out of the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository table has carried created and updated since the first migration and the projection never selected them, so core.Repo could not answer when a database was made. Nothing needed it while the surfaces were pages: a listing sorts by created in SQL and prints none of it. /query publishes a database as a record rather than as a page, and a record without its timestamps is a poorer answer for no reason — the columns are already there. CreateRepo returns the pair it wrote, so a caller need not re-read the row to learn them. The test reads them back through all three projections. A column dropped from repoSelect would otherwise surface as the zero time for every database on the instance, without failing anywhere. --- core/models.go | 8 ++++++++ db/repos.go | 5 +++-- db/repos_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/core/models.go b/core/models.go index 235efeb6354e9bcf73e10fa00e48423eb82c9f65..2e09925451a7adf0d8945ef4069c06c7e3b4f5fc 100644 --- a/core/models.go +++ b/core/models.go @@ -4,6 +4,8 @@ // depend on it freely. package core +import "time" + // Visibility mirrors the Postgres `visibility` enum. type Visibility string @@ -71,6 +73,12 @@ type Repo struct { OwnerName string Path string Visibility Visibility + // Created and Updated are the row's timestamps, in UTC. They are read by + // the surfaces that publish a database as a record rather than as a page — + // /query is the first — and are the zero time for a Repo built in memory + // rather than read from the store. + Created time.Time + Updated time.Time } // Caller is the authenticated principal for a request. A nil *Caller is an diff --git a/db/repos.go b/db/repos.go index f4f60a403af88fcdbdb7abab4a1ef1a3a88ee8ed..4c5ef8c61db016b9ab363f41efc70b68ff74dcce 100644 --- a/db/repos.go +++ b/db/repos.go @@ -17,7 +17,7 @@ import ( // nullable in the schema, so it is coalesced to the empty string. const repoSelect = ` SELECT r.id, r.name, COALESCE(r.description, ''), r.owner_id, - COALESCE(u.username, ''), r.path, r.visibility + COALESCE(u.username, ''), r.path, r.visibility, r.created, r.updated FROM repository r JOIN "user" u ON u.id = r.owner_id` @@ -27,7 +27,7 @@ func scanRepo(sc rowScanner) (*core.Repo, error) { visibility string ) if err := sc.Scan(&r.ID, &r.Name, &r.Description, &r.OwnerID, - &r.OwnerName, &r.Path, &visibility); err != nil { + &r.OwnerName, &r.Path, &visibility, &r.Created, &r.Updated); err != nil { return nil, err } r.Visibility = core.Visibility(visibility) @@ -75,6 +75,7 @@ RETURNING id` } out := *r out.ID = id + out.Created, out.Updated = now, now return &out, nil } diff --git a/db/repos_test.go b/db/repos_test.go index b237ac2d2742acabdd801b09b02fbfe3b175d591..8d1be668e83f78adb91224f8494d0fc5df573e1c 100644 --- a/db/repos_test.go +++ b/db/repos_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -72,6 +73,52 @@ func TestCreateRepoDuplicateName(t *testing.T) { } } +// The row's timestamps are read, not merely stored: /query publishes them, and +// a projection that forgot the two columns would answer the zero time for every +// database on the instance without failing anywhere. +func TestRepoCarriesItsTimestamps(t *testing.T) { + s, sqlDB, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + before := time.Now().UTC().Add(-time.Second) + insertUser(t, sqlDB, 1, "alice", core.UserTypeUser) + created := mkRepo(t, s, ctx, 1, "alice", "widgets", core.VisibilityPublic) + + assert.False(t, created.Created.IsZero(), "CreateRepo must return the row it wrote") + assert.Equal(t, created.Created, created.Updated, "a fresh row was never updated") + + for _, tc := range []struct { + name string + get func() (*core.Repo, error) + }{ + {"by id", func() (*core.Repo, error) { return s.GetRepoByID(ctx, created.ID) }}, + {"by owner and name", func() (*core.Repo, error) { return s.GetRepoByOwnerAndName(ctx, "alice", "widgets") }}, + {"through a listing", func() (*core.Repo, error) { + repos, err := s.ListReposByOwner(ctx, "alice", nil) + if err != nil { + return nil, err + } + return repos[0], nil + }}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.get() + require.NoError(t, err) + assert.False(t, got.Created.IsZero(), "created came back as the zero time") + assert.True(t, got.Created.After(before), "created is not the row's own timestamp") + assert.False(t, got.Updated.IsZero()) + }) + } + + // An update moves Updated and leaves Created where it was. + require.NoError(t, s.UpdateRepo(ctx, created.ID, "now with docs", core.VisibilityPrivate)) + got, err := s.GetRepoByID(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, created.Created.Unix(), got.Created.Unix(), "an update rewrote created") + assert.False(t, got.Updated.Before(got.Created), "updated went backwards") +} + // A rename moves the name and the on-disk path in one statement: path is // derived from (owner, name), and a row whose two halves disagree would be // served out of the wrong store.