initial commit
This commit is contained in:
122
ids.go
Normal file
122
ids.go
Normal file
@@ -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)
|
||||
}
|
||||
84
ids_test.go
Normal file
84
ids_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user