Module: Philiprehberger::CompactId

Defined in:
lib/philiprehberger/compact_id.rb,
lib/philiprehberger/compact_id/version.rb

Defined Under Namespace

Classes: Error

Constant Summary collapse

BASE58_ALPHABET =
'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
BASE62_ALPHABET =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
UUID_PATTERN =
/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
PREFIX_PATTERN =
/\A[a-zA-Z0-9]+\z/
VERSION =
'0.6.0'

Class Method Summary collapse

Class Method Details

.base58_to_base62(str) ⇒ String

Convert a Base58 string directly to Base62

Parameters:

  • str (String)

    Base58-encoded string

Returns:

  • (String)

    Base62-encoded string

Raises:

  • (Error)

    if the string contains invalid characters



150
151
152
153
# File 'lib/philiprehberger/compact_id.rb', line 150

def self.base58_to_base62(str)
  num = decode_str(str, BASE58_ALPHABET)
  encode(num, BASE62_ALPHABET, 22)
end

.base62_to_base58(str) ⇒ String

Convert a Base62 string directly to Base58

Parameters:

  • str (String)

    Base62-encoded string

Returns:

  • (String)

    Base58-encoded string

Raises:

  • (Error)

    if the string contains invalid characters



160
161
162
163
# File 'lib/philiprehberger/compact_id.rb', line 160

def self.base62_to_base58(str)
  num = decode_str(str, BASE62_ALPHABET)
  encode(num, BASE58_ALPHABET, 22)
end

.batch_generate(count, format: :base58) ⇒ Array<String>

Generate multiple compact IDs at once

Parameters:

  • count (Integer)

    number of IDs to generate

  • format (Symbol) (defaults to: :base58)

    :base58 or :base62

Returns:

  • (Array<String>)

    array of encoded UUIDs

Raises:

  • (Error)

    if format is invalid or count is not a positive integer



73
74
75
76
77
# File 'lib/philiprehberger/compact_id.rb', line 73

def self.batch_generate(count, format: :base58)
  raise Error, 'Count must be a positive integer' unless count.is_a?(Integer) && count.positive?

  Array.new(count) { generate(format) }
end

.batch_to_base58(uuids) ⇒ Array<String>

Bulk encode an array of UUIDs to Base58

Parameters:

  • uuids (Array<String>)

    array of UUIDs with dashes

Returns:

  • (Array<String>)

    array of Base58-encoded strings

Raises:

  • (Error)

    if any UUID format is invalid



128
129
130
131
132
# File 'lib/philiprehberger/compact_id.rb', line 128

def self.batch_to_base58(uuids)
  raise Error, 'Expected an Array of UUIDs' unless uuids.is_a?(Array)

  uuids.map { |uuid| to_base58(uuid) }
end

.batch_to_base62(uuids) ⇒ Array<String>

Bulk encode an array of UUIDs to Base62

Parameters:

  • uuids (Array<String>)

    array of UUIDs with dashes

Returns:

  • (Array<String>)

    array of Base62-encoded strings

Raises:

  • (Error)

    if any UUID format is invalid



139
140
141
142
143
# File 'lib/philiprehberger/compact_id.rb', line 139

def self.batch_to_base62(uuids)
  raise Error, 'Expected an Array of UUIDs' unless uuids.is_a?(Array)

  uuids.map { |uuid| to_base62(uuid) }
end

.decode(str) ⇒ String

Auto-detect format and decode to UUID

Parameters:

  • str (String)

    Base58 or Base62 encoded string

Returns:

  • (String)

    UUID with dashes

Raises:

  • (Error)

    if the format cannot be detected or string is invalid



188
189
190
191
192
193
194
195
# File 'lib/philiprehberger/compact_id.rb', line 188

def self.decode(str)
  detected = format?(str)
  case detected
  when :base58 then from_base58(str)
  when :base62 then from_base62(str)
  else raise Error, "Unable to detect format of: #{str}"
  end
end

.decode_safe(str, expected_format:) ⇒ String

