diff options
| author | Shulhan <ms@kilabit.info> | 2026-03-10 13:18:11 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2026-03-10 13:23:54 +0700 |
| commit | fcb3423fc68a8500b1ddd87e12c75ca1f607ad8b (patch) | |
| tree | 41f3ef02ea60a80cf2ba649cc1b7b616c3dba948 | |
| parent | 3f780768650cf8a55d2f7310f09c7a936cf0ae16 (diff) | |
| download | pakakeh.go-fcb3423fc68a8500b1ddd87e12c75ca1f607ad8b.tar.xz | |
lib/uuidv7: new package that implements UUID version 7
The uuidv7 package provides type UUIDv7 as the container for UUID
version 7 that satisfy the [database/sql], [encoding/gob], and
[encoding/json].
The implementation follow RFC 9562.
| -rw-r--r-- | CHANGELOG.adoc | 16 | ||||
| -rw-r--r-- | lib/uuidv7/uuidv7.go | 198 | ||||
| -rw-r--r-- | lib/uuidv7/uuidv7_example_test.go | 130 | ||||
| -rw-r--r-- | lib/uuidv7/uuidv7_test.go | 88 |
4 files changed, 430 insertions, 2 deletions
diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 33f515c9..43b4c85f 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -67,7 +67,7 @@ to redirect URL. Any trailing slash in the BasePath will be removed. -=== 🌱 Implement server auto shutdown when idle +==== 🌱 Implement server auto shutdown when idle In the `ServerOptions`, we add option `ShutdownIdleDuration` when set to non-zero value it will start a timer. @@ -77,13 +77,25 @@ then shutting down. This allow de-activating HTTP server when no connections received after specific duration to reduce the system resources. -=== 🌼 lib/http: fix possible data race in SSE connection +==== 🌼 lib/http: fix possible data race in SSE connection When server's handler call Write or WriteRaw, there is possibility that the worker for keeping the connection alive also call Write at the same time, which cause the data race. +[#v0_62_0__lib_uuidv7] +=== lib/uuidv7 + +New package that implements UUID version 7. + +The uuidv7 package provides type UUIDv7 as the container for UUID +version 7 that satisfy the [database/sql], [encoding/gob], and +[encoding/json]. + +The implementation follow RFC 9562. + + //}}} [#v0_61_0] == pakakeh.go v0.61.0 (2026-02-09) diff --git a/lib/uuidv7/uuidv7.go b/lib/uuidv7/uuidv7.go new file mode 100644 index 00000000..5a6b6e03 --- /dev/null +++ b/lib/uuidv7/uuidv7.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: 2026 M. Shulhan <ms@kilabit.info> + +// Package uuidv7 implements UUID version 7 as defined in RFC 9562. +package uuidv7 + +import ( + "crypto/rand" + "encoding/binary" + "encoding/hex" + "fmt" + "time" +) + +const hexTable = `0123456789ABCDEF` +const variantMask uint64 = 0x8000_0000_0000_0000 + +// Now defines the function variable that return the current time in UTC. +// This identifier is exported to simplify testing. +var Now = func() time.Time { + return time.Now().UTC() +} + +// Rand defines the function variable that return a random uint64. +// This identifier is exported to simplify testing. +var Rand = func() (v uint64) { + b := make([]byte, 8) + rand.Read(b) + v, _ = binary.Uvarint(b) + return v +} + +// UUIDv7 defines the container for UUID version 7 that satisfy the +// [database/sql], [encoding/gob], and [encoding/json]. +type UUIDv7 struct { + // The high 8 octets store the unix timestamp in milliseconds (6 + // octets), the version (4 bits), and clock precision (12 bits). + high uint64 + + // The low 8 octets store the variant (2 bits) and random number (62 + // bits). + low uint64 +} + +// Generate generates new UUID version 7. +func Generate() (id UUIDv7) { + now := Now() + generate(&now, &id) + return id +} + +func generate(t *time.Time, id *UUIDv7) { + unixns := uint64(t.UnixNano()) // 1773066002123456700 + millis := uint64(unixns / 1e6) // 1773066002123 + id.high = millis << 16 // 0x019c_d2f8_1ecb_0000 + id.high |= 0x7000 // 0x019c_d2f8_1ecb_7000 + prec := unixns - (millis * 1e6) // 456700 + clockPrec := uint64((prec * 4096) / 1e6) // 1870 + id.high |= clockPrec // 0x019c_d2f8_1ecb_774e + rand := Rand() // 0xffff_ffff_ffff_ffff + rand >>= 2 // 0x3fff_ffff_ffff_ffff + id.low = variantMask | rand // 0xbfff_ffff_ffff_ffff +} + +// Parse parses the UUIDv7 formated string and return it as non-zero id. +// If the string s is invalid UUIDv7 it will return zero id. +func Parse(s string) (id UUIDv7) { + id.UnmarshalText([]byte(s)) + return id +} + +// Bytes returns id as a stream of binary. +func (id UUIDv7) Bytes() (data []byte) { + data = make([]byte, 0, binary.MaxVarintLen64*2) + data = binary.BigEndian.AppendUint64(data, id.high) + data = binary.BigEndian.AppendUint64(data, id.low) + return data +} + +// IsZero returns true if all bits is zero. +func (id UUIDv7) IsZero() bool { + return id.high == 0 && id.low == 0 +} + +// MarshalBinary encodes the id to binary for [encoding/gob]. +func (id UUIDv7) MarshalBinary() (data []byte, err error) { + data = id.Bytes() + return data, nil +} + +// MarshalText encodes the id to JSON for [encoding/json]. +func (id UUIDv7) MarshalText() (data []byte, err error) { + v := id.String() + return []byte(v), nil +} + +// UnmarshalBinary decodes the data into id for [encoding/gob]. +func (id *UUIDv7) UnmarshalBinary(data []byte) (err error) { + if len(data) != 16 { + return fmt.Errorf(`uuidv7: invalid length %d`, len(data)) + } + ver := data[6] >> 4 + if ver != 7 { + return fmt.Errorf(`uuidv7: invalid version %d`, ver) + } + id.high = binary.BigEndian.Uint64(data[:8]) + id.low = binary.BigEndian.Uint64(data[8:]) + return nil +} + +// UnmarshalText decodes the JSON data into id for [encoding/json]. +func (id *UUIDv7) UnmarshalText(data []byte) (err error) { + src := make([]byte, 0, 32) + for _, c := range data { + if c == '-' { + continue + } + src = append(src, c) + } + l := len(src) + if l != 32 { + return fmt.Errorf(`uuidv7: invalid length %d`, l) + } + if src[12] != '7' { + return fmt.Errorf(`uuidv7: invalid version %c`, data[12]) + } + + l = hex.DecodedLen(len(src[:16])) + dst := make([]byte, l) + _, err = hex.Decode(dst, src[:16]) + if err != nil { + return fmt.Errorf(`uuidv7: %w`, err) + } + id.high = binary.BigEndian.Uint64(dst) + + _, err = hex.Decode(dst, src[16:]) + if err != nil { + return fmt.Errorf(`uuidv7: %w`, err) + } + id.low = binary.BigEndian.Uint64(dst) + return nil +} + +// Scan scans the raw database value into id. +// This method implement [database/sql.Scanner] interface. +func (id *UUIDv7) Scan(src any) (err error) { + switch v := src.(type) { + case []byte: + err = id.UnmarshalText(v) + if err != nil { + return err + } + default: + return fmt.Errorf(`uuidv7: Scan: invalid type %T`, src) + } + return nil +} + +// String returns the human readable format of UUIDv7 in the form of +// "0xxxxxxx-xxxx-7xxx-8xxx-xxxxxxxxxxxx". +func (id UUIDv7) String() string { + buf := make([]byte, 36) + sw := 56 + x := 0 + for sw >= 0 { + b := byte(id.high >> sw) + sw -= 8 + buf[x] = hexTable[b>>4] + buf[x+1] = hexTable[b&0x0F] + x += 2 + if x == 8 || x == 13 { + buf[x] = '-' + x++ + } + } + buf[x] = '-' + x++ + sw = 56 + for sw >= 0 { + b := byte(id.low >> sw) + sw -= 8 + buf[x] = hexTable[b>>4] + buf[x+1] = hexTable[b&0x0F] + x += 2 + if x == 23 { + buf[x] = '-' + x++ + } + } + return string(buf) +} + +// Time returns the Unix epoch timestamp in milliseconds stored in the UUIDv7. +func (id *UUIDv7) Time() time.Time { + millis := id.high >> 16 + t := time.UnixMilli(int64(millis)) + return t +} diff --git a/lib/uuidv7/uuidv7_example_test.go b/lib/uuidv7/uuidv7_example_test.go new file mode 100644 index 00000000..c0472dd5 --- /dev/null +++ b/lib/uuidv7/uuidv7_example_test.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: 2026 M. Shulhan <ms@kilabit.info> + +package uuidv7_test + +import ( + "bytes" + "encoding/gob" + "encoding/json" + "fmt" + "log" + "time" + + "git.sr.ht/~shulhan/pakakeh.go/lib/uuidv7" +) + +func ExampleGenerate() { + now := time.Date(2026, 3, 9, 14, 20, 0, 123456700, time.UTC) + uuidv7.Now = func() time.Time { + now = now.Add(time.Second) + return now + } + uuidv7.Rand = func() uint64 { + return 0xFFFF_FFFF_FFFF_FFFF + } + + id := uuidv7.Generate() + fmt.Printf("%s %s\n", id, id.Time().UTC()) + id = uuidv7.Generate() + fmt.Printf("%s %s\n", id, id.Time().UTC()) + + // Output: + // 019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF 2026-03-09 14:20:01.123 +0000 UTC + // 019CD2F8-1ECB-774E-BFFF-FFFFFFFFFFFF 2026-03-09 14:20:02.123 +0000 UTC +} + +func ExampleParse() { + id := uuidv7.Parse(``) + fmt.Printf("%t %s %s\n", id.IsZero(), id, id.Time().UTC()) + + id = uuidv7.Parse(`019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF`) + fmt.Printf("%t %s %s\n", id.IsZero(), id, id.Time().UTC()) + + // Output: + // true 00000000-0000-0000-0000-000000000000 1970-01-01 00:00:00 +0000 UTC + // false 019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF 2026-03-09 14:20:01.123 +0000 UTC +} + +func ExampleUUIDv7_MarshalBinary() { + now := time.Date(2026, 3, 9, 14, 20, 0, 123456700, time.UTC) + uuidv7.Now = func() time.Time { + now = now.Add(time.Second) + return now + } + uuidv7.Rand = func() uint64 { + return 0xFFFF_FFFF_FFFF_FFFF + } + + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + id := uuidv7.Generate() + err := enc.Encode(id) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Sent: %s\n", id) + + dec := gob.NewDecoder(&buf) + var gotID uuidv7.UUIDv7 + err = dec.Decode(&gotID) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Receive: %s\n", gotID) + + // Output: + // Sent: 019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF + // Receive: 019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF +} + +func ExampleUUIDv7_MarshalText() { + now := time.Date(2026, 3, 9, 14, 20, 0, 123456700, time.UTC) + uuidv7.Now = func() time.Time { + now = now.Add(time.Second) + return now + } + uuidv7.Rand = func() uint64 { + return 0xFFFF_FFFF_FFFF_FFFF + } + + type T struct { + ID uuidv7.UUIDv7 + } + + t := T{ + ID: uuidv7.Generate(), + } + jsonb, err := json.Marshal(t) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s\n", jsonb) + + var got T + err = json.Unmarshal(jsonb, &got) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%+v\n", got) + + // Output: + // {"ID":"019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF"} + // {ID:019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF} +} + +func ExampleUUIDv7_Scan() { + var id uuidv7.UUIDv7 + + // Scan the value from the database: + // + // dbc.QueryRow(`SELECT id ...`, &id) + + err := id.Scan([]byte(`017F22E2-79B0-7CC3-98C4-DC0C0C07398F`)) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s\n", id) + // Output: + // 017F22E2-79B0-7CC3-98C4-DC0C0C07398F +} diff --git a/lib/uuidv7/uuidv7_test.go b/lib/uuidv7/uuidv7_test.go new file mode 100644 index 00000000..70ca315c --- /dev/null +++ b/lib/uuidv7/uuidv7_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: 2026 M. Shulhan <ms@kilabit.info> + +package uuidv7 + +import ( + "testing" + + "git.sr.ht/~shulhan/pakakeh.go/lib/test" +) + +func TestUUIDv7_Scan(t *testing.T) { + listCase := []struct { + desc string + data any + expError string + }{{ + desc: `With empty data`, + data: `017F22E2-79B0-7CC3-98C4-DC0C0C07398F`, + expError: `uuidv7: Scan: invalid type string`, + }, { + desc: `With invalid version`, + data: []byte(`5c146b14-3c52-4afd-938a-375d0df1fbf6`), + expError: `uuidv7: invalid version 2`, + }} + for _, tc := range listCase { + var id UUIDv7 + err := id.Scan(tc.data) + if err != nil { + test.Assert(t, tc.desc, tc.expError, err.Error()) + continue + } + } +} + +func TestUUIDv7_UnmarshalBinary(t *testing.T) { + listCase := []struct { + desc string + expError string + data []byte + }{{ + desc: `With empty data`, + expError: `uuidv7: invalid length 0`, + }, { + desc: `With non-version 7`, + data: []byte{1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 1, 2, 3, 4}, + expError: `uuidv7: invalid version 0`, + }} + for _, tc := range listCase { + var id UUIDv7 + err := id.UnmarshalBinary(tc.data) + if err != nil { + test.Assert(t, tc.desc, tc.expError, err.Error()) + continue + } + } +} + +func TestUUIDv7_UnmarshalText(t *testing.T) { + listCase := []struct { + desc string + expError string + data []byte + }{{ + desc: `With empty data`, + expError: `uuidv7: invalid length 0`, + }, { + desc: `With non-version 7`, + data: []byte(`5c146b14-3c52-4afd-938a-375d0df1fbf6`), + expError: `uuidv7: invalid version 2`, + }, { + desc: `With invalid hex (high)`, + data: []byte(`X17F22E2-79B0-7CC3-98C4-DC0C0C07398F`), + expError: `uuidv7: encoding/hex: invalid byte: U+0058 'X'`, + }, { + desc: `With invalid hex (low)`, + data: []byte(`017F22E2-79B0-7CC3-98C4-DC0C0C07398X`), + expError: `uuidv7: encoding/hex: invalid byte: U+0058 'X'`, + }} + for _, tc := range listCase { + var id UUIDv7 + err := id.UnmarshalText(tc.data) + if err != nil { + test.Assert(t, tc.desc, tc.expError, err.Error()) + continue + } + } +} |
