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