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 @@ // imports nothing outside the standard library, so every other package can // depend on it freely. package core +import "time" + // Visibility mirrors the Postgres `visibility` enum. type Visibility string @@ -71,6 +73,12 @@ OwnerID int 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 @@ // "user" to resolve the owner's username (core.Repo.OwnerName). description is // 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 @@ r core.Repo 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 @@ return nil, fmt.Errorf("insert repository: %w", err) } 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" @@ -70,6 +71,52 @@ t.Fatalf("expected ErrNameTaken, got %v", err) } }) } +} + +// 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