Safer variant of ‘.decode` that only succeeds when the detected encoding matches the expected format. Use when consuming IDs that should be a specific encoding to prevent silent confusion between Base58 and Base62.

Parameters:

  • str (String)
  • expected_format (Symbol)

    :base58 or :base62

Returns:

  • (String)

    decoded UUID

Raises:

  • (Error)

    if the detected format does not match

  • (ArgumentError)

    if expected_format is not a known format



206
207
208
209
210
211
212
213
214
215
# File 'lib/philiprehberger/compact_id.rb', line 206

def self.decode_safe(str, expected_format:)
  unless %i[base58 base62].include?(expected_format)
    raise ArgumentError, "unknown expected_format: #{expected_format.inspect}"
  end

  detected = format?(str)
  raise Error, "expected #{expected_format} but got #{detected}" unless detected == expected_format

  expected_format == :base58 ? from_base58(str) : from_base62(str)
end

.format?(str) ⇒ Symbol

Detect the format of an encoded string

Parameters:

  • str (String)

    encoded string to check

Returns:

  • (Symbol)

    :base58, :base62, or :unknown



169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/philiprehberger/compact_id.rb', line 169

def self.format?(str)
  return :unknown unless str.is_a?(String) && !str.empty?

  # Base58 is a strict subset of Base62, so check Base58 first.
  # A string is only :base62 if it contains characters outside the Base58 alphabet.
  if valid_base58?(str)
    :base58
  elsif valid_base62?(str)
    :base62
  else
    :unknown
  end
end

.from_base58(str) ⇒ String

Decode a Base58 string back to a UUID

Parameters:

  • str (String)

    Base58-encoded string

Returns:

  • (String)

    UUID with dashes

Raises:

  • (Error)

    if the string contains invalid characters



39
40
41
# File 'lib/philiprehberger/compact_id.rb', line 39

def self.from_base58(str)
  int_to_uuid(decode_str(str, BASE58_ALPHABET))
end

.from_base62(str) ⇒ String

Decode a Base62 string back to a UUID

Parameters:

  • str (String)

    Base62-encoded string

Returns:

  • (String)

    UUID with dashes

Raises:

  • (Error)

    if the string contains invalid characters



48
49
50
# File 'lib/philiprehberger/compact_id.rb', line 48

def self.from_base62(str)
  int_to_uuid(decode_str(str, BASE62_ALPHABET))
end

.generate(format = :base58) ⇒ String

Generate a new UUID and encode it

Parameters:

  • format (Symbol) (defaults to: :base58)

    :base58 or :base62

Returns:

  • (String)

    encoded UUID

Raises:

  • (Error)

    if format is invalid



57
58
59
60
61
62
63
64
65
# File 'lib/philiprehberger/compact_id.rb', line 57

def self.generate(format = :base58)
  uuid = SecureRandom.uuid

  case format
  when :base58 then to_base58(uuid)
  when :base62 then to_base62(uuid)
  else raise Error, "Unknown format: #{format}. Use :base58 or :base62"
  end
end

.generate_prefixed(prefix, format: :base58, separator: '_') ⇒ String

Generate a compact ID with a type prefix

Parameters:

  • prefix (String)

    type prefix (e.g. ‘usr’, ‘ord’, ‘txn’)

  • format (Symbol) (defaults to: :base58)

    :base58 or :base62

  • separator (String) (defaults to: '_')

    character between prefix and ID (default ‘_’)

Returns:

  • (String)

    prefixed compact ID (e.g. ‘usr_6fpBHktS7sqEUqhp4E2nE4’)

Raises:

  • (Error)

    if prefix or separator is invalid



224
225
226
227
228
# File 'lib/philiprehberger/compact_id.rb', line 224

def self.generate_prefixed(prefix, format: :base58, separator: '_')
  validate_prefix!(prefix)
  validate_separator!(separator)
  "#{prefix}#{separator}#{generate(format)}"
end

.parse_prefixed(str, separator: '_') ⇒ Hash

Parse a prefixed compact ID into its components

Parameters:

  • str (String)

    prefixed compact ID (e.g. ‘usr_6fpBHktS7sqEUqhp4E2nE4’)

  • separator (String) (defaults to: '_')

    character between prefix and ID (default ‘_’)

Returns:

  • (Hash)

    { prefix:, id:, uuid: }

Raises:

  • (Error)

    if the string has no separator or the ID cannot be decoded



236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/philiprehberger/compact_id.rb', line 236

def self.parse_prefixed(str, separator: '_')
  raise Error, 'Expected a non-empty String' unless str.is_a?(String) && !str.empty?

  parts = str.split(separator, 2)
  raise Error, "No separator '#{separator}' found in: #{str}" if parts.length < 2 || parts[1].empty?

  prefix = parts[0]
  id = parts[1]
  uuid = decode(id)

  { prefix: prefix, id: id, uuid: uuid }
end

.sortable_id(format: :base62) ⇒ String

Generate a time-sortable ID by combining a millisecond timestamp with random bytes

IDs generated later will always sort lexicographically after earlier ones. The ID is composed of the current time in milliseconds (high bits) and 64 bits of randomness (low bits), encoded in the specified format.

Parameters:

  • format (Symbol) (defaults to: :base62)

    :base58 or :base62

Returns:

  • (String)

    time-sortable encoded ID

Raises:

  • (Error)

    if format is unsupported



88
89
90
91
92
93
94
95
96
97
98
# File 'lib/philiprehberger/compact_id.rb', line 88

def self.sortable_id(format: :base62)
  ms = (Time.now.to_f * 1000).to_i
  random = SecureRandom.random_number(2**64)
  combined = (ms << 64) | random

  case format
  when :base58 then encode(combined, BASE58_ALPHABET, 0)
  when :base62 then encode(combined, BASE62_ALPHABET, 0)
  else raise Error, "unsupported format: #{format}"
  end
end

.sortable_timestamp(str, format: :base62) ⇒ Time

Extract the embedded timestamp from a sortable ID.

Recovers the high 64 bits of the integer encoded by ‘sortable_id` (millisecond Unix epoch) and returns it as a `Time`.

