From 8d6882ac0d4d1486e3fb742f72b338daa1d9cca4 Mon Sep 17 00:00:00 2001 From: Ben Vezzani Date: Sun, 18 Jan 2026 13:37:49 -0500 Subject: [PATCH] initial commit --- go.mod | 3 ++ ids.go | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++ ids_test.go | 84 ++++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 go.mod create mode 100644 ids.go create mode 100644 ids_test.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..29267ec --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.vezzani.net/ben/smolid + +go 1.20 diff --git a/ids.go b/ids.go new file mode 100644 index 0000000..f949af8 --- /dev/null +++ b/ids.go @@ -0,0 +1,122 @@ +package smolid + +import ( + "encoding/base32" + "encoding/binary" + "fmt" + "math/rand/v2" + "strings" + "time" +) + +// Taking a reference to time.Now to make testing easier +var now = time.Now + +const ( + epoch uint64 = 1735707600000 // 2025-01-01 00:00:00 + timestampShiftOffset = 23 // The timestamp is shifted three bytes to the left + versionShiftOffset = 21 + versionMask = 0b11 << versionShiftOffset + + v1TypeShiftOffset = 9 + v1TypeFlag = 0b1 << 20 + v1TypeMask = 0b111 << v1TypeShiftOffset + v1RandomSpace = 0xfffff // In V1, the least significant two and a half bytes (20 bits) can be random + v1Version = 0b1 << 21 +) + +/* +ID is a 64-bit (8-byte) value intended to be + - URL-Friendly; short and unobtrusive in its default unpadded base32 string encoding + - temporally sortable with strong index locality + - fast-enough and unique-enough for most use cases + +Field Definitions + + - Timestamp (41 bits) The most significant 41 bits represent a millisecond-precision timestamp + The allowed timestamp range is 2025-01-01 00:00:00 - 2094-09-07 15:47:35 + - Version (2 bits) Bits 41-42 are reserved for versioning. + v1 is 0b01 + - Type Flag (1 bit): Bit 43 serves as a boolean flag. If set, the "Type/Rand" field (Byte 6) is an embedded type + identifier. + - Reserved/Random (4 bits): The remaining 4 bits of the 6th byte are currently reserved. In Version 1, these are + populated with pseudo-random data. + - Type/Random (7 bits): If the Type Flag is set, this field contains the Type Identifier. Otherwise, it + is populated with pseudo-random data. + - Random (9 bits): The remaining byte is dedicated to pseudo-random data to reasonably ensure uniqueness. + +String Format + + The string format is base32 with no padding. Canonically the string is lowercased. This decision is purely for + aesthetics, but the parser is case-insensitive and will accept uppercase base32 strings. +*/ +type ID struct { + n uint64 +} + +func New() ID { + var id = (uint64(now().UTC().UnixMilli()) - epoch) << timestampShiftOffset // set the timestamp + id |= v1Version // set the version bit + id |= rand.Uint64N(v1RandomSpace) // radom-fill the remaining space + return ID{id} +} + +func Nil() ID { return ID{0} } + +func NewForType(typ byte) ID { + id := New() // get a new v1 ID + id.n &^= v1TypeMask // clear the random data in the type space + id.n |= v1TypeFlag // set the type flag + id.n |= uint64(typ) << v1TypeShiftOffset // set the type + return id +} + +func FromString(s string) (_ ID, err error) { + defer func() { + if r := recover(); r != nil { + var ok bool + err, ok = r.(error) + if !ok { + err = fmt.Errorf("%v", r) + } + } + }() + + s = strings.ToUpper(s) + var bs []byte + bs, err = base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s) + if err != nil { + return Nil(), err + } + + return ID{binary.BigEndian.Uint64(bs)}, nil +} + +func Must(id ID, err error) ID { + if err != nil { + panic("couldn't parse id: " + err.Error()) + } + return id +} + +func (id ID) String() string { + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(id.Bytes())) +} + +func (id ID) Version() int { return int(id.n&versionMask) >> versionShiftOffset } + +func (id ID) Bytes() []byte { + var bs = make([]byte, 8) + binary.BigEndian.PutUint64(bs, id.n) + return bs +} + +func (id ID) Time() time.Time { + var ms = int64(id.n>>timestampShiftOffset + epoch) // extract the timestamp + return time.Unix(ms/1000, (ms%1000)*int64(time.Millisecond)) // fill into a time.Time +} + +func (id ID) Type() byte { + typ := id.n & v1TypeMask + return byte(typ >> v1TypeShiftOffset) +} diff --git a/ids_test.go b/ids_test.go new file mode 100644 index 0000000..1d2a783 --- /dev/null +++ b/ids_test.go @@ -0,0 +1,84 @@ +package smolid + +import ( + "testing" + "time" +) + +var testTimestamp = time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC) + +func TestIDTimestamp(t *testing.T) { + now = func() time.Time { return testTimestamp } + id := New() + // make sure it has the right timestamp + if !id.Time().Equal(testTimestamp) { + t.Errorf("Invalid time. Expected %v, got %v", testTimestamp, id.Time()) + } + // and the right version + if id.Version() != 1 { + t.Errorf("Expected version 1, got %v", id.Version()) + } +} + +func TestIDFromString(t *testing.T) { + id, err := FromString("ACPJE64AEYEZ6") + if err != nil { + t.Fatal(err) + } + if id.Version() != 1 { + t.Errorf("Expected version 1, got %v", id.Version()) + } + id, err = FromString("ccpje64aeyez6") + if err != nil { + t.Fatal(err) + } + if id.Version() != 1 { + t.Errorf("Expected version 1, got %v", id.Version()) + } +} + +func TestIDFromStringInvalid(t *testing.T) { + _, err := FromString("ACPJE64AEYEZ") + if err == nil { + t.Fatal("Expected error") + } + _, err = FromString("invalid") + if err == nil { + t.Fatal("Expected error") + } +} + +func TestNewForType(t *testing.T) { + now = func() time.Time { return testTimestamp } + const ( + MyType = iota + MyOtherType + ) + id := NewForType(MyType) + + // make sure it has the right timestamp still + if !id.Time().Equal(testTimestamp) { + t.Errorf("Invalid time. Expected %v, got %v", testTimestamp, id.Time()) + } + // and the right version still + if id.Version() != 1 { + t.Errorf("Expected version 1, got %v", id.Version()) + } + + // and finally make sure we're able to extract the embedded type id + if id.Type() != MyType { + t.Errorf("Expected type %v, got %v", MyType, id.Type()) + } + + // Then the same deal for the other type + id = NewForType(MyOtherType) + if !id.Time().Equal(testTimestamp) { + t.Errorf("Invalid time. Expected %v, got %v", testTimestamp, id.Time()) + } + if id.Version() != 1 { + t.Errorf("Expected version 1, got %v", id.Version()) + } + if id.Type() != MyOtherType { + t.Errorf("Expected type %v, got %v", MyOtherType, id.Type()) + } +}