diff --git a/README.md b/README.md index 2da1b06..d6cfa52 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ go get github.com/invopop/couch idempotently (only rewritten when their views/filters change). - **`couch/changes`** — consume CouchDB `_changes` feeds with a resumable, persisted cursor and a worker pool. -- **`couch/at`** — millisecond-precision timestamps used by `couch.Model`. +- **[`invopop/at`](https://github.com/invopop/at)** — millisecond-precision + timestamps used by `couch.Model`. It lived here as `couch/at` until it was + moved out, so anything can use the type without depending on this library. ## Usage diff --git a/at/at.go b/at/at.go deleted file mode 100644 index 4abad60..0000000 --- a/at/at.go +++ /dev/null @@ -1,111 +0,0 @@ -// Package at provides timestamp handling with millisecond precision. -package at - -import ( - "fmt" - "time" -) - -// Millisecond time formats to comply with W3C datetime format that -// contains rules for local timezones so that they always include a ":". -const ( - RFC3339Milli string = "2006-01-02T15:04:05.000Z" - RFC3339MilliWithZone string = "2006-01-02T15:04:05.000-07:00" -) - -const ( - nullString = "null" -) - -// Timestamp represents the basic time wrapper to be used in timestamps -// with millisecond precision. -type Timestamp struct { - time.Time -} - -// LocalTime ensures the local time information is included in the timestamp. -type LocalTime struct { - time.Time -} - -// Now provides a timestamp for the current UTC system Time -func Now() Timestamp { - return Timestamp{time.Now().UTC()} -} - -// LocalTimeNow is used to provide the current time in the provided location. -func LocalTimeNow(loc *time.Location) LocalTime { - return LocalTime{time.Now().In(loc)} -} - -// ParseTimestamp attempts to parse the timestamp string. -func ParseTimestamp(str string) (Timestamp, error) { - // parse with generic RFC3339 precision, which supports milliseconds - // and helps us get around issues around timestamps that don't - // include the milliseconds for whatever reason. - o, err := time.Parse(time.RFC3339, str) - if err != nil { - return Timestamp{}, fmt.Errorf("at: unable to parse timestamp: %w", err) - } - return Timestamp{o.UTC()}, nil -} - -// ParseLocalTime attempts to read in the provided time data which hopefully includes -// a zone, but is not necessarily guaranteed. -func ParseLocalTime(str string) (LocalTime, error) { - o, err := time.Parse(time.RFC3339, str) - if err != nil { - return LocalTime{}, fmt.Errorf("at: unable to parse localtime: %w", err) - } - return LocalTime{o}, nil -} - -// String provides the timestamp in RFC3339 format including milliseconds. -func (t *Timestamp) String() string { - return t.Format(RFC3339Milli) -} - -// String provides the local time including milliseconds and a time zone. -func (t *LocalTime) String() string { - return t.Format(RFC3339MilliWithZone) -} - -// UnmarshalJSON uses our timestamp parser. -func (t *Timestamp) UnmarshalJSON(data []byte) error { - s := string(data) - if s == nullString { - return nil - } - s = s[1 : len(s)-1] // no quotes - var err error - *t, err = ParseTimestamp(s) - return err -} - -// MarshalJSON provides the timestamp in JSON format -func (t Timestamp) MarshalJSON() ([]byte, error) { - if t.IsZero() { - return []byte(nullString), nil - } - return []byte(`"` + t.String() + `"`), nil -} - -// UnmarshalJSON parses the provided local time JSON data. -func (t *LocalTime) UnmarshalJSON(data []byte) error { - s := string(data) - if s == nullString { - return nil - } - s = s[1 : len(s)-1] // no quotes - var err error - *t, err = ParseLocalTime(s) - return err -} - -// MarshalJSON provides the local time in JSON format. -func (t LocalTime) MarshalJSON() ([]byte, error) { - if t.IsZero() { - return []byte(nullString), nil - } - return []byte(`"` + t.String() + `"`), nil -} diff --git a/at/at_test.go b/at/at_test.go deleted file mode 100644 index 2cba447..0000000 --- a/at/at_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package at_test - -import ( - "encoding/json" - "strings" - "testing" - "time" - - "github.com/invopop/couch/at" -) - -func TestTimestampUnmarshal(t *testing.T) { - var cases = []struct { - Given string - Expected time.Time - Error bool - }{ - // long form - {`"2009-11-10T23:19:45.123Z"`, time.Date(2009, time.November, 10, 23, 19, 45, 123000000, time.UTC), false}, - // short form - {`"2009-11-10T13:19:04Z"`, time.Date(2009, time.November, 10, 13, 19, 4, 0, time.UTC), false}, - // local form - {`"2009-11-10T13:19:04+02:00"`, time.Date(2009, time.November, 10, 11, 19, 4, 0, time.UTC), false}, - // bad form - {`"Z2009-11-10T13:19:04Z"`, time.Time{}, true}, - // nil string - {`null`, time.Time{}, false}, - } - - for _, c := range cases { - payload := []byte(c.Given) - var output at.Timestamp - if err := json.Unmarshal(payload, &output); err != nil && !c.Error { - t.Error(err) - continue - } - if !output.Equal(c.Expected) { - t.Errorf("Expected: %q, Given: %q", c.Expected, output) - } - } - - // always convert to UTC - var output at.Timestamp - if err := json.Unmarshal([]byte(`"2009-11-10T13:19:04+02:00"`), &output); err != nil { - t.Error(err) - return - } - if z, _ := output.Zone(); z != "UTC" { - t.Errorf("Expected UTC time zone, got: %q", z) - } -} - -func TestTimestampMarshal(t *testing.T) { - var cases = []struct { - Given time.Time - Expected string - }{ - {time.Date(2009, time.November, 10, 23, 19, 45, 0, time.UTC), `"2009-11-10T23:19:45.000Z"`}, - {time.Date(2009, time.November, 10, 13, 19, 4, 0, time.UTC), `"2009-11-10T13:19:04.000Z"`}, - {time.Date(2009, time.November, 10, 23, 19, 45, 123456000, time.UTC), `"2009-11-10T23:19:45.123Z"`}, - {time.Time{}, `null`}, - } - - for _, c := range cases { - ct := at.Timestamp{c.Given} - output, err := json.Marshal(ct) - if err != nil { - t.Error(err) - } - if string(output) != c.Expected { - t.Errorf("Expected: %q, Given: %q", c.Expected, output) - } - } -} - -func TestNow(t *testing.T) { - ct := at.Now() - if z, _ := ct.Zone(); z != "UTC" { - t.Errorf("Failed to get current time in UTC, got: %v", z) - } -} - -func TestLocalTimeNow(t *testing.T) { - loc, _ := time.LoadLocation("America/Lima") - ct := at.LocalTimeNow(loc) - if z, _ := ct.Zone(); z != "-05" { - t.Errorf("Failed to get expected time zone, got: %v", z) - } -} - -func TestLocalTimeUnmarshal(t *testing.T) { - tl, _ := time.LoadLocation("America/Lima") // always -5 (no DST) - tl2, _ := time.LoadLocation("Asia/Dubai") // always +4 (no DST) - var cases = []struct { - Given string - Expected time.Time - Error bool - }{ - // long form - {`"2009-11-10T23:19:45.123-05:00"`, time.Date(2009, time.November, 10, 23, 19, 45, 123000000, tl), false}, - // long form 2 - {`"2009-11-10T23:19:45.123+04:00"`, time.Date(2009, time.November, 10, 23, 19, 45, 123000000, tl2), false}, - // short form - {`"2009-11-10T23:19:45-05:00"`, time.Date(2009, time.November, 10, 23, 19, 45, 0, tl), false}, - // bad form - {`"Z2009-11-10T13:19:04Z"`, time.Time{}, true}, - // bad long form - {`"2009-11-10T23:19:45.123+0400"`, time.Time{}, true}, - // nil string - {`null`, time.Time{}, false}, - // UTC form - {`"2009-11-10T23:19:45.123Z"`, time.Date(2009, time.November, 10, 23, 19, 45, 123000000, time.UTC), false}, - } - - for _, c := range cases { - payload := []byte(c.Given) - var output at.LocalTime - if err := json.Unmarshal(payload, &output); err != nil && !c.Error { - t.Error(err) - continue - } - if !output.Equal(c.Expected) { - t.Errorf("Expected: %q, Given: %q", c.Expected, output) - } - } -} - -func TestLocalTimeMarshal(t *testing.T) { - tl, _ := time.LoadLocation("America/Lima") // always -5 (no DST) - tl2, _ := time.LoadLocation("Asia/Dubai") // always +4 (no DST) - var cases = []struct { - Given time.Time - Expected string - }{ - {time.Date(2009, time.November, 10, 23, 19, 30, 0, tl), `"2009-11-10T23:19:30.000-05:00"`}, - {time.Date(2009, time.November, 10, 13, 19, 4, 123000000, tl), `"2009-11-10T13:19:04.123-05:00"`}, - {time.Date(2009, time.November, 10, 13, 19, 4, 123000000, tl2), `"2009-11-10T13:19:04.123+04:00"`}, - {time.Time{}, `null`}, - } - - for _, c := range cases { - ct := at.LocalTime{c.Given} - output, err := json.Marshal(ct) - if err != nil { - t.Error(err) - } - if string(output) != c.Expected { - t.Errorf("Expected: %q, Given: %q", c.Expected, output) - } - } -} - -func TestTimestampInModel(t *testing.T) { - type tmodel struct { - Value string `json:"v"` - ExampleAt at.Timestamp `json:"example_at"` - EmptyAt *at.Timestamp `json:"empty_at,omitempty"` - } - x := new(tmodel) - x.Value = "bar" - - data, err := json.Marshal(x) - if err != nil { - t.Error(err) - return - } - if !strings.Contains(string(data), `"example_at":null`) { - t.Errorf("Expected output to contain value example_at, got: %v", string(data)) - } - if strings.Contains(string(data), "empty_at") { - t.Errorf("Did not expect output to contain value, got: %v", string(data)) - } -} diff --git a/go.mod b/go.mod index 9907030..abe5a07 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/go-kivik/kivik/v4 v4.5.2 + github.com/invopop/at v0.1.0 github.com/jpillora/backoff v1.0.0 github.com/rs/zerolog v1.35.1 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 7deb7db..357b346 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/gopherjs/gopherjs v1.20.1 h1:22uLWFvVcxhJ+j3dJ99NNfwGyHynxCmjhYsrcwqb github.com/gopherjs/gopherjs v1.20.1/go.mod h1:h+FTmmLgbXMmmtuZFp9bUqXciN429Wx0sJEJuMnpyfM= github.com/icza/dyno v0.0.0-20230330125955-09f820a8d9c0 h1:nHoRIX8iXob3Y2kdt9KsjyIb7iApSvb3vgsd93xb5Ow= github.com/icza/dyno v0.0.0-20230330125955-09f820a8d9c0/go.mod h1:c1tRKs5Tx7E2+uHGSyyncziFjvGpgv4H2HrqXeUQ/Uk= +github.com/invopop/at v0.1.0 h1:9s51NQSc14r85I6rdfXV0Ows8ns2trhnm3ZCmQ9/X4I= +github.com/invopop/at v0.1.0/go.mod h1:0WjKWQGq3R31S5A+Iyt8em4H3SwPPWolnrruETuZxU0= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/model.go b/model.go index db849a4..4b90005 100644 --- a/model.go +++ b/model.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/go-kivik/kivik/v4" - "github.com/invopop/couch/at" + "github.com/invopop/at" ) // Model is a standard representation of a model to be stored in CouchDB