Parameters:

  • str (String)

    a string previously produced by ‘sortable_id`

  • format (Symbol) (defaults to: :base62)

    :base58 or :base62 — encoding of the input

Returns:

  • (Time)

    the millisecond-resolution time at which the ID was generated

Raises:

  • (Error)

    when format is unsupported or the string contains invalid characters



109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/philiprehberger/compact_id.rb', line 109

def self.sortable_timestamp(str, format: :base62)
  raise Error, 'Expected a non-empty String' unless str.is_a?(String) && !str.empty?

  alphabet = case format
             when :base58 then BASE58_ALPHABET
             when :base62 then BASE62_ALPHABET
             else raise Error, "unsupported format: #{format}"
             end

  combined = decode_str(str, alphabet)
  ms = combined >> 64
  Time.at(ms / 1000.0)
end

.to_base58(uuid) ⇒ String

Encode a UUID as a Base58 string

Parameters:

  • uuid (String)

    UUID with dashes (e.g. “550e8400-e29b-41d4-a716-446655440000”)

Returns:

  • (String)

    Base58-encoded string

Raises:

  • (Error)

    if the UUID format is invalid



19
20
21
22
# File 'lib/philiprehberger/compact_id.rb', line 19

def self.to_base58(uuid)
  validate_uuid!(uuid)
  encode(uuid_to_int(uuid), BASE58_ALPHABET, 22)
end

.to_base62(uuid) ⇒ String

Encode a UUID as a Base62 string

Parameters:

  • uuid (String)

    UUID with dashes

Returns:

  • (String)

    Base62-encoded string

Raises:

  • (Error)

    if the UUID format is invalid



29
30
31
32
# File 'lib/philiprehberger/compact_id.rb', line 29

def self.to_base62(uuid)
  validate_uuid!(uuid)
  encode(uuid_to_int(uuid), BASE62_ALPHABET, 22)
end

.valid_base58?(str) ⇒ Boolean

Check if a string is valid Base58

Parameters:

  • str (String)

    string to validate

Returns:

  • (Boolean)


253
254
255
256
257
# File 'lib/philiprehberger/compact_id.rb', line 253

def self.valid_base58?(str)
  return false unless str.is_a?(String) && !str.empty?

  str.each_char.all? { |c| BASE58_ALPHABET.include?(c) }
end

.valid_base62?(str) ⇒ Boolean

Check if a string is valid Base62

Parameters:

  • str (String)

    string to validate

Returns:

  • (Boolean)


263
264
265
266
267
# File 'lib/philiprehberger/compact_id.rb', line 263

def self.valid_base62?(str)
  return false unless str.is_a?(String) && !str.empty?

  str.each_char.all? { |c| BASE62_ALPHABET.include?(c) }
end